Compare commits

..

10 Commits

Author SHA1 Message Date
Ary 1bc6c3e852 feat: lots of renaming 2024-02-25 11:32:53 -07:00
Ary 257f2259e0 feat: starting to cleanup 2024-02-25 11:25:06 -07:00
Ary 8ae3bb2df0 feat: create works, just not ideal 2024-02-25 11:19:27 -07:00
Ary ff1496d29f feat: renaming 2024-02-24 13:53:06 -07:00
Ary 04455c08ef feat: some cleanup 2024-02-24 11:54:45 -07:00
Ary 6c86ece1ff feat: deletion 2024-02-24 11:47:30 -07:00
Ary 9a2fa527c6 feat: works, but still need adjustments 2024-02-24 11:42:04 -07:00
Ary 165920b9f2 feat: wip for form 2024-02-24 11:23:05 -07:00
Ary ee3b3c0735 feat: wip 2024-02-15 17:00:23 -07:00
Ary ecaf0a209b wip 2024-02-14 11:52:24 -07:00
395 changed files with 9965 additions and 10753 deletions
-109
View File
@@ -1,109 +0,0 @@
name: Ontime build v3
on:
# Only trigger manually for now for testing
# push:
# tags: [ "*" ]
workflow_dispatch:
jobs:
build_macos:
runs-on: macOS-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build
- name: Electron - Build app
run: pnpm dist-mac
- name: Release
uses: softprops/action-gh-release@v1
with:
files: |
./apps/electron/dist/ontime-macOS-x64.dmg
./apps/electron/dist/ontime-macOS-arm64.dmg
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build_windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build
- name: Electron - Build app
run: pnpm dist-win
- name: Release
uses: softprops/action-gh-release@v1
with:
files: './apps/electron/dist/ontime-win64.exe'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build_ubuntu:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build
- name: Electron - Build app
run: pnpm dist-linux
- name: Release
uses: softprops/action-gh-release@v1
with:
files: './apps/electron/dist/ontime-linux.AppImage'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-107
View File
@@ -1,107 +0,0 @@
name: Ontime test v3
on:
# Only trigger manually for now for testing
# pull_request:
# branches: '*'
workflow_dispatch:
jobs:
unit-test:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
CI: ''
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Run code quality per package
- name: React - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./apps/client
- name: Server - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./apps/server
- name: Utils - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./packages/utils
- name: Types - Run linter
if: always()
run: pnpm lint
working-directory: ./packages/types
# We choose to run tests separately
- name: React - Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./apps/client
- name: Server - Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./apps/server
- name: Utils - Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./packages/utils
e2e-test:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build client
run: pnpm build:local
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: pnpm e2e
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
- uses: actions/upload-artifact@v4
with:
name: automated-screenshots
path: automated-screenshots/
retention-days: 14
+1 -1
View File
@@ -1 +1 @@
v18.18.2
v16.16.0
-7
View File
@@ -97,10 +97,3 @@ Other useful commands
- __List running processes__ by running `docker ps`
- __Kill running process__ by running `docker kill <process-id>`
## General Info
# APP Building
We build the app from app.js for almost all applications. The output file will still be named index.cjs. This is because of Electron.
Building the app from index.ts only applies for applications that don't use electron. index.ts will take over the initialization of the server and UI when electron isn't present.
+11 -17
View File
@@ -1,5 +1,5 @@
[![Ontime build v2](https://github.com/cpvalente/ontime/actions/workflows/build_v2.yml/badge.svg)](https://github.com/cpvalente/ontime/actions/workflows/build_v2.yml)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-green.svg)](https://www.gnu.org/licenses/gpl-3.0)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-green.svg)](https://www.gnu.org/licenses/gpl-3.0) [![Documentation in Gitbook](https://badges.aleen42.com/src/gitbook_2.svg)](https://ontime.gitbook.io)
## Download the latest releases here
@@ -17,14 +17,13 @@ Ontime is an application for creating and managing event running order and timer
The user inputs a list of events along with scheduling and event information.
This will then populate a series of screens which are available to be rendered by any device in the Network.
This makes for a simple and cheap way to distribute over a venue using a network infrastructure instead of video outputs.
This makes for a simple and cheap way to distribute over a venue using a network infrastructure instead of video
outputs.
![App Window](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/app.png)
![Views](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/overview.png)
[Read the docs to learn more](https://docs.getontime.no)
## Using Ontime
Once installed and running, Ontime starts a background server that is the heart of all processes.
@@ -38,7 +37,7 @@ You can then use the menu in the top left corner to select the desired view.
The menu will be initially hidden until there is mouse interaction.
In the case of unattended machines or automation, it is possible to use different URL to recall
individual views and extend view settings using the URL presets feature
individual views and extend view settings using the URL aliases feature
```
For the presentation views
@@ -60,7 +59,7 @@ IP.ADDRESS:4001/editor > the control interface, same as the app
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
```
More documentation is available [in our docs](https://docs.getontime.no)
More documentation is available [in our docs](https://ontime.gitbook.io)
## Feature List (in no specific order)
@@ -85,7 +84,7 @@ More documentation is available [in our docs](https://docs.getontime.no)
- WebSockets
- [x] Roll mode: run standalone using the system clock
- [x] [Headless run](#headless-run): run server in a separate machine, configure from a browser locally
- [x] [Countdown to anything!](https://docs.getontime.no/features/count-to-anything/): have
- [x] [Countdown to anything!](https://ontime.gitbook.io/v2/views/countdown): have
a countdown to any scheduled event
- [x] Multi-platform (available on Windows, MacOS and Linux)
- [x] [Companion integration](https://bitfocus.io/connections/getontime-ontime)
@@ -121,14 +120,9 @@ Ontime broadcasts its data over WebSockets. This allows you to consume its data
Writing a new view for the browser can be done with basic knowledge of HTML + CSS + Javascript (or any other language
that can run in the browser).
<br />
We have prepared a few resources to help here:
- Shipped with Ontime there is a small clock to get you started, it is available at `http://localhost:4001/external/demo` and the [code can be found here](https://github.com/cpvalente/ontime/tree/master/apps/server/src/external/demo)
- See [this repository](https://github.com/cpvalente/ontime-viewer-template-v2) with a template on
how to get you started
- See information about the [Websocket API](https://docs.getontime.no/api/osc-and-ws/)
<br />
More information [in the docs](https://docs.getontime.no/features/custom-views/)
See [this repository](https://github.com/cpvalente/ontime-viewer-template-v2) with a small template on
how to get you started and read the docs about
the [Websocket API](https://ontime.gitbook.io/v2/control-and-feedback/ontime-apis#osc-and-websocket-api)
### Headless run
@@ -136,7 +130,7 @@ You can self-host and run Ontime in a docker image.
The docker image along with documentation is [available Docker Hub at getontime/ontime](https://hub.docker.com/r/getontime/ontime)
If you want to run this image in a Raspberry Pi, please see [the docs](https://docs.getontime.no/additional-notes/use-with-rpi/)
If you want to run this image in a Raspberry Pi, please see [the docs](https://ontime.gitbook.io/v2/additional-notes/use-in-raspberry-pi)
## Roadmap
@@ -198,7 +192,7 @@ Information about the project setup can be found in the [development documentati
# Help
Help is underway! ... and can be found [here](https://docs.getontime.no)
Help is underway! ... and can be found [here](https://ontime.gitbook.io)
# License
+1 -1
View File
@@ -29,7 +29,7 @@
"react-router-dom": "^6.3.0",
"typeface-open-sans": "^1.1.13",
"web-vitals": "^3.1.1",
"zustand": "^4.5.0"
"zustand": "^4.4.7"
},
"scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
+9 -18
View File
@@ -1,8 +1,7 @@
import { lazy, Suspense } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import Log from './features/log/Log';
import withPreset from './features/PresetWrapper';
import withAlias from './features/AliasWrapper';
import withData from './features/viewers/ViewWrapper';
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
@@ -19,14 +18,14 @@ const Public = lazy(() => import('./features/viewers/public/Public'));
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock'));
const STimer = withPreset(withData(TimerView));
const SMinimalTimer = withPreset(withData(MinimalTimerView));
const SClock = withPreset(withData(ClockView));
const SCountdown = withPreset(withData(Countdown));
const SBackstage = withPreset(withData(Backstage));
const SPublic = withPreset(withData(Public));
const SLowerThird = withPreset(withData(Lower));
const SStudio = withPreset(withData(StudioClock));
const STimer = withAlias(withData(TimerView));
const SMinimalTimer = withAlias(withData(MinimalTimerView));
const SClock = withAlias(withData(ClockView));
const SCountdown = withAlias(withData(Countdown));
const SBackstage = withAlias(withData(Backstage));
const SPublic = withAlias(withData(Public));
const SLowerThird = withAlias(withData(Lower));
const SStudio = withAlias(withData(StudioClock));
const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
@@ -85,14 +84,6 @@ export default function AppRouter() {
</EditorFeatureWrapper>
}
/>
<Route
path='/log'
element={
<EditorFeatureWrapper>
<Log />
</EditorFeatureWrapper>
}
/>
{/*/!* Send to default if nothing found *!/*/}
<Route path='*' element={<STimer />} />
</Routes>
@@ -1,7 +1,7 @@
// keys in tanstack store
// REST stuff
export const ALIASES = ['aliases'];
export const APP_INFO = ['appinfo'];
export const APP_SETTINGS = ['appSettings'];
export const CUSTOM_FIELDS = ['customFields'];
export const HTTP_SETTINGS = ['httpSettings'];
export const OSC_SETTINGS = ['oscSettings'];
export const PROJECT_DATA = ['project'];
@@ -9,23 +9,19 @@ export const PROJECT_LIST = ['projectList'];
export const RUNDOWN = ['rundown'];
export const RUNTIME = ['runtimeStore'];
export const SHEET_STATE = ['sheetState'];
export const URL_PRESETS = ['urlpresets'];
export const USERFIELDS = ['userFields'];
export const VIEW_SETTINGS = ['viewSettings'];
// resolve location
const location = window.location;
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
export const isProduction = import.meta.env.MODE === 'production';
export const isDev = !isProduction;
// resolve port
const STATIC_PORT = 4001;
export const serverPort = isProduction ? location.port : STATIC_PORT;
export const serverURL = `${location.protocol}//${location.hostname}:${serverPort}`;
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
export const apiEntryUrl = `${serverURL}/data`;
export const projectDataURL = `${serverURL}/project`;
export const rundownURL = `${serverURL}/events`;
export const ontimeURL = `${serverURL}/ontime`;
+46
View File
@@ -0,0 +1,46 @@
import axios, { AxiosError } from 'axios';
import { LogLevel } from 'ontime-types';
import { generateId, millisToString } from 'ontime-utils';
import { ontimeQueryClient } from '../queryClient';
import { addLog } from '../stores/logger';
import { nowInMillis } from '../utils/time';
export function maybeAxiosError(error: unknown) {
if (axios.isAxiosError(error)) {
const statusText = (error as AxiosError).response?.statusText ?? '';
let data = (error as AxiosError).response?.data ?? '';
if (typeof data === 'object') {
if ('message' in data) {
data = JSON.stringify(data.message);
} else {
data = JSON.stringify(data);
}
}
return `${statusText}: ${data}`;
} else {
if (typeof error !== 'string') {
return JSON.stringify(error);
}
return error;
}
}
export function logAxiosError(prepend: string, error: unknown) {
const message = `${prepend}: ${maybeAxiosError(error)}`;
addLog({
id: generateId(),
origin: 'SERVER',
time: millisToString(nowInMillis()),
level: LogLevel.Error,
text: message,
});
}
/**
* Utility function invalidates react-query caches
*/
export async function invalidateAllCaches() {
await ontimeQueryClient.invalidateQueries();
}
@@ -1,38 +0,0 @@
import axios from 'axios';
import { CustomField, CustomFieldLabel, CustomFields } from 'ontime-types';
import { apiEntryUrl } from './constants';
const customFieldsPath = `${apiEntryUrl}/custom-fields`;
/**
* Requests list of known custom fields
*/
export async function getCustomFields(): Promise<CustomFields> {
const res = await axios.get(customFieldsPath);
return res.data;
}
/**
* Sets list of known custom fields
*/
export async function postCustomField(newField: CustomField): Promise<CustomFields> {
const res = await axios.post(customFieldsPath, { ...newField });
return res.data;
}
/**
* Edits single custom field
*/
export async function editCustomField(label: CustomFieldLabel, newField: CustomField): Promise<CustomFields> {
const res = await axios.put(`${customFieldsPath}/${label}`, { ...newField });
return res.data;
}
/**
* Deletes single custom field
*/
export async function deleteCustomField(label: CustomFieldLabel): Promise<CustomFields> {
const res = await axios.delete(`${customFieldsPath}/${label}`);
return res.data;
}
-155
View File
@@ -1,155 +0,0 @@
import axios, { AxiosResponse } from 'axios';
import {
CustomFields,
DatabaseModel,
GetInfo,
MessageResponse,
OntimeRundown,
ProjectData,
ProjectFileListResponse,
} from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
import fileDownload from './utils';
const dbPath = `${apiEntryUrl}/db`;
/**
* HTTP request to download db in JSON format
*/
export async function downloadRundown(fileName?: string) {
return fileDownload(
dbPath,
{ name: fileName ?? 'rundown', type: 'json' },
{ type: 'application/json;charset=utf-8;' },
);
}
/**
* HTTP request to download db in CSV format
*/
export async function downloadCSV(fileName?: string) {
return fileDownload(dbPath, { name: fileName ?? 'rundown', type: 'csv' }, { type: 'text/csv;charset=utf-8;' });
}
/**
* HTTP request to upload project file
*/
export async function uploadProjectFile(file: File): Promise<MessageResponse> {
const formData = new FormData();
formData.append('project', file);
const response = await axios.post(`${dbPath}/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
}
/**
* Make patch changes to the objects in the db
*/
export async function patchData(patchDb: Partial<DatabaseModel>): Promise<AxiosResponse<DatabaseModel>> {
return await axios.patch(dbPath, patchDb);
}
/**
* HTTP request to create a project file
*/
export async function createProject(
project: Partial<
ProjectData & {
filename: string;
}
>,
): Promise<MessageResponse> {
const res = await axios.post(`${dbPath}/new`, project);
return res.data;
}
/**
* HTTP request to get the list of available project files
*/
export async function getProjects(): Promise<ProjectFileListResponse> {
const res = await axios.get(`${dbPath}/all`);
return res.data;
}
/**
* HTTP request to load a project file
*/
export async function loadProject(filename: string): Promise<MessageResponse> {
const res = await axios.post(`${dbPath}/load`, {
filename,
});
return res.data;
}
/**
* HTTP request to duplicate a project file
*/
export async function duplicateProject(filename: string, newFilename: string): Promise<MessageResponse> {
const url = `${dbPath}/${filename}/duplicate`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.post(decodedUrl, {
newFilename,
});
return res.data;
}
/**
* HTTP request to rename a project file
*/
export async function renameProject(filename: string, newFilename: string): Promise<MessageResponse> {
const url = `${dbPath}/${filename}/rename`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.put(decodedUrl, {
newFilename,
});
return res.data;
}
/**
* HTTP request to delete a project file
*/
export async function deleteProject(filename: string): Promise<MessageResponse> {
const url = `${dbPath}/${filename}`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.delete(decodedUrl);
return res.data;
}
/**
* HTTP request to retrieve application info
*/
export async function getInfo(): Promise<GetInfo> {
const res = await axios.get(`${dbPath}/info`);
return res.data;
}
type PreviewSpreadsheetResponse = {
rundown: OntimeRundown;
customFields: CustomFields;
};
/**
* Make patch changes to the objects in the db
*/
export async function importSpreadsheetPreview(file: File, options: ImportMap): Promise<PreviewSpreadsheetResponse> {
const formData = new FormData();
formData.append('spreadsheet', file);
formData.append('options', JSON.stringify(options));
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(
`${dbPath}/spreadsheet/preview`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
},
);
return response.data;
}
+103
View File
@@ -0,0 +1,103 @@
import axios from 'axios';
import { OntimeEvent, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { rundownURL } from './apiConstants';
/**
* @description HTTP request to fetch all events
* @return {Promise}
*/
export async function fetchCachedRundown(): Promise<RundownCached> {
const res = await axios.get(`${rundownURL}/cached`);
return res.data;
}
/**
* @deprecated use fetchCachedRundown instead
* @description HTTP request to fetch all events
* @return {Promise}
*/
export async function fetchRundown(): Promise<OntimeRundown> {
const res = await axios.get(rundownURL);
return res.data;
}
/**
* @description HTTP request to post new event
* @return {Promise}
*/
export async function requestPostEvent(data: Partial<OntimeRundownEntry>) {
return axios.post(rundownURL, data);
}
/**
* @description HTTP request to put new event
* @return {Promise}
*/
export async function requestPutEvent(data: Partial<OntimeRundownEntry>) {
return axios.put(rundownURL, data);
}
type BatchEditEntry = {
data: Partial<OntimeEvent>;
ids: string[];
};
/**
* @description HTTP request to put multiple events
* @returns {Promise}
*/
export async function requestBatchPutEvents(data: BatchEditEntry) {
return axios.put(`${rundownURL}/batchEdit`, data);
}
export type ReorderEntry = {
eventId: string;
from: number;
to: number;
};
/**
* @description HTTP request to reorder events
* @return {Promise}
*/
export async function requestReorderEvent(data: ReorderEntry) {
return axios.patch(`${rundownURL}/reorder`, data);
}
/**
* @description HTTP request to request application of delay
* @return {Promise}
*/
export async function requestApplyDelay(eventId: string) {
return axios.patch(`${rundownURL}/applydelay/${eventId}`);
}
export type SwapEntry = {
from: string;
to: string;
};
/**
* @description HTTP request to swap two events
* @return {Promise}
*/
export async function requestEventSwap(data: SwapEntry) {
return axios.patch(`${rundownURL}/swap`, data);
}
/**
* @description HTTP request to delete given event
* @return {Promise}
*/
export async function requestDelete(eventId: string) {
return axios.delete(`${rundownURL}/${eventId}`);
}
/**
* @description HTTP request to delete all events
* @return {Promise}
*/
export async function requestDeleteAll() {
return axios.delete(`${rundownURL}/all`);
}
-19
View File
@@ -1,19 +0,0 @@
import axios from 'axios';
import { apiRepoLatest } from '../../externals';
export type HasUpdate = {
url: string;
version: string;
};
/**
* HTTP request to get the latest version and url from github
*/
export async function getLatestVersion(): Promise<HasUpdate> {
const res = await axios.get(`${apiRepoLatest}`);
return {
url: res.data.html_url as string,
version: res.data.tag_name as string,
};
}
-21
View File
@@ -1,21 +0,0 @@
import axios, { AxiosResponse } from 'axios';
import { HttpSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const httpPath = `${apiEntryUrl}/http`;
/**
* HTTP request to retrieve http settings
*/
export async function getHTTP(): Promise<HttpSettings> {
const res = await axios.get(httpPath);
return res.data;
}
/**
* HTTP request to mutate http settings
*/
export async function postHTTP(data: HttpSettings): Promise<AxiosResponse<HttpSettings>> {
return axios.post(httpPath, data);
}
+396
View File
@@ -0,0 +1,396 @@
import axios, { AxiosResponse } from 'axios';
import {
Alias,
DatabaseModel,
GetInfo,
HttpSettings,
MessageResponse,
OntimeRundown,
OSCSettings,
ProjectData,
ProjectFileListResponse,
Settings,
UserFields,
ViewSettings,
} from 'ontime-types';
import { ExcelImportMap } from 'ontime-utils';
import { apiRepoLatest } from '../../externals';
import fileDownload from '../utils/fileDownload';
import { ontimeURL } from './apiConstants';
/**
* @description HTTP request to retrieve application settings
* @return {Promise}
*/
export async function getSettings(): Promise<Settings> {
const res = await axios.get(`${ontimeURL}/settings`);
return res.data;
}
/**
* @description HTTP request to mutate application settings
* @return {Promise}
*/
export async function postSettings(data: Settings) {
return axios.post(`${ontimeURL}/settings`, data);
}
/**
* @description HTTP request to retrieve application info
* @return {Promise}
*/
export async function getInfo(): Promise<GetInfo> {
const res = await axios.get(`${ontimeURL}/info`);
return res.data;
}
/**
* @description HTTP request to retrieve view settings
* @return {Promise}
*/
export async function getView(): Promise<ViewSettings> {
const res = await axios.get(`${ontimeURL}/views`);
return res.data;
}
/**
* @description HTTP request to mutate view settings
* @return {Promise}
*/
export async function postViewSettings(data: ViewSettings) {
return axios.post(`${ontimeURL}/views`, data);
}
/**
* @description HTTP request to retrieve aliases
* @return {Promise}
*/
export async function getAliases(): Promise<Alias[]> {
const res = await axios.get(`${ontimeURL}/aliases`);
return res.data;
}
/**
* @description HTTP request to create an alias
* @return {Promise}
*/
export async function postAlias(data: Alias) {
return axios.post(`${ontimeURL}/aliases`, data);
}
/**
* @description HTTP request to update aliases
* @return {Promise}
*/
export async function updateAliases(data: Partial<Alias>) {
return axios.put(`${ontimeURL}/aliases`, data);
}
/**
* @description HTTP request to delete alias
* @return {Promise}
*/
export async function deleteAlias(alias: string) {
return axios.delete(`${ontimeURL}/aliases/${alias}`);
}
/**
* @description HTTP request to retrieve user fields
* @return {Promise}
*/
export async function getUserFields(): Promise<UserFields> {
const res = await axios.get(`${ontimeURL}/userfields`);
return res.data;
}
/**
* @description HTTP request to mutate user fields
* @return {Promise}
*/
export async function postUserFields(data: UserFields) {
return axios.post(`${ontimeURL}/userfields`, data);
}
/**
* @description HTTP request to retrieve osc settings
* @return {Promise}
*/
export async function getOSC(): Promise<OSCSettings> {
const res = await axios.get(`${ontimeURL}/osc`);
return res.data;
}
/**
* @description HTTP request to mutate osc settings
* @return {Promise}
*/
export async function postOSC(data: OSCSettings): Promise<AxiosResponse<OSCSettings>> {
return axios.post(`${ontimeURL}/osc`, data);
}
/**
* @description HTTP request to retrieve http settings
* @return {Promise}
*/
export async function getHTTP(): Promise<HttpSettings> {
const res = await axios.get(`${ontimeURL}/http`);
return res.data;
}
/**
* @description HTTP request to mutate http settings
* @return {Promise}
*/
export async function postHTTP(data: HttpSettings): Promise<AxiosResponse<HttpSettings>> {
return axios.post(`${ontimeURL}/http`, data);
}
/**
* @description HTTP request to download db in CSV format
*/
export const downloadCSV = (fileName?: string) => {
return fileDownload(ontimeURL, { name: fileName ?? 'rundown', type: 'csv' }, { type: 'text/csv;charset=utf-8;' });
};
/**
* @description HTTP request to download db in JSON format
*/
export const downloadRundown = (fileName?: string) => {
return fileDownload(
ontimeURL,
{ name: fileName ?? 'rundown', type: 'json' },
{ type: 'application/json;charset=utf-8;' },
);
};
// TODO: should this be extracted to shared code?
export type ProjectFileImportOptions = {
onlyRundown: boolean;
};
/**
* @description HTTP request to upload events db
* @return {Promise}
*/
export const uploadProjectFile = async (
file: File,
setProgress: (value: number) => void,
options?: Partial<ProjectFileImportOptions>,
) => {
const formData = new FormData();
formData.append('userFile', file);
const onlyRundown = Boolean(options?.onlyRundown);
await axios
.post(`${ontimeURL}/db?onlyRundown=${onlyRundown}`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
const complete = progressEvent?.total ? Math.round((progressEvent.loaded * 100) / progressEvent.total) : 0;
setProgress(complete);
},
})
.then((response) => response.data.id);
};
/**
* @description Make patch changes to the objects in the db
* @return {Promise}
*/
export async function patchData(patchDb: Partial<DatabaseModel>) {
const response = await axios.patch(`${ontimeURL}/db`, patchDb);
return response;
}
type PostPreviewExcelResponse = {
rundown: OntimeRundown;
userFields: UserFields;
};
/**
* @description Make patch changes to the objects in the db
* @return {Promise} - returns parsed rundown and userfields
*/
export async function postPreviewExcel(file: File, setProgress: (value: number) => void, options?: ExcelImportMap) {
const formData = new FormData();
formData.append('userFile', file);
formData.append('options', JSON.stringify(options));
const response: AxiosResponse<PostPreviewExcelResponse> = await axios.post(
`${ontimeURL}/preview-spreadsheet`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
const complete = progressEvent?.total ? Math.round((progressEvent.loaded * 100) / progressEvent.total) : 0;
setProgress(complete);
},
},
);
return response;
}
export type HasUpdate = {
url: string;
version: string;
};
/**
* @description HTTP request to get the latest version and url from github
* @return {Promise}
*/
export async function getLatestVersion(): Promise<HasUpdate> {
const res = await axios.get(`${apiRepoLatest}`);
return {
url: res.data.html_url as string,
version: res.data.tag_name as string,
};
}
/**
* @description HTTP request to get the list of available project files
*/
export async function getProjects(): Promise<ProjectFileListResponse> {
const res = await axios.get(`${ontimeURL}/projects`);
return res.data;
}
/**
* @description HTTP request to load a project file
*/
export async function loadProject(filename: string): Promise<MessageResponse> {
const res = await axios.post(`${ontimeURL}/load-project`, {
filename,
});
return res.data;
}
/**
* @description STEP 1
*/
export const uploadSheetClientFile = async (file: File) => {
const formData = new FormData();
formData.append('userFile', file);
const res = await axios
.post(`${ontimeURL}/sheet/clientsecret`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
.then((response) => response.data.id);
return res;
};
/**
* @description STEP 1 test
*/
export const getClientSecrect = async () => {
const response = await axios.get(`${ontimeURL}/sheet/clientsecret`);
return response.data;
};
/**
* @description STEP 2
*/
export const getSheetsAuthUrl = async () => {
const response = await axios.get(`${ontimeURL}/sheet/authentication/url`);
return response.data;
};
/**
* @description STEP 2 test
*/
export const getAuthentication = async () => {
const response = await axios.get(`${ontimeURL}/sheet/authentication`);
return response.data;
};
/**
* @description STEP 3
* @returns worksheetOptions
*/
export const postId = async (id: string) => {
const response = await axios.post(`${ontimeURL}/sheet/id`, { id });
return response.data;
};
/**
* @description STEP 4
*/
export const postWorksheet = async (id: string, worksheet: string) => {
const response = await axios.post(`${ontimeURL}/sheet/worksheet`, { id, worksheet });
return response.data;
};
/**
* @description STEP 5
*/
export const postPreviewSheet = async (id: string, options: ExcelImportMap) => {
const response = await axios.post(`${ontimeURL}/sheet/pull`, { id, options });
return response.data.data;
};
/**
* @description STEP 5
*/
export const postPushSheet = async (id: string, options: ExcelImportMap) => {
const response = await axios.post(`${ontimeURL}/sheet-push`, { id, options });
return response.data.data;
};
/**
* @description HTTP request to rename a project file
*/
export async function renameProject(filename: string, newFilename: string): Promise<MessageResponse> {
const url = `${ontimeURL}/project/${filename}/rename`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.put(decodedUrl, {
newFilename,
});
return res.data;
}
/**
* @description HTTP request to duplicate a project file
*/
export async function duplicateProject(filename: string, newFilename: string): Promise<MessageResponse> {
const url = `${ontimeURL}/project/${filename}/duplicate`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.post(decodedUrl, {
newFilename,
});
return res.data;
}
/**
* @description HTTP request to delete a project file
*/
export async function deleteProject(filename: string): Promise<MessageResponse> {
const url = `${ontimeURL}/project/${filename}`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.delete(decodedUrl);
return res.data;
}
/**
* @description HTTP request to create a project file
*/
export async function createProject(
project: Partial<
ProjectData & {
filename: string;
}
>,
): Promise<MessageResponse> {
const url = `${ontimeURL}/project`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.post(decodedUrl, project);
return res.data;
}
-21
View File
@@ -1,21 +0,0 @@
import axios, { AxiosResponse } from 'axios';
import { OSCSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const oscPath = `${apiEntryUrl}/osc`;
/**
* HTTP request to retrieve osc settings
*/
export async function getOSC(): Promise<OSCSettings> {
const res = await axios.get(oscPath);
return res.data;
}
/**
* HTTP request to mutate osc settings
*/
export async function postOSC(data: OSCSettings): Promise<AxiosResponse<OSCSettings>> {
return axios.post(oscPath, data);
}
-21
View File
@@ -1,21 +0,0 @@
import axios, { AxiosResponse } from 'axios';
import { ProjectData } from 'ontime-types';
import { apiEntryUrl } from './constants';
const projectPath = `${apiEntryUrl}/project`;
/**
* HTTP request to fetch project data
*/
export async function getProjectData(): Promise<ProjectData> {
const res = await axios.get(projectPath);
return res.data;
}
/**
* HTTP request to mutate project data
*/
export async function postProjectData(data: ProjectData): Promise<AxiosResponse<ProjectData>> {
return axios.post(projectPath, data);
}
@@ -0,0 +1,21 @@
import axios from 'axios';
import { ProjectData } from 'ontime-types';
import { projectDataURL } from './apiConstants';
/**
* @description HTTP request to fetch project data
* @return {Promise}
*/
export async function getProjectData(): Promise<ProjectData> {
const res = await axios.get(projectDataURL);
return res.data;
}
/**
* @description HTTP request to mutate project data
* @return {Promise}
*/
export async function postProjectData(data: ProjectData) {
return axios.post(projectDataURL, data);
}
-86
View File
@@ -1,86 +0,0 @@
import axios, { AxiosResponse } from 'axios';
import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { apiEntryUrl } from './constants';
const rundownPath = `${apiEntryUrl}/rundown`;
/**
* HTTP request to fetch all events
*/
export async function fetchNormalisedRundown(): Promise<RundownCached> {
const res = await axios.get(`${rundownPath}/normalised`);
return res.data;
}
/**
* HTTP request to post new event
*/
export async function requestPostEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> {
return axios.post(rundownPath, data);
}
/**
* HTTP request to put new event
*/
export async function requestPutEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> {
return axios.put(rundownPath, data);
}
type BatchEditEntry = {
data: Partial<OntimeEvent>;
ids: string[];
};
/**
* HTTP request to put multiple events
*/
export async function requestBatchPutEvents(data: BatchEditEntry): Promise<AxiosResponse<MessageResponse>> {
return axios.put(`${rundownPath}/batch`, data);
}
export type ReorderEntry = {
eventId: string;
from: number;
to: number;
};
/**
* HTTP request to reorder events
*/
export async function requestReorderEvent(data: ReorderEntry): Promise<AxiosResponse<OntimeRundownEntry>> {
return axios.patch(`${rundownPath}/reorder`, data);
}
export type SwapEntry = {
from: string;
to: string;
};
/**
* HTTP request to swap two events
*/
export async function requestEventSwap(data: SwapEntry): Promise<AxiosResponse<MessageResponse>> {
return axios.patch(`${rundownPath}/swap`, data);
}
/**
* HTTP request to request application of delay
*/
export async function requestApplyDelay(eventId: string): Promise<AxiosResponse<MessageResponse>> {
return axios.patch(`${rundownPath}/applydelay/${eventId}`);
}
/**
* HTTP request to delete given event
*/
export async function requestDelete(eventId: string): Promise<AxiosResponse<MessageResponse>> {
return axios.delete(`${rundownPath}/${eventId}`);
}
/**
* HTTP request to delete all events
*/
export async function requestDeleteAll(): Promise<AxiosResponse<MessageResponse>> {
return axios.delete(`${rundownPath}/all`);
}
-21
View File
@@ -1,21 +0,0 @@
import axios, { AxiosResponse } from 'axios';
import { Settings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const settingsPath = `${apiEntryUrl}/settings`;
/**
* HTTP request to retrieve application settings
*/
export async function getSettings(): Promise<Settings> {
const res = await axios.get(settingsPath);
return res.data;
}
/**
* HTTP request to mutate application settings
*/
export async function postSettings(data: Settings): Promise<AxiosResponse<Settings>> {
return axios.post(settingsPath, data);
}
-67
View File
@@ -1,67 +0,0 @@
import axios from 'axios';
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
const sheetsPath = `${apiEntryUrl}/sheets`;
/**
* HTTP request to verify whether we are authenticated with Google Sheet service
*/
export const verifyAuthenticationStatus = async (): Promise<{ authenticated: AuthenticationStatus }> => {
const response = await axios.get(`${sheetsPath}/connect`);
return response.data;
};
/**
* HTTP request to initiate the authentication service with google
*/
export const requestConnection = async (
file: File,
sheetId: string,
): Promise<{
verification_url: string;
user_code: string;
}> => {
const formData = new FormData();
formData.append('client_secret', file);
const response = await axios.post(`${sheetsPath}/${sheetId}/connect`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
};
/**
* HTTP request to revoke authentication to google sheet
*/
export const revokeAuthentication = async (): Promise<{ authenticated: AuthenticationStatus }> => {
const response = await axios.post(`${sheetsPath}/revoke`);
return response.data;
};
/**
* HTTP request to upload preview the contents of a google sheet as rundown
*/
export const previewRundown = async (
sheetId: string,
options: ImportMap,
): Promise<{
rundown: OntimeRundown;
customFields: CustomFields;
}> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
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 });
return response.data;
};
-21
View File
@@ -1,21 +0,0 @@
import axios from 'axios';
import { URLPreset } from 'ontime-types';
import { apiEntryUrl } from './constants';
const urlPresetsPath = `${apiEntryUrl}/url-presets`;
/**
* HTTP request to retrieve aliases
*/
export async function getUrlPresets(): Promise<URLPreset[]> {
const res = await axios.get(urlPresetsPath);
return res.data;
}
/**
* HTTP request to mutate aliases
*/
export async function postUrlPresets(data: URLPreset[]): Promise<URLPreset[]> {
return axios.post(urlPresetsPath, data);
}
-118
View File
@@ -1,118 +0,0 @@
import axios, { AxiosError } from 'axios';
import { LogLevel } from 'ontime-types';
import { generateId, millisToString } from 'ontime-utils';
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
import { ontimeQueryClient } from '../queryClient';
import { addLog } from '../stores/logger';
import { nowInMillis } from '../utils/time';
/**
* Utility unrwap a potential axios error
* @param error
* @returns
*/
export function maybeAxiosError(error: unknown) {
if (axios.isAxiosError(error)) {
const statusText = (error as AxiosError).response?.statusText ?? '';
let data = (error as AxiosError).response?.data ?? '';
if (typeof data === 'object') {
if ('message' in data) {
data = JSON.stringify(data.message);
} else {
data = JSON.stringify(data);
}
}
return `${statusText}: ${data}`;
} else {
if (typeof error !== 'string') {
return JSON.stringify(error);
}
return error;
}
}
/**
* Utility unrwaps a potential axios error and sends to logger
* @param prepend
* @param error
*/
export function logAxiosError(prepend: string, error: unknown) {
const message = `${prepend}: ${maybeAxiosError(error)}`;
addLog({
id: generateId(),
origin: 'SERVER',
time: millisToString(nowInMillis()),
level: LogLevel.Error,
text: message,
});
}
/**
* Utility function invalidates react-query caches
*/
export async function invalidateAllCaches() {
await ontimeQueryClient.invalidateQueries();
}
type FileOptions = {
name: string;
type: string;
};
type BlobOptions = {
type: string;
};
/**
* Gets DB from backend and prepares a file to be downloaded
* @param url
* @param fileOptions
* @param blobOptions
* @returns
*/
export default async function fileDownload(url: string, fileOptions: FileOptions, blobOptions: BlobOptions) {
const response = await axios({
url: `${url}/db`,
method: 'GET',
});
const headerLine = response.headers['Content-Disposition'];
let { name: fileName } = fileOptions;
const { type: fileType } = fileOptions;
const { project, rundown, customFields } = response.data;
// try and get the filename from the response
if (headerLine != null) {
const startFileNameIndex = headerLine.indexOf('"') + 1;
const endFileNameIndex = headerLine.lastIndexOf('"');
fileName = headerLine.substring(startFileNameIndex, endFileNameIndex);
}
let fileContent = '';
if (fileType === 'json') {
fileContent = JSON.stringify(response.data);
fileName += '.json';
}
if (fileType === 'csv') {
const sheetData = makeTable(project, rundown, customFields);
fileContent = makeCSV(sheetData);
fileName += '.csv';
}
const blob = new Blob([fileContent], { type: blobOptions.type });
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', downloadUrl);
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
// Clean up the URL.createObjectURL to release resources
URL.revokeObjectURL(downloadUrl);
return;
}
@@ -1,21 +0,0 @@
import axios from 'axios';
import { ViewSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const viewSettingsPath = `${apiEntryUrl}/view-settings`;
/**
* HTTP request to retrieve view settings
*/
export async function getView(): Promise<ViewSettings> {
const res = await axios.get(viewSettingsPath);
return res.data;
}
/**
* HTTP request to mutate view settings
*/
export async function postViewSettings(data: ViewSettings) {
return axios.post(viewSettingsPath, data);
}
@@ -68,7 +68,7 @@ export const ContextMenu = ({ children }: ContextMenuProps) => {
<>
{children}
<div className={style.contextMenuBackdrop} />
<Menu isOpen size='sm' gutter={0} onClose={onClose} isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
<Menu isOpen gutter={0} onClose={onClose} isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
<MenuButton
className={style.contextMenuButton}
aria-hidden
@@ -10,28 +10,20 @@ interface CopyTagProps {
label: string;
className?: string;
size?: Size;
disabled?: boolean;
}
export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
const { label, className, size = 'xs', disabled, children } = props;
const { label, className, size = 'xs', children } = props;
const handleClick = () => copyToClipboard(children as string);
return (
<Tooltip label={label} openDelay={tooltipDelayFast}>
<ButtonGroup size={size} isAttached className={className}>
<Button variant='ontime-subtle' tabIndex={-1} isDisabled={disabled}>
<Button variant='ontime-subtle' tabIndex={-1}>
{children}
</Button>
<IconButton
aria-label={label}
icon={<IoCopy />}
variant='ontime-filled'
tabIndex={-1}
onClick={handleClick}
isDisabled={disabled}
/>
<IconButton aria-label={label} icon={<IoCopy />} variant='ontime-filled' tabIndex={-1} onClick={handleClick} />
</ButtonGroup>
</Tooltip>
);
@@ -17,7 +17,7 @@ export default function DelayIndicator(props: DelayIndicatorProps) {
if (typeof delayValue === 'number') {
if (delayValue < 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue, true)}>
<span className={style.delaySymbol}>
<IoChevronDown />
</span>
@@ -27,7 +27,7 @@ export default function DelayIndicator(props: DelayIndicatorProps) {
if (delayValue > 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue, true)}>
<span className={style.delaySymbol}>
<IoChevronUp />
</span>
@@ -6,7 +6,6 @@
color: $blue-500;
transition-property: color;
transition-duration: $transition-time-action;
width: fit-content;
&.inline {
display: inline-flex;
@@ -6,24 +6,21 @@ import style from './SwatchSelect.module.scss';
interface SwatchProps {
color: string;
onClick?: (color: string) => void;
onClick: (color: string) => void;
isSelected?: boolean;
}
export default function Swatch(props: SwatchProps) {
const { color, isSelected, onClick } = props;
const handleClick = () => {
onClick?.(color);
};
const classes = cx([style.swatch, isSelected ? style.selected : null, onClick ? style.selectable : null]);
const classes = cx([style.swatch, isSelected ? style.selected : null]);
if (!color) {
return (
<div className={`${classes} ${style.center}`} onClick={handleClick}>
<div className={`${classes} ${style.center}`} onClick={() => onClick('')}>
<IoBan />
</div>
);
}
return <div className={classes} style={{ backgroundColor: `${color}` }} onClick={handleClick} />;
return <div className={classes} style={{ backgroundColor: `${color}` }} onClick={() => onClick(color)} />;
}
@@ -5,6 +5,7 @@
}
.swatch {
cursor: pointer;
width: 2rem;
height: 2rem;
aspect-ratio: 1;
@@ -14,10 +15,6 @@
&.selected {
border: 2px solid $blue-500;
}
&.selectable {
cursor: pointer;
}
}
.center {
@@ -21,7 +21,6 @@ export default function DelayInput(props: DelayInputProps) {
// avoid wrong submit on cancel
let ignoreChange = false;
// set internal value on duration change
useEffect(() => {
if (typeof duration === 'undefined') {
return;
@@ -91,6 +91,9 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
inputRef.current?.blur();
validateAndSubmit((event.target as HTMLInputElement).value);
} else if (event.key === 'Tab') {
validateAndSubmit((event.target as HTMLInputElement).value);
}
if (event.key === 'Escape') {
ignoreChange.current = true;
@@ -98,7 +101,7 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
resetValue();
}
},
[resetValue],
[resetValue, validateAndSubmit],
);
const onBlurHandler = useCallback(
@@ -1,6 +1,7 @@
import { PropsWithChildren } from 'react';
import { InputGroup } from '@chakra-ui/react';
import { InputGroup, Tooltip } from '@chakra-ui/react';
import { tooltipDelayFast } from '../../../../ontimeConfig';
import { cx } from '../../../utils/styleUtils';
import TimeInput from './TimeInput';
@@ -23,14 +24,16 @@ export default function TimeInputWithButton<T extends string>(props: PropsWithCh
return (
<InputGroup size='sm' className={inputClasses} width='fit-content'>
<TimeInput<T>
name={name}
submitHandler={submitHandler}
time={time}
placeholder={placeholder}
className={style.inputField}
disabled={disabled}
/>
<Tooltip label={placeholder} openDelay={tooltipDelayFast} variant='ontime-ondark'>
<TimeInput<T>
name={name}
submitHandler={submitHandler}
time={time}
placeholder={placeholder}
className={style.inputField}
disabled={disabled}
/>
</Tooltip>
{children}
</InputGroup>
);
@@ -1,9 +0,0 @@
import style from './LoaderOverlay.module.scss';
export default function LoaderOverlay() {
return (
<div className={style.overlay}>
<span className={style.loader} />
</div>
);
}
@@ -31,12 +31,6 @@
text-overflow: ellipsis;
}
.entry-secondary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:not(:last-child) {
padding-bottom: 8px;
}
@@ -74,3 +68,4 @@
}
}
}
@@ -2,8 +2,8 @@ import { createContext, PropsWithChildren, useContext, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { OntimeEvent } from 'ontime-types';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import { useInterval } from '../../hooks/useInterval';
import { isStringBoolean } from '../../utils/viewUtils';
interface ScheduleContextState {
events: OntimeEvent[];
@@ -13,13 +13,14 @@ interface ScheduleItemProps {
timeStart: number;
timeEnd: number;
title: string;
presenter?: string;
backstageEvent: boolean;
colour: string;
skip: boolean;
}
export default function ScheduleItem(props: ScheduleItemProps) {
const { selected, timeStart, timeEnd, title, backstageEvent, colour, skip } = props;
const { selected, timeStart, timeEnd, title, presenter, backstageEvent, colour, skip } = props;
const start = formatTime(timeStart, formatOptions);
const end = formatTime(timeEnd, formatOptions);
@@ -38,6 +39,7 @@ export default function ScheduleItem(props: ScheduleItemProps) {
</div>
</div>
<div className='entry-title'>{title}</div>
{presenter && <div className='entry-presenter'>{presenter}</div>}
</li>
);
}
@@ -1,7 +1,7 @@
.emptyContainer {
width: 100%;
text-align: center;
color: $white-10;
color: $gray-1350;
.empty {
width: 100%;
@@ -5,7 +5,7 @@ import EmptyImage from '../../../assets/images/empty.svg?react';
import style from './Empty.module.scss';
interface EmptyProps {
text?: string;
text: string;
style?: CSSProperties;
}
@@ -14,7 +14,7 @@ export default function Empty(props: EmptyProps) {
return (
<div className={style.emptyContainer} {...rest}>
<EmptyImage className={style.empty} />
{text && <span className={style.text}>{text}</span>}
<span className={style.text}>{text}</span>
</div>
);
}
@@ -3,38 +3,34 @@
.title-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
gap: 8px;
.inline {
display: flex;
}
.inline {
display: flex;
}
.title-card__title {
font-weight: 600;
font-size: clamp(32px, 3.5vw, 50px);
color: var(--color-override, $viewer-color);
line-height: 1.1em;
}
.title {
font-weight: 600;
font-size: clamp(32px, 3.5vw, 50px);
color: var(--color-override, $viewer-color);
line-height: 1.1em;
}
.title-card__label {
font-size: clamp(1rem, 1.5vw, 1.5rem);
font-weight: 400;
color: var(--secondary-color-override, $viewer-secondary-color);
margin-left: auto;
text-transform: uppercase;
.subtitle, .presenter {
font-size: clamp(24px, 2vw, 35px);
color: var(--secondary-color-override, $viewer-secondary-color);
line-height: 1.1em;
}
&--accent {
color: var(--accent-color-override, $accent-color);
}
}
.title-card__secondary {
font-size: clamp(1.5rem, 2vw, 2.25rem);
color: var(--secondary-color-override, $viewer-secondary-color);
line-height: 1.1em;
&::after {
content: '\200b';
.label {
font-size: clamp(16px, 1.5vw, 24px);
font-weight: 400;
color: var(--secondary-color-override, $viewer-secondary-color);
margin-left: auto;
text-transform: uppercase;
&.accent {
color: var(--accent-color-override, $accent-color);
}
}
}
@@ -4,12 +4,13 @@ import './TitleCard.scss';
interface TitleCardProps {
label: 'now' | 'next';
title: string;
secondary?: string;
title: string | null;
subtitle: string | null;
presenter: string | null;
}
export default function TitleCard(props: TitleCardProps) {
const { label, title, secondary } = props;
const { label, title, subtitle, presenter } = props;
const { getLocalizedString } = useTranslation();
const accent = label === 'now';
@@ -17,12 +18,11 @@ export default function TitleCard(props: TitleCardProps) {
return (
<div className='title-card'>
<div className='inline'>
<span className='title-card__title'>{title}</span>
<span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}>
{getLocalizedString(`common.${label}`)}
</span>
<span className='presenter'>{presenter}</span>
<span className={accent ? 'label accent' : 'label'}>{getLocalizedString(`common.${label}`)}</span>
</div>
<div className='title-card__secondary'>{secondary}</div>
<div className='title'>{title}</div>
<div className='subtitle'>{subtitle}</div>
</div>
);
}
@@ -1,7 +1,7 @@
import { useSearchParams } from 'react-router-dom';
import { Input, InputGroup, InputLeftElement, Select, Switch } from '@chakra-ui/react';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import { isStringBoolean } from '../../utils/viewUtils';
import { ParamField } from './types';
@@ -11,7 +11,7 @@
@extend .drawerContent;
display: flex;
justify-content: start;
gap: $section-spacing;
gap: $element-spacing;
button[type='reset'] {
padding: 0 2em;
@@ -30,9 +30,9 @@
.columnSection {
display: flex;
padding: $section-spacing 0;
padding: $element-spacing;
flex-direction: column;
gap: $element-spacing;
gap: $element-inner-spacing;
}
.title {
@@ -1,16 +1,7 @@
import { CustomFields } from 'ontime-types';
import { capitaliseFirstLetter } from '../../../features/viewers/common/viewUtils';
import { UserFields } from 'ontime-types';
import { ParamField } from './types';
const makeOptionsFromCustomFields = (customFields: CustomFields, additionalOptions?: Record<string, string>) => {
const customFieldOptions = Object.keys(customFields).reduce((acc, key) => {
return { ...acc, [`custom-${key}`]: `Custom: ${capitaliseFirstLetter(key)}` };
}, additionalOptions ?? {});
return customFieldOptions;
};
const getTimeOption = (timeFormat: string): ParamField => {
const placeholder = `${timeFormat} (default)`;
return {
@@ -102,56 +93,45 @@ export const getClockOptions = (timeFormat: string): ParamField[] => [
},
];
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields);
return [
getTimeOption(timeFormat),
hideTimerSeconds,
{
id: 'hideClock',
title: 'Hide Time Now',
description: 'Hides the Time Now field',
type: 'boolean',
defaultValue: false,
},
{
id: 'secondary-src',
title: 'Secondary text',
description: 'Select the data source for the secondary text',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
{
id: 'hideCards',
title: 'Hide Cards',
description: 'Hides the Now and Next cards',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideProgress',
title: 'Hide progress bar',
description: 'Hides the progress bar',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideMessage',
title: 'Hide Presenter Message',
description: 'Prevents the screen from displaying messages from the presenter',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideExternal',
title: 'Hide External',
description: 'Prevents the screen from displaying the external field',
type: 'boolean',
defaultValue: false,
},
];
};
export const getTimerOptions = (timeFormat: string): ParamField[] => [
getTimeOption(timeFormat),
hideTimerSeconds,
{
id: 'hideClock',
title: 'Hide Time Now',
description: 'Hides the Time Now field',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideCards',
title: 'Hide Cards',
description: 'Hides the Now and Next cards',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideProgress',
title: 'Hide progress bar',
description: 'Hides the progress bar',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideMessage',
title: 'Hide Presenter Message',
description: 'Prevents the screen from displaying messages from the presenter',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideExternal',
title: 'Hide External',
description: 'Prevents the screen from displaying the external field',
type: 'boolean',
defaultValue: false,
},
];
export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
hideTimerSeconds,
@@ -246,218 +226,185 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
},
];
export const getLowerThirdOptions = (customFields: CustomFields): ParamField[] => {
const topSourceOptions = makeOptionsFromCustomFields(customFields, {
title: 'Title',
lowerMsg: 'Lower Third Message',
});
export const LOWER_THIRD_OPTIONS: ParamField[] = [
{
id: 'trigger',
title: 'Animation Trigger',
description: '',
type: 'option',
values: {
event: 'Event Load',
manual: 'Manual',
},
defaultValue: 'event',
},
{
id: 'top-src',
title: 'Top Text',
description: '',
type: 'option',
values: {
title: 'Title',
subtitle: 'Subtitle',
presenter: 'Presenter',
lowerMsg: 'Lower Thrid Message',
},
defaultValue: 'title',
},
{
id: 'bottom-src',
title: 'Bottom Text',
description: 'Select the text source for the bottom element',
type: 'option',
values: {
title: 'Title',
subtitle: 'Subtitle',
presenter: 'Presenter',
lowerMsg: 'Lower Thrid Message',
},
defaultValue: 'subtitle',
},
{
id: 'top-colour',
title: 'Top Text Colour',
description: 'Top text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'bottom-colour',
title: 'Bottom Text Colour',
description: 'Bottom text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'top-bg',
title: 'Top Background Colour',
description: 'Top text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'bottom-bg',
title: 'Bottom Background Colour',
description: 'Bottom text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'top-size',
title: 'Top Text Size',
description: 'Font size of the top text',
type: 'string',
placeholder: '65px',
},
{
id: 'bottom-size',
title: 'Bottom Text Size',
description: 'Font size of the bottom text',
type: 'string',
placeholder: '64px',
},
{
id: 'width',
title: 'Minimum Width',
description: 'Minimum Width of the element',
type: 'number',
prefix: '%',
placeholder: '45 (default)',
},
{
id: 'transition',
title: 'Transition',
description: 'Transition in time in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'delay',
title: 'Delay',
description: 'Delay between transition in and out in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'key',
title: 'Key Colour',
description: 'Colour of the background',
prefix: '#',
type: 'string',
placeholder: 'ffffffff (default)',
},
{
id: 'line-colour',
title: 'Line Colour',
description: 'Colour of the line',
prefix: '#',
type: 'string',
placeholder: 'ff0000ff (default)',
},
];
const bottomSourceOptions = makeOptionsFromCustomFields(customFields, {
title: 'Title',
lowerMsg: 'Lower Third Message',
});
return [
{
id: 'trigger',
title: 'Animation Trigger',
description: '',
type: 'option',
values: {
event: 'Event Load',
manual: 'Manual',
},
defaultValue: 'event',
},
{
id: 'top-src',
title: 'Top Text',
description: '',
type: 'option',
values: topSourceOptions,
defaultValue: 'title',
},
{
id: 'bottom-src',
title: 'Bottom Text',
description: 'Select the data source for the bottom element',
type: 'option',
values: bottomSourceOptions,
defaultValue: 'lowerMsg',
},
{
id: 'top-colour',
title: 'Top Text Colour',
description: 'Top text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'bottom-colour',
title: 'Bottom Text Colour',
description: 'Bottom text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'top-bg',
title: 'Top Background Colour',
description: 'Top text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'bottom-bg',
title: 'Bottom Background Colour',
description: 'Bottom text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'top-size',
title: 'Top Text Size',
description: 'Font size of the top text',
type: 'string',
placeholder: '65px',
},
{
id: 'bottom-size',
title: 'Bottom Text Size',
description: 'Font size of the bottom text',
type: 'string',
placeholder: '64px',
},
{
id: 'width',
title: 'Minimum Width',
description: 'Minimum Width of the element',
type: 'number',
prefix: '%',
placeholder: '45 (default)',
},
{
id: 'transition',
title: 'Transition',
description: 'Transition in time in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'delay',
title: 'Delay',
description: 'Delay between transition in and out in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'key',
title: 'Key Colour',
description: 'Colour of the background',
prefix: '#',
type: 'string',
placeholder: 'ffffffff (default)',
},
{
id: 'line-colour',
title: 'Line Colour',
description: 'Colour of the line',
prefix: '#',
type: 'string',
placeholder: 'ff0000ff (default)',
},
];
};
export const getBackstageOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
return [
getTimeOption(timeFormat),
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overlow',
type: 'number',
placeholder: '7 (default)',
},
{
id: 'secondary-src',
title: 'Event secondary text',
description: 'Select the data source for auxiliary text shown in now and next cards',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
];
};
export const getPublicOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields);
return [
getTimeOption(timeFormat),
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overlow',
type: 'number',
placeholder: '7 (default)',
},
{
id: 'secondary-src',
title: 'Event secondary text',
description: 'Select the data source for auxiliary text shown in now and next cards',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
];
};
export const getBackstageOptions = (timeFormat: string): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overlow',
type: 'number',
placeholder: '7 (default)',
},
];
export const getPublicOptions = (timeFormat: string): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overlow',
type: 'number',
placeholder: '7 (default)',
},
];
export const getStudioClockOptions = (timeFormat: string): ParamField[] => [
getTimeOption(timeFormat),
hideTimerSeconds,
];
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ParamField[] => {
const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
const customFieldSelect = Object.keys(customFields).reduce((acc, key) => {
return { ...acc, [key]: `Custom: ${capitaliseFirstLetter(key)}` };
}, {});
export const getOperatorOptions = (userFields: UserFields, timeFormat: string): ParamField[] => {
return [
getTimeOption(timeFormat),
{
@@ -472,29 +419,45 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
title: 'Main data field',
description: 'Field to be shown in the first line of text',
type: 'option',
values: fieldOptions,
defaultValue: 'title',
values: {
title: 'Title',
subtitle: 'Subtitle',
presenter: 'Presenter',
},
},
{
id: 'secondary',
title: 'Secondary data field',
description: 'Field to be shown in the second line of text',
type: 'option',
values: fieldOptions,
defaultValue: '',
values: {
title: 'Title',
subtitle: 'Subtitle',
presenter: 'Presenter',
},
},
{
id: 'subscribe',
title: 'Highlight Field',
description: 'Choose a custom field to highlight',
description: 'Choose a field to highlight',
type: 'option',
values: customFieldSelect,
defaultValue: '',
values: {
user0: userFields.user0 || 'user0',
user1: userFields.user1 || 'user1',
user2: userFields.user2 || 'user2',
user3: userFields.user3 || 'user3',
user4: userFields.user4 || 'user4',
user5: userFields.user5 || 'user5',
user6: userFields.user6 || 'user6',
user7: userFields.user7 || 'user7',
user8: userFields.user8 || 'user8',
user9: userFields.user9 || 'user9',
},
},
{
id: 'shouldEdit',
title: 'Edit custom field',
description: 'Allows editing an events selected custom field by long pressing.',
title: 'Edit user field',
description: 'Allows editing an events user field by long pressing on it. Needs a selected highlighted field',
type: 'boolean',
defaultValue: false,
},
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { ALIASES } from '../api/apiConstants';
import { getAliases } from '../api/ontimeApi';
export default function useAliases() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: ALIASES,
queryFn: getAliases,
placeholderData: [],
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data, status, isFetching, isError, refetch };
}
@@ -1,22 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { CustomFields } from 'ontime-types';
import { queryRefetchInterval } from '../../ontimeConfig';
import { CUSTOM_FIELDS } from '../api/constants';
import { getCustomFields } from '../api/customFields';
const placeholder: CustomFields = {};
export default function useCustomFields() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: CUSTOM_FIELDS,
queryFn: getCustomFields,
placeholderData: placeholder,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval,
networkMode: 'always',
});
return { data: data ?? placeholder, status, isFetching, isError, refetch };
}
@@ -1,9 +1,9 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { HTTP_SETTINGS } from '../api/constants';
import { getHTTP, postHTTP } from '../api/http';
import { logAxiosError } from '../api/utils';
import { HTTP_SETTINGS } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils';
import { getHTTP, postHTTP } from '../api/ontimeApi';
import { httpPlaceholder } from '../models/Http';
import { ontimeQueryClient } from '../queryClient';
@@ -2,8 +2,8 @@ import { useQuery } from '@tanstack/react-query';
import { GetInfo } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { APP_INFO } from '../api/constants';
import { getInfo } from '../api/db';
import { APP_INFO } from '../api/apiConstants';
import { getInfo } from '../api/ontimeApi';
import { ontimePlaceholderInfo } from '../models/Info';
export default function useInfo() {
@@ -17,5 +17,5 @@ export default function useInfo() {
networkMode: 'always',
});
return { data: data ?? ontimePlaceholderInfo, status, isError, refetch, isFetching };
return { data, status, isError, refetch, isFetching };
}
@@ -1,9 +1,9 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { OSC_SETTINGS } from '../api/constants';
import { getOSC, postOSC } from '../api/osc';
import { logAxiosError } from '../api/utils';
import { OSC_SETTINGS } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils';
import { getOSC, postOSC } from '../api/ontimeApi';
import { oscPlaceholderSettings } from '../models/OscSettings';
import { ontimeQueryClient } from '../queryClient';
@@ -1,8 +1,8 @@
import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_DATA } from '../api/constants';
import { getProjectData } from '../api/project';
import { PROJECT_DATA } from '../api/apiConstants';
import { getProjectData } from '../api/projectDataApi';
import { projectDataPlaceholder } from '../models/ProjectData';
export default function useProjectData() {
@@ -2,8 +2,8 @@ import { useQuery } from '@tanstack/react-query';
import { ProjectFileListResponse } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_LIST } from '../api/constants';
import { getProjects } from '../api/db';
import { PROJECT_LIST } from '../api/apiConstants';
import { getProjects } from '../api/ontimeApi';
const placeholderProjectList: ProjectFileListResponse = {
files: [],
@@ -3,8 +3,8 @@ import { useQuery } from '@tanstack/react-query';
import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
import { queryRefetchInterval } from '../../ontimeConfig';
import { RUNDOWN } from '../api/constants';
import { fetchNormalisedRundown } from '../api/rundown';
import { RUNDOWN } from '../api/apiConstants';
import { fetchCachedRundown } from '../api/eventsApi';
// revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 };
@@ -12,7 +12,7 @@ const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as Normali
export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
queryKey: RUNDOWN,
queryFn: fetchNormalisedRundown,
queryFn: fetchCachedRundown,
placeholderData: cachedRundownPlaceholder,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
@@ -1,8 +1,8 @@
import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { APP_SETTINGS } from '../api/constants';
import { getSettings } from '../api/settings';
import { APP_SETTINGS } from '../api/apiConstants';
import { getSettings } from '../api/ontimeApi';
import { ontimePlaceholderSettings } from '../models/OntimeSettings';
export default function useSettings() {
@@ -1,19 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { URL_PRESETS } from '../api/constants';
import { getUrlPresets } from '../api/urlPresets';
export default function useUrlPresets() {
const { data, status, isError, refetch } = useQuery({
queryKey: URL_PRESETS,
queryFn: getUrlPresets,
placeholderData: [],
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? [], status, isError, refetch };
}
@@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { queryRefetchInterval } from '../../ontimeConfig';
import { USERFIELDS } from '../api/apiConstants';
import { getUserFields } from '../api/ontimeApi';
import { userFieldsPlaceholder } from '../models/UserFields';
export default function useUserFields() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: USERFIELDS,
queryFn: getUserFields,
placeholderData: userFieldsPlaceholder,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval,
networkMode: 'always',
});
return { data, status, isFetching, isError, refetch };
}
@@ -1,8 +1,8 @@
import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { VIEW_SETTINGS } from '../api/constants';
import { getView } from '../api/viewSettings';
import { VIEW_SETTINGS } from '../api/apiConstants';
import { getView } from '../api/ontimeApi';
import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
export default function useViewSettings() {
+3 -11
View File
@@ -3,7 +3,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { getLinkedTimes, getPreviousEventNormal, reorderArray, swapEventData } from 'ontime-utils';
import { RUNDOWN } from '../api/constants';
import { RUNDOWN } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils';
import {
ReorderEntry,
requestApplyDelay,
@@ -15,8 +16,7 @@ import {
requestPutEvent,
requestReorderEvent,
SwapEntry,
} from '../api/rundown';
import { logAxiosError } from '../api/utils';
} from '../api/eventsApi';
import { useEditorSettings } from '../stores/editorSettings';
import { forgivingStringToMillis } from '../utils/dateConfig';
@@ -150,13 +150,6 @@ export const useEventAction = () => {
[_updateEventMutation],
);
const updateCustomField = useCallback(
async (eventId: string, field: string, value: string) => {
updateEvent({ id: eventId, custom: { [field]: { value } } });
},
[updateEvent],
);
type TimeField = 'timeStart' | 'timeEnd' | 'duration';
/**
* Updates time of existing event
@@ -560,6 +553,5 @@ export const useEventAction = () => {
swapEvents,
updateEvent,
updateTimer,
updateCustomField,
};
};
@@ -6,7 +6,7 @@
import { useMemo, useRef } from 'react';
import { isDev } from '../api/constants';
import { isDev } from '../api/apiConstants';
type noop = (this: any, ...args: any[]) => any;
+4 -14
View File
@@ -41,6 +41,9 @@ export const setMessage = {
publicVisible: (payload: boolean) => socketSendJson('message', { public: { visible: payload } }),
lowerText: (payload: string) => socketSendJson('message', { lower: { text: payload } }),
lowerVisible: (payload: boolean) => socketSendJson('message', { lower: { visible: payload } }),
externalText: (payload: string) => socketSendJson('message', { external: { visible: payload } }),
externalVisible: (payload: boolean) => socketSendJson('message', { external: { visible: payload } }),
onAir: (payload: boolean) => socketSendJson('onAir', payload),
timerBlink: (payload: boolean) => socketSendJson('message', { timer: { blink: payload } }),
timerBlackout: (payload: boolean) => socketSendJson('message', { timer: { blackout: payload } }),
};
@@ -161,24 +164,11 @@ export const useProgressData = () => {
export const setClientName = (newName: string) => socketSendJson('set-client-name', newName);
export const useRuntimeOverview = () => {
const featureSelector = (state: RuntimeStore) => ({
plannedStart: state.runtime.plannedStart,
actualStart: state.runtime.actualStart,
plannedEnd: state.runtime.plannedEnd,
expectedEnd: state.runtime.expectedEnd,
});
return useRuntimeStore(featureSelector);
};
export const useRuntimePlaybackOverview = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
clock: state.clock,
numEvents: state.runtime.numEvents,
selectedEventIndex: state.runtime.selectedEventIndex,
offset: state.runtime.offset,
numEvents: state.runtime.numEvents,
});
return useRuntimeStore(featureSelector);
@@ -0,0 +1,14 @@
import { UserFields } from 'ontime-types';
export const userFieldsPlaceholder: UserFields = {
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
};
@@ -3,7 +3,6 @@ import { create } from 'zustand';
export enum AppMode {
Run = 'run',
Edit = 'edit',
Freeze = 'freeze',
}
const appModeKey = 'ontime-app-mode';
+1 -17
View File
@@ -37,13 +37,8 @@ export const runtimeStorePlaceholder: RuntimeStore = {
},
},
runtime: {
selectedEventIndex: null,
numEvents: 0,
offset: 0,
plannedStart: 0,
plannedEnd: 0,
actualStart: null,
expectedEnd: null,
selectedEventIndex: null,
},
eventNow: null,
eventNext: null,
@@ -68,14 +63,3 @@ export const runtimeStore = createWithEqualityFn<RuntimeStore>(
export const useRuntimeStore = <T>(selector: (state: RuntimeStore) => T) =>
useStoreWithEqualityFn(runtimeStore, selector, deepCompare);
/**
* Allows patching a property of the runtime store
* @param key
* @param value
*/
export function patchRuntime<K extends keyof RuntimeStore>(key: K, value: RuntimeStore[K]): void {
const state = runtimeStore.getState();
state[key] = value;
runtimeStore.setState({ ...state });
}
@@ -1,8 +1,8 @@
import { resolvePath } from 'react-router-dom';
import { generateUrlFromPreset, getRouteFromPreset, validateUrlPresetPath } from '../urlPresets';
import { generateURLFromAlias, getAliasRoute, validateAlias } from '../aliases';
describe('A preset fails if incorrect', () => {
describe('An alias fails if incorrect', () => {
const testsToFail = [
// no empty
'',
@@ -21,11 +21,11 @@ describe('A preset fails if incorrect', () => {
testsToFail.forEach((t) =>
it(`${t}`, () => {
expect(validateUrlPresetPath(t).isValid).toBeFalsy();
expect(validateAlias(t).status).toBeFalsy();
}),
);
});
describe('generateUrlFromPreset and getRouteFromPreset function', () => {
describe('generateURLFromAlias and getAliasRoute function', () => {
test('generate the expected url from an alias', () => {
const testData = [
{
@@ -41,10 +41,10 @@ describe('generateUrlFromPreset and getRouteFromPreset function', () => {
},
];
expect(generateUrlFromPreset(testData[0])).toStrictEqual(expected[0].url);
expect(generateURLFromAlias(testData[0])).toStrictEqual(expected[0].url);
});
test('generate the url to redirect to when the current URL is just the alias', () => {
const presets = [
const aliases = [
{
enabled: true,
alias: 'demopage',
@@ -52,7 +52,7 @@ describe('generateUrlFromPreset and getRouteFromPreset function', () => {
},
];
// let current location be the alias
const location = resolvePath(presets[0].alias);
const location = resolvePath(aliases[0].alias);
const expected = [
{
@@ -60,10 +60,10 @@ describe('generateUrlFromPreset and getRouteFromPreset function', () => {
},
];
expect(getRouteFromPreset(location, presets, null)).toStrictEqual(expected[0].url);
expect(getAliasRoute(location, aliases, null)).toStrictEqual(expected[0].url);
});
test('generate the url to redirect to when the current URL the same url but with a change of params', () => {
const presets = [
const aliases = [
{
enabled: true,
alias: 'demopage',
@@ -71,22 +71,22 @@ describe('generateUrlFromPreset and getRouteFromPreset function', () => {
},
];
// let current location be the actual url with alias attached to it
const location = resolvePath(presets[0].pathAndParams);
const location = resolvePath(aliases[0].pathAndParams);
const urlSearchParams = new URLSearchParams(location.search);
urlSearchParams.append('alias', presets[0].alias); //
urlSearchParams.append('alias', aliases[0].alias); //
// update current alias with extra param
presets[0].pathAndParams += '&eventId=674';
aliases[0].pathAndParams += '&eventId=674';
const expected = [
{
url: '/timer?user=guest&eventId=674&alias=demopage',
},
];
expect(getRouteFromPreset(location, presets, urlSearchParams)).toStrictEqual(expected[0].url);
expect(getAliasRoute(location, aliases, urlSearchParams)).toStrictEqual(expected[0].url);
});
test('generate no url to redirect to when the current URL the same url', () => {
const presets = [
const aliases = [
{
enabled: true,
alias: 'demopage',
@@ -94,10 +94,10 @@ describe('generateUrlFromPreset and getRouteFromPreset function', () => {
},
];
// let current location be the actual url with alias attached to it
const location = resolvePath(presets[0].pathAndParams);
const location = resolvePath(aliases[0].pathAndParams);
const urlSearchParams = new URLSearchParams(location.search);
urlSearchParams.append('alias', presets[0].alias); //
urlSearchParams.append('alias', aliases[0].alias); //
expect(getRouteFromPreset(location, presets, urlSearchParams)).toBeNull();
expect(getAliasRoute(location, aliases, urlSearchParams)).toBeNull();
});
});
@@ -14,7 +14,6 @@ describe('test forgivingStringToMillis()', () => {
{ value: '1h0m0s', expect: 1000 * 60 * 60 },
{ value: '23h0m0s', expect: 1000 * 60 * 60 * 23 },
{ value: '12h12m12s', expect: 12 * 1000 + 12 * 60 * 1000 + 12 * 1000 * 60 * 60 },
{ value: '12H12M12S', expect: 12 * 1000 + 12 * 60 * 1000 + 12 * 1000 * 60 * 60 },
{ value: '2m', expect: 2 * 60 * 1000 },
{ value: '1h5s', expect: 1000 * 60 * 60 + 1000 * 5 },
{ value: '1h2m', expect: 1000 * 60 * 60 + 1000 * 60 * 2 },
@@ -261,17 +260,17 @@ describe('test forgivingStringToMillis()', () => {
describe('millisToDelayString()', () => {
it('returns null for null values', () => {
expect(millisToDelayString(null)).toBe('');
expect(millisToDelayString(null)).toBeNull();
});
it('returns null 0', () => {
expect(millisToDelayString(0)).toBe('');
expect(millisToDelayString(0)).toBeNull();
});
describe('converts values in seconds', () => {
it('shows a simple string with value in seconds', () => {
expect(millisToDelayString(10000)).toBe('+10 sec');
expect(millisToDelayString(10000, true)).toBe('+10 sec');
});
it('... and its negative counterpart', () => {
expect(millisToDelayString(-10000)).toBe('-10 sec');
expect(millisToDelayString(-10000, true)).toBe('-10 sec');
});
const underAMinute = [1, 500, 1000, 6000, 55000, 59999];
@@ -280,36 +279,37 @@ describe('millisToDelayString()', () => {
expect(millisToDelayString(value)?.endsWith('sec')).toBe(true);
});
});
expect(millisToDelayString(null)).toBeNull();
});
describe('converts values in minutes', () => {
it('shows a simple string with value in minutes', () => {
expect(millisToDelayString(720000)).toBe('+12 min');
expect(millisToDelayString(720000, true)).toBe('+12 min');
});
it('... and its negative counterpart', () => {
expect(millisToDelayString(-720000)).toBe('-12 min');
expect(millisToDelayString(-720000, true)).toBe('-12 min');
});
it('shows a simple string with value in minutes and seconds', () => {
expect(millisToDelayString(630000)).toBe('+00:10:30');
expect(millisToDelayString(630000, true)).toBe('+00:10:30');
});
it('... and its negative counterpart', () => {
expect(millisToDelayString(-630000)).toBe('-00:10:30');
expect(millisToDelayString(-630000, true)).toBe('-00:10:30');
});
const underAnHour = [60000, 360000, 720000];
underAnHour.forEach((value) => {
it(`handles ${value}`, () => {
expect(millisToDelayString(value)?.endsWith('min')).toBe(true);
expect(millisToDelayString(value, true)?.endsWith('min')).toBe(true);
});
});
});
describe('converts values with full time string', () => {
it('positive added time', () => {
expect(millisToDelayString(45015000)).toBe('+12:30:15');
expect(millisToDelayString(45015000, true)).toBe('+12:30:15');
});
it('negative added time', () => {
expect(millisToDelayString(-45015000)).toBe('-12:30:15');
expect(millisToDelayString(-45015000, true)).toBe('-12:30:15');
});
});
});
@@ -1,4 +1,4 @@
import { EndAction, EventCustomFields, OntimeEvent, SupportedEvent, TimerType } from 'ontime-types';
import { EndAction, OntimeEvent, SupportedEvent, TimerType } from 'ontime-types';
import { cloneEvent } from '../eventsManager';
@@ -9,6 +9,8 @@ describe('cloneEvent()', () => {
type: SupportedEvent.Event,
title: 'title',
cue: 'cue',
subtitle: 'subtitle',
presenter: 'presenter',
note: 'note',
timeStart: 0,
duration: 10,
@@ -19,11 +21,18 @@ describe('cloneEvent()', () => {
skip: false,
colour: 'F00',
revision: 10,
user0: 'user0',
user1: 'user1',
user2: 'user2',
user3: 'user3',
user4: 'user4',
user5: 'user5',
user6: 'user6',
user7: 'user7',
user8: 'user8',
user9: 'user9',
timeWarning: 120000,
timeDanger: 60000,
custom: {
lighting: { value: '3' },
} as EventCustomFields,
} as OntimeEvent;
const cloned = cloneEvent(original);
@@ -31,6 +40,8 @@ describe('cloneEvent()', () => {
// @ts-expect-error -- safeguarding this
expect(cloned?.id).toBe(undefined);
expect(cloned.title).toBe(original.title);
expect(cloned.subtitle).toBe(original.subtitle);
expect(cloned.presenter).toBe(original.presenter);
expect(cloned.note).toBe(original.note);
expect(cloned.endAction).toBe(original.endAction);
expect(cloned.timerType).toBe(original.timerType);
@@ -44,6 +55,5 @@ describe('cloneEvent()', () => {
expect(cloned.revision).toBe(0);
expect(cloned.timeWarning).toBe(original.timeWarning);
expect(cloned.timeDanger).toBe(original.timeDanger);
expect(cloned.custom).toStrictEqual({});
});
});
@@ -1,4 +1,4 @@
import { isAlphanumeric, isIPAddress, isNotEmpty, isOnlyNumbers, startsWithHttp, startsWithSlash } from '../regex';
import { isIPAddress, isOnlyNumbers, startsWithHttp, startsWithSlash } from '../regex';
describe('simple tests for regex', () => {
test('isOnlyNumbers', () => {
@@ -48,28 +48,4 @@ describe('simple tests for regex', () => {
expect(startsWithSlash.test(t)).toBe(false);
});
});
test('isAlphanumeric', () => {
const right = ['dsafdsafa9f9sdafdsSADFHASDF', '1231', '1', 'a', 'asdas1asdas', '11as', '1'];
const wrong = ['with space', 'with @', '#'];
right.forEach((t) => {
expect(isAlphanumeric.test(t)).toBe(true);
});
wrong.forEach((t) => {
expect(isAlphanumeric.test(t)).toBe(false);
});
});
test('isNotEmpty', () => {
const right = ['notempty'];
const wrong = ['', ' '];
right.forEach((t) => {
expect(isNotEmpty.test(t)).toBe(true);
});
wrong.forEach((t) => {
expect(isNotEmpty.test(t)).toBe(false);
});
});
});
@@ -16,13 +16,13 @@ describe('nowInMillis()', () => {
describe('formatTime()', () => {
it('parses 24h strings', () => {
const ms = 13 * 60 * 60 * 1000;
const time = formatTime(ms, { format12: 'hh:mm:ss', format24: 'HH:mm:ss' }, (_format12, format24) => format24);
const time = formatTime(ms, {format12: "hh:mm:ss", format24: "HH:mm:ss" }, (_format12, format24) => format24);
expect(time).toStrictEqual('13:00:00');
});
it('parses same string in 12h strings', () => {
const ms = 13 * 60 * 60 * 1000;
const time = formatTime(ms, { format12: 'hh:mm:ss a', format24: 'HH:mm:ss' }, (format12, _format24) => format12);
const time = formatTime(ms, {format12: "hh:mm:ss a", format24: "HH:mm:ss" }, (format12, _format24) => format12);
expect(time).toStrictEqual('01:00:00 PM');
});
@@ -34,7 +34,7 @@ describe('formatTime()', () => {
it('handles negative times', () => {
const ms = 1 * 60 * 60 * 1000;
const time = formatTime(-ms, { format12: 'hh:mm a', format24: 'HH:mm' }, (_format12, format24) => format24);
const time = formatTime(-ms, {format12: "hh:mm a", format24: "HH:mm" }, (_format12, format24) => format24);
expect(time).toStrictEqual('-01:00');
});
});
+77
View File
@@ -0,0 +1,77 @@
import isEqual from 'react-fast-compare';
import { Location, resolvePath } from 'react-router-dom';
import { Alias } from 'ontime-types';
/**
* Validates an alias against defined parameters
* @param {string} alias
* @returns {{message: string, status: boolean}}
*/
export const validateAlias = (alias: string) => {
const valid = { status: true, message: 'ok' };
if (alias === '' || alias == null) {
// cannot be empty
valid.status = false;
valid.message = 'should not be empty';
} else if (alias.includes('http') || alias.includes('https') || alias.includes('www')) {
// cannot contain http, https or www
valid.status = false;
valid.message = 'should not include http, https, www';
} else if (alias.includes('127.0.0.1') || alias.includes('localhost') || alias.includes('0.0.0.0')) {
// aliases cannot contain hostname
valid.status = false;
valid.message = 'should not include hostname';
} else if (alias.includes('editor')) {
// no editor
valid.status = false;
valid.message = 'No aliases to editor page allowed';
}
return valid;
};
/**
* Gets the URL to send an alias to
* @param location
* @param data
* @param searchParams
*/
export const getAliasRoute = (location: Location, data: Alias[], searchParams: URLSearchParams) => {
const currentURL = location.pathname.substring(1);
// we need to check if the whole url here is an alias, so we can redirect
const foundAlias = data.filter((d) => d.alias === currentURL && d.enabled)[0];
if (foundAlias) {
return generateURLFromAlias(foundAlias);
}
const aliasOnPage = searchParams.get('alias');
for (const d of data) {
if (aliasOnPage) {
// if the alias fits the alias on this page, but the URL is different, we redirect user to the new URL
// if we have the same alias and its enabled and its not empty
if (d.alias !== '' && d.enabled && d.alias === aliasOnPage) {
const newAliasPath = resolvePath(d.pathAndParams);
const urlParams = new URLSearchParams(newAliasPath.search);
urlParams.set('alias', d.alias);
// we confirm either the url parameters does not match or the url path doesnt
if (!isEqual(urlParams, searchParams) || newAliasPath.pathname !== location.pathname) {
// we then redirect to the alias route, since the view listening to this alias has an outdated URL
return `${newAliasPath.pathname}?${urlParams}`;
}
}
}
}
return null;
};
/**
* Generate URL from an alias
* @param aliasData
*/
export const generateURLFromAlias = (aliasData: Alias) => {
const newAliasPath = resolvePath(aliasData.pathAndParams);
const urlParams = new URLSearchParams(newAliasPath.search);
urlParams.set('alias', aliasData.alias);
return `${newAliasPath.pathname}?${urlParams}`;
};
+10 -14
View File
@@ -1,4 +1,3 @@
import { MaybeNumber } from 'ontime-types';
import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
/**
@@ -46,19 +45,17 @@ function checkAmPm(value: string) {
* @param {string} value
*/
function checkMatchers(value: string) {
const hoursMatch = /(\d+)h/i.exec(value);
const hoursMatch = /(\d+)h/.exec(value);
const hoursMatchValue = hoursMatch ? parse(hoursMatch[1]) : 0;
const minutesMatch = /(\d+)m/i.exec(value);
const minutesMatch = /(\d+)m/.exec(value);
const minutesMatchValue = minutesMatch ? parse(minutesMatch[1]) : 0;
const secondsMatch = /(\d+)s/i.exec(value);
const secondsMatch = /(\d+)s/.exec(value);
const secondsMatchValue = secondsMatch ? parse(secondsMatch[1]) : 0;
if (hoursMatchValue > 0 || minutesMatchValue > 0 || secondsMatchValue > 0) {
return (
hoursMatchValue * MILLIS_PER_HOUR + minutesMatchValue * MILLIS_PER_MINUTE + secondsMatchValue * MILLIS_PER_SECOND
);
return hoursMatchValue * MILLIS_PER_HOUR + minutesMatchValue * MILLIS_PER_MINUTE + secondsMatchValue * MILLIS_PER_SECOND;
}
return { hoursMatchValue };
}
@@ -158,22 +155,21 @@ export const forgivingStringToMillis = (value: string): number => {
return millis;
};
export function millisToDelayString(millis: MaybeNumber, format: 'compact' | 'expanded' = 'compact'): string {
export function millisToDelayString(millis: number | null, small = false): undefined | string | null {
if (millis == null || millis === 0) {
return '';
return null;
}
const isNegative = millis < 0;
const absMillis = Math.abs(millis);
const isCompact = format === 'compact';
const delayed = isCompact ? '+' : 'delayed by ';
const ahead = isCompact ? '-' : 'ahead by ';
const delayed = small ? '+' : 'delayed by ';
const ahead = small ? '-' : 'ahead by ';
if (absMillis < MILLIS_PER_MINUTE) {
return `${isNegative ? ahead : delayed}${formatFromMillis(absMillis, 's')} sec`;
} else if (absMillis < MILLIS_PER_HOUR && absMillis % MILLIS_PER_MINUTE === 0) {
return `${isNegative ? ahead : delayed}${formatFromMillis(absMillis, 'm')} min`;
} else {
return `${isNegative ? ahead : delayed}${formatFromMillis(absMillis, 'HH:mm:ss')}`;
}
return `${isNegative ? ahead : delayed}${formatFromMillis(absMillis, 'HH:mm:ss')}`;
}
@@ -6,11 +6,16 @@ import { OntimeEvent, SupportedEvent } from 'ontime-types';
* @param {string} [after]
* @return {OntimeEvent} clean event
*/
type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>;
type ClonedEvent = Omit<
OntimeEvent,
'id' | 'cue' | 'user0' | 'user1' | 'user2' | 'user3' | 'user4' | 'user5' | 'user6' | 'user7' | 'user8' | 'user9'
>;
export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
return {
type: SupportedEvent.Event,
title: event.title,
subtitle: event.subtitle,
presenter: event.presenter,
note: event.note,
timeStart: event.timeStart,
duration: event.duration,
@@ -26,6 +31,5 @@ export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
revision: 0,
timeWarning: event.timeWarning,
timeDanger: event.timeDanger,
custom: {},
};
};
@@ -0,0 +1,56 @@
import axios from 'axios';
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
type FileOptions = {
name: string;
type: string;
};
type BlobOptions = {
type: string;
};
export default async function fileDownload(url: string, fileOptions: FileOptions, blobOptions: BlobOptions) {
const response = await axios({
url: `${url}/db`,
method: 'GET',
});
const headerLine = response.headers['Content-Disposition'];
let { name: fileName } = fileOptions;
const { type: fileType } = fileOptions;
const { project, rundown, userFields } = response.data;
// try and get the filename from the response
if (headerLine != null) {
const startFileNameIndex = headerLine.indexOf('"') + 1;
const endFileNameIndex = headerLine.lastIndexOf('"');
fileName = headerLine.substring(startFileNameIndex, endFileNameIndex);
}
let fileContent = '';
if (fileType === 'json') {
fileContent = JSON.stringify(response.data);
fileName += '.json';
}
if (fileType === 'csv') {
const sheetData = makeTable(project, rundown, userFields);
fileContent = makeCSV(sheetData);
fileName += '.csv';
}
const blob = new Blob([fileContent], { type: blobOptions.type });
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', downloadUrl);
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
// Clean up the URL.createObjectURL to release resources
URL.revokeObjectURL(downloadUrl);
return;
}
-2
View File
@@ -7,5 +7,3 @@ export const isOnlyNumbers = /^\d+$/;
export const isIPAddress = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/;
export const startsWithHttp = /^http:\/\//;
export const startsWithSlash = /^\//;
export const isAlphanumeric = /^[a-z0-9]+$/i;
export const isNotEmpty = /\S/;
+21 -50
View File
@@ -1,10 +1,10 @@
import { Log, RuntimeStore } from 'ontime-types';
import { isProduction, RUNTIME, websocketUrl } from '../api/constants';
import { isProduction, RUNTIME, websocketUrl } from '../api/apiConstants';
import { ontimeQueryClient } from '../queryClient';
import { socketClientName } from '../stores/connectionName';
import { addLog } from '../stores/logger';
import { patchRuntime, runtimeStore } from '../stores/runtime';
import { runtimeStore } from '../stores/runtime';
export let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null;
@@ -12,7 +12,6 @@ const reconnectInterval = 1000;
export let shouldReconnect = true;
export let hasConnected = false;
export let reconnectAttempts = 0;
export const connectSocket = (preferredClientName?: string) => {
websocket = new WebSocket(websocketUrl);
@@ -53,6 +52,7 @@ export const connectSocket = (preferredClientName?: string) => {
return;
}
// TODO: implement partial store updates
switch (type) {
case 'client-name': {
socketClientName.getState().setName(payload);
@@ -69,54 +69,34 @@ export const connectSocket = (preferredClientName?: string) => {
}
break;
}
case 'ontime-clock': {
patchRuntime('clock', payload);
updateDevTools({ clock: payload });
case 'ontime-playback': {
const state = runtimeStore.getState();
state.timer.playback = payload;
runtimeStore.setState(state);
break;
}
case 'ontime-timer': {
patchRuntime('timer', payload);
updateDevTools({ timer: payload });
break;
}
case 'ontime-onAir': {
patchRuntime('onAir', payload);
updateDevTools({ onAir: payload });
break;
}
case 'ontime-message': {
patchRuntime('message', payload);
updateDevTools({ message: payload });
const state = runtimeStore.getState();
state.timer = payload;
runtimeStore.setState(state);
break;
}
case 'ontime-runtime': {
patchRuntime('runtime', payload);
updateDevTools({ runtime: payload });
const state = runtimeStore.getState();
state.runtime = payload;
runtimeStore.setState(state);
break;
}
case 'ontime-eventNow': {
patchRuntime('eventNow', payload);
updateDevTools({ eventNow: payload });
case 'ontime-message': {
const state = runtimeStore.getState();
state.message = payload;
runtimeStore.setState(state);
break;
}
case 'ontime-publicEventNow': {
patchRuntime('publicEventNow', payload);
updateDevTools({ publicEventNow: payload });
break;
}
case 'ontime-eventNext': {
patchRuntime('eventNext', payload);
updateDevTools({ eventNext: payload });
break;
}
case 'ontime-publicEventNext': {
patchRuntime('publicEventNext', payload);
updateDevTools({ publicEventNext: payload });
break;
}
case 'ontime-timer1': {
patchRuntime('timer1', payload);
updateDevTools({ timer1: payload });
case 'ontime-onAir': {
const state = runtimeStore.getState();
state.onAir = payload;
runtimeStore.setState(state);
break;
}
}
@@ -145,12 +125,3 @@ export const socketSendJson = (type: string, payload?: unknown) => {
}),
);
};
function updateDevTools(newData: Partial<RuntimeStore>) {
if (!isProduction) {
ontimeQueryClient.setQueryData(RUNTIME, (oldData: RuntimeStore) => ({
...oldData,
...newData,
}));
}
}
@@ -29,7 +29,3 @@ export const getAccessibleColour = (bgColour?: string): ColourCombination => {
* @param classNames - css modules objects
*/
export const cx = (classNames: any[]) => classNames.filter(Boolean).join(' ');
export const enDash = '';
export const timerPlaceholder = '––:––:––';
+1 -1
View File
@@ -2,7 +2,7 @@ import { MaybeNumber, Settings, TimeFormat } from 'ontime-types';
import { formatFromMillis } from 'ontime-utils';
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
import { APP_SETTINGS } from '../api/constants';
import { APP_SETTINGS } from '../api/apiConstants';
import { ontimeQueryClient } from '../queryClient';
/**
@@ -1,47 +0,0 @@
/**
* Collection of rules for pre-validating a spreadsheet
* @param file
*/
export function validateSpreadsheetImport(file: File) {
if (!isExcelFile(file)) {
throw new Error('Unknown file type');
}
// Check if file is empty
if (file.size === 0) {
throw new Error('File is empty');
}
// Limit file size of an excel file to around 10MB
if (file.size > 10_000_000) {
throw new Error('File size limit (10MB) exceeded');
}
}
/**
* Collection of rules for pre-validating a project file
* @param file
*/
export function validateProjectFile(file: File) {
if (!isOntimeFile(file)) {
throw new Error('Unknown file type');
}
// Check if file is empty
if (file.size === 0) {
throw new Error('File is empty');
}
// Limit file size of a project file to around 1MB
if (file.size > 1_000_000) {
throw new Error('File size limit (10MB) exceeded');
}
}
export function isExcelFile(file: File | null) {
return file?.name.endsWith('.xlsx');
}
export function isOntimeFile(file: File | null) {
return file?.name.endsWith('.json');
}
@@ -1,76 +0,0 @@
import isEqual from 'react-fast-compare';
import { Location, resolvePath } from 'react-router-dom';
import { URLPreset } from 'ontime-types';
/**
* Validates a preset against defined parameters
* @param {string} preset
* @returns {{message: string, isValid: boolean}}
*/
export const validateUrlPresetPath = (preset: string): { message: string; isValid: boolean } => {
if (preset === '' || preset == null) {
return { isValid: false, message: 'Path cannot be empty' };
}
if (preset.includes('http') || preset.includes('https') || preset.includes('www')) {
return { isValid: false, message: 'Path should not include http, https, www' };
}
if (preset.includes('127.0.0.1') || preset.includes('localhost') || preset.includes('0.0.0.0')) {
return { isValid: false, message: 'Path should not include hostname' };
}
if (preset.includes('editor')) {
// no editor
return { isValid: false, message: 'No path to editor page allowed' };
}
return { isValid: true, message: 'ok' };
};
/**
* Gets the URL to send a preset to
* @param location
* @param data
* @param searchParams
*/
export const getRouteFromPreset = (location: Location, data: URLPreset[], searchParams: URLSearchParams) => {
const currentURL = location.pathname.substring(1);
// we need to check if the whole url here is an alias, so we can redirect
const foundPreset = data.filter((d) => d.alias === currentURL && d.enabled)[0];
if (foundPreset) {
return generateUrlFromPreset(foundPreset);
}
const presetOnPage = searchParams.get('alias');
for (const d of data) {
if (presetOnPage) {
// if the alias fits the preset on this page, but the URL is different, we redirect user to the new URL
// if we have the same alias and its enabled and its not empty
if (d.alias !== '' && d.enabled && d.alias === presetOnPage) {
const newPath = resolvePath(d.pathAndParams);
const urlParams = new URLSearchParams(newPath.search);
urlParams.set('alias', d.alias);
// we confirm either the url parameters does not match or the url path doesnt
if (!isEqual(urlParams, searchParams) || newPath.pathname !== location.pathname) {
// we then redirect to the alias route, since the view listening to this alias has an outdated URL
return `${newPath.pathname}?${urlParams}`;
}
}
}
}
return null;
};
/**
* Generate URL from an preset
* @param presetData
*/
export const generateUrlFromPreset = (presetData: URLPreset) => {
const newPresetPath = resolvePath(presetData.pathAndParams);
const urlParams = new URLSearchParams(newPresetPath.search);
urlParams.set('alias', presetData.alias);
return `${newPresetPath.pathname}?${urlParams}`;
};
@@ -0,0 +1,6 @@
export function isStringBoolean(text: string | null) {
if (text === null) {
return false;
}
return text?.toLowerCase() === 'true' || text === '1';
}
+1 -1
View File
@@ -2,4 +2,4 @@ export const githubUrl = 'https://www.github.com/cpvalente/ontime';
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
export const websiteUrl = 'https://www.getontime.no';
export const documentationUrl = 'https://docs.getontime.no';
export const gitbookUrl = 'https://ontime.gitbook.io';
@@ -2,12 +2,12 @@
import { ComponentType, useEffect } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import useUrlPresets from '../common/hooks-query/useUrlPresets';
import { getRouteFromPreset } from '../common/utils/urlPresets';
import useAliases from '../common/hooks-query/useAliases';
import { getAliasRoute } from '../common/utils/aliases';
const withPreset = <P extends object>(Component: ComponentType<P>) => {
const withAlias = <P extends object>(Component: ComponentType<P>) => {
return (props: Partial<P>) => {
const { data } = useUrlPresets();
const { data } = useAliases();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const location = useLocation();
@@ -15,7 +15,7 @@ const withPreset = <P extends object>(Component: ComponentType<P>) => {
// navigate if is alias route
useEffect(() => {
if (!data) return;
const url = getRouteFromPreset(location, data, searchParams);
const url = getAliasRoute(location, data, searchParams);
// navigate to this route if its not empty
if (url) {
navigate(url);
@@ -26,4 +26,4 @@ const withPreset = <P extends object>(Component: ComponentType<P>) => {
};
};
export default withPreset;
export default withAlias;
@@ -7,5 +7,4 @@
gap: 0.25rem;
overflow: hidden;
background-color: black;
}
@@ -3,13 +3,11 @@ import { ErrorBoundary } from '@sentry/react';
import { useKeyDown } from '../../common/hooks/useKeyDown';
import AboutPanel from './panel/about-panel/AboutPanel';
import GeneralPanel from './panel/general-panel/GeneralPanel';
import IntegrationsPanel from './panel/integrations-panel/IntegrationsPanel';
import InterfacePanel from './panel/interface-panel/InterfacePanel';
import LogPanel from './panel/log-panel/LogPanel';
import ProjectPanel from './panel/project-panel/ProjectPanel';
import ProjectSettingsPanel from './panel/project-settings-panel/ProjectSettingsPanel';
import SourcesPanel from './panel/sources-panel/SourcesPanel';
import UrlPresetPanel from './panel/url-preset-panel/UrlPresetPanel';
import PanelContent from './panel-content/PanelContent';
import PanelList from './panel-list/PanelList';
import { useSettingsStore } from './settingsStore';
@@ -31,11 +29,9 @@ export default function AppSettings() {
<PanelList />
<PanelContent onClose={closeSettings}>
{selectedPanel === 'project' && <ProjectPanel />}
{selectedPanel === 'general' && <GeneralPanel />}
{selectedPanel === 'project_settings' && <ProjectSettingsPanel />}
{selectedPanel === 'sources' && <SourcesPanel />}
{selectedPanel === 'interface' && <InterfacePanel />}
{selectedPanel === 'integrations' && <IntegrationsPanel />}
{selectedPanel === 'project_settings' && <ProjectSettingsPanel />}
{selectedPanel === 'url_presets' && <UrlPresetPanel />}
{selectedPanel === 'about' && <AboutPanel />}
{selectedPanel === 'log' && <LogPanel />}
</PanelContent>
@@ -2,7 +2,6 @@
position: absolute;
top: 1rem;
right: 1rem;
z-index: 100;
}
.contentWrapper {
@@ -1,5 +1,5 @@
import { PropsWithChildren } from 'react';
import { Button } from '@chakra-ui/react';
import { IconButton } from '@chakra-ui/react';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import style from './PanelContent.module.scss';
@@ -14,9 +14,7 @@ export default function PanelContent(props: PropsWithChildren<PanelContentProps>
return (
<div className={style.contentWrapper}>
<div className={style.corner}>
<Button onClick={onClose} aria-label='close' rightIcon={<IoClose />} variant='ontime-subtle'>
Close settings
</Button>
<IconButton onClick={onClose} aria-label='close' icon={<IoClose />} variant='ontime-ghosted-white' />
</div>
<div className={style.content}>{children}</div>
</div>
@@ -41,7 +41,7 @@ export default function PanelList() {
</li>
{panel.secondary?.map((secondary) => {
return (
<li key={secondary.id} onClick={() => handleSelect(panel)} className={style.secondary} role='button'>
<li key={secondary.id} onClick={() => handleSelect(panel)} className={style.secondary}>
{secondary.label}
</li>
);
@@ -17,24 +17,17 @@ $inner-padding: 1rem;
}
.title {
font-size: 1.375rem;
padding: 0 2rem;
font-weight: 600;
font-size: 1.25rem;
padding-left: $inner-padding;
display: flex;
align-items: center;
justify-content: space-between;
color: $gray-300;
}
.section {
position: relative;
margin-top: 2rem;
font-size: calc(1rem - 1px);
max-width: 800px;
display: flex;
flex-direction: column;
gap: 1rem;
color: $ui-white;
}
.paragraph {
@@ -42,9 +35,8 @@ $inner-padding: 1rem;
}
.card {
position: relative;
padding: 2rem;
background-color: $white-3;
background-color: $white-1;
border: 1px solid $gray-1100;
border-radius: 3px;
}
@@ -56,8 +48,8 @@ $inner-padding: 1rem;
}
.pad {
padding: 0 2rem;
max-height: 550px;
padding: 0 1rem;
max-height: 500px;
overflow-y: scroll;
}
@@ -66,25 +58,15 @@ $inner-padding: 1rem;
border-collapse: collapse;
font-size: calc(1rem - 2px);
text-align: left;
margin-bottom: 2rem;
thead {
position: sticky;
top: 0;
z-index: 3;
box-shadow: 0 1px $white-10;
}
tr {
padding: 1rem 0;
}
th {
border-bottom: 1px solid $white-10;
font-weight: 400;
color: $gray-400;
background-color: $gray-1350;
white-space: nowrap;
text-transform: capitalize;
}
th,
@@ -98,7 +80,7 @@ $inner-padding: 1rem;
}
.listGroup {
padding: 0 2rem;
padding: 0.5rem 1rem 0 1rem;
> li:not(:last-child) {
border-bottom: 1px solid $white-10;
@@ -130,49 +112,4 @@ $inner-padding: 1rem;
.divider {
border-top: 1px solid $white-10;
margin: 1rem -2rem;
z-index: 999;
}
.overlay {
position: absolute;
z-index: 10;
width: 100%;
height: 100%;
backdrop-filter: blur(2px);
display: grid;
place-content: center;
background-color: $black-10;
}
.loader {
$loader-size: 4rem;
width: $loader-size;
height: $loader-size;
background: $blue-500;
display: inline-block;
border-radius: 50%;
box-sizing: border-box;
animation: animloader 1s ease-in infinite;
}
@keyframes animloader {
0% {
transform: scale(0);
opacity: 0.6;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@keyframes animloader {
0% {
transform: scale(0);
opacity: 0.6;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@@ -1,7 +1,5 @@
import { HTMLAttributes, ReactNode } from 'react';
import { cx } from '../../../common/utils/styleUtils';
import style from './Panel.module.scss';
export function Header({ children }: { children: ReactNode }) {
@@ -43,11 +41,10 @@ export function Card({ children, ...props }: { children: ReactNode } & JSX.Intri
);
}
export function Table({ className, children }: { className?: string; children: ReactNode }) {
const classes = cx([style.table, className]);
export function Table({ children }: { children: ReactNode }) {
return (
<div className={style.pad}>
<table className={classes}>{children}</table>
<table className={style.table}>{children}</table>
</div>
);
}
@@ -81,14 +78,3 @@ export function Error({ children }: { children: ReactNode }) {
export function Divider() {
return <hr className={style.divider} />;
}
export function Loader({ isLoading }: { isLoading: boolean }) {
if (!isLoading) {
return null;
}
return (
<div className={style.overlay}>
<div className={style.loader} />
</div>
);
}
@@ -1,6 +1,6 @@
import { version } from '../../../../../package.json';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import { documentationUrl, githubUrl, websiteUrl } from '../../../../externals';
import { gitbookUrl, githubUrl, websiteUrl } from '../../../../externals';
import * as Panel from '../PanelUtils';
import CheckUpdatesButton from './CheckUpdatesButton';
@@ -17,14 +17,18 @@ export default function AboutPanel() {
</Panel.Paragraph>
</Panel.Section>
<Panel.Section>
<Panel.SubHeader>Links</Panel.SubHeader>
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
<Panel.Card>
<Panel.SubHeader>Links</Panel.SubHeader>
<ExternalLink href={gitbookUrl}>Read the docs over at GitBook</ExternalLink>
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
</Panel.Card>
</Panel.Section>
<Panel.Section>
<Panel.SubHeader>Current version</Panel.SubHeader>
<Panel.Paragraph>{`You are currently using Ontime ${version}`}</Panel.Paragraph>
<CheckUpdatesButton version={version} />
<Panel.Card>
<Panel.SubHeader>Current version</Panel.SubHeader>
<Panel.Paragraph>{`You are currently using Ontime ${version}`}</Panel.Paragraph>
<CheckUpdatesButton version={version} />
</Panel.Card>
</Panel.Section>
</>
);
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { Button } from '@chakra-ui/react';
import { getLatestVersion, HasUpdate } from '../../../../common/api/external';
import { getLatestVersion, HasUpdate } from '../../../../common/api/ontimeApi';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import style from '../Panel.module.scss';
@@ -52,14 +52,7 @@ export default function CheckUpdatesButton(props: CheckUpdatesButtonProps) {
return (
<>
<Button
onClick={versionCheck}
variant='ontime-filled'
isLoading={isFetching}
isDisabled={disableButton}
size='sm'
maxWidth='max-content'
>
<Button onClick={versionCheck} variant='ontime-filled' isLoading={isFetching} isDisabled={disableButton}>
Check for updates
</Button>
<ResolveUpdateMessage updateMessage={updateMessage} />
@@ -1,24 +0,0 @@
.actionButtons {
display: flex;
gap: 1em;
}
.pad {
padding: 0 2rem;
}
.fit {
width: fit-content;
}
.aliasConstrain {
min-width: 12em;
}
.fullWidth {
width: 100%;
}
.flex {
display: flex;
}
@@ -1,16 +0,0 @@
import * as Panel from '../PanelUtils';
import GeneralPanelForm from './GeneralPanelForm';
import UrlPresetsForm from './UrlPresetsForm';
import ViewSettingsForm from './ViewSettingsForm';
export default function GeneralPanel() {
return (
<>
<Panel.Header>Settings</Panel.Header>
<GeneralPanelForm />
<ViewSettingsForm />
<UrlPresetsForm />
</>
);
}
@@ -1,163 +0,0 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Button, Input, Select } from '@chakra-ui/react';
import { Settings } from 'ontime-types';
import { postSettings } from '../../../../common/api/settings';
import { maybeAxiosError } from '../../../../common/api/utils';
import useSettings from '../../../../common/hooks-query/useSettings';
import { isOnlyNumbers } from '../../../../common/utils/regex';
import * as Panel from '../PanelUtils';
import GeneralPinInput from './GeneralPinInput';
import style from './GeneralPanel.module.scss';
export type GeneralPanelFormValues = {
filename: string;
};
export default function GeneralPanelForm() {
const { data, status, refetch } = useSettings();
const {
handleSubmit,
register,
reset,
setError,
formState: { isSubmitting, isDirty, isValid, errors },
} = useForm<Settings>({
defaultValues: data,
values: data,
resetOptions: {
keepDirtyValues: true,
},
});
// update form if we get new data from server
useEffect(() => {
if (data) {
reset(data);
}
}, [data, reset]);
const onSubmit = async (formData: Settings) => {
try {
await postSettings(formData);
} catch (error) {
const message = maybeAxiosError(error);
setError('root', { message });
} finally {
await refetch();
}
};
const disableInputs = status === 'pending';
const disableSubmit = isSubmitting || !isDirty || !isValid;
const submitError = '';
const onReset = () => {
reset(data);
};
const isLoading = status === 'pending';
return (
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} id='app-settings'>
<Panel.Card>
<Panel.SubHeader>
General settings
<div className={style.actionButtons}>
<Button isDisabled={!isDirty || isSubmitting} variant='ontime-ghosted' size='sm' onClick={onReset}>
Revert to saved
</Button>
<Button
type='submit'
form='app-settings'
isLoading={isSubmitting}
isDisabled={disableSubmit}
variant='ontime-filled'
size='sm'
>
Save
</Button>
</div>
</Panel.SubHeader>
{submitError && <Panel.Error>{submitError}</Panel.Error>}
<Panel.Divider />
<Panel.Section>
<Panel.Loader isLoading={isLoading} />
<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'
size='sm'
type='number'
variant='ontime-filled'
maxLength={5}
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.ListItem>
<Panel.Field
title='Editor pin code'
description='Protect the editor view with a pin code'
error={errors.editorKey?.message}
/>
<GeneralPinInput register={register} formName='editorKey' isDisabled={disableInputs} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Operator pin code'
description='Protect the operator and cuesheet views with a pin code'
error={errors.operatorKey?.message}
/>
<GeneralPinInput register={register} formName='operatorKey' isDisabled={disableInputs} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Time format'
description='Default time format to show in views 12 /24 hours'
error={errors.timeFormat?.message}
/>
<Select variant='ontime' size='sm' width='auto' isDisabled={disableInputs} {...register('timeFormat')}>
<option value='12'>12 hours 11:00:10 PM</option>
<option value='24'>24 hours 23:00:10</option>
</Select>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Views language'
description='Language to be displayed in views'
error={errors.language?.message}
/>
<Select variant='ontime' size='sm' width='auto' isDisabled={disableInputs} {...register('language')}>
<option value='en'>English</option>
<option value='fr'>French</option>
<option value='de'>German</option>
<option value='it'>Italian</option>
<option value='no'>Norwegian</option>
<option value='pt'>Portuguese</option>
<option value='es'>Spanish</option>
<option value='sv'>Swedish</option>
</Select>
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -1,207 +0,0 @@
import { useEffect } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertIcon, Button, IconButton, Input, Switch } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { URLPreset } from 'ontime-types';
import { postUrlPresets } from '../../../../common/api/urlPresets';
import { maybeAxiosError } from '../../../../common/api/utils';
import TooltipActionBtn from '../../../../common/components/buttons/TooltipActionBtn';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
import { handleLinks } from '../../../../common/utils/linkUtils';
import { validateUrlPresetPath } from '../../../../common/utils/urlPresets';
import * as Panel from '../PanelUtils';
import style from './GeneralPanel.module.scss';
const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/';
type FormData = {
data: URLPreset[];
};
export default function UrlPresetsForm() {
const { data, status, refetch } = useUrlPresets();
const {
control,
handleSubmit,
register,
reset,
setError,
formState: { isSubmitting, isDirty, isValid, errors },
} = useForm<FormData>({
mode: 'onBlur',
defaultValues: { data },
values: { data },
resetOptions: {
keepDirtyValues: true,
},
});
const { fields, prepend, remove } = useFieldArray({
name: 'data',
control,
});
// reset form if we get new data from backend
useEffect(() => {
if (data) {
reset({ data });
}
}, [data, reset]);
const onSubmit = async (formData: FormData) => {
for (let i = 0; i < formData.data.length; i++) {
const preset = formData.data[i];
const { isValid, message } = validateUrlPresetPath(preset.pathAndParams);
if (!isValid) {
setError(`data.${i}.pathAndParams`, { message });
return;
}
}
try {
await postUrlPresets(formData.data);
} catch (error) {
const message = maybeAxiosError(error);
setError('root', { message });
} finally {
await refetch();
}
};
const onReset = () => {
reset({ data });
};
const addNew = () => {
prepend({
enabled: false,
alias: '',
pathAndParams: '',
});
};
const isLoading = status === 'pending';
const canSubmit = !isSubmitting && isDirty && isValid;
return (
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} data-testid='url-preset-form'>
<Panel.Card>
<Panel.SubHeader>
URL Presets
<div className={style.actionButtons}>
<Button variant='ontime-ghosted' size='md' onClick={onReset} isDisabled={!canSubmit}>
Revert to saved
</Button>
<Button variant='ontime-filled' size='md' type='submit' isDisabled={!canSubmit} isLoading={isSubmitting}>
Save
</Button>
</div>
</Panel.SubHeader>
<Panel.Divider />
<Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon />
<AlertDescription>
URL Presets
<br />
<br />
Custom presets allow providing a short name for any ontime URL. <br />
- Providing dynamic URLs for automation or unattended screens <br />- Simplifying complex URLs
<br />
<br />
<ExternalLink href={urlPresetsDocs}>See the docs</ExternalLink>
</AlertDescription>
</Alert>
<Panel.Section>
<Panel.Loader isLoading={isLoading} />
<Panel.Title>
Manage presets
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={addNew}>
New
</Button>
</Panel.Title>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
{errors?.data && <Panel.Error>{errors.data.message}</Panel.Error>}
<Panel.Table>
<thead>
<tr>
<th className={style.fit}>Active</th>
<th className={style.aliasConstrain}>Preset</th>
<th className={style.fullWidth}>URL</th>
<th />
</tr>
</thead>
<tbody>
{fields.map((preset, index) => {
const maybeAliasError = errors.data?.[index]?.alias?.message;
const maybeUrlError = errors.data?.[index]?.pathAndParams?.message;
return (
<tr key={preset.id}>
<td className={style.fit}>
<Switch
{...register(`data.${index}.enabled`)}
variant='ontime'
data-testid={`field__enable_${index}`}
/>
</td>
<td className={style.aliasConstrain}>
<Input
{...register(`data.${index}.alias`, {
required: { value: true, message: 'Required field' },
})}
size='sm'
variant='ontime-filled'
placeholder='URL Preset'
data-testid={`field__alias_${index}`}
autoComplete='off'
/>
<Panel.Error>{maybeAliasError}</Panel.Error>
</td>
<td className={style.fullWidth}>
<Input
{...register(`data.${index}.pathAndParams`, {
required: { value: true, message: 'Required field' },
})}
size='sm'
variant='ontime-filled'
placeholder='URL (portion after ontime Port)'
data-testid={`field__url_${index}`}
autoComplete='off'
/>
<Panel.Error>{maybeUrlError}</Panel.Error>
</td>
<td className={style.flex}>
<TooltipActionBtn
size='sm'
clickHandler={(event) => handleLinks(event, preset.alias)}
tooltip='Test preset'
aria-label='Test preset'
variant='ontime-ghosted'
color='#e2e2e2' // $gray-200
icon={<IoOpenOutline />}
data-testid={`field__test_${index}`}
/>
<IconButton
size='sm'
onClick={() => remove(index)}
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
data-testid={`field__delete_${index}`}
/>
</td>
</tr>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -1,137 +0,0 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertIcon, Button, Input, Switch } from '@chakra-ui/react';
import { ViewSettings } from 'ontime-types';
import { maybeAxiosError } from '../../../../common/api/utils';
import { postViewSettings } from '../../../../common/api/viewSettings';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import { PopoverPickerRHF } from '../../../../common/components/input/popover-picker/PopoverPicker';
import useInfo from '../../../../common/hooks-query/useInfo';
import useViewSettings from '../../../../common/hooks-query/useViewSettings';
import * as Panel from '../PanelUtils';
import style from './GeneralPanel.module.scss';
const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/';
export default function ViewSettingsForm() {
const { data, status, refetch } = useViewSettings();
const { data: info, status: infoStatus } = useInfo();
const {
control,
handleSubmit,
register,
reset,
setError,
formState: { isSubmitting, isDirty },
} = useForm<ViewSettings>({
defaultValues: data,
values: data,
resetOptions: {
keepDirtyValues: true,
},
});
// update form if we get new data from server
useEffect(() => {
if (data) {
reset(data);
}
}, [data, reset]);
const onSubmit = async (formData: ViewSettings) => {
const newData = {
...formData,
};
try {
await postViewSettings(newData);
} catch (error) {
const message = maybeAxiosError(error);
setError('root', { message });
} finally {
await refetch();
}
};
const onReset = () => {
reset(data);
};
if (!control) {
return null;
}
const isLoading = status === 'pending' || infoStatus === 'pending';
return (
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} id='view-settings'>
<Panel.Card>
<Panel.SubHeader>
View settings
<div className={style.actionButtons}>
<Button isDisabled={!isDirty} variant='ontime-ghosted' size='sm' onClick={onReset}>
Revert to saved
</Button>
<Button type='submit' isLoading={isSubmitting} isDisabled={!isDirty} variant='ontime-filled' size='sm'>
Save
</Button>
</div>
</Panel.SubHeader>
<Panel.Divider />
<Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon />
<AlertDescription>
You can override the styles of the viewers with a custom CSS file. <br />
{info?.cssOverride && `In your installation the file is at ${info?.cssOverride}`}
<br />
<br />
<ExternalLink href={cssOverrideDocsUrl}>See the docs</ExternalLink>
</AlertDescription>
</Alert>
<Panel.Section>
<Panel.Loader isLoading={isLoading} />
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Override CSS styles'
description='Enables overriding view styles with custom stylesheet'
/>
<Switch {...register('overrideStyles')} variant='ontime' size='lg' />
</Panel.ListItem>
</Panel.ListGroup>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='Timer colour' description='Default colour of a running timer' />
<PopoverPickerRHF name='normalColor' control={control} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Warning colour' description='Colour of a running timer in warning mode' />
<PopoverPickerRHF name='warningColor' control={control} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Danger colour' description='Colour of a running timer in danger mode' />
<PopoverPickerRHF name='dangerColor' control={control} />
</Panel.ListItem>
</Panel.ListGroup>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='End message' description='If no end message is provided, timer will continue' />
<Input
size='sm'
autoComplete='off'
variant='ontime-filled'
maxLength={150}
width='275px'
placeholder='Message shown when timer reaches end'
{...register('endMessage')}
/>
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -5,7 +5,7 @@ import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { HttpSettings } from 'ontime-types';
import { generateId } from 'ontime-utils';
import { maybeAxiosError } from '../../../../common/api/utils';
import { maybeAxiosError } from '../../../../common/api/apiUtils';
import { useHttpSettings, usePostHttpSettings } from '../../../../common/hooks-query/useHttpSettings';
import { isKeyEscape } from '../../../../common/utils/keyEvent';
import { startsWithHttp } from '../../../../common/utils/regex';
@@ -16,7 +16,7 @@ import { cycles } from './integrationUtils';
import style from './IntegrationsPanel.module.css';
export default function HttpIntegrations() {
const { data, status } = useHttpSettings();
const { data } = useHttpSettings();
const { mutateAsync } = usePostHttpSettings();
const {
@@ -69,7 +69,6 @@ export default function HttpIntegrations() {
};
const canSubmit = !isSubmitting && isDirty && isValid;
const isLoading = status === 'pending';
return (
<Panel.Section>
@@ -78,7 +77,7 @@ export default function HttpIntegrations() {
HTTP
<div className={style.flex}>
<Button variant='ontime-ghosted' size='sm' onClick={() => reset()} isDisabled={!canSubmit}>
Revert to saved
Reset
</Button>
<Button
variant='ontime-filled'
@@ -88,13 +87,12 @@ export default function HttpIntegrations() {
isDisabled={!canSubmit}
isLoading={isSubmitting}
>
Save
Save changes
</Button>
</div>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section as='form' id='http-form' onSubmit={handleSubmit(onSubmit)} onKeyDown={preventEscape}>
<Panel.Loader isLoading={isLoading} />
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.ListGroup>
<Panel.ListItem>
@@ -2,8 +2,8 @@
width: 100%;
}
.fitContents.fitContents {
width: max-content; /* override chakra */
.fitContents {
width: max-content !important; /* override chakra */
}
.flex {
@@ -6,7 +6,7 @@ import * as Panel from '../PanelUtils';
import HttpIntegrations from './HttpIntegrations';
import OscIntegrations from './OscIntegrations';
const integrationDocsUrl = 'https://docs.getontime.no/api/integrations/';
const integrationDocsUrl = 'https://ontime.gitbook.io/v2/control-and-feedback/integrations';
export default function IntegrationsPanel() {
return (
@@ -16,7 +16,7 @@ export default function IntegrationsPanel() {
<Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon />
<AlertDescription>
Integrations allow Ontime to receive commands or send its data to other systems in your workflow. <br />
Integrations allow Ontime to receive commands or send its data to other systems in your workflow. <br />{' '}
<br />
Currently supported protocols are OSC (Open Sound Control), HTTP and Websockets. <br />
WebSockets are used for Ontime and cannot be configured independently. <br />
@@ -5,7 +5,7 @@ import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { OSCSettings } from 'ontime-types';
import { generateId } from 'ontime-utils';
import { maybeAxiosError } from '../../../../common/api/utils';
import { maybeAxiosError } from '../../../../common/api/apiUtils';
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
import { isKeyEscape } from '../../../../common/utils/keyEvent';
import { isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
@@ -16,7 +16,7 @@ import { cycles } from './integrationUtils';
import style from './IntegrationsPanel.module.css';
export default function OscIntegrations() {
const { data, status } = useOscSettings();
const { data } = useOscSettings();
const { mutateAsync } = useOscSettingsMutation();
const {
@@ -75,7 +75,6 @@ export default function OscIntegrations() {
};
const canSubmit = !isSubmitting && isDirty && isValid;
const isLoading = status === 'pending';
return (
<Panel.Card>
@@ -83,7 +82,7 @@ export default function OscIntegrations() {
Open Sound Control
<div className={style.flex}>
<Button variant='ontime-ghosted' size='sm' onClick={() => reset()} isDisabled={!canSubmit}>
Revert to saved
Reset
</Button>
<Button
variant='ontime-filled'
@@ -93,16 +92,12 @@ export default function OscIntegrations() {
isDisabled={!canSubmit}
isLoading={isSubmitting}
>
Save
Save changes
</Button>
</div>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section as='form' id='osc-form' onSubmit={handleSubmit(onSubmit)} onKeyDown={preventEscape}>
<Panel.Loader isLoading={isLoading} />
<Panel.Title>OSC Settings</Panel.Title>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.ListGroup>
<Panel.ListItem>
@@ -1,17 +1,8 @@
import { TimerLifeCycle } from 'ontime-types';
type CycleLabel = {
id: number;
label: string;
value: keyof typeof TimerLifeCycle;
};
export const cycles: CycleLabel[] = [
export const cycles = [
{ id: 1, label: 'On Load', value: 'onLoad' },
{ id: 2, label: 'On Start', value: 'onStart' },
{ id: 3, label: 'On Pause', value: 'onPause' },
{ id: 4, label: 'On Stop', value: 'onStop' },
{ id: 5, label: 'Every second', value: 'onClock' },
{ id: 5, label: 'On Timer Update', value: 'onUpdate' },
{ id: 5, label: 'Every second', value: 'onUpdate' },
{ id: 6, label: 'On Finish', value: 'onFinish' },
];
@@ -1,55 +0,0 @@
import { Switch } from '@chakra-ui/react';
import { useEditorSettings } from '../../../../common/stores/editorSettings';
import * as Panel from '../PanelUtils';
export default function EditorSettingsForm() {
const eventSettings = useEditorSettings((state) => state.eventSettings);
const setShowQuickEntry = useEditorSettings((state) => state.setShowQuickEntry);
const setStartTimeIsLastEnd = useEditorSettings((state) => state.setStartTimeIsLastEnd);
const setDefaultPublic = useEditorSettings((state) => state.setDefaultPublic);
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Editor settings</Panel.SubHeader>
<Panel.Divider />
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Show quick entry'
description='Whether the quick entry buttons show under selected event'
/>
<Switch
variant='ontime'
size='lg'
defaultChecked={eventSettings.showQuickEntry}
onChange={(event) => setShowQuickEntry(event.target.checked)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Start time is last end'
description='New events start time will be the previous event end'
/>
<Switch
variant='ontime'
size='lg'
defaultChecked={eventSettings.startTimeIsLastEnd}
onChange={(event) => setStartTimeIsLastEnd(event.target.checked)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Default public' description='New events will be public' />
<Switch
variant='ontime'
size='lg'
defaultChecked={eventSettings.defaultPublic}
onChange={(event) => setDefaultPublic(event.target.checked)}
/>
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Card>
</Panel.Section>
);
}

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