mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
Compare commits
109 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ef0b81743 | |||
| 5a5450d7ef | |||
| 6f1f10ccff | |||
| 5ff7861968 | |||
| f1a1c358bc | |||
| efd19353e9 | |||
| a29a493678 | |||
| 27b179276d | |||
| 155cf48a17 | |||
| 358ad79ae4 | |||
| c1fcdf7065 | |||
| 1fe58e21be | |||
| 3c0e7ba4d5 | |||
| e8f159d894 | |||
| 168d7103b1 | |||
| 63d7250a70 | |||
| 88f21e2186 | |||
| d9a18b0c42 | |||
| df588b8845 | |||
| 5be504ce8f | |||
| 6268e327b9 | |||
| 1c81b3a23c | |||
| d28895ce9c | |||
| 1a23287863 | |||
| 0b3ba65df3 | |||
| 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 | |||
| 75cbcd8e85 | |||
| 701f24cece | |||
| 166160dda9 | |||
| 9c5116f197 | |||
| deb1f043c7 | |||
| cbba19a9b1 | |||
| a5daa76616 | |||
| 08b8e73393 | |||
| 2f3cf9825a | |||
| b0f02e726d | |||
| ac40292813 | |||
| 2ac00ee8cc | |||
| 972f9e35fd | |||
| 1d7ee163a0 | |||
| 1054ec0a5e | |||
| fc2e76db0a | |||
| ef3d541eb3 | |||
| 8daf3313f2 | |||
| 93c913ec5a | |||
| 71b2fa8e21 | |||
| f2b78e0f32 | |||
| b70ef80320 | |||
| 426d42ab63 | |||
| 242cefd35c | |||
| 6beda1575a | |||
| ba75d52d29 | |||
| 35a4361ae9 | |||
| 77225383a4 | |||
| f0e81c0cf0 | |||
| 9355d7bf27 | |||
| cd39227af7 | |||
| 14e21214af | |||
| 03184b0321 | |||
| cd5af769ab | |||
| d116670318 |
@@ -37,32 +37,31 @@ jobs:
|
||||
run: pnpm build:docker
|
||||
|
||||
- name: Docker Login
|
||||
uses: docker/login-action@v2.1.0
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Docker Setup Buildx
|
||||
uses: docker/setup-buildx-action@v2.5.0
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Setup Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: ${{ secrets.DOCKERHUB_USERNAME }}/ontime
|
||||
tags: |
|
||||
type=semver,pattern=v{{version}}
|
||||
type=semver,pattern=v{{major}}
|
||||
type=raw,value=nightly,enable=${{ github.event.release.prerelease == true }}
|
||||
|
||||
- name: Build and push stable release
|
||||
if: github.event.release.prerelease == false
|
||||
uses: docker/build-push-action@v4.0.0
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
# Push is a shorthand for --output=type=registry
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
|
||||
|
||||
- name: Build and push pre-release
|
||||
if: github.event.release.prerelease == true
|
||||
uses: docker/build-push-action@v4.0.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
# Push is a shorthand for --output=type=registry
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -103,6 +103,9 @@ jobs:
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: './apps/electron/dist/ontime-linux.AppImage'
|
||||
files: |
|
||||
./apps/electron/dist/ontime-linux-x86_64.AppImage
|
||||
./apps/electron/dist/ontime-linux-arm64.AppImage
|
||||
./apps/electron/dist/ontime-linux-armv7l.AppImage
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -40,6 +40,7 @@ override.css
|
||||
|
||||
# working stuff
|
||||
**/TODO.md
|
||||
**.local.**
|
||||
|
||||
# docker utils
|
||||
ontime-db
|
||||
|
||||
@@ -10,13 +10,15 @@
|
||||
<a href="https://www.buymeacoffee.com/cpvalente" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="32"></a>
|
||||
|
||||
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe">Windows</a>
|
||||
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">MacOS Arm</a>
|
||||
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">MacOS Intel</a>
|
||||
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
|
||||
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">macOS (Apple Silicon)</a>
|
||||
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">macOS (Intel)</a>
|
||||
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux-x86_64.AppImage">Linux (Intel / AMD 64-bit)</a>
|
||||
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux-arm64.AppImage">Linux (ARM 64-bit, Raspberry Pi 4+)</a>
|
||||
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux-armv7l.AppImage">Linux (ARM 32-bit, older Raspberry Pi)</a>
|
||||
|
||||
... or
|
||||
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
|
||||
- Install from <a href="https://www.npmjs.com/package/ontime">NPM</a>
|
||||
- Install from <a href="https://www.npmjs.com/package/@getontime/cli">NPM</a>
|
||||
- Install from <a href="https://formulae.brew.sh/cask/ontime">Homebrew</a>
|
||||
|
||||
## Need help?
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "4.2.1",
|
||||
"version": "4.5.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "4.2.1",
|
||||
"version": "4.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@base-ui-components/react": "1.0.0-beta.4",
|
||||
"@base-ui/react": "1.0.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -19,18 +19,18 @@
|
||||
"axios": "^1.12.2",
|
||||
"csv-stringify": "^6.6.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^19.1.1",
|
||||
"react": "^19.2.3",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-fast-compare": "^3.2.2",
|
||||
"react-hook-form": "^7.62.0",
|
||||
"react-icons": "5.5.0",
|
||||
"react-qr-code": "^2.0.18",
|
||||
"react-router": "^7.8.2",
|
||||
"react-router": "^7.11.0",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"react-virtuoso": "^4.14.0",
|
||||
"react-virtuoso": "^4.17.0",
|
||||
"web-vitals": "^5.1.0",
|
||||
"zustand": "^5.0.8"
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"scripts": {
|
||||
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { BrowserRouter } from 'react-router';
|
||||
import { Tooltip } from '@base-ui-components/react/tooltip';
|
||||
import { Tooltip } from '@base-ui/react/tooltip';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
|
||||
// apply global axios config defaults
|
||||
import './common/api/axios.config';
|
||||
|
||||
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
|
||||
import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverlay';
|
||||
import { AppContextProvider } from './common/context/AppContext';
|
||||
|
||||
@@ -4,14 +4,15 @@ import { TranslationObject } from 'ontime-types';
|
||||
import { ontimeQueryClient } from '../../common/queryClient';
|
||||
|
||||
import { apiEntryUrl, customTranslationsURL, TRANSLATION } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
|
||||
const assetsPath = `${apiEntryUrl}/assets`;
|
||||
|
||||
/**
|
||||
* HTTP request to get css contents
|
||||
*/
|
||||
export async function getCSSContents(): Promise<string> {
|
||||
const res = await axios.get(`${assetsPath}/css`);
|
||||
export async function getCSSContents(options?: RequestOptions): Promise<string> {
|
||||
const res = await axios.get(`${assetsPath}/css`, { signal: options?.signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
@@ -35,8 +36,8 @@ export async function restoreCSSContents(): Promise<string> {
|
||||
/**
|
||||
* HTTP request to get user translation
|
||||
*/
|
||||
export async function getUserTranslation(): Promise<TranslationObject> {
|
||||
const res = await axios.get(customTranslationsURL);
|
||||
export async function getUserTranslation(options?: RequestOptions): Promise<TranslationObject> {
|
||||
const res = await axios.get(customTranslationsURL, { signal: options?.signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,14 +9,15 @@ import type {
|
||||
} from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
|
||||
const automationsPath = `${apiEntryUrl}/automations`;
|
||||
|
||||
/**
|
||||
* HTTP request to get the automations settings
|
||||
*/
|
||||
export async function getAutomationSettings(): Promise<AutomationSettings> {
|
||||
const res = await axios.get(automationsPath);
|
||||
export async function getAutomationSettings(options?: RequestOptions): Promise<AutomationSettings> {
|
||||
const res = await axios.get(automationsPath, { signal: options?.signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import axios from 'axios';
|
||||
|
||||
import { axiosConfig } from './requestTimeouts';
|
||||
|
||||
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 { apiEntryUrl } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
|
||||
const customFieldsPath = `${apiEntryUrl}/custom-fields`;
|
||||
|
||||
/**
|
||||
* Requests list of known custom fields
|
||||
*/
|
||||
export async function getCustomFields(): Promise<CustomFields> {
|
||||
const res = await axios.get(customFieldsPath);
|
||||
export async function getCustomFields(options?: RequestOptions): Promise<CustomFields> {
|
||||
const res = await axios.get(customFieldsPath, { signal: options?.signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import axios, { AxiosResponse } from 'axios';
|
||||
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
import { axiosConfig } from './requestTimeouts';
|
||||
import { createBlob, downloadBlob } from './utils';
|
||||
|
||||
const dbPath = `${apiEntryUrl}/db`;
|
||||
@@ -10,7 +12,7 @@ const dbPath = `${apiEntryUrl}/db`;
|
||||
* HTTP request to the current DB
|
||||
*/
|
||||
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();
|
||||
formData.append('project', file);
|
||||
const response = await axios.post(`${dbPath}/upload`, formData, {
|
||||
timeout: axiosConfig.longTimeout,
|
||||
headers: {
|
||||
'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
|
||||
*/
|
||||
export async function getProjects(): Promise<ProjectFileListResponse> {
|
||||
const res = await axios.get(`${dbPath}/all`);
|
||||
export async function getProjects(options?: RequestOptions): Promise<ProjectFileListResponse> {
|
||||
const res = await axios.get(`${dbPath}/all`, {
|
||||
signal: options?.signal,
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { CustomFields, Rundown, RundownSummary } from 'ontime-types';
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
import { axiosConfig } from './requestTimeouts';
|
||||
import { downloadBlob } from './utils';
|
||||
|
||||
const excelPath = `${apiEntryUrl}/excel`;
|
||||
@@ -11,10 +13,12 @@ const excelPath = `${apiEntryUrl}/excel`;
|
||||
* upload Excel file to server
|
||||
* @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();
|
||||
formData.append('excel', file);
|
||||
const response = await axios.post(`${excelPath}/upload`, formData, {
|
||||
signal: requestOptions?.signal,
|
||||
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
@@ -27,19 +31,31 @@ type PreviewSpreadsheetResponse = {
|
||||
customFields: CustomFields;
|
||||
summary: RundownSummary;
|
||||
};
|
||||
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
|
||||
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
|
||||
options,
|
||||
});
|
||||
export async function importRundownPreview(
|
||||
options: ImportMap,
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const response = await axios.get(`${excelPath}/${rundownId}/export`, {
|
||||
signal: requestOptions?.signal,
|
||||
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import axios from 'axios';
|
||||
|
||||
import { apiRepoLatest } from '../../externals';
|
||||
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
|
||||
export type HasUpdate = {
|
||||
url: string;
|
||||
version: string;
|
||||
@@ -10,8 +12,8 @@ export type HasUpdate = {
|
||||
/**
|
||||
* HTTP request to get the latest version and url from github
|
||||
*/
|
||||
export async function getLatestVersion(): Promise<HasUpdate> {
|
||||
const res = await axios.get(apiRepoLatest);
|
||||
export async function getLatestVersion(options?: RequestOptions): Promise<HasUpdate> {
|
||||
const res = await axios.get(apiRepoLatest, { signal: options?.signal });
|
||||
return {
|
||||
url: res.data.html_url 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 { apiEntryUrl } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
import { axiosConfig } from './requestTimeouts';
|
||||
|
||||
const projectPath = `${apiEntryUrl}/project`;
|
||||
|
||||
/**
|
||||
* HTTP request to fetch project data
|
||||
*/
|
||||
export async function getProjectData(): Promise<ProjectData> {
|
||||
const res = await axios.get(projectPath);
|
||||
export async function getProjectData(options?: RequestOptions): Promise<ProjectData> {
|
||||
const res = await axios.get(projectPath, {
|
||||
signal: options?.signal,
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
@@ -27,6 +32,7 @@ export async function uploadProjectLogo(file: File): Promise<AxiosResponse<Proje
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
const response = await axios.post(`${projectPath}/upload`, formData, {
|
||||
timeout: axiosConfig.longTimeout,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
|
||||
@@ -4,14 +4,15 @@ import { OntimeReport } from 'ontime-types';
|
||||
import { ontimeQueryClient } from '../../common/queryClient';
|
||||
|
||||
import { apiEntryUrl, REPORT } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
|
||||
export const reportUrl = `${apiEntryUrl}/report`;
|
||||
|
||||
/**
|
||||
* HTTP request to fetch all reports
|
||||
*/
|
||||
export async function fetchReport(): Promise<OntimeReport> {
|
||||
const res = await axios.get(reportUrl);
|
||||
export async function fetchReport(options?: RequestOptions): Promise<OntimeReport> {
|
||||
const res = await axios.get(reportUrl, { signal: options?.signal });
|
||||
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 { apiEntryUrl } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
|
||||
type RundownId = string;
|
||||
const rundownPath = `${apiEntryUrl}/rundowns`;
|
||||
@@ -11,17 +12,33 @@ const rundownPath = `${apiEntryUrl}/rundowns`;
|
||||
/**
|
||||
* HTTP request to fetch a list of existing rundowns
|
||||
*/
|
||||
export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
|
||||
const res = await axios.get(rundownPath);
|
||||
export async function fetchProjectRundownList(options?: RequestOptions): Promise<ProjectRundownsList> {
|
||||
const res = await axios.get(rundownPath, { signal: options?.signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to fetch all entries in the currently loaded rundown
|
||||
*/
|
||||
export async function fetchCurrentRundown(): Promise<Rundown> {
|
||||
const res = await axios.get(`${rundownPath}/current`);
|
||||
export async function fetchCurrentRundown(options?: RequestOptions): Promise<Rundown> {
|
||||
const res = await axios.get(`${rundownPath}/current`, { signal: options?.signal });
|
||||
if (!isValidRundown(res.data)) {
|
||||
throw new Error('Invalid rundown payload');
|
||||
}
|
||||
return res.data;
|
||||
|
||||
function isValidRundown(x: any): x is Rundown {
|
||||
return (
|
||||
x &&
|
||||
typeof x === 'object' &&
|
||||
typeof x.id === 'string' &&
|
||||
Array.isArray(x.order) &&
|
||||
Array.isArray(x.flatOrder) &&
|
||||
x.entries &&
|
||||
typeof x.entries === 'object' &&
|
||||
typeof x.revision === 'number'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,14 +2,15 @@ import axios from 'axios';
|
||||
import { GetInfo, LinkOptions } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
|
||||
const sessionPath = `${apiEntryUrl}/session`;
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve application info
|
||||
*/
|
||||
export async function getInfo(): Promise<GetInfo> {
|
||||
const res = await axios.get(`${sessionPath}/info`);
|
||||
export async function getInfo(options?: RequestOptions): Promise<GetInfo> {
|
||||
const res = await axios.get(`${sessionPath}/info`, { signal: options?.signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { Settings } from 'ontime-types';
|
||||
import { PortInfo, Settings } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
|
||||
const settingsPath = `${apiEntryUrl}/settings`;
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve application settings
|
||||
*/
|
||||
export async function getSettings(): Promise<Settings> {
|
||||
const res = await axios.get(settingsPath);
|
||||
export async function getSettings(options?: RequestOptions): Promise<Settings> {
|
||||
const res = await axios.get(settingsPath, { signal: options?.signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
@@ -26,3 +27,18 @@ export async function postSettings(data: Settings): Promise<AxiosResponse<Settin
|
||||
export async function postShowWelcomeDialog(show: boolean) {
|
||||
axios.post(`${settingsPath}/welcomedialog`, { show });
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve server port
|
||||
*/
|
||||
export async function getServerPort(): Promise<PortInfo> {
|
||||
const res = await axios.get(`${settingsPath}/serverport`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to set server port
|
||||
*/
|
||||
export async function postServerPort(serverPort: number): Promise<AxiosResponse<PortInfo>> {
|
||||
return axios.post(`${settingsPath}/serverport`, { serverPort });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { AuthenticationStatus, CustomFields, Rundown, RundownSummary } from 'ont
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
import { axiosConfig } from './requestTimeouts';
|
||||
|
||||
const sheetsPath = `${apiEntryUrl}/sheets`;
|
||||
|
||||
@@ -23,6 +25,7 @@ export const verifyAuthenticationStatus = async (): Promise<{
|
||||
export const requestConnection = async (
|
||||
file: File,
|
||||
sheetId: string,
|
||||
requestOptions?: RequestOptions,
|
||||
): Promise<{
|
||||
verification_url: string;
|
||||
user_code: string;
|
||||
@@ -31,6 +34,8 @@ export const requestConnection = async (
|
||||
formData.append('client_secret', file);
|
||||
|
||||
const response = await axios.post(`${sheetsPath}/${sheetId}/connect`, formData, {
|
||||
signal: requestOptions?.signal,
|
||||
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
@@ -53,24 +58,50 @@ export const revokeAuthentication = async (): Promise<{ authenticated: Authentic
|
||||
export const previewRundown = async (
|
||||
sheetId: string,
|
||||
options: ImportMap,
|
||||
requestOptions?: RequestOptions,
|
||||
): Promise<{
|
||||
rundown: Rundown;
|
||||
customFields: CustomFields;
|
||||
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;
|
||||
};
|
||||
|
||||
export const getWorksheetNames = async (sheetId: string): Promise<string[]> => {
|
||||
const response: AxiosResponse<string[]> = await axios.post(`${sheetsPath}/${sheetId}/worksheets`);
|
||||
export const getWorksheetNames = async (sheetId: string, requestOptions?: RequestOptions): Promise<string[]> => {
|
||||
const response: AxiosResponse<string[]> = await axios.post(
|
||||
`${sheetsPath}/${sheetId}/worksheets`,
|
||||
undefined,
|
||||
{
|
||||
signal: requestOptions?.signal,
|
||||
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* HTTP request to upload the rundown to a google sheet
|
||||
*/
|
||||
export const uploadRundown = async (sheetId: string, options: ImportMap): Promise<void> => {
|
||||
const response = await axios.post(`${sheetsPath}/${sheetId}/write`, { options });
|
||||
export const uploadRundown = async (
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -2,14 +2,15 @@ import axios from 'axios';
|
||||
import { URLPreset } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
|
||||
const urlPresetsPath = `${apiEntryUrl}/url-presets`;
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve all presets
|
||||
*/
|
||||
export async function getUrlPresets(): Promise<URLPreset[]> {
|
||||
const res = await axios.get(urlPresetsPath);
|
||||
export async function getUrlPresets(options?: RequestOptions): Promise<URLPreset[]> {
|
||||
const res = await axios.get(urlPresetsPath, { signal: options?.signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import axios from 'axios';
|
||||
import type { ViewSettings } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
|
||||
const viewSettingsPath = `${apiEntryUrl}/view-settings`;
|
||||
|
||||
@@ -9,8 +10,8 @@ const viewSettingsPath = `${apiEntryUrl}/view-settings`;
|
||||
* HTTP request to fetch view settings
|
||||
* @returns
|
||||
*/
|
||||
export async function getViewSettings(): Promise<ViewSettings> {
|
||||
const res = await axios.get(viewSettingsPath);
|
||||
export async function getViewSettings(options?: RequestOptions): Promise<ViewSettings> {
|
||||
const res = await axios.get(viewSettingsPath, { signal: options?.signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
.subtle {
|
||||
background: $gray-1050;
|
||||
color: $blue-400;
|
||||
line-height: 1em;
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
background: $gray-1000;
|
||||
color: $blue-500;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: $gray-1100;
|
||||
border-color: $gray-1250;
|
||||
}
|
||||
}
|
||||
|
||||
.subtle-white {
|
||||
background: $gray-1050;
|
||||
color: $ui-white;
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
background: $gray-1000;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: $gray-1100;
|
||||
border-color: $gray-1250;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: $gray-1050;
|
||||
}
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: $blue-700;
|
||||
color: $ui-white;
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
background: $blue-600;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: $blue-800;
|
||||
border-color: $blue-900;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: $viewer-opacity-disabled;
|
||||
}
|
||||
}
|
||||
|
||||
.destructive {
|
||||
background: $red-700;
|
||||
color: $ui-white;
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
background: $red-600;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: $red-800;
|
||||
border-color: $red-900;
|
||||
}
|
||||
}
|
||||
|
||||
.subtle-destructive {
|
||||
background: $gray-1050;
|
||||
color: $red-400;
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
background: $gray-1000;
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: $gray-1100;
|
||||
border-color: $gray-1250;
|
||||
}
|
||||
}
|
||||
|
||||
.ghosted {
|
||||
background: transparent;
|
||||
color: $blue-500;
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
background: $gray-1000;
|
||||
color: $blue-500;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: $gray-1100;
|
||||
border-color: $gray-1250;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: $opacity-disabled;
|
||||
}
|
||||
}
|
||||
|
||||
.ghosted-white {
|
||||
background: transparent;
|
||||
color: $ui-white;
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
background: $gray-1000;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: $gray-1100;
|
||||
border-color: $gray-1250;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: $opacity-disabled;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.ghosted-destructive {
|
||||
background: transparent;
|
||||
color: $red-500;
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
background: $gray-1000;
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: $gray-1100;
|
||||
border-color: $gray-1250;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: $opacity-disabled;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
@use '@/theme/viewerDefs' as *;
|
||||
@import './BaseButtonStyles.module.scss';
|
||||
@use './buttonVariants' as base;
|
||||
|
||||
@include base.button-variants;
|
||||
|
||||
.baseButton {
|
||||
position: relative;
|
||||
@@ -81,4 +82,3 @@
|
||||
.fluid {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@use '@/theme/viewerDefs' as *;
|
||||
@import './BaseButtonStyles.module.scss';
|
||||
@use './buttonVariants' as base;
|
||||
|
||||
@include base.button-variants;
|
||||
|
||||
.baseIconButton {
|
||||
aspect-ratio: 1;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
@use '@/theme/themeTokens' as *;
|
||||
@use '@/theme/viewerDefs' as *;
|
||||
|
||||
@mixin button-variants {
|
||||
.subtle {
|
||||
background: $gray-1050;
|
||||
color: $blue-400;
|
||||
line-height: 1em;
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
background: $gray-1000;
|
||||
color: $blue-500;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: $gray-1100;
|
||||
border-color: $gray-1250;
|
||||
}
|
||||
}
|
||||
|
||||
.subtle-white {
|
||||
background: $gray-1050;
|
||||
color: $ui-white;
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
background: $gray-1000;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: $gray-1100;
|
||||
border-color: $gray-1250;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: $gray-1050;
|
||||
}
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: $blue-700;
|
||||
color: $ui-white;
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
background: $blue-600;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: $blue-800;
|
||||
border-color: $blue-900;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: $viewer-opacity-disabled;
|
||||
}
|
||||
}
|
||||
|
||||
.destructive {
|
||||
background: $red-700;
|
||||
color: $ui-white;
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
background: $red-600;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: $red-800;
|
||||
border-color: $red-900;
|
||||
}
|
||||
}
|
||||
|
||||
.subtle-destructive {
|
||||
background: $gray-1050;
|
||||
color: $red-400;
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
background: $gray-1000;
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: $gray-1100;
|
||||
border-color: $gray-1250;
|
||||
}
|
||||
}
|
||||
|
||||
.ghosted {
|
||||
background: transparent;
|
||||
color: $blue-500;
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
background: $gray-1000;
|
||||
color: $blue-500;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: $gray-1100;
|
||||
border-color: $gray-1250;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: $opacity-disabled;
|
||||
}
|
||||
}
|
||||
|
||||
.ghosted-white {
|
||||
background: transparent;
|
||||
color: $ui-white;
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
background: $gray-1000;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: $gray-1100;
|
||||
border-color: $gray-1250;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: $opacity-disabled;
|
||||
}
|
||||
}
|
||||
|
||||
.ghosted-destructive {
|
||||
background: transparent;
|
||||
color: $red-500;
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
background: $gray-1000;
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: $gray-1100;
|
||||
border-color: $gray-1250;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: $opacity-disabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { IoCheckmark } from 'react-icons/io5';
|
||||
import { Checkbox as BaseCheckbox } from '@base-ui-components/react/checkbox';
|
||||
import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox';
|
||||
|
||||
import style from './Checkbox.module.scss';
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ import { navigatorConstants } from '../../../viewerConfig';
|
||||
import { setClientRemote } from '../../hooks/useSocket';
|
||||
import useUrlPresets from '../../hooks-query/useUrlPresets';
|
||||
import Button from '../buttons/Button';
|
||||
import Dialog from '../dialog/Dialog';
|
||||
import Info from '../info/Info';
|
||||
import Input from '../input/input/Input';
|
||||
import AppLink from '../link/app-link/AppLink';
|
||||
import Modal from '../modal/Modal';
|
||||
import Select from '../select/Select';
|
||||
|
||||
import style from './RedirectClientModal.module.scss';
|
||||
@@ -57,7 +57,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
showCloseButton
|
||||
@@ -98,7 +98,10 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
||||
fluid
|
||||
options={viewOptions}
|
||||
defaultValue={viewOptions[0].value}
|
||||
onValueChange={(value) => setSelected(value)}
|
||||
onValueChange={(value) => {
|
||||
if (value === null) return;
|
||||
setSelected(value);
|
||||
}}
|
||||
disabled={enabledPresets.length === 0}
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PropsWithChildren, useRef, useState } from 'react';
|
||||
import { IoCheckmark, IoCopy } from 'react-icons/io5';
|
||||
|
||||
import copyToClipboard from '../../utils/copyToClipboard';
|
||||
import { copyToClipboard } from '../../utils/copyToClipboard';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import Button from '../buttons/Button';
|
||||
import IconButton from '../buttons/IconButton';
|
||||
@@ -25,15 +25,19 @@ export default function CopyTag({
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleClick = () => {
|
||||
copyToClipboard(copyValue);
|
||||
setCopied(true);
|
||||
const handleClick = async () => {
|
||||
try {
|
||||
await copyToClipboard(copyValue);
|
||||
setCopied(true);
|
||||
|
||||
// reset copied state
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
// reset copied state
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// ignore errors
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
transform: translateX(-50%);
|
||||
|
||||
padding-inline: 1rem;
|
||||
min-width: min(420px, 90vw);
|
||||
min-width: min(600px, 90vw);
|
||||
|
||||
background-color: $gray-1250;
|
||||
color: $ui-white;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { IoClose } from 'react-icons/io5';
|
||||
import { Dialog as BaseDialog } from '@base-ui-components/react/dialog';
|
||||
import { Dialog as BaseDialog } from '@base-ui/react/dialog';
|
||||
|
||||
import IconButton from '../buttons/IconButton';
|
||||
|
||||
@@ -12,7 +12,7 @@ interface DialogProps {
|
||||
showCloseButton?: boolean;
|
||||
showBackdrop?: boolean;
|
||||
bodyElements: ReactNode;
|
||||
footerElements: ReactNode;
|
||||
footerElements?: ReactNode;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,11 +31,12 @@
|
||||
outline: 0;
|
||||
cursor: default;
|
||||
padding-block: 0.5rem;
|
||||
padding-inline: 1rem 2rem;
|
||||
padding-inline: 1rem;
|
||||
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
line-height: 1em;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
|
||||
svg {
|
||||
color: $gray-500;
|
||||
@@ -69,6 +70,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
.item .iconStrong {
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: $gray-400;
|
||||
font-size: calc(1rem - 2px);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.shortcut {
|
||||
color: $gray-500;
|
||||
font-size: calc(1rem - 4px);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.separator {
|
||||
margin: 0.25rem 0.75rem;
|
||||
height: 1px;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { IconType } from 'react-icons';
|
||||
import { Menu as BaseMenu } from '@base-ui-components/react/menu';
|
||||
import { Menu as BaseMenu } from '@base-ui/react/menu';
|
||||
|
||||
import style from './DropdownMenu.module.scss';
|
||||
|
||||
@@ -8,9 +8,11 @@ type DropdownMenuItemDivider = { type: 'divider' };
|
||||
type DropdownMenuItem = {
|
||||
type: 'item' | 'destructive';
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: IconType;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
shortcut?: string;
|
||||
};
|
||||
|
||||
export type DropdownMenuOption = DropdownMenuItemDivider | DropdownMenuItem;
|
||||
@@ -31,9 +33,21 @@ export function DropdownMenu({ items, children, ...triggerProps }: PropsWithChil
|
||||
return <BaseMenu.Separator key={index} className={style.separator} />;
|
||||
}
|
||||
return (
|
||||
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled} data-type={item.type}>
|
||||
{item.icon && <item.icon />}
|
||||
{item.label}
|
||||
<BaseMenu.Item
|
||||
key={index}
|
||||
className={style.item}
|
||||
onClick={item.onClick}
|
||||
disabled={item.disabled}
|
||||
data-type={item.type}
|
||||
>
|
||||
<span className={style.content}>
|
||||
<span className={style.label}>
|
||||
{item.icon && <item.icon />}
|
||||
{item.label}
|
||||
</span>
|
||||
{item.description && <span className={style.description}>{item.description}</span>}
|
||||
</span>
|
||||
{item.shortcut && <span className={style.shortcut}>{item.shortcut}</span>}
|
||||
</BaseMenu.Item>
|
||||
);
|
||||
})}
|
||||
@@ -69,8 +83,14 @@ export function PositionedDropdownMenu({ items, isOpen, position, onClose }: Pos
|
||||
}
|
||||
return (
|
||||
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
|
||||
{item.icon && <item.icon />}
|
||||
{item.label}
|
||||
<span className={style.content}>
|
||||
<span className={style.label}>
|
||||
{item.icon && <item.icon />}
|
||||
{item.label}
|
||||
</span>
|
||||
{item.description && <span className={style.description}>{item.description}</span>}
|
||||
</span>
|
||||
{item.shortcut && <span className={style.shortcut}>{item.shortcut}</span>}
|
||||
</BaseMenu.Item>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -59,3 +59,10 @@
|
||||
height: 0.75em;
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: relative;
|
||||
border-radius: $panel-border-radius;
|
||||
background-color: $bg-container-l2;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@@ -55,3 +55,7 @@ interface SeparatorProps extends HTMLAttributes<HTMLDivElement> {
|
||||
export function Separator({ className, orientation = 'vertical', ...elementProps }: SeparatorProps) {
|
||||
return <div className={cx([style.separator, style[orientation], className])} role='separator' {...elementProps} />;
|
||||
}
|
||||
|
||||
export function Panel({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cx([style.panel, className])} {...props} />;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { HexAlphaColorPicker, HexColorInput } from 'react-colorful';
|
||||
import { Popover } from '@base-ui-components/react/popover';
|
||||
import { Popover } from '@base-ui/react/popover';
|
||||
|
||||
import PopoverContents from '../../popover/Popover';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { IoClose } from 'react-icons/io5';
|
||||
import { Dialog as BaseDialog } from '@base-ui-components/react/dialog';
|
||||
import { Dialog as BaseDialog } from '@base-ui/react/dialog';
|
||||
|
||||
import IconButton from '../buttons/IconButton';
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function Modal({
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) onClose();
|
||||
}}
|
||||
dismissible={false}
|
||||
disablePointerDismissal
|
||||
>
|
||||
<BaseDialog.Portal>
|
||||
{showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { memo } from 'react';
|
||||
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
|
||||
import { LuCoffee } from 'react-icons/lu';
|
||||
import { useLocation } from 'react-router';
|
||||
import { Dialog } from '@base-ui-components/react/dialog';
|
||||
import { Dialog } from '@base-ui/react/dialog';
|
||||
import { useDisclosure, useFullscreen } from '@mantine/hooks';
|
||||
|
||||
import { isLocalhost, supportsFullscreen } from '../../../externals';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { Popover } from '@base-ui-components/react/popover';
|
||||
import { Popover } from '@base-ui/react/popover';
|
||||
|
||||
import style from './Popover.module.scss';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Radio } from '@base-ui-components/react/radio';
|
||||
import { RadioGroup as BaseRadioGroup } from '@base-ui-components/react/radio-group';
|
||||
import { Radio } from '@base-ui/react/radio';
|
||||
import { RadioGroup as BaseRadioGroup } from '@base-ui/react/radio-group';
|
||||
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
@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;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $blue-500;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.scrollbar {
|
||||
background: transparent;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms;
|
||||
|
||||
&[data-orientation='vertical'] {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&[data-orientation='horizontal'] {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
&[data-orientation='horizontal'][data-has-overflow-x] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
&[data-scrolling] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
|
||||
&[data-hovering],
|
||||
&[data-scrolling] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.thumb {
|
||||
background: $thumb-color;
|
||||
border-radius: 2px;
|
||||
|
||||
&:hover {
|
||||
background: $thumb-color-hover;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { CSSProperties, PropsWithChildren, Ref } from 'react';
|
||||
import { ScrollArea } from '@base-ui/react/scroll-area';
|
||||
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import style from './ScrollArea.module.scss';
|
||||
|
||||
interface ScrollAreaProps {
|
||||
className?: string;
|
||||
viewportClassName?: string;
|
||||
contentClassName?: string;
|
||||
contentStyle?: CSSProperties;
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
orientation?: 'vertical' | 'horizontal';
|
||||
}
|
||||
|
||||
export default function StyledScrollArea({
|
||||
className,
|
||||
viewportClassName,
|
||||
contentClassName,
|
||||
contentStyle,
|
||||
children,
|
||||
ref,
|
||||
orientation = 'vertical',
|
||||
}: PropsWithChildren<ScrollAreaProps>) {
|
||||
return (
|
||||
<ScrollArea.Root className={cx([style.root, className])}>
|
||||
<ScrollArea.Viewport ref={ref} className={cx([style.viewport, viewportClassName])}>
|
||||
<ScrollArea.Content className={contentClassName} style={contentStyle}>
|
||||
{children}
|
||||
</ScrollArea.Content>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar className={style.scrollbar} orientation={orientation}>
|
||||
<ScrollArea.Thumb className={style.thumb} />
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { IoCheckmark } from 'react-icons/io5';
|
||||
import { LuChevronsUpDown } from 'react-icons/lu';
|
||||
import { Select as BaseSelect } from '@base-ui-components/react/select';
|
||||
import { Select as BaseSelect } from '@base-ui/react/select';
|
||||
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Switch as BaseSwitch } from '@base-ui-components/react/switch';
|
||||
import { Switch as BaseSwitch } from '@base-ui/react/switch';
|
||||
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import style from './Tag.module.scss';
|
||||
|
||||
export default function Tag({ children }: { children: ReactNode }) {
|
||||
return <span className={style.tag}>{children}</span>;
|
||||
interface TagProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Tag({ className, children }: PropsWithChildren<TagProps>) {
|
||||
return <span className={`${style.tag} ${className || ''}`}>{children}</span>;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
.title-card__title {
|
||||
color: var(--color-override, $viewer-color);
|
||||
padding-right: 1em;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.title-card__placeholder {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { Tooltip as BaseTooltip } from '@base-ui-components/react/tooltip';
|
||||
import { Tooltip as BaseTooltip } from '@base-ui/react/tooltip';
|
||||
|
||||
import style from './Tooltip.module.scss';
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { projectLogoPath } from '../../api/constants';
|
||||
|
||||
import './ViewLogo.scss';
|
||||
@@ -7,13 +9,19 @@ interface ViewLogoProps {
|
||||
className: string;
|
||||
}
|
||||
|
||||
export default function ViewLogo(props: ViewLogoProps) {
|
||||
const { name, className } = props;
|
||||
export default function ViewLogo({ name, className }: ViewLogoProps) {
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
const hideImage = () => {
|
||||
if (!imageRef.current) return;
|
||||
|
||||
imageRef.current.style.display = 'none';
|
||||
};
|
||||
|
||||
// we wrap the image in a div to help maintain the aspect ratio
|
||||
return (
|
||||
<div className={className}>
|
||||
<img alt='' src={`${projectLogoPath}/${name}`} className='viewLogo' />
|
||||
<img ref={imageRef} alt='' src={`${projectLogoPath}/${name}`} className='viewLogo' onError={hideImage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ComponentProps, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
import { isStringBoolean } from '../../../views/common/viewUtils';
|
||||
import Checkbox from '../checkbox/Checkbox';
|
||||
import Input from '../input/input/Input';
|
||||
import Select, { SelectOption } from '../select/Select';
|
||||
@@ -160,7 +160,16 @@ function ControlledSelect({ id, initialValue, options }: ControlledSelectProps)
|
||||
}, [initialValue]);
|
||||
|
||||
return (
|
||||
<Select size='large' name={id} options={options} value={selected} onValueChange={(value) => setSelected(value)} />
|
||||
<Select
|
||||
size='large'
|
||||
name={id}
|
||||
options={options}
|
||||
value={selected}
|
||||
onValueChange={(value) => {
|
||||
if (value === null) return;
|
||||
setSelected(value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FormEvent, memo } from 'react';
|
||||
import { IoClose } from 'react-icons/io5';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { Dialog } from '@base-ui-components/react/dialog';
|
||||
import { Dialog } from '@base-ui/react/dialog';
|
||||
import { OntimeView } from 'ontime-types';
|
||||
|
||||
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
|
||||
@@ -11,7 +11,7 @@ import IconButton from '../buttons/IconButton';
|
||||
import Info from '../info/Info';
|
||||
|
||||
import { ViewOption } from './viewParams.types';
|
||||
import { getURLSearchParamsFromObj } from './viewParams.utils';
|
||||
import { getPreservedSearchParams, getURLSearchParamsFromObj } from './viewParams.utils';
|
||||
import { useViewParamsEditorStore } from './viewParamsEditor.store';
|
||||
import { ViewParamsPresets } from './ViewParamsPresets';
|
||||
import ViewParamsSection from './ViewParamsSection';
|
||||
@@ -25,17 +25,19 @@ interface EditFormDrawerProps {
|
||||
|
||||
export default memo(ViewParamsEditor);
|
||||
function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
||||
const [_, setSearchParams] = useSearchParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { data: viewSettings } = useViewSettings();
|
||||
const { isOpen, close } = useViewParamsEditorStore();
|
||||
const isSmallScreen = useIsSmallScreen();
|
||||
|
||||
const getPreservedParams = () => getPreservedSearchParams(searchParams, viewOptions);
|
||||
|
||||
const handleClose = () => {
|
||||
close();
|
||||
};
|
||||
|
||||
const resetParams = () => {
|
||||
setSearchParams();
|
||||
setSearchParams(getPreservedParams());
|
||||
};
|
||||
|
||||
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
|
||||
@@ -43,6 +45,10 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
||||
|
||||
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
|
||||
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
|
||||
const preservedParams = getPreservedParams();
|
||||
preservedParams.forEach((value, key) => {
|
||||
newSearchParams.append(key, value);
|
||||
});
|
||||
setSearchParams(newSearchParams);
|
||||
|
||||
if (isSmallScreen) {
|
||||
|
||||
@@ -191,3 +191,26 @@ export function getURLSearchParamsFromObj(paramsObj: ViewParamsObj, paramFields:
|
||||
|
||||
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,
|
||||
} = useQuery({
|
||||
queryKey: APP_VERSION,
|
||||
queryFn: getLatestVersion,
|
||||
queryFn: ({ signal }) => getLatestVersion({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
|
||||
@@ -8,12 +8,9 @@ import { automationPlaceholderSettings } from '../models/AutomationSettings';
|
||||
export default function useAutomationSettings() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: AUTOMATION,
|
||||
queryFn: getAutomationSettings,
|
||||
queryFn: ({ signal }) => getAutomationSettings({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt: number) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch };
|
||||
|
||||
@@ -10,12 +10,9 @@ const placeholder: CustomFields = {};
|
||||
export default function useCustomFields() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: CUSTOM_FIELDS,
|
||||
queryFn: getCustomFields,
|
||||
queryFn: ({ signal }) => getCustomFields({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
return { data: data ?? placeholder, status, isFetching, isError, refetch };
|
||||
|
||||
@@ -8,7 +8,7 @@ import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
export function useCustomTranslation() {
|
||||
const { data, status, refetch } = useQuery({
|
||||
queryKey: TRANSLATION,
|
||||
queryFn: getUserTranslation,
|
||||
queryFn: ({ signal }) => getUserTranslation({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
});
|
||||
|
||||
@@ -9,12 +9,9 @@ import { ontimePlaceholderInfo } from '../models/Info';
|
||||
export default function useInfo() {
|
||||
const { data, status, isError, refetch, isFetching } = useQuery<GetInfo>({
|
||||
queryKey: APP_INFO,
|
||||
queryFn: getInfo,
|
||||
queryFn: ({ signal }) => getInfo({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
return { data: data ?? ontimePlaceholderInfo, status, isError, refetch, isFetching };
|
||||
|
||||
@@ -8,7 +8,7 @@ import { projectDataPlaceholder } from '../models/ProjectData';
|
||||
export default function useProjectData() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: PROJECT_DATA,
|
||||
queryFn: getProjectData,
|
||||
queryFn: ({ signal }) => getProjectData({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
});
|
||||
|
||||
@@ -14,12 +14,9 @@ const placeholderProjectList: ProjectFileListResponse = {
|
||||
function useProjectList() {
|
||||
const { data, status, refetch } = useQuery({
|
||||
queryKey: PROJECT_LIST,
|
||||
queryFn: getProjects,
|
||||
queryFn: ({ signal }) => getProjects({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt: number) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
return { data: data ?? placeholderProjectList, status, refetch };
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { createRundown, deleteRundown, duplicateRundown, fetchProjectRundownList
|
||||
export function useProjectRundowns() {
|
||||
const { data, status, isError, refetch, isFetching } = useQuery<ProjectRundownsList>({
|
||||
queryKey: PROJECT_RUNDOWNS,
|
||||
queryFn: fetchProjectRundownList,
|
||||
queryFn: ({ signal }) => fetchProjectRundownList({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
});
|
||||
|
||||
@@ -8,11 +8,8 @@ import { fetchReport } from '../api/report';
|
||||
export default function useReport() {
|
||||
const { data, refetch } = useQuery<OntimeReport>({
|
||||
queryKey: REPORT,
|
||||
queryFn: fetchReport,
|
||||
queryFn: ({ signal }) => fetchReport({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
networkMode: 'always',
|
||||
staleTime: MILLIS_PER_HOUR,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
|
||||
|
||||
@@ -8,8 +8,6 @@ import { fetchCurrentRundown } from '../api/rundown';
|
||||
import { useSelectedEventId } from '../hooks/useSocket';
|
||||
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
|
||||
|
||||
import useProjectData from './useProjectData';
|
||||
|
||||
// revision is -1 so that the remote revision is higher
|
||||
const cachedRundownPlaceholder: Rundown = {
|
||||
id: 'default',
|
||||
@@ -26,16 +24,16 @@ const cachedRundownPlaceholder: Rundown = {
|
||||
export default function useRundown() {
|
||||
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
|
||||
queryKey: RUNDOWN,
|
||||
queryFn: fetchCurrentRundown,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
});
|
||||
|
||||
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
|
||||
}
|
||||
|
||||
export function useRundownWithMetadata() {
|
||||
const { data, status } = useRundown();
|
||||
const { selectedEventId } = useSelectedEventId();
|
||||
const selectedEventId = useSelectedEventId();
|
||||
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
|
||||
return { data, status, rundownMetadata };
|
||||
}
|
||||
@@ -46,37 +44,20 @@ export function useRundownWithMetadata() {
|
||||
*/
|
||||
export function useFlatRundown() {
|
||||
const { data, status } = useRundown();
|
||||
const { data: projectData } = useProjectData();
|
||||
|
||||
const loadedProject = useRef<string>('');
|
||||
const [prevRevision, setPrevRevision] = useState<number>(-1);
|
||||
const [flatRundown, setFlatRundown] = useState<OntimeEntry[]>([]);
|
||||
|
||||
// update data whenever the revision changes
|
||||
useEffect(() => {
|
||||
if (data.revision !== -1 || data.revision !== prevRevision) {
|
||||
const flatRundown = data.flatOrder.map((id) => data.entries[id]);
|
||||
setFlatRundown(flatRundown);
|
||||
setPrevRevision(data.revision);
|
||||
const flatRundown = useMemo(() => {
|
||||
if (data.revision === -1) {
|
||||
return [];
|
||||
}
|
||||
}, [data.entries, data.flatOrder, data.revision, prevRevision]);
|
||||
|
||||
// TODO: should we have a project id field?
|
||||
// TODO(v4): cleanup as part of load multiple rundowns
|
||||
// invalidate current version if project changes
|
||||
useEffect(() => {
|
||||
if (projectData?.title !== loadedProject.current) {
|
||||
setPrevRevision(-1);
|
||||
loadedProject.current = projectData?.title ?? '';
|
||||
}
|
||||
}, [projectData]);
|
||||
return data.flatOrder.map((id) => data.entries[id]).filter((entry): entry is OntimeEntry => entry !== undefined);
|
||||
}, [data]);
|
||||
|
||||
return { data: flatRundown, rundownId: data.id, status };
|
||||
}
|
||||
|
||||
export function useFlatRundownWithMetadata() {
|
||||
const { data, status } = useRundown();
|
||||
const { selectedEventId } = useSelectedEventId();
|
||||
const selectedEventId = useSelectedEventId();
|
||||
|
||||
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
|
||||
return { data: rundownWithMetadata, status };
|
||||
@@ -84,6 +65,10 @@ export function useFlatRundownWithMetadata() {
|
||||
|
||||
/**
|
||||
* Provides access to a partial rundown based on a filter callback
|
||||
*
|
||||
* Callers MUST memoize the callback with useCallback to prevent
|
||||
* re-filtering on every render.
|
||||
*
|
||||
*/
|
||||
export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
|
||||
const { data, status } = useFlatRundownWithMetadata();
|
||||
@@ -100,11 +85,6 @@ export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boo
|
||||
export function useEntry(entryId: EntryId | null): OntimeEntry | null {
|
||||
const { data: rundown } = useRundown();
|
||||
|
||||
// track the specific entry we care about
|
||||
const entry = useMemo(() => {
|
||||
if (entryId === null) return null;
|
||||
return rundown.entries[entryId];
|
||||
}, [entryId, rundown.entries]);
|
||||
|
||||
return entry;
|
||||
if (entryId === null) return null;
|
||||
return rundown.entries[entryId] ?? null;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ontimePlaceholderSettings } from '../models/OntimeSettings';
|
||||
export default function useSettings() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: APP_SETTINGS,
|
||||
queryFn: getSettings,
|
||||
queryFn: ({ signal }) => getSettings({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
select: (data) => {
|
||||
const unobfuscated = { ...data };
|
||||
|
||||
@@ -13,7 +13,7 @@ interface FetchProps {
|
||||
export default function useUrlPresets({ skip = false }: FetchProps = {}) {
|
||||
const { data, status, isError, refetch } = useQuery({
|
||||
queryKey: URL_PRESETS,
|
||||
queryFn: getUrlPresets,
|
||||
queryFn: ({ signal }) => getUrlPresets({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
enabled: !skip,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
|
||||
export default function useViewSettings() {
|
||||
const { data, status } = useQuery({
|
||||
queryKey: VIEW_SETTINGS,
|
||||
queryFn: getViewSettings,
|
||||
queryFn: ({ signal }) => getViewSettings({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
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 { DropdownMenuOption } from '../components/dropdown-menu/DropdownMenu';
|
||||
|
||||
export const useContextMenu = <T extends HTMLElement>(options: DropdownMenuOption[]) => {
|
||||
type ContextMenuOptions = () => DropdownMenuOption[];
|
||||
|
||||
export const useContextMenu = <T extends HTMLElement>(options: ContextMenuOptions) => {
|
||||
const setContextMenu = useContextMenuStore((state) => state.setContextMenu);
|
||||
|
||||
const localCreateContextMenu = (contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => {
|
||||
// prevent browser default context menu from showing up
|
||||
contextMenuEvent.preventDefault();
|
||||
const localCreateContextMenu = useCallback(
|
||||
(contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => {
|
||||
// prevent browser default context menu from showing up
|
||||
contextMenuEvent.preventDefault();
|
||||
|
||||
const { pageX, pageY } = contextMenuEvent;
|
||||
return setContextMenu({ x: pageX, y: pageY }, options);
|
||||
};
|
||||
const { pageX, pageY } = contextMenuEvent;
|
||||
const menuOptions = options();
|
||||
return setContextMenu({ x: pageX, y: pageY }, menuOptions);
|
||||
},
|
||||
[options, setContextMenu],
|
||||
);
|
||||
|
||||
return [localCreateContextMenu];
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
EntryId,
|
||||
@@ -6,15 +6,31 @@ import {
|
||||
isOntimeEvent,
|
||||
isOntimeGroup,
|
||||
MaybeString,
|
||||
OntimeDelay,
|
||||
OntimeEntry,
|
||||
OntimeEvent,
|
||||
OntimeGroup,
|
||||
OntimeMilestone,
|
||||
PatchWithId,
|
||||
Rundown,
|
||||
SupportedEntry,
|
||||
TimeField,
|
||||
TimeStrategy,
|
||||
TransientEventPayload,
|
||||
} 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 { RUNDOWN } from '../api/constants';
|
||||
@@ -85,9 +101,64 @@ export const useEntryActions = () => {
|
||||
* @private
|
||||
*/
|
||||
const { mutateAsync: addEntryMutation } = useMutation({
|
||||
mutationFn: ([rundownId, entry]: Parameters<typeof postAddEntry>) => postAddEntry(rundownId, entry),
|
||||
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
|
||||
mutationFn: ([rundownId, entry]: [string, PatchWithId & InsertOptions]) => postAddEntry(rundownId, entry),
|
||||
onMutate: async ([_rundownId, entry]) => {
|
||||
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');
|
||||
}
|
||||
|
||||
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
|
||||
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 {
|
||||
await addEntryMutation([rundownId, newEntry]);
|
||||
} catch (error) {
|
||||
@@ -840,22 +911,40 @@ export const useEntryActions = () => {
|
||||
[getCurrentRundownData, swapEventsMutation],
|
||||
);
|
||||
|
||||
return {
|
||||
addEntry,
|
||||
applyDelay,
|
||||
batchUpdateEvents,
|
||||
clone,
|
||||
deleteEntry,
|
||||
deleteAllEntries,
|
||||
ungroup,
|
||||
getEntryById,
|
||||
groupEntries,
|
||||
move,
|
||||
reorderEntry,
|
||||
swapEvents,
|
||||
updateEntry,
|
||||
updateTimer,
|
||||
};
|
||||
return useMemo(
|
||||
() => ({
|
||||
addEntry,
|
||||
applyDelay,
|
||||
batchUpdateEvents,
|
||||
clone,
|
||||
deleteEntry,
|
||||
deleteAllEntries,
|
||||
ungroup,
|
||||
getEntryById,
|
||||
groupEntries,
|
||||
move,
|
||||
reorderEntry,
|
||||
swapEvents,
|
||||
updateEntry,
|
||||
updateTimer,
|
||||
}),
|
||||
[
|
||||
addEntry,
|
||||
applyDelay,
|
||||
batchUpdateEvents,
|
||||
clone,
|
||||
deleteEntry,
|
||||
deleteAllEntries,
|
||||
ungroup,
|
||||
getEntryById,
|
||||
groupEntries,
|
||||
move,
|
||||
reorderEntry,
|
||||
swapEvents,
|
||||
updateEntry,
|
||||
updateTimer,
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -887,3 +976,29 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
|
||||
|
||||
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 } }),
|
||||
};
|
||||
|
||||
export const useSelectedEventId = createSelector((state: RuntimeStore) => ({
|
||||
selectedEventId: state.eventNow?.id ?? null,
|
||||
}));
|
||||
export const useSelectedEventId = createSelector((state: RuntimeStore) => state.eventNow?.id ?? null);
|
||||
|
||||
export const useCurrentGroupId = createSelector((state: RuntimeStore) => ({
|
||||
currentGroupId: state.groupNow?.id ?? null,
|
||||
}));
|
||||
export const useCurrentGroupId = createSelector((state: RuntimeStore) => state.groupNow?.id ?? null);
|
||||
|
||||
export const setEventPlayback = {
|
||||
loadEvent: (id: string) => sendSocket('load', { id }),
|
||||
@@ -147,9 +143,7 @@ export const useTimer = createSelector((state: RuntimeStore) => ({
|
||||
...state.timer,
|
||||
}));
|
||||
|
||||
export const useClock = createSelector((state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
}));
|
||||
export const useClock = createSelector((state: RuntimeStore) => state.clock);
|
||||
|
||||
export const useNextFlag = createSelector((state: RuntimeStore) => ({
|
||||
id: state.eventFlag?.id ?? null,
|
||||
@@ -173,28 +167,16 @@ export const useExpectedStartData = createSelector((state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
}));
|
||||
|
||||
export const usePing = createSelector((state: RuntimeStore) => ({
|
||||
ping: state.ping,
|
||||
}));
|
||||
export const usePing = createSelector((state: RuntimeStore) => state.ping);
|
||||
|
||||
/** convert ping into a derived value which changes less often */
|
||||
export const useIsOnline = createSelector((state: RuntimeStore) => ({
|
||||
isOnline: state.ping > 0,
|
||||
}));
|
||||
export const useIsOnline = createSelector((state: RuntimeStore) => state.ping > 0);
|
||||
|
||||
export const useOffsetMode = createSelector((state: RuntimeStore) => ({
|
||||
offsetMode: state.offset.mode,
|
||||
}));
|
||||
export const useOffsetMode = createSelector((state: RuntimeStore) => state.offset.mode);
|
||||
|
||||
export const setOffsetMode = (payload: OffsetMode) => sendSocket('offsetmode', payload);
|
||||
|
||||
export const usePlayback = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
playback: state.timer.playback,
|
||||
});
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
export const usePlayback = createSelector((state: RuntimeStore) => state.timer.playback);
|
||||
|
||||
/* ======================= Overview data subscriptions ======================= */
|
||||
|
||||
@@ -204,9 +186,7 @@ export const useStartTimesOverview = createSelector((state: RuntimeStore) => ({
|
||||
plannedEnd: state.rundown.plannedEnd,
|
||||
}));
|
||||
|
||||
export const useRundownExpectedEnd = createSelector((state: RuntimeStore) => ({
|
||||
expectedEnd: state.offset.expectedRundownEnd,
|
||||
}));
|
||||
export const useRundownExpectedEnd = createSelector((state: RuntimeStore) => state.offset.expectedRundownEnd);
|
||||
|
||||
export const useProgressOverview = createSelector((state: RuntimeStore) => ({
|
||||
numEvents: state.rundown.numEvents,
|
||||
@@ -222,19 +202,19 @@ export const useGroupTimerOverView = createSelector((state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
mode: state.offset.mode,
|
||||
groupExpectedEnd: state.offset.expectedGroupEnd,
|
||||
// we can force these numbers to 0 fo this use case to avoid null checks
|
||||
// we can force these numbers to 0 for this use case to avoid null checks
|
||||
actualGroupStart: state.rundown.actualGroupStart ?? 0,
|
||||
currentDay: state.eventNow?.dayOffset ?? 0,
|
||||
currentDay: state.rundown.currentDay ?? 0,
|
||||
playback: state.timer.playback,
|
||||
}));
|
||||
|
||||
export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
mode: state.offset.mode,
|
||||
// we can force these numbers to 0 fo this use case to avoid null checks
|
||||
// we can force these numbers to 0 for this use case to avoid null checks
|
||||
actualStart: state.rundown.actualStart ?? 0,
|
||||
plannedStart: state.rundown.plannedStart ?? 0,
|
||||
currentDay: state.eventNow?.dayOffset ?? 0,
|
||||
currentDay: state.rundown.currentDay ?? 0,
|
||||
playback: state.timer.playback,
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Settings } from 'ontime-types';
|
||||
|
||||
export const ontimePlaceholderSettings: Settings = {
|
||||
version: '4.0.0',
|
||||
serverPort: 4001,
|
||||
editorKey: null,
|
||||
operatorKey: null,
|
||||
timeFormat: '24',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||
|
||||
import { isOntimeCloud } from '../externals';
|
||||
@@ -8,10 +9,15 @@ export const ontimeQueryClient = new QueryClient({
|
||||
queries: {
|
||||
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
|
||||
networkMode: 'always',
|
||||
networkMode: isOntimeCloud ? 'online' : 'always',
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
retry: (failureCount, error) => {
|
||||
if (axios.isCancel(error)) {
|
||||
return false;
|
||||
}
|
||||
return failureCount < 5;
|
||||
},
|
||||
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 5000),
|
||||
},
|
||||
mutations: {
|
||||
/**
|
||||
@@ -21,6 +27,7 @@ export const ontimeQueryClient = new QueryClient({
|
||||
* - use 'online' for clients that are connected to the cloud
|
||||
*/
|
||||
networkMode: isOntimeCloud ? 'online' : 'always',
|
||||
retry: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,10 +2,13 @@ import { create } from 'zustand';
|
||||
|
||||
type EntryCopyStore = {
|
||||
entryCopyId: string | null;
|
||||
setEntryCopyId: (eventId: string | null) => void;
|
||||
entryCopyMode: 'copy' | 'cut';
|
||||
setEntryCopyId: (eventId: string | null, mode?: 'copy' | 'cut') => void;
|
||||
};
|
||||
|
||||
export const useEntryCopy = create<EntryCopyStore>()((set) => ({
|
||||
entryCopyId: null,
|
||||
setEntryCopyId: (entryCopyId: string | null) => set({ entryCopyId }),
|
||||
entryCopyMode: 'copy',
|
||||
setEntryCopyId: (entryCopyId: string | null, mode: 'copy' | 'cut' = 'copy') =>
|
||||
set({ entryCopyId, entryCopyMode: mode }),
|
||||
}));
|
||||
|
||||
@@ -82,6 +82,57 @@ describe('getRouteFromPreset()', () => {
|
||||
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cuesheet presets', () => {
|
||||
const cuesheetPreset: URLPreset[] = [
|
||||
{
|
||||
enabled: true,
|
||||
alias: 'cuesheet-4685d6',
|
||||
target: OntimeView.Cuesheet,
|
||||
search: '',
|
||||
options: {
|
||||
read: 'full',
|
||||
write: '-',
|
||||
},
|
||||
},
|
||||
];
|
||||
const cuesheetPresetWithoutOptions: URLPreset[] = [
|
||||
{
|
||||
enabled: true,
|
||||
alias: 'cuesheet-basic',
|
||||
target: OntimeView.Cuesheet,
|
||||
search: '',
|
||||
},
|
||||
];
|
||||
const cuesheetPresetWithNavLock: URLPreset[] = [
|
||||
{
|
||||
enabled: true,
|
||||
alias: 'cuesheet-locked',
|
||||
target: OntimeView.Cuesheet,
|
||||
search: 'n=1',
|
||||
},
|
||||
];
|
||||
|
||||
it('keeps cuesheet aliases masked when permissions are stored in preset options', () => {
|
||||
const location = resolvePath('/cuesheet-4685d6');
|
||||
expect(getRouteFromPreset(location, cuesheetPreset)).toBe('preset/cuesheet-4685d6');
|
||||
});
|
||||
|
||||
it('preserves feature params when redirecting masked cuesheet aliases', () => {
|
||||
const location = resolvePath('/cuesheet-4685d6?n=1&token=123');
|
||||
expect(getRouteFromPreset(location, cuesheetPreset)).toBe('preset/cuesheet-4685d6?n=1&token=123');
|
||||
});
|
||||
|
||||
it('keeps cuesheet aliases masked even when preset options are absent', () => {
|
||||
const location = resolvePath('/cuesheet-basic');
|
||||
expect(getRouteFromPreset(location, cuesheetPresetWithoutOptions)).toBe('preset/cuesheet-basic');
|
||||
});
|
||||
|
||||
it('applies navigation lock from preset search params when alias is opened', () => {
|
||||
const location = resolvePath('/cuesheet-locked');
|
||||
expect(getRouteFromPreset(location, cuesheetPresetWithNavLock)).toBe('preset/cuesheet-locked?n=1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generatePathFromPreset()', () => {
|
||||
@@ -115,6 +166,14 @@ describe('arePathsEquivalent()', () => {
|
||||
expect(arePathsEquivalent('preset/minimal', 'preset/minimal?test=b')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('distinguishes preset paths with different lock or token params', () => {
|
||||
expect(arePathsEquivalent('preset/minimal', 'preset/minimal?n=1')).toBeFalsy();
|
||||
expect(arePathsEquivalent('preset/minimal?n=1', 'preset/minimal?n=1')).toBeTruthy();
|
||||
expect(arePathsEquivalent('preset/minimal', 'preset/minimal?token=abc')).toBeFalsy();
|
||||
expect(arePathsEquivalent('preset/minimal?n=1&token=abc', 'preset/minimal?n=1')).toBeFalsy();
|
||||
expect(arePathsEquivalent('preset/minimal?n=1&token=abc', 'preset/minimal?n=1&token=abc')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('considers edge cases for the url sharing feature', () => {
|
||||
expect(arePathsEquivalent('timer?test=a&n=1=token=123', 'timer?test=b')).toBeFalsy();
|
||||
expect(arePathsEquivalent('timer?test=a&n=1=token=123', 'timer?test=a')).toBeTruthy();
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
// we need to this as a promise because safari
|
||||
export default async function copyToClipboard(text: string) {
|
||||
setTimeout(async () => await navigator.clipboard?.writeText(text));
|
||||
/**
|
||||
* copy text to clipboard
|
||||
* @throws if not supported or permission denied
|
||||
*/
|
||||
export async function copyToClipboard(text: string) {
|
||||
await navigator.clipboard?.writeText(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy to clipboard but safely ignore errors
|
||||
*/
|
||||
export async function safeCopyToClipboard(text: string): Promise<void> {
|
||||
try {
|
||||
await copyToClipboard(text);
|
||||
} catch {
|
||||
// Silently ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,13 @@ import { patchRuntime, patchRuntimeProperty } from '../stores/runtime';
|
||||
|
||||
let websocket: WebSocket | 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 reconnectAttempts = 0;
|
||||
@@ -49,7 +55,11 @@ export const connectSocket = () => {
|
||||
const preferredClientName = getClientName();
|
||||
|
||||
websocket.onopen = () => {
|
||||
clearTimeout(reconnectTimeout as NodeJS.Timeout);
|
||||
const isReconnect = hasConnected;
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout);
|
||||
reconnectTimeout = null;
|
||||
}
|
||||
hasConnected = true;
|
||||
reconnectAttempts = 0;
|
||||
|
||||
@@ -59,24 +69,38 @@ export const connectSocket = () => {
|
||||
path: window.location.pathname + window.location.search,
|
||||
name: preferredClientName,
|
||||
});
|
||||
invalidateAllCaches(); // assume all data to be stale after a reconnect
|
||||
|
||||
if (isReconnect) {
|
||||
invalidateAllCaches();
|
||||
}
|
||||
setOnlineStatus(true);
|
||||
};
|
||||
|
||||
websocket.onclose = () => {
|
||||
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(() => {
|
||||
if (reconnectAttempts > 2) {
|
||||
reconnectTimeout = null;
|
||||
if (reconnectAttempts > socketConfig.offlineAttemptsThreshold) {
|
||||
setOnlineStatus(false);
|
||||
}
|
||||
console.warn('WebSocket: attempting reconnect');
|
||||
console.warn(`WebSocket: reconnecting now (#${reconnectAttempts + 1}, waited ${delay}ms)`);
|
||||
if (websocket && websocket.readyState === WebSocket.CLOSED) {
|
||||
reconnectAttempts += 1;
|
||||
connectSocket();
|
||||
}
|
||||
}, reconnectInterval);
|
||||
}, delay);
|
||||
};
|
||||
|
||||
websocket.onerror = (error) => {
|
||||
|
||||
@@ -39,11 +39,13 @@ export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): str
|
||||
// NOTE: verify that this resolves correctly in cloud
|
||||
const currentPath = `${location.pathname}${location.search}`.substring(1);
|
||||
const currentURL = getCurrentPath(location);
|
||||
const token = new URLSearchParams(location.search).get('token');
|
||||
const isLocked = location.search.includes('n=1');
|
||||
const locationParams = new URLSearchParams(location.search);
|
||||
const token = locationParams.get('token');
|
||||
|
||||
for (const preset of urlPresets) {
|
||||
if (!preset.enabled) continue;
|
||||
const presetParams = new URLSearchParams(preset.search);
|
||||
const isLocked = locationParams.get('n') === '1' || presetParams.get('n') === '1';
|
||||
/**
|
||||
* If the page is a known alias it would be like
|
||||
* /preset/{alias} <- locked to a preset
|
||||
@@ -53,7 +55,9 @@ export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): str
|
||||
* we need to compare the saved preset to the current path to see if we need to redirect
|
||||
*/
|
||||
if (preset.alias === currentURL || preset.target === currentURL) {
|
||||
const newPath = generatePathFromPreset(preset.target, preset.search, preset.alias, isLocked, token);
|
||||
const newPath = shouldMaskPresetPath(preset)
|
||||
? generateMaskedPathFromPreset(preset.alias, isLocked, token)
|
||||
: generatePathFromPreset(preset.target, preset.search, preset.alias, isLocked, token);
|
||||
/**
|
||||
* if the current path is equivalent to the new path, we return null
|
||||
* this means we will not redirect
|
||||
@@ -114,6 +118,30 @@ export function generatePathFromPreset(
|
||||
return `${path.pathname}?${searchParams}`.substring(1);
|
||||
}
|
||||
|
||||
function generateMaskedPathFromPreset(alias: string, locked: boolean, token: string | null): string {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (locked) {
|
||||
searchParams.set('n', '1');
|
||||
}
|
||||
|
||||
if (token) {
|
||||
searchParams.set('token', token);
|
||||
}
|
||||
|
||||
const path = `preset/${alias}`;
|
||||
const search = searchParams.toString();
|
||||
if (!search) {
|
||||
return path;
|
||||
}
|
||||
|
||||
return `${path}?${search}`;
|
||||
}
|
||||
|
||||
function shouldMaskPresetPath(preset: URLPreset): boolean {
|
||||
return preset.target === OntimeView.Cuesheet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility checks if two paths are equivalent
|
||||
* For preset paths, only compares the path (since params are stored in session)
|
||||
@@ -123,16 +151,19 @@ export function arePathsEquivalent(currentPath: string, newPath: string): boolea
|
||||
const currentUrl = new URL(currentPath, document.location.origin);
|
||||
const newUrl = new URL(newPath, document.location.origin);
|
||||
|
||||
// For preset paths, only compare the path
|
||||
if (currentUrl.pathname.startsWith('/preset/') || newUrl.pathname.startsWith('/preset/')) {
|
||||
return currentUrl.pathname === newUrl.pathname;
|
||||
}
|
||||
|
||||
// For regular paths, compare path and search params (ignoring token)
|
||||
if (currentUrl.pathname !== newUrl.pathname) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For preset paths, only n and token are meaningful — ignore everything else
|
||||
if (currentUrl.pathname.startsWith('/preset/')) {
|
||||
return (
|
||||
currentUrl.searchParams.get('n') === newUrl.searchParams.get('n') &&
|
||||
currentUrl.searchParams.get('token') === newUrl.searchParams.get('token')
|
||||
);
|
||||
}
|
||||
|
||||
// For regular paths, compare all search params except n and token
|
||||
currentUrl.searchParams.delete('token');
|
||||
currentUrl.searchParams.delete('n');
|
||||
newUrl.searchParams.delete('token');
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ ul {
|
||||
|
||||
.tabs {
|
||||
width: min(30vw, 300px);
|
||||
flex: 0 0 min(30vw, 300px);
|
||||
min-width: min(30vw, 300px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -212,6 +212,11 @@ $inner-padding: 1rem;
|
||||
&.apart {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&.wrap {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes animloader {
|
||||
|
||||
@@ -107,7 +107,7 @@ export function BlockQuote({ children }: { children: ReactNode }) {
|
||||
return <blockquote className={style.blockquote}>{children}</blockquote>;
|
||||
}
|
||||
|
||||
export function Error({ children, className }: { children: ReactNode } & React.JSX.IntrinsicElements['div']) {
|
||||
export function Error({ children, className }: PropsWithChildren & React.JSX.IntrinsicElements['div']) {
|
||||
return <div className={cx([style.fieldError, className])}>{children}</div>;
|
||||
}
|
||||
|
||||
@@ -131,16 +131,25 @@ type InlineProps<C extends AllowedInlineTags> = {
|
||||
as?: C;
|
||||
relation?: 'inner' | 'component' | 'section';
|
||||
align?: 'start' | 'end' | 'apart';
|
||||
className?: string;
|
||||
};
|
||||
wrap?: 'wrap' | 'nowrap';
|
||||
} & Omit<React.JSX.IntrinsicElements[C], 'align'>;
|
||||
|
||||
export function InlineElements<C extends AllowedInlineTags = 'div'>({
|
||||
children,
|
||||
as,
|
||||
relation = 'component',
|
||||
align = 'start',
|
||||
wrap = 'nowrap',
|
||||
className,
|
||||
...elementProps
|
||||
}: PropsWithChildren<InlineProps<C>>) {
|
||||
const Element = as ?? 'div';
|
||||
return <Element className={cx([style.inlineElements, style[relation], style[align], className])}>{children}</Element>;
|
||||
return (
|
||||
<Element
|
||||
{...(elementProps as HTMLAttributes<HTMLElement>)}
|
||||
className={cx([style.inlineElements, style[relation], style[align], style[wrap], className])}
|
||||
>
|
||||
{children}
|
||||
</Element>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -230,10 +230,18 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
<div key={key} className={style.filterSection}>
|
||||
<label>
|
||||
Runtime data source
|
||||
<Select
|
||||
value={watch(`filters.${index}.field`)}
|
||||
onValueChange={(value) => setValue(`filters.${index}.field`, value, { shouldDirty: true })}
|
||||
options={fieldList.map(({ value, label }) => ({ value, label }))}
|
||||
<Select<string | null>
|
||||
// need to normalize '' to null for the Select to show the placeholder
|
||||
value={watch(`filters.${index}.field`) || null}
|
||||
onValueChange={(value) => {
|
||||
if (value === null) return;
|
||||
setValue(`filters.${index}.field`, value, { shouldDirty: true });
|
||||
}}
|
||||
options={fieldList.map(({ value, label }) => ({
|
||||
value,
|
||||
label,
|
||||
disabled: value === null,
|
||||
}))}
|
||||
aria-label='Event field'
|
||||
/>
|
||||
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
|
||||
@@ -242,11 +250,14 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
Matching condition
|
||||
<Select
|
||||
value={watch(`filters.${index}.operator`)}
|
||||
onValueChange={(value) =>
|
||||
setValue(`filters.${index}.operator`, value as 'equals' | 'not_equals' | 'contains', {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
onValueChange={(value: string | null) => {
|
||||
if (value === null) return;
|
||||
setValue(
|
||||
`filters.${index}.operator`,
|
||||
value as 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains' | 'not_contains',
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
}}
|
||||
options={[
|
||||
{ value: 'equals', label: 'equals' },
|
||||
{ value: 'not_equals', label: 'not equals' },
|
||||
@@ -282,7 +293,8 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Outputs</h3>
|
||||
<Info>
|
||||
Automation outputs can be used to send data from Ontime to external software.
|
||||
Automation outputs can be used to send data from Ontime to external software <br />
|
||||
or to change properties of Ontime itself.
|
||||
<ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink>
|
||||
</Info>
|
||||
|
||||
|
||||
+20
-11
@@ -44,8 +44,9 @@ export default function OntimeActionForm({
|
||||
<label>
|
||||
Action
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
handleSetAction(value as OntimeActionKey);
|
||||
onValueChange={(value: OntimeActionKey | null) => {
|
||||
if (value === null) return;
|
||||
handleSetAction(value);
|
||||
}}
|
||||
value={watch(`outputs.${index}.action`)}
|
||||
options={[
|
||||
@@ -97,13 +98,13 @@ export default function OntimeActionForm({
|
||||
Visibility
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
// we need to translate the undefined value to 'untouched'
|
||||
const translatedValue = value === 'untouched' ? undefined : (value as boolean | undefined);
|
||||
// we need to translate the null to undefined so it becomes 'untouched'
|
||||
const translatedValue = value === null ? undefined : value;
|
||||
setValue(`outputs.${index}.visible`, translatedValue, { shouldDirty: true });
|
||||
}}
|
||||
value={watch(`outputs.${index}.visible`) === undefined ? 'untouched' : watch(`outputs.${index}.visible`)}
|
||||
value={watch(`outputs.${index}.visible`)}
|
||||
options={[
|
||||
{ value: 'untouched', label: 'Untouched' },
|
||||
{ value: null, label: 'Untouched' },
|
||||
{ value: true, label: 'Show' },
|
||||
{ value: false, label: 'Hide' },
|
||||
]}
|
||||
@@ -116,23 +117,31 @@ export default function OntimeActionForm({
|
||||
{selectedAction === 'message-secondary' && (
|
||||
<label>
|
||||
Timer secondary source
|
||||
<Select
|
||||
<Select<SecondarySource | 'null' | null>
|
||||
onValueChange={(value) => {
|
||||
setValue(`outputs.${index}.secondarySource`, value as SecondarySource, { shouldDirty: true });
|
||||
// null -> no selection
|
||||
if (value === null) return;
|
||||
// 'null' -> clear the secondary source
|
||||
if (value === 'null') {
|
||||
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
|
||||
return;
|
||||
}
|
||||
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
|
||||
}}
|
||||
value={watch(`outputs.${index}.secondarySource`)}
|
||||
options={[
|
||||
{ value: null, label: 'Select secondary source', disabled: true },
|
||||
{ value: null, label: 'Select secondary source' },
|
||||
{ value: 'aux1', label: 'Auxiliary timer 1' },
|
||||
{ value: 'aux2', label: 'Auxiliary timer 2' },
|
||||
{ value: 'aux3', label: 'Auxiliary timer 3' },
|
||||
{ value: 'external', label: 'External' },
|
||||
{ value: 'null', label: 'None' },
|
||||
{ value: 'secondary', label: 'Secondary' },
|
||||
{ value: 'null', label: 'None' }, // allow the user to clear the secondary source
|
||||
]}
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className={style.test}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -106,7 +106,10 @@ export default function TriggerForm({
|
||||
Lifecycle trigger
|
||||
<Select
|
||||
value={watch('trigger')}
|
||||
onValueChange={(value) => setValue('trigger', value as TimerLifeCycle, { shouldDirty: true })}
|
||||
onValueChange={(value) => {
|
||||
if (value === null) return;
|
||||
setValue('trigger', value as TimerLifeCycle, { shouldDirty: true });
|
||||
}}
|
||||
options={cycles.map((cycle) => ({ value: cycle.value, label: cycle.label }))}
|
||||
aria-label='Lifecycle trigger'
|
||||
/>
|
||||
@@ -116,7 +119,10 @@ export default function TriggerForm({
|
||||
Automation title
|
||||
<Select
|
||||
value={watch('automationId')}
|
||||
onValueChange={(value) => setValue('automationId', value, { shouldDirty: true })}
|
||||
onValueChange={(value: string | null) => {
|
||||
if (value === null) return;
|
||||
setValue('automationId', value, { shouldDirty: true });
|
||||
}}
|
||||
options={automationSelect}
|
||||
aria-label='Automation title'
|
||||
/>
|
||||
|
||||
@@ -27,6 +27,7 @@ export function isAutomation(automation: AutomationDTO | Automation): automation
|
||||
}
|
||||
|
||||
const staticSelectProperties = [
|
||||
{ value: null, label: 'Select field' },
|
||||
{ value: 'eventNow.id', label: 'ID' },
|
||||
{ value: 'eventNow.title', label: 'Title' },
|
||||
{ value: 'eventNow.cue', label: 'Cue' },
|
||||
@@ -42,7 +43,7 @@ const staticNextSelectProperties = [
|
||||
];
|
||||
|
||||
type SelectableField = {
|
||||
value: string; // string encodes path in runtime state object
|
||||
value: string | null; // string encodes path in runtime state object
|
||||
label: string;
|
||||
};
|
||||
|
||||
|
||||
+10
@@ -54,6 +54,13 @@ const eventStaticPropertiesNext = [
|
||||
'{{eventNext.delay}}',
|
||||
];
|
||||
|
||||
const staticAuxProperties = (index: 1 | 2 | 3) => [
|
||||
`{{auxtimer${index}.current}}`,
|
||||
`{{auxtimer${index}.duration}}`,
|
||||
`{{auxtimer${index}.playback}}`,
|
||||
`{{auxtimer${index}.direction}}`,
|
||||
];
|
||||
|
||||
/**
|
||||
* Creates a it of possible autocomplete suggestions
|
||||
* Based on RuntimeState
|
||||
@@ -68,6 +75,9 @@ export function makeAutoCompleteList(customFields: CustomFields): string[] {
|
||||
...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`),
|
||||
...eventStaticPropertiesNext,
|
||||
...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`),
|
||||
...staticAuxProperties(1),
|
||||
...staticAuxProperties(2),
|
||||
...staticAuxProperties(3),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -142,7 +142,10 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
|
||||
options={targetOptions}
|
||||
{...register('target', { required: 'Target is required' })}
|
||||
value={watch('target')}
|
||||
onValueChange={(value) => setValue('target', value, { shouldDirty: true })}
|
||||
onValueChange={(value: OntimeViewPresettable | null) => {
|
||||
if (value === null) return;
|
||||
setValue('target', value, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
+12
-3
@@ -51,7 +51,10 @@ export default function RundownDefaultSettings() {
|
||||
/>
|
||||
<Select
|
||||
value={defaultTimeStrategy}
|
||||
onValueChange={(value) => setTimeStrategy(value as TimeStrategy)}
|
||||
onValueChange={(value: TimeStrategy | null) => {
|
||||
if (value === null) return;
|
||||
setTimeStrategy(value);
|
||||
}}
|
||||
options={[
|
||||
{ value: TimeStrategy.LockDuration, label: 'Duration' },
|
||||
{ value: TimeStrategy.LockEnd, label: 'End Time' },
|
||||
@@ -73,7 +76,10 @@ export default function RundownDefaultSettings() {
|
||||
<Panel.Field title='Timer type' description='Default type of timer for new events' />
|
||||
<Select
|
||||
value={defaultTimerType}
|
||||
onValueChange={(value) => setDefaultTimerType(value as TimerType)}
|
||||
onValueChange={(value: TimerType | null) => {
|
||||
if (value === null) return;
|
||||
setDefaultTimerType(value);
|
||||
}}
|
||||
options={[
|
||||
{ value: TimerType.CountDown, label: 'Count down' },
|
||||
{ value: TimerType.CountUp, label: 'Count up' },
|
||||
@@ -86,7 +92,10 @@ export default function RundownDefaultSettings() {
|
||||
<Panel.Field title='End Action' description='Default end action for new events' />
|
||||
<Select
|
||||
value={defaultEndAction}
|
||||
onValueChange={(value) => setDefaultEndAction(value as EndAction)}
|
||||
onValueChange={(value: EndAction | null) => {
|
||||
if (value === null) return;
|
||||
setDefaultEndAction(value);
|
||||
}}
|
||||
options={[
|
||||
{ value: EndAction.None, label: 'None' },
|
||||
{ value: EndAction.LoadNext, label: 'Load next' },
|
||||
|
||||
+4
-3
@@ -162,9 +162,10 @@ export default function ImportMapForm({
|
||||
<Select
|
||||
id={importName as string}
|
||||
value={watch(label as keyof NamedImportMap) as string}
|
||||
onValueChange={(value: string) =>
|
||||
setValue(label as keyof NamedImportMap, value, { shouldDirty: true })
|
||||
}
|
||||
onValueChange={(value: string | null) => {
|
||||
if (value === null) return;
|
||||
setValue(label as keyof NamedImportMap, value, { shouldDirty: true });
|
||||
}}
|
||||
options={worksheetNames?.map((name) => ({ value: name, label: name })) || []}
|
||||
/>
|
||||
</td>
|
||||
|
||||
+6
-2
@@ -1,5 +1,7 @@
|
||||
import { ImportCustom, ImportMap } from 'ontime-utils';
|
||||
|
||||
import { makeStageKey } from '../../../../../../common/utils/localStorage';
|
||||
|
||||
export type NamedImportMap = typeof namedImportMap;
|
||||
|
||||
// Record of label and import name
|
||||
@@ -63,12 +65,14 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
|
||||
};
|
||||
}
|
||||
|
||||
const importMapKey = makeStageKey('import-map');
|
||||
|
||||
export function persistImportMap(options: NamedImportMap) {
|
||||
localStorage.setItem('import-options', JSON.stringify(options));
|
||||
localStorage.setItem(importMapKey, JSON.stringify(options));
|
||||
}
|
||||
|
||||
function getPersistImportMap(): unknown {
|
||||
const options = localStorage.getItem('import-options');
|
||||
const options = localStorage.getItem(importMapKey);
|
||||
if (!options) {
|
||||
throw new Error('no import options found');
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function InfoNif() {
|
||||
const handleClick = (address: string) => openLink(address);
|
||||
|
||||
return (
|
||||
<Panel.InlineElements>
|
||||
<Panel.InlineElements wrap='wrap'>
|
||||
{data.networkInterfaces.map((nif) => {
|
||||
// interfaces outside localhost wont have access
|
||||
if (nif.name === 'localhost' && !isLocalhost) return null;
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
||||
}
|
||||
|
||||
function OntimeCloudStats() {
|
||||
const { ping } = usePing();
|
||||
const ping = usePing();
|
||||
|
||||
/**
|
||||
* Send immediate ping request, and keep sending on an interval
|
||||
|
||||
@@ -7,12 +7,9 @@ import { postSettings } from '../../../../common/api/settings';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import Select from '../../../../common/components/select/Select';
|
||||
import useSettings from '../../../../common/hooks-query/useSettings';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { isOnlyNumbers } from '../../../../common/utils/regex';
|
||||
import { isOntimeCloud } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import GeneralPinInput from './composite/GeneralPinInput';
|
||||
@@ -32,7 +29,6 @@ export default function GeneralSettings() {
|
||||
} = useForm<Settings>({
|
||||
mode: 'onChange',
|
||||
defaultValues: data,
|
||||
values: data,
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
@@ -102,33 +98,6 @@ export default function GeneralSettings() {
|
||||
<Info>Changes to the time format and views language do not affect the editor view</Info>
|
||||
<Panel.Loader isLoading={isLoading} />
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Ontime server port'
|
||||
description={
|
||||
isOntimeCloud
|
||||
? 'Server port disabled for Ontime Cloud'
|
||||
: 'Port ontime server listens in. Defaults to 4001 (needs app restart)'
|
||||
}
|
||||
error={errors.serverPort?.message}
|
||||
/>
|
||||
<Input
|
||||
id='serverPort'
|
||||
type='number'
|
||||
maxLength={5}
|
||||
style={{ width: '75px' }}
|
||||
disabled={isOntimeCloud}
|
||||
{...register('serverPort', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
|
||||
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
|
||||
pattern: {
|
||||
value: isOnlyNumbers,
|
||||
message: 'Value should be numeric',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Editor pin code'
|
||||
@@ -153,7 +122,10 @@ export default function GeneralSettings() {
|
||||
/>
|
||||
<Select
|
||||
value={watch('timeFormat')}
|
||||
onValueChange={(value) => setValue('timeFormat', value as '12' | '24', { shouldDirty: true })}
|
||||
onValueChange={(value: '12' | '24' | null) => {
|
||||
if (value === null) return;
|
||||
setValue('timeFormat', value, { shouldDirty: true });
|
||||
}}
|
||||
defaultValue='24'
|
||||
options={[
|
||||
{ value: '12', label: '12 hours 11:00:10 PM' },
|
||||
@@ -169,7 +141,10 @@ export default function GeneralSettings() {
|
||||
/>
|
||||
<Select
|
||||
value={watch('language')}
|
||||
onValueChange={(value) => setValue('language', value, { shouldDirty: true })}
|
||||
onValueChange={(value: string | null) => {
|
||||
if (value === null) return;
|
||||
setValue('language', value, { shouldDirty: true });
|
||||
}}
|
||||
disabled={disableInputs}
|
||||
defaultValue='en'
|
||||
options={[
|
||||
|
||||
@@ -33,7 +33,6 @@ export default function ProjectData() {
|
||||
setValue,
|
||||
} = useForm({
|
||||
defaultValues: data,
|
||||
values: data,
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { PortInfo } from 'ontime-types';
|
||||
|
||||
import { getServerPort, postServerPort } from '../../../../common/api/settings';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { isOnlyNumbers } from '../../../../common/utils/regex';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
interface ServerPortForm {
|
||||
serverPort: number;
|
||||
}
|
||||
|
||||
export default function ServerPortSettings() {
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
setError,
|
||||
formState: { isSubmitting, isDirty, isValid, errors },
|
||||
} = useForm<ServerPortForm>({
|
||||
mode: 'onChange',
|
||||
defaultValues: { serverPort: 4001 },
|
||||
});
|
||||
|
||||
const [pendingRestart, setPendingRestart] = useState<boolean>(false);
|
||||
|
||||
const setPort = useCallback((info: PortInfo) => {
|
||||
reset({ serverPort: info.port });
|
||||
setPendingRestart(info.pendingRestart);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getServerPort()
|
||||
.then(setPort)
|
||||
.catch(() => setError('root', { message: 'Failed to load server port' }));
|
||||
}, [reset, setError, setPort]);
|
||||
|
||||
const onSubmit = async (formData: ServerPortForm) => {
|
||||
if (formData.serverPort < 1024 || formData.serverPort > 65535) {
|
||||
setError('serverPort', { message: 'Port must be within range 1024 - 65535' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await postServerPort(formData.serverPort);
|
||||
setPort(await getServerPort());
|
||||
} catch (error) {
|
||||
setError('root', { message: maybeAxiosError(error) });
|
||||
}
|
||||
};
|
||||
|
||||
const onReset = async () => {
|
||||
try {
|
||||
setPort(await getServerPort());
|
||||
} catch (error) {
|
||||
setError('root', { message: 'Failed to load server port' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Section
|
||||
as='form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
onKeyDown={(event) => preventEscape(event, onReset)}
|
||||
id='server-port-settings'
|
||||
>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Server port
|
||||
<Panel.InlineElements>
|
||||
{pendingRestart && <Tag>A port change is pending and will happen on the next restart</Tag>}
|
||||
<Button disabled={!isDirty || isSubmitting} variant='ghosted' onClick={onReset}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
form='server-port-settings'
|
||||
name='server-port-settings-submit'
|
||||
loading={isSubmitting}
|
||||
disabled={!isDirty || !isValid || isSubmitting}
|
||||
variant='primary'
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Ontime server port'
|
||||
description='Port ontime server listens in. Defaults to 4001 (needs app restart)'
|
||||
error={errors.serverPort?.message}
|
||||
/>
|
||||
<Input
|
||||
id='serverPort'
|
||||
type='number'
|
||||
maxLength={5}
|
||||
style={{ width: '75px' }}
|
||||
{...register('serverPort', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
|
||||
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
|
||||
pattern: {
|
||||
value: isOnlyNumbers,
|
||||
message: 'Value should be numeric',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { isDocker } from '../../../../externals';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import GeneralSettings from './GeneralSettings';
|
||||
import ProjectData from './ProjectData';
|
||||
import ServerPortSettings from './ServerPortSettings';
|
||||
import ViewSettings from './ViewSettings';
|
||||
|
||||
export default function SettingsPanel({ location }: PanelBaseProps) {
|
||||
const dataRef = useScrollIntoView<HTMLDivElement>('data', location);
|
||||
const generalRef = useScrollIntoView<HTMLDivElement>('general', location);
|
||||
const portRef = useScrollIntoView<HTMLDivElement>('port', location);
|
||||
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
|
||||
|
||||
return (
|
||||
@@ -23,6 +26,11 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
|
||||
<div ref={viewRef}>
|
||||
<ViewSettings />
|
||||
</div>
|
||||
{!isDocker && (
|
||||
<div ref={portRef}>
|
||||
<ServerPortSettings />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ export default function ViewSettings() {
|
||||
formState: { isSubmitting, isDirty, errors },
|
||||
} = useForm<ViewSettingsType>({
|
||||
defaultValues: data,
|
||||
values: data,
|
||||
resetOptions: {
|
||||
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 Button from '../../../../../common/components/buttons/Button';
|
||||
@@ -81,7 +81,9 @@ export default function CodeEditorModal({ isOpen, onClose }: CodeEditorModalProp
|
||||
showCloseButton
|
||||
showBackdrop
|
||||
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={
|
||||
<div className={style.column}>
|
||||
|
||||
@@ -75,7 +75,10 @@ export default function QuickStart({ isOpen, onClose }: QuickStartProps) {
|
||||
/>
|
||||
<Select
|
||||
value={watch('settings.timeFormat')}
|
||||
onValueChange={(value: '12' | '24') => setValue('settings.timeFormat', value, { shouldDirty: true })}
|
||||
onValueChange={(value: '12' | '24' | null) => {
|
||||
if (value === null) return;
|
||||
setValue('settings.timeFormat', value, { shouldDirty: true });
|
||||
}}
|
||||
defaultValue='24'
|
||||
options={[
|
||||
{ value: '12', label: '12 hours 11:00:10 PM' },
|
||||
@@ -91,7 +94,10 @@ export default function QuickStart({ isOpen, onClose }: QuickStartProps) {
|
||||
/>
|
||||
<Select
|
||||
value={watch('settings.language')}
|
||||
onValueChange={(value: string) => setValue('settings.language', value, { shouldDirty: true })}
|
||||
onValueChange={(value: string | null) => {
|
||||
if (value === null) return;
|
||||
setValue('settings.language', value, { shouldDirty: true });
|
||||
}}
|
||||
defaultValue='en'
|
||||
options={[
|
||||
{ value: 'en', label: 'English' },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import useAppVersion from '../../common/hooks-query/useAppVersion';
|
||||
import { isDocker } from '../../externals';
|
||||
|
||||
export type SettingsOption = {
|
||||
id: string;
|
||||
@@ -18,6 +19,7 @@ const staticOptions = [
|
||||
{ id: 'settings__data', label: 'Project data' },
|
||||
{ id: 'settings__general', label: 'General settings' },
|
||||
{ id: 'settings__view', label: 'View settings' },
|
||||
{ id: 'settings__port', label: 'Server Port' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -90,7 +92,10 @@ const staticOptions = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type SettingsOptionId = (typeof staticOptions)[number]['id'];
|
||||
// a child of navigation or a child of secondary navigation
|
||||
export type SettingsOptionId =
|
||||
| (typeof staticOptions)[number]['id']
|
||||
| Extract<(typeof staticOptions)[number], { secondary: object }>['secondary'][number]['id'];
|
||||
|
||||
export function useAppSettingsMenu() {
|
||||
const { data } = useAppVersion();
|
||||
@@ -99,6 +104,14 @@ export function useAppSettingsMenu() {
|
||||
() =>
|
||||
staticOptions.map((option) => ({
|
||||
...option,
|
||||
// if we are in docker don't show the port option
|
||||
secondary:
|
||||
'secondary' in option
|
||||
? isDocker && option.id === 'settings'
|
||||
? [...option.secondary.filter(({ id }) => id !== 'settings__port')]
|
||||
: [...option.secondary]
|
||||
: undefined,
|
||||
// if there is an update then highlight the about setting
|
||||
highlight: option.id === 'about' && data.hasUpdates ? 'New version available' : undefined,
|
||||
})),
|
||||
[data],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user