Compare commits

...

63 Commits

Author SHA1 Message Date
Carlos Valente d1ae407996 chore: version bump (#764) 2024-02-06 11:46:08 +01:00
Carlos Valente 467b595375 fix: issue where a end time can be overflowing (#761) 2024-02-06 11:42:28 +01:00
Carlos Valente 965a7092d9 fix: timer type clock is not negative in public displays (#747) 2024-01-30 12:35:36 +01:00
Alex Christoffer Rasmussen 661337fef9 fix: change all ExcelImportMaps to lowercase (#716)
* fix: change all ExcelImportMaps to lowercase

* chore: bump patch version
2024-01-15 21:37:04 +01:00
Carlos Valente 71092afc02 ontime release (#694)
* chore: version bump

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2024-01-10 20:50:19 +01:00
Alex Christoffer Rasmussen b1ba47a80f Fix/external path (#702)
* leave styles as is but move other external to `external` folder
2024-01-09 22:57:06 +01:00
Alex Christoffer Rasmussen 9611722a6d fix: use background-color: transparent; for overlap indicators (#699) 2024-01-09 16:42:50 +01:00
Alex Christoffer Rasmussen 92cefa1331 refactor: errors from integrationcontroller (#700)
* refactor: these functions don't throw, test the return success and then throw

* refactor: no need to catch and re-throw
2024-01-09 16:01:17 +01:00
Alex Christoffer Rasmussen c0fb865959 feat: google sheets(#579)
---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-01-08 15:51:58 +01:00
Oliver Herrmann 068ec800f4 add localdocker build commands (#638) 2024-01-05 21:04:52 +01:00
Alex Christoffer Rasmussen 1a8b35a5ea feat: show overlap (#633)
* feat: show spacing or overlap
2024-01-05 21:04:16 +01:00
Fabian Posenau 40bede200c nightly builds hopefully (#681)
Co-authored-by: Fabian Posenau <fabian@fphome.de>
2023-12-30 10:04:42 +01:00
Fabian Posenau f3e2944c55 fix docker compose ontime version (#680)
Co-authored-by: Fabian Posenau <fabian@fphome.de>
2023-12-29 22:25:41 +01:00
Fabian Posenau 09f2874784 Fix Visual bug on rundown (#678)
* fix: bug with draw order on past rundown items

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>
2023-12-29 20:18:05 +01:00
Carlos Valente 8afffb9870 style: fix missing overrides (#666)
* style: fix missing overrides

* style: fix recover idle colour
2023-12-22 21:05:23 +01:00
Carlos Valente 33575c551a refactor: improve typing on transform (#626)
* refactor: improve typing on transform

* chore: use placeholder for data errors
2023-12-19 10:03:25 +01:00
Carlos Valente 3173fb57fc chore: add italian to languages (#656) 2023-12-18 15:07:48 +01:00
Carlos Valente 7d33019f53 refactor: prevent accidental edit in operator view (#659)
* refactor: prevent accidental edit in operator view
2023-12-18 15:07:30 +01:00
Matteo Gheza 6526b32d4c Add italian client translation (#655)
* Add Italian client translation
2023-12-17 20:59:05 +01:00
Carlos Valente d6aef5ea31 fix: correct link for CSS overrides (#650) 2023-12-14 20:14:09 +01:00
Alex Christoffer Rasmussen 8756b396cc feat: minimal demo page (#644)
* minimal

* eslint-browser

* dynamic port, reconnect socket, msTo string without Date

* 404 on external not found
2023-12-14 13:27:18 +01:00
Carlos Valente 5a2f711eab refactor: cleanup sentry errors (#653) 2023-12-13 22:12:54 +01:00
Carlos Valente a56e2b1c1c fix: link for mac download (#648) 2023-12-13 19:22:17 +01:00
Carlos Valente da829dc09f Release preparations (#642)
* chore: version bump
2023-12-11 22:06:19 +01:00
Carlos Valente c4d65dbe5f fix: display correct end time in backstage (#635) 2023-12-10 19:12:29 +01:00
Alex Christoffer Rasmussen 2586b0b10c feat: http integration (#575)
* addtime endpoint

* create universal Subscription

* add http integration

* catch error from HTTP integration emit

* unify osc and http validateSubscriptionEntry

* add necessary endpoint for http subscription

* make subscription part of modal generic

* add http subscription to integration modal

* remove log

* Revert "addtime endpoint"

This reverts commit 4c039220dc.

* reuse agent and test url compatibility

* simplify retun path

* add todo in UI

* test for http protocol

* import not needed yet

* lint

* fix merge

* lint

* fix httpPlaceholder

* wip: prepare endpoints

* wip: temporary fix to get form to work

* disable HTTP integration if enabledOut==false

* register/unregister http

* refactor: subscription types and form register

* refactor: validation

* cleanup

* cleanup

* try GOT

* allow https

* split url and searchParams allow for post option

* add options to post

* add retry count

* fix test

* Revert "fix test"

This reverts commit 927e88370f.

* Revert "add options to post"

This reverts commit 0523a68ef4.

* Revert "split url and searchParams allow for post option"

This reverts commit 54ab8d4ffe.

* missing retryCount in httpPlaceholder

* remove global this

* remove retry count

* remove https

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2023-12-10 16:56:42 +01:00
Alex Christoffer Rasmussen abe27d54a2 refactor: use add time in client (#636) 2023-12-08 21:46:29 +01:00
Alex Christoffer Rasmussen 1343818917 Improve change api (#628)
* fix: jsdoc comments

* more human casting of string values to boolean

* add coerceColour to get named colors to work

* cue can't pass isKeyOfType event check

* dont mutate value

* alloow cue in eventDef

* lint

* css named or hex formatting

* "sideEffects": false

* test colour hex regex

* roll eventDef back to master

* parse property

* don't export cssColour names

* remove import

* only allow string types to colour

* handle transparent colour

* mix alpha with bg colour

* only allow updating events

* descriptive names

* add test for getAccessibleColour

* readability

* move isColourHex to regex-utils

* simplify and add test
2023-12-08 12:55:04 +01:00
Carlos Valente a93cd3e9b1 style: prevent menu mirroring (#632) 2023-12-07 17:57:04 +01:00
Alex Christoffer Rasmussen 813eb9ad86 Merge pull request #604 from cpvalente/roll-back
add skippedOutOfEvent to TimerService
2023-12-04 12:53:43 +01:00
Carlos Valente 34e9b1ef07 refactor: clarify responsabilities 2023-11-30 21:35:09 +01:00
arc-alex a67b89190d guard and test midnight 2023-11-28 16:04:12 +01:00
arc-alex ecd151a0a8 use expectedFinish 2023-11-28 15:27:19 +01:00
asharonbaltazar 4cbc09864c Params Input Placeholders (#627)
* add `placeholder?: string;` to `StringField` & `NumberField`

* add `placeholder` to inputs

* move values from `defaultValue` to `placeholder`
2023-11-27 18:25:46 -05:00
arc-alex 20d5d8b129 move skip logic to timerUtils 2023-11-27 17:42:42 +01:00
Alex Christoffer Rasmussen 11db39ee73 Merge pull request #623 from cpvalente/osc-enabledOut
Osc disable/enable output
2023-11-27 16:56:54 +01:00
asharonbaltazar edb79d5819 Default Values in ParamEditor (#610)
* add `defaultvalue` to `Field` types

* add `defaultValue` to constants; turn into functions for time values

* update constant imports/calls in view components

* use `defaultValue`s in `ParamInput`

* change `Clear` to `Reset`to better reflect actions

* make `defaultValue` optional rather than `undefined`

* update forgotten constants

* add `defaultValue` to boolean input

* change `101010` to `000000`

* add `prefix` property to types

* implement `prefix` for `ParamInput`s

* change `paramField` to `prop`

* add `onEditDrawerClose` to `resetParams`

* `ViewParamsEditor` omits default values

* undo close on reset

* move `useSettings` into `ViewWrapper`

* change `settings` to include `undefined`
2023-11-26 15:57:18 -05:00
arc-alex 715fa66444 disable/enable from ui without restart 2023-11-26 20:41:33 +01:00
arc-alex 27f6d10677 actually disable/enable osc output 2023-11-26 20:41:01 +01:00
Carlos Valente f2504072ce feat: add change endpoint to ws (#618)
* feat: add change endpoint to ws
2023-11-26 19:02:16 +01:00
Carlos Valente 9e285ee8f3 chore: version bump (#616) 2023-11-25 22:31:22 +01:00
Carlos Valente 1ccf2108eb style: tweaks on info data (#622) 2023-11-25 22:31:09 +01:00
Carlos Valente db56ac8049 style: separe arguments in copy fields (#621) 2023-11-25 22:28:54 +01:00
Carlos Valente 0660f014c8 fix: prevent autocomplete (#620) 2023-11-25 22:04:47 +01:00
Carlos Valente 41c26778cb fix: prevent shortcut collision with menu (#619) 2023-11-25 21:55:27 +01:00
Carlos Valente 804e4ad33e chore: disable save params feature (#615) 2023-11-24 18:29:37 +01:00
arc-alex b11041938d self documenting 2023-11-24 14:05:12 +01:00
Carlos Valente 5be60ff6c3 feat: show progress in op and cuesheet (#605) 2023-11-24 12:04:38 +01:00
Carlos Valente bf8ebe942e feat: allow editing field from operator (#609)
* feat: allow editing field from operator

Co-authored-by: arc-alex <ac@omnivox.dk>

* style: functional and presentation tweaks
---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2023-11-24 12:04:22 +01:00
Carlos Valente bdd216d881 feat: customise timer elements (#607)
* feat: customise timer elements
2023-11-22 21:38:35 +01:00
arc-alex e6b4f537af rollSkipLimit 2023-11-22 19:05:08 +01:00
Alex Christoffer Rasmussen 797a0edf57 External field (#580)
* feat: add external endpoint

* add external to messagecontrol

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2023-11-22 08:06:48 +01:00
asharonbaltazar c4886a617c remove storedViewParams from the dep array (#606) 2023-11-20 18:41:41 -05:00
Carlos Valente a79ae7e0c4 fix: cuesheet header (#600)
* `useLocalStorage` hook uses `useSyncExternalStore`
---------

Co-authored-by: asharonbaltazar <asharonbaltazar@outlook.com>
2023-11-20 21:11:52 +01:00
arc-alex 8cabb347e9 remove test log 2023-11-20 18:24:11 +01:00
arc-alex 22d89c6fbc add _startAt to all tests 2023-11-20 17:51:50 +01:00
arc-alex facce4f096 trigger doRoll if clock is less than startAt 2023-11-20 17:44:39 +01:00
Carlos Valente afa62a6e38 test: alias feature (#602) 2023-11-20 11:45:17 +01:00
Carlos Valente 1b55dbc170 style: clock period (#601)
* style: tweaks to studio override

* style: improvements to 12 hour format
2023-11-20 09:53:35 +01:00
Carlos Valente 2316bbebac chore: type improvements (#598)
* chore: type improvements

* ci: typechecking in pipeline
2023-11-18 08:35:54 +01:00
Carlos Valente 884ab0b67b refactor: rundown (#597)
* refactor: add revision number to rundown

* chore: migrate query
2023-11-17 16:18:02 +01:00
Carlos Valente 58239af8bb fix: distinguish user initiated scroll (#596) 2023-11-17 13:01:28 +01:00
Alex Christoffer Rasmussen c59e070076 Local build (#595)
* add build:electron script

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2023-11-17 13:01:08 +01:00
184 changed files with 5716 additions and 1226 deletions
+13 -1
View File
@@ -44,7 +44,8 @@ jobs:
- name: Docker Setup Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Build and push Docker images
- name: Build and push stable release
if: github.event.release.prerelease == false
uses: docker/build-push-action@v4.0.0
with:
context: .
@@ -54,3 +55,14 @@ jobs:
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
- name: Build and push pre-release
if: github.event.release.prerelease == true
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
+6 -6
View File
@@ -28,19 +28,19 @@ jobs:
run: pnpm install --frozen-lockfile
# Run code quality per package
- name: React - Run linter
- name: React - Run linter + TypeScript checks
if: always()
run: pnpm lint
run: pnpm lint && tsc --noEmit
working-directory: ./apps/client
- name: Server - Run linter
- name: Server - Run linter + TypeScript checks
if: always()
run: pnpm lint
run: pnpm lint && tsc --noEmit
working-directory: ./apps/server
- name: Utils - Run linter
- name: Utils - Run linter + TypeScript checks
if: always()
run: pnpm lint
run: pnpm lint && tsc --noEmit
working-directory: ./packages/utils
- name: Types - Run linter
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm lint
pnpm lint-staged
+2 -1
View File
@@ -76,7 +76,7 @@ You can generate a distribution for your OS by running the following steps.
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Build the UI and server__ by running `turbo build:local`
- __Build the UI and server__ by running `turbo build:electron`
- __Create the package__ by running `turbo dist-win`, `turbo dist-mac` or `turbo dist-linux`
The build distribution assets will be at `.apps/electron/dist`
@@ -89,6 +89,7 @@ While it should allow for a generic setup, it might need to be modified to fit y
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Build packages__ by running `pnpm build:localdocker`
- __Build docker image from__ by running `docker build -t getontime/ontime`
- __Run docker image from compose__ by running `docker-compose up -d`
+1 -1
View File
@@ -4,7 +4,7 @@
## Download the latest releases here
<div style="display: flex; justify-content: space-around">
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS.dmg"><img alt="Download MacOS" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/mac-download.png"/></a>
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg"><img alt="Download MacOS" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/mac-download.png"/></a>
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe"><img alt="Download Windows" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/win-download.png"/></a>
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage"><img alt="Download Linux" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/linux-download.png"/></a>
<a href="https://hub.docker.com/r/getontime/ontime"><img alt="Get from Dockerhub" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/dockerhub.png"/></a>
+7 -4
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "2.16.2",
"version": "2.28.16",
"private": true,
"dependencies": {
"@chakra-ui/react": "^2.7.0",
@@ -12,8 +12,8 @@
"@react-icons/all-files": "^4.1.0",
"@sentry/react": "^7.46.0",
"@sentry/tracing": "^7.46.0",
"@tanstack/react-query": "^4.28.0",
"@tanstack/react-query-devtools": "^4.29.0",
"@tanstack/react-query": "^5.8.4",
"@tanstack/react-query-devtools": "^5.8.4",
"@tanstack/react-table": "^8.9.2",
"autosize": "^6.0.1",
"axios": "^1.2.0",
@@ -38,8 +38,11 @@
"dev": "cross-env BROWSER=none vite",
"build": "vite build",
"build:local": "cross-env NODE_ENV=local vite build",
"build:electron": "cross-env NODE_ENV=local vite build",
"build:docker": "vite build",
"build:localdocker": "cross-env NODE_ENV=local vite build",
"lint": "eslint . --quiet",
"lint-staged": "eslint",
"test": "vitest",
"test:pipeline": "vitest run",
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build"
@@ -58,7 +61,7 @@
},
"devDependencies": {
"@sentry/vite-plugin": "^0.4.0",
"@tanstack/eslint-plugin-query": "^4.26.2",
"@tanstack/eslint-plugin-query": "^5.8.4",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.1.1",
"@testing-library/user-event": "^14.1.1",
+3 -2
View File
@@ -2,13 +2,14 @@
export const PROJECT_DATA = ['project'];
export const ALIASES = ['aliases'];
export const USERFIELDS = ['userFields'];
export const RUNDOWN_TABLE_KEY = 'rundown';
export const RUNDOWN_TABLE = [RUNDOWN_TABLE_KEY];
export const RUNDOWN = ['rundown'];
export const APP_INFO = ['appinfo'];
export const OSC_SETTINGS = ['oscSettings'];
export const HTTP_SETTINGS = ['httpSettings'];
export const APP_SETTINGS = ['appSettings'];
export const VIEW_SETTINGS = ['viewSettings'];
export const RUNTIME = ['runtimeStore'];
export const SHEET_STATE = ['sheetState'];
const location = window.location;
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
+8 -8
View File
@@ -42,12 +42,12 @@ export function logAxiosError(prepend: string, error: unknown) {
* Utility function invalidates react-query caches
*/
export async function invalidateAllCaches() {
await ontimeQueryClient.invalidateQueries(['project']);
await ontimeQueryClient.invalidateQueries(['aliases']);
await ontimeQueryClient.invalidateQueries(['userFields']);
await ontimeQueryClient.invalidateQueries(['rundown']);
await ontimeQueryClient.invalidateQueries(['appinfo']);
await ontimeQueryClient.invalidateQueries(['oscSettings']);
await ontimeQueryClient.invalidateQueries(['appSettings']);
await ontimeQueryClient.invalidateQueries(['viewSettings']);
await ontimeQueryClient.invalidateQueries({ queryKey: ['project'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['aliases'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['userFields'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['rundown'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['appinfo'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['oscSettings'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['appSettings'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['viewSettings'] });
}
+11 -1
View File
@@ -1,5 +1,5 @@
import axios from 'axios';
import { OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import { GetRundownCached, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import { rundownURL } from './apiConstants';
@@ -7,6 +7,16 @@ import { rundownURL } from './apiConstants';
* @description HTTP request to fetch all events
* @return {Promise}
*/
export async function fetchCachedRundown(): Promise<GetRundownCached> {
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;
+91
View File
@@ -3,6 +3,7 @@ import {
Alias,
DatabaseModel,
GetInfo,
HttpSettings,
OntimeRundown,
OSCSettings,
OscSubscription,
@@ -104,6 +105,23 @@ export async function getOSC(): Promise<OSCSettings> {
return res.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) {
return axios.post(`${ontimeURL}/http`, data);
}
/**
* @description HTTP request to mutate osc settings
* @return {Promise}
@@ -227,3 +245,76 @@ export async function getLatestVersion(): Promise<HasUpdate> {
export async function postNew(initialData: Partial<ProjectData>) {
return axios.post(`${ontimeURL}/new`, initialData);
}
/**
* @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;
};
@@ -19,9 +19,11 @@ interface TextInputProps extends BaseProps {
isTextArea?: false;
}
type ResizeOptions = 'horizontal' | 'vertical' | 'none';
interface TextAreaProps extends BaseProps {
isTextArea: true;
resize?: 'horizontal' | 'vertical' | 'none';
resize?: ResizeOptions;
}
type InputProps = TextInputProps | TextAreaProps;
@@ -35,7 +37,7 @@ export default function TextInput(props: InputProps) {
const textInputProps = useReactiveTextInput(initialText, submitCallback, { submitOnEnter: true });
const textAreaProps = useReactiveTextInput(initialText, submitCallback);
let resize = 'none';
let resize: ResizeOptions = 'none';
if (isTextArea) {
resize = (props as TextAreaProps)?.resize ?? 'none';
}
@@ -17,9 +17,8 @@ interface TimeInputProps {
time?: number;
delay?: number;
placeholder: string;
validationHandler: (entry: TimeEntryField, val: number) => boolean;
previousEnd?: number;
warning?: string;
tooltip?: string;
}
function ButtonInitial(name: TimeEntryField) {
@@ -29,25 +28,15 @@ function ButtonInitial(name: TimeEntryField) {
return '';
}
function ButtonTooltip(name: TimeEntryField, warning?: string) {
if (name === 'timeStart') return `Start${warning ? `: ${warning}` : ''}`;
if (name === 'timeEnd') return `End${warning ? `: ${warning}` : ''}`;
if (name === 'durationOverride') return `Duration${warning ? `: ${warning}` : ''}`;
function ButtonTooltip(name: TimeEntryField, tooltip?: string) {
if (name === 'timeStart') return `Start${tooltip ? `: ${tooltip}` : ''}`;
if (name === 'timeEnd') return `End${tooltip ? `: ${tooltip}` : ''}`;
if (name === 'durationOverride') return `Duration${tooltip ? `: ${tooltip}` : ''}`;
return '';
}
export default function TimeInput(props: TimeInputProps) {
const {
id,
name,
submitHandler,
time = 0,
delay = 0,
placeholder,
validationHandler,
previousEnd = 0,
warning,
} = props;
const { id, name, submitHandler, time = 0, delay = 0, placeholder, previousEnd = 0 } = props;
const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>('');
@@ -103,15 +92,12 @@ export default function TimeInput(props: TimeInputProps) {
// check if time is different from before
if (newValMillis === time) return false;
// validate with parent
if (!validationHandler(name, newValMillis)) return false;
// update entry
submitHandler(name, newValMillis);
return true;
},
[name, previousEnd, submitHandler, time, validationHandler],
[name, previousEnd, submitHandler, time],
);
/**
@@ -171,11 +157,11 @@ export default function TimeInput(props: TimeInputProps) {
const isDelayed = delay !== 0;
const inputClasses = cx([style.timeInput, isDelayed ? style.delayed : null]);
const buttonClasses = cx([style.inputButton, isDelayed ? style.delayed : null, warning ? style.warn : null]);
const buttonClasses = cx([style.inputButton, isDelayed ? style.delayed : null]);
const TooltipLabel = useMemo(() => {
return ButtonTooltip(name, warning);
}, [name, warning]);
return ButtonTooltip(name, '');
}, [name]);
const ButtonText = useMemo(() => {
return ButtonInitial(name);
@@ -212,6 +198,7 @@ export default function TimeInput(props: TimeInputProps) {
onKeyDown={onKeyDownHandler}
value={value}
maxLength={8}
autoComplete='off'
/>
</InputGroup>
);
@@ -10,6 +10,7 @@ $progress-bar-br: 3px;
border-radius: $progress-bar-br;
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
display: flex;
overflow: hidden;
&--hidden {
display: none;
@@ -31,7 +32,6 @@ $progress-bar-br: 3px;
position: absolute;
height: inherit;
right: 0;
border-radius: $progress-bar-br;
width: 100%;
}
@@ -39,12 +39,10 @@ $progress-bar-br: 3px;
position: absolute;
height: inherit;
right: 0;
border-radius: 0 $progress-bar-br $progress-bar-br 0;
}
.multiprogress-bar__bg-danger {
position: absolute;
height: inherit;
right: 0;
border-radius: 0 $progress-bar-br $progress-bar-br 0;
}
@@ -3,7 +3,7 @@ import { clamp } from '../../utils/math';
import './MultiPartProgressBar.scss';
interface MultiPartProgressBar {
now: number;
now: number | null;
complete: number;
normalColor: string;
warning: number;
@@ -17,23 +17,27 @@ interface MultiPartProgressBar {
export default function MultiPartProgressBar(props: MultiPartProgressBar) {
const { now, complete, normalColor, warning, warningColor, danger, dangerColor, hidden, className = '' } = props;
const percentComplete = 100 - clamp(100 - (Math.max(now, 0) * 100) / complete, 0, 100);
const percentComplete = 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
const dangerWidth = clamp((danger / complete) * 100, 0, 100);
const warningWidth = clamp((warning / complete) * 100, 0, 100);
return (
<div className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${className}`}>
<div className='multiprogress-bar__bg-normal' style={{ backgroundColor: normalColor }} />
<div
className='multiprogress-bar__bg-warning'
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
/>
<div
className='multiprogress-bar__bg-danger'
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
/>
<div className='multiprogress-bar__indicator' style={{ width: `${percentComplete}%` }} />
{now !== null && (
<>
<div className='multiprogress-bar__bg-normal' style={{ backgroundColor: normalColor }} />
<div
className='multiprogress-bar__bg-warning'
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
/>
<div
className='multiprogress-bar__bg-danger'
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
/>
<div className='multiprogress-bar__indicator' style={{ width: `${percentComplete}%` }} />
</>
)}
</div>
);
}
@@ -10,10 +10,6 @@ $icon-color: $ui-white;
$button-bg: $gray-1050;
$button-size: 48px;
.mirror {
transform: rotate(180deg);
}
.buttonContainer {
display: flex;
flex-direction: column;
@@ -12,7 +12,6 @@ import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { navigatorConstants } from '../../../viewerConfig';
import useClickOutside from '../../hooks/useClickOutside';
import useFullscreen from '../../hooks/useFullscreen';
import { useKeyDown } from '../../hooks/useKeyDown';
import { useViewOptionsStore } from '../../stores/viewOptions';
import RenameClientModal from './rename-client-modal/RenameClientModal';
@@ -23,18 +22,19 @@ function NavigationMenu() {
const location = useLocation();
const { isFullScreen, toggleFullScreen } = useFullscreen();
const { mirror, toggleMirror } = useViewOptionsStore();
const { toggleMirror } = useViewOptionsStore();
const [showButton, setShowButton] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
useClickOutside(menuRef, () => setShowMenu(false));
const { isOpen, onOpen, onClose } = useDisclosure();
const toggleMenu = () => setShowMenu((prev) => !prev);
useKeyDown(toggleMenu, ' ', { isDisabled: searchParams.get('edit') === 'true' || isOpen });
// show on mouse move
useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null;
const setShowMenuTrue = () => {
@@ -63,7 +63,7 @@ function NavigationMenu() {
};
return createPortal(
<div id='navigation-menu-portal' ref={menuRef} className={mirror ? style.mirror : ''}>
<div id='navigation-menu-portal' ref={menuRef}>
<RenameClientModal isOpen={isOpen} onClose={onClose} />
<div className={`${style.buttonContainer} ${!showButton && !showMenu ? style.hidden : ''}`}>
<button onClick={toggleMenu} aria-label='toggle menu' className={style.navButton}>
@@ -1,7 +1,12 @@
import SuperscriptTime from '../../../features/viewers/common/superscript-time/SuperscriptTime';
import { formatTime } from '../../utils/time';
import './Schedule.scss';
const formatOptions = {
format: 'hh:mm a',
};
interface ScheduleItemProps {
selected: 'past' | 'now' | 'future';
timeStart: number;
@@ -16,8 +21,8 @@ interface ScheduleItemProps {
export default function ScheduleItem(props: ScheduleItemProps) {
const { selected, timeStart, timeEnd, title, presenter, backstageEvent, colour, skip } = props;
const start = formatTime(timeStart, { format: 'hh:mm' });
const end = formatTime(timeEnd, { format: 'hh:mm' });
const start = formatTime(timeStart, formatOptions);
const end = formatTime(timeEnd, formatOptions);
const userColour = colour !== '' ? colour : '';
const selectStyle = `entry--${selected}`;
@@ -25,7 +30,12 @@ export default function ScheduleItem(props: ScheduleItemProps) {
<li className={`entry ${selectStyle} ${skip ? 'skip' : ''}`}>
<div className='entry-times'>
<span className='entry-colour' style={{ backgroundColor: userColour }} />
{`${start}${end} ${backstageEvent ? '*' : ''}`}
<div style={{ display: 'flex' }}>
<SuperscriptTime time={start} />
{' → '}
<SuperscriptTime time={end} />
{backstageEvent ? '*' : ''}
</div>
</div>
<div className='entry-title'>{title}</div>
{presenter && <div className='entry-presenter'>{presenter}</div>}
@@ -1,5 +1,5 @@
import { useSearchParams } from 'react-router-dom';
import { Input, Select, Switch } from '@chakra-ui/react';
import { Input, InputGroup, InputLeftElement, Select, Switch } from '@chakra-ui/react';
import { isStringBoolean } from '../../utils/viewUtils';
@@ -9,16 +9,22 @@ interface EditFormInputProps {
paramField: ParamField;
}
export default function ParamInput({ paramField }: EditFormInputProps) {
export default function ParamInput(props: EditFormInputProps) {
const [searchParams] = useSearchParams();
const { id, type } = paramField;
const { paramField } = props;
const { id, type, defaultValue } = paramField;
if (type === 'option') {
const optionFromParams = searchParams.get(id);
const defaultOptionValue = optionFromParams || undefined;
const defaultOptionValue = optionFromParams || defaultValue;
return (
<Select placeholder='Select an option' variant='ontime' name={id} defaultValue={defaultOptionValue}>
<Select
placeholder={defaultValue ? undefined : 'Select an option'}
variant='ontime'
name={id}
defaultValue={defaultOptionValue}
>
{Object.entries(paramField.values).map(([key, value]) => (
<option key={key} value={key}>
{value}
@@ -29,19 +35,38 @@ export default function ParamInput({ paramField }: EditFormInputProps) {
}
if (type === 'boolean') {
const defaultCheckedValue = isStringBoolean(searchParams.get(id)) ?? false;
const defaultCheckedValue = isStringBoolean(searchParams.get(id)) || defaultValue;
// checked value should be 'true', so it can be captured by the form event
return <Switch variant='ontime' name={id} defaultChecked={defaultCheckedValue} value='true' />;
}
if (type === 'number') {
const defaultNumberValue = searchParams.get(id) ?? '';
const { prefix, placeholder } = paramField;
const defaultNumberValue = searchParams.get(id) ?? defaultValue;
return <Input type='number' step='any' variant='ontime-filled' name={id} defaultValue={defaultNumberValue} />;
return (
<InputGroup variant='ontime-filled'>
{prefix && <InputLeftElement pointerEvents='none'>{prefix}</InputLeftElement>}
<Input
type='number'
step='any'
variant='ontime-filled'
name={id}
defaultValue={defaultNumberValue}
placeholder={placeholder}
/>
</InputGroup>
);
}
const defaultStringValue = searchParams.get(id) ?? '';
const defaultStringValue = searchParams.get(id) ?? defaultValue;
const { prefix, placeholder } = paramField;
return <Input variant='ontime-filled' name={id} defaultValue={defaultStringValue} />;
return (
<InputGroup variant='ontime-filled'>
{prefix && <InputLeftElement pointerEvents='none'>{prefix}</InputLeftElement>}
<Input name={id} defaultValue={defaultStringValue} placeholder={placeholder} />
</InputGroup>
);
}
@@ -22,9 +22,15 @@ import style from './ViewParamsEditor.module.scss';
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
type SavedViewParams = Record<string, ViewParamsObj>;
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj) =>
Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => {
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ParamField[]) => {
const defaultValues = paramFields.map(({ defaultValue }) => String(defaultValue));
return Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => {
if (typeof value === 'string' && value.length) {
if (defaultValues.includes(value)) {
return newSearchParams;
}
newSearchParams.set(id, value);
return newSearchParams;
@@ -32,6 +38,7 @@ const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj) =>
return newSearchParams;
}, new URLSearchParams());
};
interface EditFormDrawerProps {
paramFields: ParamField[];
@@ -51,6 +58,12 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
}
}, [searchParams, onOpen]);
/**
* disabling this for now, this feature needs more testing
* - we seem to have a bug where this is conflicting with the aliases
* - I wonder if the logic below needs to be inside an effect,
* both localStorage and searchParams should trigger a component update when they change
useEffect(() => {
const viewParamsObjFromLocalStorage = storedViewParams[pathname];
@@ -59,36 +72,37 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
setSearchParams(defaultSearchParams);
}
// linter is asking for `setSearchParams` in the useEffect deps
// rule is disabled since adding `setSearchParams` results in unnecessary re-renders
// linter is asking for `setSearchParams` & `storedViewParams` in the useEffect deps
// rule is disabled since adding `setSearchParams` & `storedViewParams` results in unnecessary re-renders
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storedViewParams, pathname]);
}, [pathname]);
const onEditDrawerClose = () => {
*/
const onCloseWithoutSaving = () => {
onClose();
searchParams.delete('edit');
setSearchParams(searchParams);
};
const clearParams = () => {
const resetParams = () => {
setStoredViewParams({ ...storedViewParams, [pathname]: {} });
setSearchParams();
onClose();
};
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
formEvent.preventDefault();
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject);
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, paramFields);
setStoredViewParams({ ...storedViewParams, [pathname]: newParamsObject });
setSearchParams(newSearchParams);
};
return (
<Drawer isOpen={isOpen} placement='right' onClose={onEditDrawerClose} size='lg'>
<Drawer isOpen={isOpen} placement='right' onClose={onCloseWithoutSaving} size='lg'>
<DrawerOverlay />
<DrawerContent>
<DrawerHeader className={style.drawerHeader}>
@@ -111,10 +125,10 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
</DrawerBody>
<DrawerFooter className={style.drawerFooter}>
<Button variant='ontime-ghosted' onClick={clearParams} type='reset'>
Clear
<Button variant='ontime-ghosted' onClick={resetParams} type='reset'>
Reset
</Button>
<Button variant='ontime-subtle' onClick={onEditDrawerClose}>
<Button variant='ontime-subtle' onClick={onCloseWithoutSaving}>
Cancel
</Button>
<Button variant='ontime-filled' form='edit-params-form' type='submit'>
@@ -1,46 +1,56 @@
import { UserFields } from 'ontime-types';
import { TimeFormat } from 'ontime-types/src/definitions/core/TimeFormat.type';
import { ParamField } from './types';
export const TIME_FORMAT_OPTION: ParamField = {
export const getTimeOption = (timeFormat: TimeFormat): ParamField => ({
id: 'format',
title: '12 / 24 hour timer',
description: 'Whether to show the time in 12 or 24 hour mode. Overrides the global setting from preferences',
type: 'option',
values: { '12': '12 hour AM/PM', '24': '24 hour' },
};
defaultValue: timeFormat,
});
export const CLOCK_OPTIONS: ParamField[] = [
TIME_FORMAT_OPTION,
export const getClockOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'key',
title: 'Key Colour',
description: 'Background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffff (default)',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Colour of text background in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Arial Black (default)',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'alignx',
@@ -48,12 +58,14 @@ export const CLOCK_OPTIONS: ParamField[] = [
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'aligny',
@@ -61,47 +73,94 @@ export const CLOCK_OPTIONS: ParamField[] = [
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
];
export const TIMER_OPTIONS: ParamField[] = [TIME_FORMAT_OPTION];
export const getTimerOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
{
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[] = [
{
id: 'key',
title: 'Key Colour',
description: 'Background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffff (default)',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Colour of text background in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Arial Black (default)',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'alignx',
@@ -109,12 +168,14 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'aligny',
@@ -122,145 +183,162 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'hideovertime',
title: 'Hide Overtime',
description: 'Whether to suppress overtime styles (red borders and red text)',
type: 'boolean',
defaultValue: false,
},
{
id: 'hidemessages',
title: 'Hide Message Overlay',
description: 'Whether to hide the overlay from showing timer screen messages',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideendmessage',
title: 'Hide End Message',
description: 'Whether to hide end message and continue showing the clock if timer is in overtime',
type: 'boolean',
defaultValue: false,
},
];
export const LOWER_THIRDS_OPTIONS: ParamField[] = [
{
id: 'preset',
title: 'Preset',
description: 'Selects a style preset (0-1)',
type: 'number',
},
{
id: 'size',
title: 'Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'transition',
title: 'Transition',
description: 'Transition in time in seconds (default 5)',
description: 'Transition in time in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffffa (default)',
},
{
id: 'bg',
title: 'Text Background',
description: 'Text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000033 (default)',
},
{
id: 'key',
title: 'Key Colour',
description: 'Screen background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000033 (default)',
},
{
id: 'fadeout',
title: 'Fadeout',
description: 'Time (in seconds) the lower third displays before fading out',
type: 'number',
placeholder: '3 (default)',
},
];
export const BACKSTAGE_OPTIONS: ParamField[] = [
TIME_FORMAT_OPTION,
export const getBackstageOptions = (timeFormat: TimeFormat): 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 PUBLIC_OPTIONS: ParamField[] = [
TIME_FORMAT_OPTION,
export const getPublicOptions = (timeFormat: TimeFormat): 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 STUDIO_CLOCK_OPTIONS: ParamField[] = [
TIME_FORMAT_OPTION,
export const getStudioClockOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'seconds',
title: 'Show Seconds',
description: 'Shows seconds in clock',
type: 'boolean',
defaultValue: false,
},
];
export const getOperatorOptions = (userFields: UserFields): ParamField[] => {
export const getOperatorOptions = (userFields: UserFields, timeFormat: TimeFormat): ParamField[] => {
return [
TIME_FORMAT_OPTION,
getTimeOption(timeFormat),
{
id: 'showseconds',
title: 'Show seconds',
description: 'Schedule shows hh:mm:ss',
type: 'boolean',
defaultValue: false,
},
{
id: 'hidepast',
title: 'Hide Past Events',
description: 'Whether to events that have passed',
type: 'boolean',
defaultValue: false,
},
{
id: 'main',
@@ -302,5 +380,12 @@ export const getOperatorOptions = (userFields: UserFields): ParamField[] => {
user9: userFields.user9 || 'user9',
},
},
{
id: 'shouldEdit',
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,
},
];
};
@@ -4,9 +4,13 @@ type BaseField = {
description: string;
};
type OptionsField = { type: 'option'; values: Record<string, string> };
type StringField = { type: 'string' };
type BooleanField = { type: 'boolean' };
type NumberField = { type: 'number' };
type OptionsField = {
type: 'option';
values: Record<string, string>;
defaultValue?: string;
};
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
type BooleanField = { type: 'boolean'; defaultValue: boolean };
export type ParamField = BaseField & (StringField | BooleanField | NumberField | OptionsField);
@@ -25,7 +25,7 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
const [operatorAuth, setOperatorAuth] = useState(true);
useEffect(() => {
if (status === 'loading') return;
if (status === 'pending') return;
if (!data) return;
const previousEditor = sessionStorage.getItem(storageKeys.editor);
@@ -0,0 +1,33 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { HttpSettings } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
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';
export function useHttpSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: HTTP_SETTINGS,
queryFn: getHTTP,
placeholderData: httpPlaceholder,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
// we need to jump through some hoops because of the type op port
return { data: data! as unknown as HttpSettings, status, isFetching, isError, refetch };
}
export function usePostHttpSettings() {
const { isPending, mutateAsync } = useMutation({
mutationFn: postHTTP,
onError: (error) => logAxiosError('Error saving HTTP settings', error),
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: HTTP_SETTINGS }),
});
return { isPending, mutateAsync };
}
@@ -1,5 +1,4 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { OSCSettings } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { OSC_SETTINGS } from '../api/apiConstants';
@@ -11,7 +10,10 @@ import { ontimeQueryClient } from '../queryClient';
export default function useOscSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: OSC_SETTINGS,
queryFn: getOSC,
queryFn: async () => {
const oscData = await getOSC();
return { ...oscData, portIn: String(oscData.portIn), portOut: String(oscData.portOut) };
},
placeholderData: oscPlaceholderSettings,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
@@ -19,25 +21,24 @@ export default function useOscSettings() {
networkMode: 'always',
});
// we need to jump through some hoops because of the type op port
return { data: data! as unknown as OSCSettings, status, isFetching, isError, refetch };
return { data: data ?? oscPlaceholderSettings, status, isFetching, isError, refetch };
}
export function useOscSettingsMutation() {
const { isLoading, mutateAsync } = useMutation({
const { isPending, mutateAsync } = useMutation({
mutationFn: postOSC,
onError: (error) => logAxiosError('Error saving OSC settings', error),
onSuccess: (res) => ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data),
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
});
return { isLoading, mutateAsync };
return { isPending, mutateAsync };
}
export function usePostOscSubscriptions() {
const { isLoading, mutateAsync } = useMutation({
const { isPending, mutateAsync } = useMutation({
mutationFn: postOscSubscriptions,
onError: (error) => logAxiosError('Error saving OSC settings', error),
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
});
return { isLoading, mutateAsync };
return { isPending, mutateAsync };
}
@@ -1,19 +1,29 @@
import { useQuery } from '@tanstack/react-query';
import { GetRundownCached } from 'ontime-types';
import { queryRefetchInterval } from '../../ontimeConfig';
import { RUNDOWN_TABLE } from '../api/apiConstants';
import { fetchRundown } from '../api/eventsApi';
import { RUNDOWN } from '../api/apiConstants';
import { fetchCachedRundown } from '../api/eventsApi';
const cachedRundownPlaceholder = { rundown: [], revision: -1 };
// TODO: can we leverage structural sharing to see if data has changed?
export default function useRundown() {
const { data, status, isError, refetch } = useQuery({
queryKey: RUNDOWN_TABLE,
queryFn: fetchRundown,
placeholderData: [],
const { data, status, isError, refetch, isFetching } = useQuery<GetRundownCached>({
queryKey: RUNDOWN,
queryFn: fetchCachedRundown,
placeholderData: cachedRundownPlaceholder,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval,
networkMode: 'always',
// structuralSharing: (oldData: GetRundownCached | undefined, newData: GetRundownCached) => {
// if (oldData === undefined) {
// return cachedRundownPlaceholder;
// }
// const hasDataChanged = oldData?.revision === newData.revision;
// return hasDataChanged ? oldData : newData;
// },
});
return { data, status, isError, refetch };
return { data: data?.rundown ?? [], status, isError, refetch, isFetching };
}
+71 -48
View File
@@ -1,9 +1,9 @@
import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import { GetRundownCached, isOntimeEvent, OntimeRundownEntry } from 'ontime-types';
import { getCueCandidate, swapOntimeEvents } from 'ontime-utils';
import { RUNDOWN_TABLE, RUNDOWN_TABLE_KEY } from '../api/apiConstants';
import { RUNDOWN } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils';
import {
ReorderEntry,
@@ -36,7 +36,7 @@ export const useEventAction = () => {
// Fetch anyway, just to be sure
mutationFn: requestPostEvent,
onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE);
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
@@ -67,8 +67,10 @@ export const useEventAction = () => {
after: options?.after,
};
const rundown = queryClient.getQueryData<GetRundownCached>(RUNDOWN)?.rundown ?? [];
if (newEvent?.cue === undefined) {
newEvent.cue = getCueCandidate(queryClient.getQueryData(RUNDOWN_TABLE) || [], options?.after);
newEvent.cue = getCueCandidate(rundown, options?.after);
}
// hard coding duration value to be as expected for now
@@ -78,7 +80,6 @@ export const useEventAction = () => {
}
if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) {
const rundown = queryClient.getQueryData(RUNDOWN_TABLE) as OntimeRundown;
const previousEvent = rundown.find((event) => event.id === applicationOptions.lastEventId);
if (previousEvent !== undefined && previousEvent.type === 'event') {
newEvent.timeStart = previousEvent.timeEnd;
@@ -115,25 +116,35 @@ export const useEventAction = () => {
// we optimistically update here
onMutate: async (newEvent) => {
// cancel ongoing queries
await queryClient.cancelQueries([RUNDOWN_TABLE_KEY, newEvent.id]);
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousEvent = queryClient.getQueryData([RUNDOWN_TABLE_KEY, newEvent.id]);
// optimistically update object
queryClient.setQueryData([RUNDOWN_TABLE_KEY, newEvent.id], newEvent);
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
if (previousData) {
// optimistically update object
const optimisticRundown = [...previousData.rundown];
const index = optimisticRundown.findIndex((event) => event.id === newEvent.id);
if (index > -1) {
// @ts-expect-error -- we expect the event types to match
optimisticRundown[index] = { ...optimisticRundown[index], ...newEvent };
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 });
}
}
// Return a context with the previous and new events
return { previousEvent, newEvent };
return { previousData, newEvent };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _newEvent, context) => {
queryClient.setQueryData([RUNDOWN_TABLE_KEY, context?.newEvent.id], context?.previousEvent);
queryClient.setQueryData(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: async () => {
await queryClient.invalidateQueries([RUNDOWN_TABLE_KEY]);
await queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
@@ -161,28 +172,37 @@ export const useEventAction = () => {
// we optimistically update here
onMutate: async (eventId) => {
// cancel ongoing queries
await queryClient.cancelQueries([RUNDOWN_TABLE_KEY, eventId]);
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousEvents = queryClient.getQueryData(RUNDOWN_TABLE);
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
const filtered = [...(previousEvents as OntimeRundown)].filter((e) => e.id !== eventId);
if (previousData) {
// optimistically update object
const optimisticRundown = [...previousData.rundown];
const index = optimisticRundown.findIndex((event) => event.id === eventId);
if (index > -1) {
optimisticRundown.splice(index, 1);
// optimistically update object
queryClient.setQueryData(RUNDOWN_TABLE, filtered);
queryClient.setQueryData(RUNDOWN, {
rundown: optimisticRundown,
revision: -1,
});
}
}
// Return a context with the previous and new events
return { previousEvents };
return { previousData };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
queryClient.setQueryData(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE);
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
@@ -210,26 +230,26 @@ export const useEventAction = () => {
// we optimistically update here
onMutate: async () => {
// cancel ongoing queries
await queryClient.cancelQueries(RUNDOWN_TABLE, { exact: true });
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousEvents = queryClient.getQueryData(RUNDOWN_TABLE);
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
// optimistically update object
queryClient.setQueryData(RUNDOWN_TABLE, []);
queryClient.setQueryData(RUNDOWN, { rundown: [], revision: -1 });
// Return a context with the previous and new events
return { previousEvents };
return { previousData };
},
// Mutation fails, rollback undos optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
queryClient.setQueryData(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE);
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
@@ -253,7 +273,7 @@ export const useEventAction = () => {
mutationFn: requestApplyDelay,
// Mutation finished, failed or successful
onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE);
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
@@ -281,30 +301,32 @@ export const useEventAction = () => {
// we optimistically update here
onMutate: async (data) => {
// cancel ongoing queries
await queryClient.cancelQueries(RUNDOWN_TABLE, { exact: true });
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousEvents = queryClient.getQueryData(RUNDOWN_TABLE);
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
const e = [...(previousEvents as OntimeRundown)];
const [reorderedItem] = e.splice(data.from, 1);
e.splice(data.to, 0, reorderedItem);
if (previousData) {
// optimistically update object
const optimisticRundown = [...previousData.rundown];
const [reorderedItem] = optimisticRundown.splice(data.from, 1);
optimisticRundown.splice(data.to, 0, reorderedItem);
// optimistically update object
queryClient.setQueryData(RUNDOWN_TABLE, e);
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 });
}
// Return a context with the previous and new events
return { previousEvents };
return { previousData };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
queryClient.setQueryData(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE);
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
@@ -337,31 +359,32 @@ export const useEventAction = () => {
// we optimistically update here
onMutate: async ({ from, to }) => {
// cancel ongoing queries
await queryClient.cancelQueries(RUNDOWN_TABLE, { exact: true });
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const rundown = queryClient.getQueryData(RUNDOWN_TABLE) as OntimeRundown;
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
if (previousData) {
// optimistically update object
const fromEventIndex = previousData.rundown.findIndex((event) => event.id === from);
const toEventIndex = previousData.rundown.findIndex((event) => event.id === to);
const fromEventIndex = rundown.findIndex((event) => event.id === from);
const toEventIndex = rundown.findIndex((event) => event.id === to);
const optimisticRundown = swapOntimeEvents(previousData.rundown, fromEventIndex, toEventIndex);
const previousEvents = swapOntimeEvents(rundown, fromEventIndex, toEventIndex);
// optimistically update object
queryClient.setQueryData(RUNDOWN_TABLE, previousEvents);
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 });
}
// Return a context with the previous events
return { previousEvents };
return { previousData };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
queryClient.setQueryData(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE);
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
@@ -21,7 +21,7 @@ interface UseFollowComponentProps {
scrollRef: MutableRefObject<HTMLElement | null>;
doFollow: boolean;
topOffset?: number;
setScrollFlag?: () => void;
setScrollFlag?: (newValue: boolean) => void;
}
export default function useFollowComponent(props: UseFollowComponentProps) {
@@ -34,14 +34,15 @@ export default function useFollowComponent(props: UseFollowComponentProps) {
}
if (followRef.current && scrollRef.current) {
setScrollFlag?.(true);
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
setScrollFlag?.();
scrollToComponent(
followRef as MutableRefObject<HTMLElement>,
scrollRef as MutableRefObject<HTMLElement>,
topOffset,
);
setScrollFlag?.(false);
});
}
@@ -1,3 +1,5 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-nocheck -- working on it
import { useCallback, useEffect, useState } from 'react';
interface WebkitDocument extends Document {
+34 -41
View File
@@ -1,53 +1,46 @@
import { useEffect, useState } from 'react';
import { useSyncExternalStore } from 'react';
/**
* @description utility hook to handle state in local storage
* @param key
* @param initialValue
*/
export const useLocalStorage = <T>(key: string, initialValue: T): [T, (value: T | ((val: T) => T)) => void] => {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(`ontime-${key}`);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
return initialValue;
}
});
const STORAGE_EVENT = 'ontime-storage';
useEffect(() => {
const handleStorageChange = (event: StorageEvent) => {
if (event.storageArea === window.localStorage && event.key === key) {
try {
const newValue = event.newValue ? JSON.parse(event.newValue) : initialValue;
setStoredValue(newValue);
} catch (_) {
/* empty */
}
}
};
function getSnapshot(key: string): string | null {
try {
return window.localStorage.getItem(`ontime-${key}`);
} catch {
return null;
}
}
window.addEventListener('storage', handleStorageChange);
function getParsedJson<T>(localStorageValue: string | null, initialValue: T): T {
try {
return localStorageValue ? JSON.parse(localStorageValue) : initialValue;
} catch {
return initialValue;
}
}
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, [initialValue, key]);
export const useLocalStorage = <T>(key: string, initialValue: T) => {
const localStorageValue = useSyncExternalStore(subscribe, () => getSnapshot(key));
const parsedLocalStorageValue = getParsedJson(localStorageValue, initialValue);
/**
* @description Set value to local storage
* @param value
*/
const setValue = (value: T | ((val: T) => T)) => {
try {
// Allow value to be a function so we have same API as useState
const valueToStore = value instanceof Function ? value(storedValue) : value;
const setLocalStorageValue = (value: T | ((val: T) => T)) => {
// Allow value to be a function so we have same API as useState
const valueToStore = value instanceof Function ? value(parsedLocalStorageValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(`ontime-${key}`, JSON.stringify(valueToStore));
} catch (error) {
console.error(error);
}
localStorage.setItem(`ontime-${key}`, JSON.stringify(valueToStore));
window.dispatchEvent(new StorageEvent(STORAGE_EVENT));
};
return [storedValue, setValue];
return [parsedLocalStorageValue, setLocalStorageValue] as const;
};
function subscribe(callback: () => void) {
window.addEventListener(STORAGE_EVENT, callback);
return () => {
window.removeEventListener(STORAGE_EVENT, callback);
};
}
@@ -0,0 +1,64 @@
import { MouseEvent, SyntheticEvent, TouchEvent, useMemo, useRef } from 'react';
type LongPressOptions = {
threshold?: number;
onStart?: (e: SyntheticEvent) => void;
onFinish?: (e: SyntheticEvent) => void;
onCancel?: (e: SyntheticEvent) => void;
};
type LongPressFns = {
onMouseDown: (e: MouseEvent) => void;
onMouseUp: (e: MouseEvent) => void;
onMouseLeave: (e: MouseEvent) => void;
onTouchStart: (e: TouchEvent) => void;
onTouchEnd: (e: TouchEvent) => void;
};
export default function useLongPress(callback: () => void, options: LongPressOptions = {}): LongPressFns {
const { threshold = 400, onStart, onFinish, onCancel } = options;
const isLongPressActive = useRef(false);
const isPressed = useRef(false);
const timerId = useRef<NodeJS.Timer>();
return useMemo(() => {
const start = (event: SyntheticEvent) => {
if (onStart) {
onStart(event);
}
isPressed.current = true;
timerId.current = setTimeout(() => {
callback();
isLongPressActive.current = true;
}, threshold);
};
const cancel = (event: SyntheticEvent) => {
if (isLongPressActive.current) {
if (onFinish) {
onFinish(event);
}
} else if (isPressed.current) {
if (onCancel) {
onCancel(event);
}
}
isLongPressActive.current = false;
isPressed.current = false;
if (timerId.current) {
clearTimeout(timerId.current);
}
};
return {
onMouseDown: start,
onMouseUp: cancel,
onMouseLeave: cancel,
onTouchStart: start,
onTouchEnd: cancel,
};
}, [callback, threshold, onCancel, onFinish, onStart]);
}
+5 -2
View File
@@ -27,6 +27,7 @@ export const useMessageControl = () => {
timerMessage: state.timerMessage,
publicMessage: state.publicMessage,
lowerMessage: state.lowerMessage,
externalMessage: state.externalMessage,
onAir: state.onAir,
});
@@ -40,6 +41,8 @@ export const setMessage = {
publicVisible: (payload: boolean) => socketSendJson('set-public-message-visible', payload),
lowerText: (payload: string) => socketSendJson('set-lower-message-text', payload),
lowerVisible: (payload: boolean) => socketSendJson('set-lower-message-visible', payload),
externalText: (payload: string) => socketSendJson('set-external-message-text', payload),
externalVisible: (payload: boolean) => socketSendJson('set-external-message-visible', payload),
onAir: (payload: boolean) => socketSendJson('set-onAir', payload),
timerBlink: (payload: boolean) => socketSendJson('set-timer-blink', payload),
timerBlackout: (payload: boolean) => socketSendJson('set-timer-blackout', payload),
@@ -72,8 +75,8 @@ export const setPlayback = {
reload: () => {
socketSendJson('reload');
},
delay: (amount: number) => {
socketSendJson('delay', amount);
addTime: (amount: number) => {
socketSendJson('addtime', amount);
},
};
+11 -24
View File
@@ -1,26 +1,13 @@
export const httpPlaceholder = {
onLoad: {
url: '',
enabled: false,
},
onStart: {
url: '',
enabled: false,
},
onUpdate: {
url: '',
enabled: false,
},
onPause: {
url: '',
enabled: false,
},
onStop: {
url: '',
enabled: false,
},
onFinish: {
url: '',
enabled: false,
import { HttpSettings } from 'ontime-types';
export const httpPlaceholder: HttpSettings = {
enabledOut: false,
subscriptions: {
onLoad: [],
onStart: [],
onUpdate: [],
onPause: [],
onStop: [],
onFinish: [],
},
};
+1 -1
View File
@@ -3,7 +3,7 @@ import { QueryClient } from '@tanstack/react-query';
export const ontimeQueryClient = new QueryClient({
defaultOptions: {
queries: {
cacheTime: 1000 * 60 * 10, // 10 min
gcTime: 1000 * 60 * 10, // 10 min
},
},
});
+5 -1
View File
@@ -3,7 +3,7 @@ import { Playback, RuntimeStore } from 'ontime-types';
import { useStore } from 'zustand';
import { createStore } from 'zustand/vanilla';
export const runtimeStorePlaceholder = {
export const runtimeStorePlaceholder: RuntimeStore = {
timer: {
clock: 0,
current: null,
@@ -33,6 +33,10 @@ export const runtimeStorePlaceholder = {
text: '',
visible: false,
},
externalMessage: {
text: '',
visible: false,
},
onAir: false,
loaded: {
numEvents: 0,
@@ -1,4 +1,4 @@
import { isIPAddress, isOnlyNumbers } from '../regex';
import { isIPAddress, isOnlyNumbers, startsWithHttp } from '../regex';
describe('simple tests for regex', () => {
test('isOnlyNumbers', () => {
@@ -24,4 +24,16 @@ describe('simple tests for regex', () => {
expect(isIPAddress.test(t)).toBe(false);
});
});
test('startsWithHttp', () => {
const right = ['http://test'];
const wrong = ['https://test', 'testing', '123.0.1'];
right.forEach((t) => {
expect(startsWithHttp.test(t)).toBe(true);
});
wrong.forEach((t) => {
expect(startsWithHttp.test(t)).toBe(false);
});
});
});
@@ -1,4 +1,4 @@
import { cx } from '../styleUtils';
import { cx, getAccessibleColour } from '../styleUtils';
import style from './styleUtils.module.scss';
@@ -13,3 +13,24 @@ describe('cx()', () => {
expect(merged).toMatchSnapshot();
});
});
describe('getAccessibleColour()', () => {
it('handles named colours', () => {
const colour = 'red';
const { backgroundColor, color } = getAccessibleColour(colour);
expect(backgroundColor).toBe('#FF0000FF');
expect(color).toBe('#fffffa');
});
it('handles hex colours', () => {
const colour = '#0F0';
const { backgroundColor, color } = getAccessibleColour(colour);
expect(backgroundColor).toBe('#00FF00FF');
expect(color).toBe('black');
});
it('handles transparens', () => {
const colour = '#0F08';
const { backgroundColor, color } = getAccessibleColour(colour);
expect(backgroundColor).toBe('#0C940CFF');
expect(color).toBe('#fffffa');
});
});
@@ -26,4 +26,14 @@ describe('formatTime()', () => {
const time = formatTime(ms);
expect(time).toStrictEqual('...');
});
it('shows 12h format without times', () => {
const ms = 13 * 60 * 60 * 1000;
const options = {
showSeconds: false,
format: 'hh:mm a',
};
const time = formatTime(ms, options, () => '12');
expect(time).toStrictEqual('01:00 PM');
});
});
+1
View File
@@ -1,2 +1,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:\/\//;
+5 -3
View File
@@ -13,13 +13,15 @@ type ColourCombination = {
export const getAccessibleColour = (bgColour?: string): ColourCombination => {
if (bgColour) {
try {
const textColor = Color(bgColour).isLight() ? 'black' : '#fffffa';
return { backgroundColor: bgColour, color: textColor };
const originalColour = Color(bgColour);
const backgroundColorMix = originalColour.alpha(1).mix(Color('#1a1a1a'), 1 - originalColour.alpha());
const textColor = backgroundColorMix.isLight() ? 'black' : '#fffffa';
return { backgroundColor: backgroundColorMix.hexa(), color: textColor };
} catch (_error) {
/* we do not handle errors here */
}
}
return { backgroundColor: '#000', color: '#fffffa' };
return { backgroundColor: '#1a1a1a', color: '#fffffa' };
};
/**
@@ -1,44 +1 @@
export type TimeEntryField = 'timeStart' | 'timeEnd' | 'durationOverride';
/**
* @description Checks which field the value relates to
*/
export const handleTimeEntry = (
field: TimeEntryField,
val: number,
timeStart: number,
timeEnd: number,
): { start: number; end: number; durationOverride: boolean } => {
let start = timeStart;
let end = timeEnd;
let durationOverride = false;
if (field === 'timeStart') {
start = val;
} else if (field === 'timeEnd') {
end = val;
} else {
durationOverride = field === 'durationOverride';
}
return { start, end, durationOverride };
};
/**
* @description Validates time entry
*/
export const validateEntry = (
field: TimeEntryField,
value: number,
timeStart: number,
timeEnd: number,
): { value: boolean; warnings: { start?: string; end?: string; duration?: string } } => {
const validate = { value: true, warnings: { start: '', end: '', duration: '' } };
const { start, end } = handleTimeEntry(field, value, timeStart, timeEnd);
if (end < start) {
validate.warnings.start = 'Start time later than end time';
}
return validate;
};
@@ -1,4 +1,4 @@
import { Input } from '@chakra-ui/react';
import { IconButton, Input } from '@chakra-ui/react';
import { IoEye } from '@react-icons/all-files/io5/IoEye';
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
@@ -13,13 +13,14 @@ interface InputRowProps {
placeholder: string;
text: string;
visible?: boolean;
readonly?: boolean;
actionHandler: (action: string, payload: object) => void;
changeHandler: (newValue: string) => void;
className?: string;
}
export default function InputRow(props: InputRowProps) {
const { label, placeholder, text, visible, actionHandler, changeHandler, className } = props;
const { label, placeholder, text, visible, actionHandler, changeHandler, className, readonly } = props;
const handleInputChange = (newValue: string) => {
changeHandler(newValue);
@@ -33,19 +34,31 @@ export default function InputRow(props: InputRowProps) {
<Input
size='sm'
variant='ontime-filled'
readOnly={readonly}
disabled={readonly}
value={text}
onChange={(event) => handleInputChange(event.target.value)}
placeholder={placeholder}
/>
<TooltipActionBtn
clickHandler={() => actionHandler('update', { field: 'isPublic', value: !visible })}
tooltip={visible ? 'Make invisible' : 'Make visible'}
aria-label={`Toggle ${label}`}
openDelay={tooltipDelayMid}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
size='sm'
/>
{readonly ? (
<IconButton
size='sm'
isDisabled
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
aria-label={`Toggle ${label}`}
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
/>
) : (
<TooltipActionBtn
clickHandler={() => actionHandler('update', { field: 'isPublic', value: !visible })}
tooltip={visible ? 'Make invisible' : 'Make visible'}
aria-label={`Toggle ${label}`}
openDelay={tooltipDelayMid}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
size='sm'
/>
)}
</div>
</div>
);
@@ -27,7 +27,3 @@
color: $action-text-color;
}
}
.padTop {
margin-top: $section-spacing;
}
@@ -34,34 +34,48 @@ export default function MessageControl() {
actionHandler={() => setMessage.lowerVisible(!data.lowerMessage.visible)}
/>
<InputRow
label='Timer message'
placeholder='Shown in stage timer'
label='Timer'
placeholder='Message shown in stage timer'
text={data.timerMessage.text || ''}
visible={data.timerMessage.visible || false}
changeHandler={(newValue) => setMessage.presenterText(newValue)}
actionHandler={() => setMessage.presenterVisible(!data.timerMessage.visible)}
/>
<div className={style.buttonSection}>
<label className={style.label}>Timer message blink</label>
<label className={style.label}>Blackout timer screens</label>
<Button
size='sm'
className={`${data.timerMessage.timerBlink ? style.blink : ''}`}
variant={data.timerMessage.timerBlink ? 'ontime-filled' : 'ontime-subtle'}
leftIcon={data.timerMessage.timerBlink ? <IoSunny size='24px' /> : <IoSunnyOutline size='24px' />}
leftIcon={data.timerMessage.timerBlink ? <IoSunny size='1rem' /> : <IoSunnyOutline size='1rem' />}
onClick={() => setMessage.timerBlink(!data.timerMessage.timerBlink)}
data-testid='toggle timer blink'
/>
>
Blink message
</Button>
<Button
size='sm'
className={style.blackoutButton}
variant={data.timerMessage.timerBlackout ? 'ontime-filled' : 'ontime-subtle'}
leftIcon={data.timerMessage.timerBlackout ? <IoEye size='24px' /> : <IoEyeOffOutline size='24px' />}
leftIcon={data.timerMessage.timerBlackout ? <IoEye size='1rem' /> : <IoEyeOffOutline size='1rem' />}
onClick={() => setMessage.timerBlackout(!data.timerMessage.timerBlackout)}
data-testid='toggle timer blackout'
/>
>
Blackout screen
</Button>
</div>
<div className={`${style.onAirSection} ${style.padTop}`}>
<InputRow
label='External Message'
placeholder='-'
readonly
text={data.externalMessage.text || ''}
visible={data.externalMessage.visible || false}
changeHandler={() => undefined}
actionHandler={() => undefined}
/>
<div className={style.onAirSection}>
<label className={style.label}>Toggle On Air state</label>
<Button
size='sm'
variant={data.onAir ? 'ontime-filled' : 'ontime-subtle'}
leftIcon={data.onAir ? <IoMicSharp size='24px' /> : <IoMicOffOutline size='24px' />}
onClick={() => setMessage.onAir(!data.onAir)}
@@ -86,22 +86,22 @@ export default function PlaybackTimer(props: PlaybackTimerProps) {
)}
<div className={style.btn}>
<Tooltip label='Remove 1 minute' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<TapButton onClick={() => setPlayback.delay(-1)} disabled={disableButtons} aspect='square'>
<TapButton onClick={() => setPlayback.addTime(-60)} disabled={disableButtons} aspect='square'>
-1
</TapButton>
</Tooltip>
<Tooltip label='Add 1 minute' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<TapButton onClick={() => setPlayback.delay(1)} disabled={disableButtons} aspect='square'>
<TapButton onClick={() => setPlayback.addTime(60)} disabled={disableButtons} aspect='square'>
+1
</TapButton>
</Tooltip>
<Tooltip label='Remove 5 minutes' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<TapButton onClick={() => setPlayback.delay(-5)} disabled={disableButtons} aspect='square'>
<TapButton onClick={() => setPlayback.addTime(-5 * 60)} disabled={disableButtons} aspect='square'>
-5
</TapButton>
</Tooltip>
<Tooltip label='Add 5 minutes' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<TapButton onClick={() => setPlayback.delay(+5)} disabled={disableButtons} aspect='square'>
<TapButton onClick={() => setPlayback.addTime(+5 * 60)} disabled={disableButtons} aspect='square'>
+5
</TapButton>
</Tooltip>
@@ -5,6 +5,7 @@ import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundo
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useLocalStorage } from '../../common/hooks/useLocalStorage';
import { getAccessibleColour } from '../../common/utils/styleUtils';
import BlockRow from './cuesheet-table-elements/BlockRow';
import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
@@ -24,10 +25,7 @@ interface CuesheetProps {
}
export default function Cuesheet({ data, columns, handleUpdate, selectedId }: CuesheetProps) {
const followSelected = useCuesheetSettings((state) => state.followSelected);
const showSettings = useCuesheetSettings((state) => state.showSettings);
const showDelayBlock = useCuesheetSettings((state) => state.showDelayBlock);
const showPrevious = useCuesheetSettings((state) => state.showPrevious);
const { followSelected, showSettings, showDelayBlock, showPrevious } = useCuesheetSettings();
const [columnVisibility, setColumnVisibility] = useLocalStorage('table-hidden', {});
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>('table-order', initialColumnOrder);
@@ -66,7 +64,9 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
setColumnSizing({});
};
const headerGroups = table.getHeaderGroups;
const headerGroups = table.getHeaderGroups();
const rowModel = table.getRowModel();
const allLeafColumns = table.getAllLeafColumns();
let eventIndex = 0;
let isPast = Boolean(selectedId);
@@ -75,7 +75,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
<>
{showSettings && (
<CuesheetTableSettings
columns={table.getAllLeafColumns()}
columns={allLeafColumns}
handleResetResizing={resetColumnResizing}
handleResetReordering={resetColumnOrder}
handleClearToggles={setAllVisible}
@@ -85,7 +85,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
<table className={style.cuesheet}>
<CuesheetHeader headerGroups={headerGroups} />
<tbody>
{table.getRowModel().rows.map((row) => {
{rowModel.rows.map((row) => {
const key = row.original.id;
const isSelected = selectedId === key;
if (isSelected) {
@@ -121,8 +121,8 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
} else if (row.original.colour) {
try {
// the colour is user defined and might be invalid
const colour = new Color(row.original.colour).alpha(0.25);
rowBgColour = colour.hsl().string();
const accessibleBackgroundColor = Color(getAccessibleColour(row.original.colour).backgroundColor);
rowBgColour = accessibleBackgroundColor.fade(0.75).hexa();
} catch (_error) {
/* we do not handle errors here */
}
@@ -19,6 +19,6 @@
& > * {
border: 1px solid $white-10;
border-radius: 4px;
border-radius: 3px;
}
}
}
@@ -8,6 +8,7 @@ import useRundown from '../../common/hooks-query/useRundown';
import useUserFields from '../../common/hooks-query/useUserFields';
import ExportModal, { ExportType } from '../modals/export-modal/ExportModal';
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
import CuesheetTableHeader from './cuesheet-table-header/CuesheetTableHeader';
import Cuesheet from './Cuesheet';
import { makeCuesheetColumns } from './cuesheetCols';
@@ -140,6 +141,7 @@ export default function CuesheetWrapper() {
return (
<div className={styles.tableWrapper} data-testid='cuesheet'>
<CuesheetTableHeader handleExport={handleOpenModal} featureData={featureData} />
<CuesheetProgress />
<Cuesheet data={rundown} columns={columns} handleUpdate={handleUpdate} selectedId={featureData.selectedEventId} />
<ExportModal isOpen={isModalOpen} onClose={onModalClose} />
</div>
@@ -0,0 +1,3 @@
.progressOverride {
height: 1rem;
}
@@ -0,0 +1,24 @@
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import { useTimer } from '../../../common/hooks/useSocket';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
import styles from "./CuesheetProgress.module.scss"
export default function CuesheetProgress() {
const { data } = useViewSettings();
const timer = useTimer();
const totalTime = (timer.duration ?? 0) + (timer.addedTime ?? 0);
return (
<MultiPartProgressBar
now={timer.current}
complete={totalTime}
normalColor={data!.normalColor}
warning={data!.warningThreshold}
warningColor={data!.warningColor}
danger={data!.dangerThreshold}
dangerColor={data!.dangerColor}
className={styles.progressOverride}
/>
);
}
@@ -23,7 +23,7 @@ import { SortableCell } from './SortableCell';
import style from '../Cuesheet.module.scss';
interface CuesheetHeaderProps {
headerGroups: () => HeaderGroup<OntimeRundownEntry>[];
headerGroups: HeaderGroup<OntimeRundownEntry>[];
}
function CuesheetHeader(props: CuesheetHeaderProps) {
@@ -75,7 +75,7 @@ function CuesheetHeader(props: CuesheetHeaderProps) {
return (
<thead className={style.tableHeader}>
{headerGroups().map((headerGroup) => {
{headerGroups.map((headerGroup) => {
const key = headerGroup.id;
return (
@@ -19,8 +19,8 @@ function EventRow(props: PropsWithChildren<EventRowProps>) {
const ownRef = useRef<HTMLTableRowElement>(null);
const [isVisible, setIsVisible] = useState(false);
const bgColour = colour;
const textColour = getAccessibleColour(bgColour);
const textColour = getAccessibleColour(colour);
const bgColour = textColour.backgroundColor;
useLayoutEffect(() => {
const observer = new IntersectionObserver(
@@ -55,7 +55,7 @@ function EventRow(props: PropsWithChildren<EventRowProps>) {
style={{ opacity: `${isPast ? pastOpacity : '1'}` }}
ref={selectedRef ?? ownRef}
>
<td className={style.indexColumn} style={{ backgroundColor: bgColour, color: textColour?.color }}>
<td className={style.indexColumn} style={{ backgroundColor: bgColour, color: textColour.color }}>
{eventIndex}
</td>
{isVisible ? children : null}
@@ -1,4 +1,4 @@
import { memo } from 'react';
import { memo, ReactNode } from 'react';
import { Button, Checkbox, Switch } from '@chakra-ui/react';
import { Column } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
@@ -22,12 +22,14 @@ interface CuesheetTableSettingsProps {
function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
const showPrevious = useCuesheetSettings((state) => state.showPrevious);
const togglePreviousVisibility = useCuesheetSettings((state) => state.togglePreviousVisibility);
const showDelayBlock = useCuesheetSettings((state) => state.showDelayBlock);
const toggleDelayVisibility = useCuesheetSettings((state) => state.toggleDelayVisibility);
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
const toggleDelayedTimes = useCuesheetSettings((state) => state.toggleDelayedTimes);
const {
showPrevious,
toggleDelayVisibility,
showDelayBlock,
showDelayedTimes,
toggleDelayedTimes,
togglePreviousVisibility,
} = useCuesheetSettings();
return (
<div className={style.tableSettings}>
@@ -44,7 +46,7 @@ function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
defaultChecked={visible}
onChange={column.getToggleVisibilityHandler()}
/>
{columnHeader}
{columnHeader as ReactNode}
</label>
);
})}
@@ -9,7 +9,7 @@ import { millisToString } from 'ontime-utils';
* @return {string}
*/
export const parseField = (field: keyof OntimeRundown, data: unknown): string => {
export const parseField = <T extends OntimeEntryCommonKeys>(field: T, data: unknown): string => {
let val;
switch (field) {
case 'timeStart':
@@ -96,6 +96,7 @@ export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, userF
rundown.forEach((entry) => {
const row: string[] = [];
// @ts-expect-error -- not sure how to type this
fieldOrder.forEach((field) => row.push(parseField(field, entry[field])));
data.push(row);
});
@@ -5,6 +5,7 @@ import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'
import MenuBar from '../menu/MenuBar';
import AboutModal from '../modals/about-modal/AboutModal';
import QuickStart from '../modals/quick-start/QuickStart';
import SheetsModal from '../modals/sheets-modal/SheetsModal';
import UploadModal from '../modals/upload-modal/UploadModal';
import styles from './Editor.module.scss';
@@ -28,6 +29,7 @@ export default function Editor() {
} = useDisclosure();
const { isOpen: isAboutModalOpen, onOpen: onAboutModalOpen, onClose: onAboutModalClose } = useDisclosure();
const { isOpen: isQuickStartOpen, onOpen: onQuickStartOpen, onClose: onQuickStartClose } = useDisclosure();
const { isOpen: isSheetsOpen, onOpen: onSheetsOpen, onClose: onSheetsClose } = useDisclosure();
// Set window title
useEffect(() => {
@@ -42,6 +44,7 @@ export default function Editor() {
<IntegrationModal onClose={onIntegrationModalClose} isOpen={isIntegrationModalOpen} />
<AboutModal onClose={onAboutModalClose} isOpen={isAboutModalOpen} />
<SettingsModal isOpen={isSettingsOpen} onClose={onSettingsClose} />
<SheetsModal onClose={onSheetsClose} isOpen={isSheetsOpen} />
</ErrorBoundary>
<div className={styles.mainContainer} data-testid='event-editor'>
<div id='settings' className={styles.settings}>
@@ -58,6 +61,8 @@ export default function Editor() {
onAboutOpen={onAboutModalOpen}
isQuickStartOpen={isQuickStartOpen}
onQuickStartOpen={onQuickStartOpen}
isSheetsOpen={isSheetsOpen}
onSheetsOpen={onSheetsOpen}
/>
</ErrorBoundary>
</div>
@@ -73,8 +73,8 @@ export default function EventEditor() {
handleSubmit={handleSubmit}
>
<CopyTag label='Event ID'>{event.id}</CopyTag>
<CopyTag label='OSC trigger by id'>{`/ontime/gotoid/${event.id}`}</CopyTag>
<CopyTag label='OSC trigger by cue'>{`/ontime/gotocue/${event.cue}`}</CopyTag>
<CopyTag label='OSC trigger by id'>{`/ontime/gotoid "${event.id}"`}</CopyTag>
<CopyTag label='OSC trigger by cue'>{`/ontime/gotocue "${event.cue}"`}</CopyTag>
</EventEditorDataRight>
</div>
);
@@ -40,6 +40,7 @@ export default function CountedTextInput(props: CountedTextInputProps) {
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
autoComplete='off'
/>
</div>
);
@@ -1,13 +1,12 @@
import { memo, useState } from 'react';
import { memo } from 'react';
import { Select, Switch } from '@chakra-ui/react';
import { EndAction, OntimeEvent, TimerType } from 'ontime-types';
import { calculateDuration, millisToString } from 'ontime-utils';
import { calculateDuration, dayInMs, millisToString } from 'ontime-utils';
import TimeInput from '../../../common/components/input/time-input/TimeInput';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { millisToDelayString } from '../../../common/utils/dateConfig';
import { cx } from '../../../common/utils/styleUtils';
import { TimeEntryField, validateEntry } from '../../../common/utils/timesManager';
import style from '../EventEditor.module.scss';
@@ -29,21 +28,13 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
const { eventId, timeStart, timeEnd, duration, delay, isPublic, endAction, timerType } = props;
const { updateEvent } = useEventAction();
const [warning, setWarnings] = useState({ start: '', end: '', duration: '' });
const timerValidationHandler = (entry: TimeEntryField, val: number) => {
const valid = validateEntry(entry, val, timeStart, timeEnd);
setWarnings((prev) => ({ ...prev, ...valid.warnings }));
return valid.value;
};
const handleSubmit = (field: TimeActions, value: number | string | boolean) => {
const newEventData: Partial<OntimeEvent> = { id: eventId };
switch (field) {
case 'durationOverride': {
// duration defines timeEnd
newEventData.duration = value as number;
newEventData.timeEnd = timeStart + (value as number);
newEventData.timeEnd = timeStart + ((value as number) % dayInMs);
break;
}
case 'timeStart': {
@@ -87,11 +78,9 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
id='timeStart'
name='timeStart'
submitHandler={handleSubmit}
validationHandler={timerValidationHandler}
time={timeStart}
delay={delay}
placeholder='Start'
warning={warning.start}
/>
<label className={inputTimeLabels} htmlFor='timeEnd'>
{endLabel}
@@ -100,11 +89,9 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
id='timeEnd'
name='timeEnd'
submitHandler={handleSubmit}
validationHandler={timerValidationHandler}
time={timeEnd}
delay={delay}
placeholder='End'
warning={warning.end}
/>
<label className={style.inputLabel} htmlFor='durationOverride'>
Duration
@@ -113,10 +100,8 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
id='durationOverride'
name='durationOverride'
submitHandler={handleSubmit}
validationHandler={timerValidationHandler}
time={duration}
placeholder='Duration'
warning={warning.duration}
/>
</div>
<div className={style.timeSettings}>
@@ -5,6 +5,7 @@
display: flex;
justify-content: space-between;
gap: 1rem;
font-size: $inner-section-text-size;
.title {
white-space: nowrap;
@@ -14,12 +15,12 @@
.selected {
min-width: max-content;
font-size: $inner-section-text-size;
color: $label-gray;
}
}
.description {
font-size: $inner-section-text-size;
color: $label-gray;
white-space: nowrap;
overflow: hidden;
+16
View File
@@ -1,5 +1,7 @@
import { memo, useCallback, useEffect, useState } from 'react';
import { VStack } from '@chakra-ui/react';
import { IoCloud } from '@react-icons/all-files/io5/IoCloud';
import { IoCloudOutline } from '@react-icons/all-files/io5/IoCloudOutline';
import { IoColorWand } from '@react-icons/all-files/io5/IoColorWand';
import { IoExtensionPuzzle } from '@react-icons/all-files/io5/IoExtensionPuzzle';
import { IoExtensionPuzzleOutline } from '@react-icons/all-files/io5/IoExtensionPuzzleOutline';
@@ -31,6 +33,8 @@ interface MenuBarProps {
onAboutOpen: () => void;
isQuickStartOpen: boolean;
onQuickStartOpen: () => void;
isSheetsOpen: boolean;
onSheetsOpen: () => void;
}
const buttonStyle = {
@@ -58,6 +62,8 @@ const MenuBar = (props: MenuBarProps) => {
onAboutOpen,
isQuickStartOpen,
onQuickStartOpen,
isSheetsOpen,
onSheetsOpen,
} = props;
const { isElectron, sendToElectron } = useElectronEvent();
@@ -174,6 +180,16 @@ const MenuBar = (props: MenuBarProps) => {
/>
<div className={style.gap} />
<TooltipActionBtn
{...buttonStyle}
isDisabled={appMode === AppMode.Run}
icon={isSheetsOpen ? <IoCloud /> : <IoCloudOutline />}
className={isSheetsOpen ? style.open : ''}
clickHandler={onSheetsOpen}
tooltip='Sheets'
aria-label='Sheets'
size='sm'
/>
<TooltipActionBtn
{...buttonStyle}
isDisabled={appMode === AppMode.Run}
@@ -2,8 +2,9 @@ import { ModalBody, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/r
import ModalWrapper from '../ModalWrapper';
import OscIntegration from './OscIntegration';
import OscSettings from './OscSettings';
import HttpIntegration from './http/HttpIntegration';
import OscIntegration from './osc/OscIntegration';
import OscSettings from './osc/OscSettings';
import styles from '../Modal.module.scss';
@@ -30,6 +31,7 @@ export default function IntegrationModal(props: IntegrationModalProps) {
<TabList>
<Tab>OSC</Tab>
<Tab>OSC Integration</Tab>
<Tab>HTTP Integration</Tab>
</TabList>
<TabPanels>
<TabPanel>
@@ -38,6 +40,9 @@ export default function IntegrationModal(props: IntegrationModalProps) {
<TabPanel>
<OscIntegration />
</TabPanel>
<TabPanel>
<HttpIntegration />
</TabPanel>
</TabPanels>
</Tabs>
</ModalBody>
@@ -0,0 +1,152 @@
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Switch } from '@chakra-ui/react';
import type { HttpSettings } from 'ontime-types';
import { TimerLifeCycle } from 'ontime-types';
import { useHttpSettings, usePostHttpSettings } from '../../../../common/hooks-query/useHttpSettings';
import { useEmitLog } from '../../../../common/stores/logger';
import ModalLoader from '../../modal-loader/ModalLoader';
import OntimeModalFooter from '../../OntimeModalFooter';
import { OntimeCycle, sectionText } from '../integration.utils';
import HttpSubscriptionRow from './HttpSubscriptionRow';
import styles from '../../Modal.module.scss';
export default function HttpIntegration() {
const { data, isFetching } = useHttpSettings();
const { mutateAsync } = usePostHttpSettings();
const { emitError } = useEmitLog();
const {
control,
handleSubmit,
register,
reset,
formState: { isSubmitting, isDirty, isValid },
} = useForm<HttpSettings>({
mode: 'onBlur',
defaultValues: data,
values: data,
resetOptions: {
keepDirtyValues: true,
},
});
const [showSection, setShowSection] = useState<OntimeCycle>(TimerLifeCycle.onLoad);
useEffect(() => {
if (data) {
reset(data);
}
}, [data, reset]);
const resetForm = () => {
reset(data);
};
const onSubmit = async (values: HttpSettings) => {
try {
const newSettings: HttpSettings = {
enabledOut: Boolean(values.enabledOut),
subscriptions: {
onLoad: values.subscriptions.onLoad ?? [],
onStart: values.subscriptions.onStart ?? [],
onPause: values.subscriptions.onPause ?? [],
onStop: values.subscriptions.onStop ?? [],
onUpdate: values.subscriptions.onUpdate ?? [],
onFinish: values.subscriptions.onFinish ?? [],
},
};
await mutateAsync(newSettings);
} catch (error) {
emitError(`Error setting HTML: ${error}`);
}
};
if (isFetching) {
return <ModalLoader />;
}
const placeholder = 'http://x.x.x.x:xxxx/api/path';
return (
<form onSubmit={handleSubmit(onSubmit)} className={styles.sectionContainer} id='http-subscriptions'>
<div className={styles.splitSection}>
<div>
<span className={`${styles.sectionTitle} ${styles.main}`}>HTTP Output</span>
<span className={styles.sectionSubtitle}>Ontime data feedback</span>
</div>
<Switch {...register('enabledOut')} variant='ontime-on-light' />
</div>
<HttpSubscriptionRow
cycle={TimerLifeCycle.onLoad}
title={sectionText.onLoad.title}
subtitle={sectionText.onLoad.subtitle}
visible={showSection === TimerLifeCycle.onLoad}
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<HttpSubscriptionRow
cycle={TimerLifeCycle.onStart}
title={sectionText.onStart.title}
subtitle={sectionText.onStart.subtitle}
visible={showSection === TimerLifeCycle.onStart}
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<HttpSubscriptionRow
cycle={TimerLifeCycle.onPause}
title={sectionText.onPause.title}
subtitle={sectionText.onPause.subtitle}
visible={showSection === TimerLifeCycle.onPause}
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<HttpSubscriptionRow
cycle={TimerLifeCycle.onStop}
title={sectionText.onStop.title}
subtitle={sectionText.onStop.subtitle}
visible={showSection === TimerLifeCycle.onStop}
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<HttpSubscriptionRow
cycle={TimerLifeCycle.onUpdate}
title={sectionText.onUpdate.title}
subtitle={sectionText.onUpdate.subtitle}
visible={showSection === TimerLifeCycle.onUpdate}
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<HttpSubscriptionRow
cycle={TimerLifeCycle.onFinish}
title={sectionText.onFinish.title}
subtitle={sectionText.onFinish.subtitle}
visible={showSection === TimerLifeCycle.onFinish}
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<OntimeModalFooter
formId='http-subscriptions'
handleRevert={resetForm}
isDirty={isDirty}
isValid={isValid}
isSubmitting={isSubmitting}
/>
</form>
);
}
@@ -0,0 +1,95 @@
import { Control, useFieldArray, UseFormRegister } from 'react-hook-form';
import { Button, IconButton, Input, Switch } from '@chakra-ui/react';
import { FiChevronUp } from '@react-icons/all-files/fi/FiChevronUp';
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
import { HttpSettings, TimerLifeCycle } from 'ontime-types';
import { useEmitLog } from '../../../../common/stores/logger';
import { startsWithHttp } from '../../../../common/utils/regex';
import collapseStyles from '../../../../common/components/collapse-bar/CollapseBar.module.scss';
import styles from '../../Modal.module.scss';
interface SubscriptionRowProps {
cycle: TimerLifeCycle;
title: string;
subtitle: string;
visible: boolean;
setShowSection: (cycle: TimerLifeCycle) => void;
register: UseFormRegister<HttpSettings>;
control: Control<HttpSettings>;
placeholder: string;
}
export default function SubscriptionRow(props: SubscriptionRowProps) {
const { cycle, title, subtitle, visible, setShowSection, register, control, placeholder } = props;
const { emitError } = useEmitLog();
const { fields, append, remove } = useFieldArray({
name: `subscriptions.${cycle}`,
control,
});
const hasTooManyOptions = fields.length >= 3;
const headerStyle = `${styles.splitSection} ${visible ? '' : styles.showPointer}`;
const sectionTitle = `${title} ${fields.length ? fields.length : '-'} / 3`;
const handleAddNew = () => {
if (hasTooManyOptions) {
emitError(`Maximum amount of ${cycle} subscriptions reached (3)`);
return;
}
append({
message: '',
enabled: false,
});
};
return (
<>
<div className={headerStyle} onClick={() => setShowSection(cycle)}>
<div>
<span className={`${styles.sectionTitle} ${styles.main}`}>{sectionTitle}</span>
{visible && <span className={styles.sectionSubtitle}>{subtitle}</span>}
</div>
<FiChevronUp className={visible ? collapseStyles.moreCollapsed : collapseStyles.moreExpanded} />
</div>
{visible && (
<>
{fields.map((subscription, index) => (
<div key={subscription.id} className={styles.entryRow}>
<IconButton
icon={<IoRemove />}
onClick={() => remove(index)}
aria-label='delete'
size='xs'
colorScheme='red'
/>
<Input
placeholder={placeholder}
size='xs'
variant='ontime-filled-on-light'
autoComplete='off'
{...register(`subscriptions.${cycle}.${index}.message`, {
pattern: { value: startsWithHttp, message: 'Request address must start with http://' },
})}
/>
<Switch variant='ontime-on-light' {...register(`subscriptions.${cycle}.${index}.enabled`)} />
</div>
))}
<Button
onClick={handleAddNew}
className={styles.shiftRight}
isDisabled={hasTooManyOptions}
size='xs'
colorScheme='blue'
variant='outline'
padding='0 2em'
>
Add new
</Button>
</>
)}
</>
);
}
@@ -0,0 +1,30 @@
import { TimerLifeCycle } from 'ontime-types';
export type OntimeCycle = keyof typeof TimerLifeCycle;
export const sectionText: { [key in TimerLifeCycle]: { title: string; subtitle: string } } = {
onLoad: {
title: 'On Load',
subtitle: 'Triggers when a timer is loaded',
},
onStart: {
title: 'On Start',
subtitle: 'Triggers when a timer starts',
},
onPause: {
title: 'On Pause',
subtitle: 'Triggers when a running timer is paused',
},
onStop: {
title: 'On Stop',
subtitle: 'Triggers when a running timer is stopped',
},
onUpdate: {
title: 'On Every Second',
subtitle: 'Triggers when a running timer is updated (at least once a second, can be more)',
},
onFinish: {
title: 'On Finish',
subtitle: 'Triggers when a running reaches 0',
},
};
@@ -3,43 +3,15 @@ import { useForm } from 'react-hook-form';
import type { OscSubscription } from 'ontime-types';
import { TimerLifeCycle } from 'ontime-types';
import useOscSettings, { usePostOscSubscriptions } from '../../../common/hooks-query/useOscSettings';
import { useEmitLog } from '../../../common/stores/logger';
import ModalLoader from '../modal-loader/ModalLoader';
import OntimeModalFooter from '../OntimeModalFooter';
import useOscSettings, { usePostOscSubscriptions } from '../../../../common/hooks-query/useOscSettings';
import { useEmitLog } from '../../../../common/stores/logger';
import ModalLoader from '../../modal-loader/ModalLoader';
import OntimeModalFooter from '../../OntimeModalFooter';
import { type OntimeCycle, sectionText } from '../integration.utils';
import OscSubscriptionRow from './OscSubscriptionRow';
import styles from '../Modal.module.scss';
type OntimeCycle = keyof typeof TimerLifeCycle;
const sectionText: { [key in TimerLifeCycle]: { title: string; subtitle: string } } = {
onLoad: {
title: 'On Load',
subtitle: 'Triggers when a timer is loaded',
},
onStart: {
title: 'On Start',
subtitle: 'Triggers when a timer starts',
},
onPause: {
title: 'On Pause',
subtitle: 'Triggers when a running timer is paused',
},
onStop: {
title: 'On Stop',
subtitle: 'Triggers when a running timer is stopped',
},
onUpdate: {
title: 'On Every Second',
subtitle: 'Triggers when a running timer is updated (at least once a second, can be more)',
},
onFinish: {
title: 'On Finish',
subtitle: 'Triggers when a running reaches 0',
},
};
import styles from '../../Modal.module.scss';
export default function OscIntegration() {
const { data, isFetching } = useOscSettings();
@@ -92,6 +64,7 @@ export default function OscIntegration() {
return <ModalLoader />;
}
const placeholder = 'OSC message';
return (
<form onSubmit={handleSubmit(onSubmit)} className={styles.sectionContainer} id='osc-subscriptions'>
<OscSubscriptionRow
@@ -102,6 +75,7 @@ export default function OscIntegration() {
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<OscSubscriptionRow
cycle={TimerLifeCycle.onStart}
@@ -111,6 +85,7 @@ export default function OscIntegration() {
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<OscSubscriptionRow
cycle={TimerLifeCycle.onPause}
@@ -120,6 +95,7 @@ export default function OscIntegration() {
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<OscSubscriptionRow
cycle={TimerLifeCycle.onStop}
@@ -129,6 +105,7 @@ export default function OscIntegration() {
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<OscSubscriptionRow
cycle={TimerLifeCycle.onUpdate}
@@ -138,6 +115,7 @@ export default function OscIntegration() {
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<OscSubscriptionRow
cycle={TimerLifeCycle.onFinish}
@@ -147,6 +125,7 @@ export default function OscIntegration() {
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<OntimeModalFooter
formId='osc-subscriptions'
@@ -2,14 +2,14 @@ import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { FormControl, Input, Switch } from '@chakra-ui/react';
import useOscSettings, { useOscSettingsMutation } from '../../../common/hooks-query/useOscSettings';
import { PlaceholderSettings } from '../../../common/models/OscSettings';
import { useEmitLog } from '../../../common/stores/logger';
import { isIPAddress, isOnlyNumbers } from '../../../common/utils/regex';
import ModalLoader from '../modal-loader/ModalLoader';
import OntimeModalFooter from '../OntimeModalFooter';
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
import { PlaceholderSettings } from '../../../../common/models/OscSettings';
import { useEmitLog } from '../../../../common/stores/logger';
import { isIPAddress, isOnlyNumbers } from '../../../../common/utils/regex';
import ModalLoader from '../../modal-loader/ModalLoader';
import OntimeModalFooter from '../../OntimeModalFooter';
import styles from '../Modal.module.scss';
import styles from '../../Modal.module.scss';
export default function OscSettings() {
const { data, isFetching } = useOscSettings();
@@ -57,7 +57,6 @@ export default function OscSettings() {
};
const resetForm = () => {
// @ts-expect-error -- we know the types dont match
reset(data);
};
@@ -2,12 +2,12 @@ import { Control, useFieldArray, UseFormRegister } from 'react-hook-form';
import { Button, IconButton, Input, Switch } from '@chakra-ui/react';
import { FiChevronUp } from '@react-icons/all-files/fi/FiChevronUp';
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
import { OscSubscription, TimerLifeCycle } from 'ontime-types';
import type { OscSubscription, TimerLifeCycle } from 'ontime-types';
import { useEmitLog } from '../../../common/stores/logger';
import { useEmitLog } from '../../../../common/stores/logger';
import collapseStyles from '../../../common/components/collapse-bar/CollapseBar.module.scss';
import styles from '../Modal.module.scss';
import collapseStyles from '../../../../common/components/collapse-bar/CollapseBar.module.scss';
import styles from '../../Modal.module.scss';
interface OscSubscriptionRowProps {
cycle: TimerLifeCycle;
@@ -17,10 +17,11 @@ interface OscSubscriptionRowProps {
setShowSection: (cycle: TimerLifeCycle) => void;
register: UseFormRegister<OscSubscription>;
control: Control<OscSubscription>;
placeholder: string;
}
export default function OscSubscriptionRow(props: OscSubscriptionRowProps) {
const { cycle, title, subtitle, visible, setShowSection, register, control } = props;
const { cycle, title, subtitle, visible, setShowSection, register, control, placeholder } = props;
const { emitError } = useEmitLog();
const { fields, append, remove } = useFieldArray({
name: cycle,
@@ -64,7 +65,7 @@ export default function OscSubscriptionRow(props: OscSubscriptionRowProps) {
colorScheme='red'
/>
<Input
placeholder='OSC Message'
placeholder={placeholder}
size='xs'
variant='ontime-filled-on-light'
autoComplete='off'
@@ -18,7 +18,7 @@ import {
} from '@chakra-ui/react';
import type { ProjectData } from 'ontime-types';
import { PROJECT_DATA, RUNDOWN_TABLE } from '../../../common/api/apiConstants';
import { PROJECT_DATA, RUNDOWN } from '../../../common/api/apiConstants';
import { postNew } from '../../../common/api/ontimeApi';
import useProjectData from '../../../common/hooks-query/useProjectData';
import { projectDataPlaceholder } from '../../../common/models/ProjectData';
@@ -52,8 +52,8 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
const onSubmit = async (data: Partial<ProjectData>) => {
try {
await postNew(data);
await ontimeQueryClient.invalidateQueries(PROJECT_DATA);
await ontimeQueryClient.invalidateQueries(RUNDOWN_TABLE);
await ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_DATA });
await ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
onClose();
} catch (_) {
@@ -48,7 +48,7 @@ export default function AliasesForm() {
useEffect(() => {
if (data) {
reset(data);
reset({ aliases: data });
}
}, [data, reset]);
@@ -78,7 +78,7 @@ export default function AliasesForm() {
});
};
const disableInputs = status === 'loading';
const disableInputs = status === 'pending';
const hasTooManyOptions = fields.length >= 20;
if (isFetching) {
@@ -112,6 +112,7 @@ export default function AliasesForm() {
icon={<IoRemove />}
colorScheme='red'
isDisabled={disableInputs}
data-testid={`field__delete_${index}`}
/>
<Input
{...inputProps}
@@ -121,6 +122,7 @@ export default function AliasesForm() {
variant='ontime-filled-on-light'
placeholder='URL Alias'
isDisabled={disableInputs}
data-testid={`field__alias_${index}`}
/>
<Input
{...inputProps}
@@ -130,6 +132,7 @@ export default function AliasesForm() {
variant='ontime-filled-on-light'
placeholder='URL (portion after ontime Port)'
isDisabled={disableInputs}
data-testid={`field__url_${index}`}
/>
<TooltipActionBtn
clickHandler={(event) => handleLinks(event, alias.alias)}
@@ -140,8 +143,14 @@ export default function AliasesForm() {
icon={<IoOpenOutline />}
colorScheme='red'
isDisabled={disableInputs}
data-testid={`field__test_${index}`}
/>
<Switch
{...register(`aliases.${index}.enabled`)}
variant='ontime-on-light'
isDisabled={disableInputs}
data-testid={`field__enable_${index}`}
/>
<Switch {...register(`aliases.${index}.enabled`)} variant='ontime-on-light' isDisabled={disableInputs} />
</li>
);
})}
@@ -50,7 +50,7 @@ export default function AppSettingsModal() {
reset(data);
};
const disableInputs = status === 'loading';
const disableInputs = status === 'pending';
if (isFetching) {
return <ModalLoader />;
@@ -87,7 +87,7 @@ export default function AppSettingsModal() {
description='Protect the editor with a pin code'
error={errors.editorKey?.message}
>
<ModalPinInput register={register} formName='editorKey' isDisabled={disableInputs} />
<ModalPinInput register={register as any} formName='editorKey' isDisabled={disableInputs} />
</ModalSplitInput>
<ModalSplitInput
field='operatorKey'
@@ -95,7 +95,7 @@ export default function AppSettingsModal() {
description='Protect the cuesheet with a pin code'
error={errors.operatorKey?.message}
>
<ModalPinInput register={register} formName='operatorKey' isDisabled={disableInputs} />
<ModalPinInput register={register as any} formName='operatorKey' isDisabled={disableInputs} />
</ModalSplitInput>
<div style={{ height: '16px' }} />
<ModalSplitInput
@@ -119,6 +119,7 @@ export default function AppSettingsModal() {
<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>
@@ -51,7 +51,7 @@ export default function CuesheetSettingsForm() {
reset(data);
};
const disableInputs = status === 'loading';
const disableInputs = status === 'pending';
if (isFetching) {
return <ModalLoader />;
@@ -48,7 +48,7 @@ export default function ProjectDataForm() {
reset(data);
};
const disableInputs = status === 'loading';
const disableInputs = status === 'pending';
if (isFetching) {
return <ModalLoader />;
@@ -79,7 +79,7 @@ export default function ViewSettingsForm() {
return null;
}
const disableInputs = status === 'loading';
const disableInputs = status === 'pending';
if (isFetching || isFetchingInfo) {
return <ModalLoader />;
@@ -94,7 +94,7 @@ export default function ViewSettingsForm() {
<AlertTitle>CSS Override</AlertTitle>
<AlertDescription>
Ontime will use the CSS file at its install location. <br />
<span className={style.url}>{info.cssOverride}</span>
<span className={style.url}>{info?.cssOverride}</span>
<ModalLink href={cssOverrideDocsUrl}>For more information, see the docs</ModalLink>
</AlertDescription>
</div>
@@ -0,0 +1,469 @@
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import {
Alert,
AlertDescription,
AlertIcon,
AlertTitle,
Button,
HStack,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Select,
} from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { OntimeRundown, ProjectData, UserFields } from 'ontime-types';
import { defaultExcelImportMap, ExcelImportMap } from 'ontime-utils';
import { PROJECT_DATA, RUNDOWN, USERFIELDS } from '../../../common/api/apiConstants';
import { maybeAxiosError } from '../../../common/api/apiUtils';
import {
getAuthentication,
getClientSecrect,
getSheetsAuthUrl,
patchData,
postId,
postPreviewSheet,
postPushSheet,
postWorksheet,
uploadSheetClientFile,
} from '../../../common/api/ontimeApi';
import { projectDataPlaceholder } from '../../../common/models/ProjectData';
import { userFieldsPlaceholder } from '../../../common/models/UserFields';
import { openLink } from '../../../common/utils/linkUtils';
import ModalLink from '../ModalLink';
import PreviewExcel from '../upload-modal/preview/PreviewExcel';
import ExcelFileOptions from '../upload-modal/upload-options/ExcelFileOptions';
import Step from './Step';
interface SheetsModalProps {
onClose: () => void;
isOpen: boolean;
}
export default function SheetsModal(props: SheetsModalProps) {
const { isOpen, onClose } = props;
const queryClient = useQueryClient();
const [rundown, setRundown] = useState<OntimeRundown | null>(null);
const [userFields, setUserFields] = useState<UserFields | null>(null);
const [project, setProject] = useState<ProjectData | null>(null);
const [id, setSheetId] = useState('');
const [worksheet, setWorksheet] = useState('');
const [worksheetOptions, setWorksheetOptions] = useState<string[]>([]);
const [direction, setDirection] = useState('none');
const excelFileOptions = useRef<ExcelImportMap>(defaultExcelImportMap);
const fileInputRef = useRef<HTMLInputElement>(null);
const [state, setState] = useState({
clientSecret: { complete: false, message: '' },
authenticate: { complete: false, message: '' },
id: { complete: false, message: '' },
worksheet: { complete: false, message: '' },
pullPush: { complete: false, message: '' },
});
useEffect(() => {
if (isOpen) {
setDirection('none');
testClientSecret();
if (state.clientSecret.complete) testAuthentication();
if (state.authenticate.complete) testSheetId();
}
}, [isOpen]);
const handleClose = () => {
setRundown(null);
setProject(null);
setUserFields(null);
onClose();
};
//SETP-1 Upload Client ID
const handleClick = () => {
fileInputRef.current?.click();
};
const handleFile = (event: ChangeEvent<HTMLInputElement>) => {
if (!event.target.files?.length) {
setState({
clientSecret: { complete: false, message: 'Missing file' },
authenticate: { complete: false, message: '' },
id: { complete: false, message: '' },
worksheet: { complete: false, message: '' },
pullPush: { complete: false, message: '' },
});
return;
}
const selectedFile = event.target.files[0];
uploadSheetClientFile(selectedFile)
.then(() => {
setState({ ...state, clientSecret: { complete: true, message: '' } });
})
.catch((err) => {
const message = maybeAxiosError(err);
setState({
clientSecret: { complete: false, message },
authenticate: { complete: false, message: '' },
id: { complete: false, message: '' },
worksheet: { complete: false, message: '' },
pullPush: { complete: false, message: '' },
});
});
};
const testClientSecret = () => {
getClientSecrect()
.then(() => {
setState({ ...state, clientSecret: { complete: true, message: '' } });
})
.catch((err) => {
const message = maybeAxiosError(err);
setState({
clientSecret: { complete: false, message },
authenticate: { complete: false, message: '' },
id: { complete: false, message: '' },
worksheet: { complete: false, message: '' },
pullPush: { complete: false, message: '' },
});
});
};
//SETP-2 Authenticate
const handleAuthenticate = () => {
getSheetsAuthUrl()
.then((data) => {
openLink(data);
window.addEventListener('focus', () => testAuthentication(), { once: true });
})
.catch((err) => {
const message = maybeAxiosError(err);
setState({
...state,
authenticate: { complete: false, message },
id: { complete: false, message: '' },
worksheet: { complete: false, message: '' },
pullPush: { complete: false, message: '' },
});
});
};
const testAuthentication = () => {
getAuthentication()
.then(() => {
setState({ ...state, authenticate: { complete: true, message: '' } });
})
.catch((error) => {
const message = maybeAxiosError(error);
setState({
...state,
authenticate: { complete: false, message },
id: { complete: false, message: '' },
worksheet: { complete: false, message: '' },
pullPush: { complete: false, message: '' },
});
});
};
//SETP-3 set sheet ID
const testSheetId = () => {
postId(id)
.then((data) => {
setState({ ...state, id: { complete: true, message: '' } });
setWorksheetOptions(data.worksheetOptions);
})
.catch((err) => {
const message = maybeAxiosError(err);
setState({
...state,
id: { complete: false, message },
worksheet: { complete: false, message: '' },
pullPush: { complete: false, message: '' },
});
setWorksheetOptions([]);
});
};
//SETP-4 Select Worksheet
const testWorksheet = (value: string) => {
excelFileOptions.current.worksheet = value;
setWorksheet(value);
postWorksheet(id, worksheet)
.then(() => {
setState({ ...state, worksheet: { complete: true, message: '' } });
})
.catch((err) => {
const message = maybeAxiosError(err);
setState({ ...state, worksheet: { complete: false, message }, pullPush: { complete: false, message: '' } });
});
};
//SETP-5 Upload / Download
const updateExcelFileOptions = <T extends keyof ExcelImportMap>(field: T, value: ExcelImportMap[T]) => {
if (excelFileOptions.current[field] !== value) {
excelFileOptions.current = { ...excelFileOptions.current, [field]: value };
}
};
const handlePullData = () => {
postPreviewSheet(id, excelFileOptions.current)
.then((data) => {
setProject(data.project);
setRundown(data.rundown);
setUserFields(data.userFields);
})
.catch((error) => {
const message = maybeAxiosError(error);
setDirection('none');
setState({ ...state, pullPush: { complete: false, message } });
});
};
const handlePushData = () => {
postPushSheet(id, excelFileOptions.current)
.then(() => {
setDirection('none');
setState({ ...state, pullPush: { complete: true, message: '' } });
})
.catch((error) => {
const message = maybeAxiosError(error);
setDirection('none');
setState({ ...state, pullPush: { complete: false, message } });
});
};
//GET preview
const handleFinalise = async () => {
if (rundown && userFields && project) {
let doClose = false;
try {
await patchData({ rundown, userFields, project });
queryClient.setQueryData(RUNDOWN, rundown);
queryClient.setQueryData(USERFIELDS, userFields);
queryClient.setQueryData(PROJECT_DATA, project);
await queryClient.invalidateQueries({
queryKey: [...RUNDOWN, ...USERFIELDS, ...PROJECT_DATA],
});
doClose = true;
} catch (error) {
const message = maybeAxiosError(error);
console.error(message);
} finally {
if (doClose) {
handleClose();
}
}
}
};
return (
<Modal
onClose={handleClose}
isOpen={isOpen}
closeOnOverlayClick={false}
motionPreset='slideInBottom'
size='xl'
scrollBehavior='inside'
preserveScrollBarGap
variant='ontime-upload'
>
<ModalOverlay />
<ModalContent>
<ModalHeader>Rundown from sheets (experimental)</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Alert status='info' variant='ontime-on-light-info'>
<AlertIcon />
<div style={{ display: 'flex', flexDirection: 'column' }}>
<AlertTitle>Sync with Google Sheets</AlertTitle>
<AlertDescription>
<ModalLink href='https://ontime.gitbook.io/v2/features/google-sheet'>
For more information, see the docs
</ModalLink>
</AlertDescription>
</div>
</Alert>
{!rundown ? (
direction === 'up' || direction === 'down' ? (
<ExcelFileOptions optionsRef={excelFileOptions} updateOptions={updateExcelFileOptions} />
) : (
<>
<Step
title='1 - Upload OAuth 2.0 Client ID'
completed={state.clientSecret.complete}
disabled={false}
error={state.clientSecret.message}
>
<Input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
onChange={handleFile}
accept='.json'
data-testid='file-input'
/>
<div style={{ display: 'flex', gap: '1em' }}>
<Button size='sm' variant='ontime-subtle-on-light' onClick={handleClick}>
{state.clientSecret.complete ? 'Reupload Client ID' : 'Upload Client ID'}
</Button>
<Button size='sm' variant='ontime-ghosted-on-light' onClick={testClientSecret}>
Retry Client ID
</Button>
</div>
</Step>
<Step
title='2 - Authenticate with Google'
completed={state.authenticate.complete}
disabled={!state.clientSecret.complete}
error={state.authenticate.message}
>
<div style={{ display: 'flex', gap: '1em' }}>
<Button
size='sm'
variant='ontime-subtle-on-light'
onClick={handleAuthenticate}
isDisabled={!state.clientSecret.complete}
>
Authenticate
</Button>
<Button
size='sm'
variant='ontime-ghosted-on-light'
onClick={testAuthentication}
isDisabled={!state.clientSecret.complete}
>
Retry Connection
</Button>
</div>
</Step>
<Step
title='3 - Add Document ID'
completed={state.id.complete}
disabled={!state.authenticate.complete}
error={state.id.message}
>
<HStack>
<Input
type='text'
size='sm'
variant='ontime-filled-on-light'
isDisabled={!state.authenticate.complete}
value={id}
onChange={(event) => setSheetId(event.target.value)}
/>
<Button
isDisabled={!state.authenticate.complete}
size='sm'
variant='ontime-subtle-on-light'
padding='0 2em'
onClick={testSheetId}
>
Connect
</Button>
</HStack>
</Step>
<Step
title='4 - Select Worksheet to import'
completed={state.worksheet.complete}
disabled={worksheetOptions.length == 0}
>
<Select
size='sm'
isDisabled={worksheetOptions.length == 0}
placeholder='Select a worksheet'
onChange={(event) => testWorksheet(event.target.value)}
value={worksheet}
>
{worksheetOptions.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</Select>
</Step>
<Step title='5 - Upload / Download rundown' completed={false} disabled={!state.worksheet.complete}>
<div style={{ display: 'flex', gap: '1em' }}>
<Button
isDisabled={!state.worksheet.complete}
variant='ontime-subtle-on-light'
padding='0 2em'
onClick={() => setDirection('up')}
>
Upload
</Button>
<Button
isDisabled={!state.worksheet.complete}
variant='ontime-subtle-on-light'
padding='0 2em'
onClick={() => setDirection('down')}
>
Download
</Button>
</div>
</Step>
</>
)
) : (
<PreviewExcel
rundown={rundown ?? []}
project={project ?? projectDataPlaceholder}
userFields={userFields ?? userFieldsPlaceholder}
/>
)}
</ModalBody>
<ModalFooter>
{rundown ? (
<div style={{ display: 'flex', gap: '1em' }}>
<Button
onClick={() => {
setRundown(null);
setDirection('none');
}}
variant='ontime-ghost-on-light'
>
Go Back
</Button>
<Button variant='ontime-filled' padding='0 2em' onClick={handleFinalise}>
Import
</Button>
</div>
) : direction === 'up' ? (
<div style={{ display: 'flex', gap: '1em' }}>
<Button onClick={() => setDirection('none')} variant='ontime-ghost-on-light'>
Go Back
</Button>
<Button variant='ontime-filled' padding='0 2em' onClick={handlePushData}>
Upload
</Button>
</div>
) : direction === 'down' ? (
<div style={{ display: 'flex', gap: '1em' }}>
<Button onClick={() => setDirection('none')} variant='ontime-ghost-on-light'>
Go Back
</Button>
<Button variant='ontime-filled' padding='0 2em' onClick={handlePullData}>
Preview
</Button>
</div>
) : (
<></>
)}
</ModalFooter>
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,42 @@
@use '../../../theme/ontimeColours' as *;
.wrapper {
padding: 0.5rem;
}
.header {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
padding-bottom: 1rem;
}
.step {
width: 1.25rem;
height: 1.25rem;
font-size: 0.75rem;
display: grid;
place-content: center;
}
.title {
display: inline;
color: $gray-500;
text-transform: uppercase;
cursor: pointer;
}
.check {
color: $green-700;
}
.errorIcon {
color: $red-700;
}
.errorText {
color: $red-700;
font-size: 0.75rem;
}
@@ -0,0 +1,47 @@
import { PropsWithChildren, useEffect, useMemo, useState } from 'react';
import { IoCheckmarkCircle } from '@react-icons/all-files/io5/IoCheckmarkCircle';
import { IoCloseCircle } from '@react-icons/all-files/io5/IoCloseCircle';
import { IoRadioButtonOffOutline } from '@react-icons/all-files/io5/IoRadioButtonOffOutline';
import style from './Step.module.scss';
interface StepProps {
title: string;
disabled: boolean;
completed: boolean;
error?: string;
}
export default function Step(props: PropsWithChildren<StepProps>) {
const { title, disabled, completed, error, children } = props;
const [collapsed, setCollapsed] = useState(disabled);
const handleCollapse = () => setCollapsed((prev) => !prev);
const icon = useMemo(() => {
if (completed) return <IoCheckmarkCircle className={style.step} style={{ color: 'green' }} />;
if (error) return <IoCloseCircle className={style.step} style={{ color: 'red' }} />;
return <IoRadioButtonOffOutline className={style.step} />;
}, [completed, error]);
useEffect(() => {
if (completed) {
setCollapsed(true);
}
}, [completed]);
return (
<div className={style.wrapper}>
<div className={style.header} onClick={handleCollapse}>
{icon}
<span className={style.title}>{title}</span>
</div>
{!collapsed && (
<>
{error && <div className={style.errorText}>{error}</div>}
{children}
</>
)}
</div>
);
}
@@ -13,7 +13,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { OntimeRundown, ProjectData, UserFields } from 'ontime-types';
import { defaultExcelImportMap, ExcelImportMap } from 'ontime-utils';
import { PROJECT_DATA, RUNDOWN_TABLE, USERFIELDS } from '../../../common/api/apiConstants';
import { PROJECT_DATA, RUNDOWN, USERFIELDS } from '../../../common/api/apiConstants';
import { invalidateAllCaches, maybeAxiosError } from '../../../common/api/apiUtils';
import {
patchData,
@@ -155,11 +155,11 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
setSubmitting(true);
try {
await patchData({ rundown, userFields, project });
queryClient.setQueryData(RUNDOWN_TABLE, rundown);
queryClient.setQueryData(RUNDOWN, { rundown, revision: -1 });
queryClient.setQueryData(USERFIELDS, userFields);
queryClient.setQueryData(PROJECT_DATA, project);
await queryClient.invalidateQueries({
queryKey: [...RUNDOWN_TABLE, ...USERFIELDS, ...PROJECT_DATA],
queryKey: [...RUNDOWN, ...USERFIELDS, ...PROJECT_DATA],
});
doClose = true;
} catch (error) {
@@ -22,3 +22,24 @@
.spacer {
min-height: 95vh;
}
.editPrompt {
position: fixed;
z-index: 1;
left: 50%;
transform: translate(-50%, 0);
text-align: center;
background: rgba(black, 0.6);
border-radius: 2px;
padding: 0.5em 2em;
color: gold;
opacity: 0;
transition-property: opacity;
transition-duration: 0.3s;
&.show {
opacity: 1;
}
}
+57 -13
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { isOntimeEvent, OntimeEvent, SupportedEvent, UserFields } from 'ontime-types';
import { getFirstEvent, getLastEvent } from 'ontime-utils';
@@ -11,9 +11,12 @@ import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useOperator } from '../../common/hooks/useSocket';
import useProjectData from '../../common/hooks-query/useProjectData';
import useRundown from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import useUserFields from '../../common/hooks-query/useUserFields';
import { debounce } from '../../common/utils/debounce';
import { isStringBoolean } from '../../common/utils/viewUtils';
import EditModal from './edit-modal/EditModal';
import FollowButton from './follow-button/FollowButton';
import OperatorBlock from './operator-block/OperatorBlock';
import OperatorEvent from './operator-event/OperatorEvent';
@@ -24,15 +27,25 @@ import style from './Operator.module.scss';
const selectedOffset = 50;
type TitleFields = Pick<OntimeEvent, 'title' | 'subtitle' | 'presenter'>;
export type EditEvent = Pick<OntimeEvent, 'id' | 'cue'> & { fieldLabel?: string; fieldValue: string };
export type PartialEdit = EditEvent & {
field: keyof UserFields;
};
export default function Operator() {
const { data, status } = useRundown();
const { data: userFields, status: userFieldsStatus } = useUserFields();
const { data: projectData, status: projectDataStatus } = useProjectData();
const timeoutId = useRef<NodeJS.Timeout | null>(null);
const featureData = useOperator();
const [searchParams] = useSearchParams();
const { data: settings } = useSettings();
const [showEditPrompt, setShowEditPrompt] = useState(false);
const [editEvent, setEditEvent] = useState<PartialEdit | null>(null);
const isAutomatedScroll = useRef(false);
const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
@@ -41,7 +54,6 @@ export default function Operator() {
scrollRef: scrollRef,
doFollow: !lockAutoScroll,
topOffset: selectedOffset,
setScrollFlag: () => (isAutomatedScroll.current = true),
});
// Set window title
@@ -65,13 +77,8 @@ export default function Operator() {
setLockAutoScroll(false);
};
const handleScroll = () => {
// prevent considering automated scrolls as user scrolls
if (isAutomatedScroll.current) {
isAutomatedScroll.current = false;
return;
}
// prevent considering automated scrolls as user scrolls
const handleUserScroll = () => {
if (selectedRef?.current && scrollRef?.current) {
const selectedRect = selectedRef.current.getBoundingClientRect();
const scrollerRect = scrollRef.current.getBoundingClientRect();
@@ -82,22 +89,50 @@ export default function Operator() {
}
}
};
const debouncedHandleScroll = debounce(handleUserScroll, 1000);
const handleScroll = () => {
if (timeoutId.current) {
clearTimeout(timeoutId.current);
}
timeoutId.current = setTimeout(() => {
setShowEditPrompt(false);
}, 700);
setShowEditPrompt(true);
debouncedHandleScroll();
};
const handleEdit = useCallback(
(event: EditEvent) => {
const field = searchParams.get('subscribe') as keyof UserFields | null;
if (field) {
setEditEvent({ ...event, field });
}
},
[searchParams],
);
const missingData = !data || !userFields || !projectData;
const isLoading = status === 'loading' || userFieldsStatus === 'loading' || projectDataStatus === 'loading';
const isLoading = status === 'pending' || userFieldsStatus === 'pending' || projectDataStatus === 'pending';
if (missingData || isLoading) {
return <Empty text='Loading...' />;
}
// get fields which the user subscribed to
const shouldEdit = searchParams.get('shouldEdit');
const subscribe = searchParams.get('subscribe') as keyof UserFields | null;
const canEdit = shouldEdit && subscribe;
const main = searchParams.get('main') as keyof TitleFields | null;
const secondary = searchParams.get('secondary') as keyof TitleFields | null;
const subscribedAlias = subscribe ? userFields[subscribe] : '';
const showSeconds = isStringBoolean(searchParams.get('showseconds'));
const operatorOptions = getOperatorOptions(userFields);
const operatorOptions = getOperatorOptions(userFields, settings?.timeFormat ?? '24');
let isPast = Boolean(featureData.selectedEventId);
const hidePast = isStringBoolean(searchParams.get('hidepast'));
@@ -108,6 +143,7 @@ export default function Operator() {
<div className={style.operatorContainer}>
<NavigationMenu />
<ViewParamsEditor paramFields={operatorOptions} />
{editEvent && <EditModal event={editEvent} onClose={() => setEditEvent(null)} />}
<StatusBar
projectTitle={projectData.title}
@@ -119,7 +155,13 @@ export default function Operator() {
lastId={lastEvent?.id}
/>
<div className={style.operatorEvents} onScroll={handleScroll} ref={scrollRef}>
{canEdit && (
<div className={`${style.editPrompt} ${showEditPrompt ? style.show : undefined}`}>
Press and hold to edit user field
</div>
)}
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{data.map((entry) => {
if (isOntimeEvent(entry)) {
const isSelected = featureData.selectedEventId === entry.id;
@@ -139,6 +181,7 @@ export default function Operator() {
return (
<OperatorEvent
key={entry.id}
id={entry.id}
colour={entry.colour}
cue={entry.cue}
main={mainField}
@@ -153,6 +196,7 @@ export default function Operator() {
showSeconds={showSeconds}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
onLongPress={canEdit ? handleEdit : () => undefined}
/>
);
}
@@ -0,0 +1,30 @@
@use '../../../theme/v2Styles' as *;
@use '../../../theme/ontimeColours' as *;
.editModal {
position: absolute;
z-index: 2;
margin: 0 auto;
top: 20%;
left: 50%;
transform: translateX(-50%);
padding: 1rem;
background-color: $gray-1250;
display: flex;
flex-direction: column;
gap: 1rem;
min-width: min(400px, 90vw);
box-shadow: $box-shadow-l1;
.buttonRow {
margin-top: auto;
display: flex;
justify-content: space-between;
gap: 1rem;
button {
width: 100%;
}
}
}
@@ -0,0 +1,57 @@
import { useRef, useState } from 'react';
import { Button, Textarea } from '@chakra-ui/react';
import { OntimeEvent } from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction';
import type { PartialEdit } from '../Operator';
import style from './EditModal.module.scss';
interface EditModalProps {
event: PartialEdit;
onClose: () => void;
}
export default function EditModal(props: EditModalProps) {
const { event, onClose } = props;
const { updateEvent } = useEventAction();
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const handleSave = async () => {
setLoading(true);
const newValue = inputRef.current?.value;
const partialEvent: Partial<OntimeEvent> = {
id: event.id,
[event.field]: newValue,
};
await updateEvent(partialEvent);
setLoading(false);
onClose();
};
const fieldLabel = event?.fieldLabel ?? event.field;
return (
<div className={style.editModal}>
<div>{`Editing field ${fieldLabel} in cue ${event.cue}`}</div>
<Textarea
ref={inputRef}
variant='ontime-filled'
placeholder={`Add value for ${fieldLabel} field`}
defaultValue={event.fieldValue}
isDisabled={loading}
/>
<div className={style.buttonRow}>
<Button variant='ontime-subtle' onClick={onClose} isDisabled={loading}>
Cancel
</Button>
<Button variant='ontime-filled' onClick={handleSave} isDisabled={loading}>
Save
</Button>
</div>
</div>
);
}
@@ -2,9 +2,11 @@
@use '../../../../src/theme/v2Styles' as *;
.followButton {
position: relative;
bottom: 12rem;
margin: 0 auto;
position: fixed;
bottom: 1.5rem;
bottom: calc(1.5rem + env(safe-area-inset-bottom));
left: 50%;
transform: translateX(-50%);
z-index: 1;
display: flex;
@@ -1,13 +1,16 @@
import { memo, RefObject } from 'react';
import { memo, RefObject, SyntheticEvent } from 'react';
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
import useLongPress from '../../../common/hooks/useLongPress';
import { useTimer } from '../../../common/hooks/useSocket';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatTime } from '../../../common/utils/time';
import type { EditEvent } from '../Operator';
import style from './OperatorEvent.module.scss';
interface OperatorEventProps {
id: string;
colour: string;
cue: string;
main: string;
@@ -22,6 +25,7 @@ interface OperatorEventProps {
showSeconds: boolean;
isPast: boolean;
selectedRef?: RefObject<HTMLDivElement>;
onLongPress: (event: EditEvent) => void;
}
// extract this to contain re-renders
@@ -32,6 +36,7 @@ function RollingTime() {
function OperatorEvent(props: OperatorEventProps) {
const {
id,
colour,
cue,
main,
@@ -46,8 +51,17 @@ function OperatorEvent(props: OperatorEventProps) {
showSeconds,
isPast,
selectedRef,
onLongPress,
} = props;
const handleLongPress = (event?: SyntheticEvent) => {
// we dont have an event out of useLongPress
event?.preventDefault();
onLongPress({ id, cue, fieldLabel: subscribedAlias, fieldValue: subscribed ?? '' });
};
const mouseHandlers = useLongPress(handleLongPress, { threshold: 800 });
const start = formatTime(timeStart, { showSeconds });
const end = formatTime(timeEnd, { showSeconds });
@@ -61,7 +75,7 @@ function OperatorEvent(props: OperatorEventProps) {
]);
return (
<div className={operatorClasses} ref={selectedRef}>
<div className={operatorClasses} ref={selectedRef} onContextMenu={handleLongPress} {...mouseHandlers}>
<div className={style.binder} style={{ ...cueColours }}>
<span className={style.cue}>{cue}</span>
</div>
@@ -15,16 +15,19 @@
background-color: $gray-1350;
z-index: 2;
padding: 0.5rem 1rem;
border-bottom: 1px solid $white-10;
box-shadow: $large-top-drawer-shadow;
}
.timers {
display: grid;
padding: 0.5rem 1rem;
grid-template-areas:
"playback timer1B timer2B timer3B";
grid-template-columns: 1fr auto auto auto;
column-gap: 1.5rem;
align-items: center;
}
.playbackIcon {
@@ -83,7 +86,7 @@
// tablet
@media (min-width: $min-tablet) {
.statusBar {
.timers {
grid-template-areas:
"playback timer1B timer2A timer3A"
"title title timer2B timer3B";
@@ -103,3 +106,7 @@
display: flex;
}
}
.progressOverride {
border-radius: 0;
}
@@ -1,11 +1,9 @@
import { useMemo } from 'react';
import { Playback } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
import { useTimer } from '../../../common/hooks/useSocket';
import { cx } from '../../../common/utils/styleUtils';
import { formatTime } from '../../../common/utils/time';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
import StatusBarProgress from './StatusBarProgress';
import StatusBarTimers from './StatusBarTimers';
import styles from './StatusBar.module.scss';
@@ -22,73 +20,20 @@ interface StatusBarProps {
export default function StatusBar(props: StatusBarProps) {
const { projectTitle, playback, selectedEventId, firstStart, firstId, lastEnd, lastId } = props;
const timer = useTimer();
const getTimeStart = () => {
if (firstStart === undefined) {
return '...';
}
if (selectedEventId) {
if (firstId === selectedEventId) {
return millisToString(timer.expectedFinish);
}
}
return millisToString(firstStart);
};
const getTimeEnd = () => {
if (lastEnd === undefined) {
return '...';
}
if (selectedEventId) {
if (lastId === selectedEventId) {
return millisToString(timer.expectedFinish);
}
}
return millisToString(lastEnd);
};
// use user defined format
const timeNow = formatTime(timer.clock, {
showSeconds: true,
});
const runningTime = millisToString(timer.current);
const elapsedTime = millisToString(timer.elapsed);
const PlaybackIconComponent = useMemo(() => {
const isPlaying = playback === Playback.Play || playback === Playback.Roll;
const classes = cx([styles.playbackIcon, isPlaying ? styles.active : null]);
return <PlaybackIcon state={playback} skipTooltip className={classes} />;
}, [playback]);
const { data } = useViewSettings();
return (
<div className={styles.statusBar}>
{PlaybackIconComponent}
<div className={styles.timeNow}>
<span className={styles.label}>Time now</span>
<span className={styles.timer}>{timeNow}</span>
</div>
<div className={styles.elapsedTime}>
<span className={styles.label}>Elapsed time</span>
<span className={styles.timer}>{elapsedTime}</span>
</div>
<div className={styles.runningTime}>
<span className={styles.label}>Running timer</span>
<span className={styles.timer}>{runningTime}</span>
</div>
<span className={styles.title}>{projectTitle}</span>
<div className={styles.startTime}>
<span className={styles.label}>Scheduled start</span>
<span className={styles.timer}>{getTimeStart()}</span>
</div>
<div className={styles.endTime}>
<span className={styles.label}>Scheduled end</span>
<span className={styles.timer}>{getTimeEnd()}</span>
</div>
<StatusBarTimers
projectTitle={projectTitle}
playback={playback}
selectedEventId={selectedEventId}
firstStart={firstStart}
firstId={firstId}
lastEnd={lastEnd}
lastId={lastId}
/>
{data && <StatusBarProgress viewSettings={data} />}
</div>
);
}
@@ -0,0 +1,30 @@
import { ViewSettings } from 'ontime-types';
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import { useTimer } from '../../../common/hooks/useSocket';
import styles from './StatusBar.module.scss';
interface StatusBarProgressProps {
viewSettings: ViewSettings;
}
export default function StatusBarProgress(props: StatusBarProgressProps) {
const { viewSettings } = props;
const timer = useTimer();
const totalTime = (timer.duration ?? 0) + (timer.addedTime ?? 0);
return (
<MultiPartProgressBar
now={timer.current}
complete={totalTime}
normalColor={viewSettings.normalColor}
warning={viewSettings.warningThreshold}
warningColor={viewSettings.warningColor}
danger={viewSettings.dangerThreshold}
dangerColor={viewSettings.dangerColor}
className={styles.progressOverride}
/>
);
}
@@ -0,0 +1,94 @@
import { useMemo } from 'react';
import { Playback } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
import { useTimer } from '../../../common/hooks/useSocket';
import { cx } from '../../../common/utils/styleUtils';
import { formatTime } from '../../../common/utils/time';
import styles from './StatusBar.module.scss';
interface StatusBarTimersProps {
projectTitle: string;
playback: Playback;
selectedEventId: string | null;
firstStart?: number;
firstId?: string;
lastEnd?: number;
lastId?: string;
}
export default function StatusBarTimers(props: StatusBarTimersProps) {
const { projectTitle, playback, selectedEventId, firstStart, firstId, lastEnd, lastId } = props;
const timer = useTimer();
const getTimeStart = () => {
if (firstStart === undefined) {
return '...';
}
if (selectedEventId) {
if (firstId === selectedEventId) {
return millisToString(timer.expectedFinish);
}
}
return millisToString(firstStart);
};
const getTimeEnd = () => {
if (lastEnd === undefined) {
return '...';
}
if (selectedEventId) {
if (lastId === selectedEventId) {
return millisToString(timer.expectedFinish);
}
}
return millisToString(lastEnd);
};
const PlaybackIconComponent = useMemo(() => {
const isPlaying = playback === Playback.Play || playback === Playback.Roll;
const classes = cx([styles.playbackIcon, isPlaying ? styles.active : null]);
return <PlaybackIcon state={playback} skipTooltip className={classes} />;
}, [playback]);
// use user defined format
const timeNow = formatTime(timer.clock, {
showSeconds: true,
});
const runningTime = millisToString(timer.current);
const elapsedTime = millisToString(timer.elapsed);
return (
<div className={styles.timers}>
{PlaybackIconComponent}
<div className={styles.timeNow}>
<span className={styles.label}>Time now</span>
<span className={styles.timer}>{timeNow}</span>
</div>
<div className={styles.elapsedTime}>
<span className={styles.label}>Elapsed time</span>
<span className={styles.timer}>{elapsedTime}</span>
</div>
<div className={styles.runningTime}>
<span className={styles.label}>Running timer</span>
<span className={styles.timer}>{runningTime}</span>
</div>
<span className={styles.title}>{projectTitle}</span>
<div className={styles.startTime}>
<span className={styles.label}>Scheduled start</span>
<span className={styles.timer}>{getTimeStart()}</span>
</div>
<div className={styles.endTime}>
<span className={styles.label}>Scheduled end</span>
<span className={styles.timer}>{getTimeEnd()}</span>
</div>
</div>
);
}
@@ -198,7 +198,9 @@ export default function Rundown(props: RundownProps) {
if (index === 0) {
eventIndex = 0;
}
let isFirstEvent = false;
if (entry.type === SupportedEvent.Event) {
isFirstEvent = eventIndex === 0;
eventIndex++;
previousEnd = thisEnd;
thisEnd = entry.timeEnd;
@@ -220,6 +222,7 @@ export default function Rundown(props: RundownProps) {
<RundownEntry
type={entry.type}
isPast={isPast}
isFirstEvent={isFirstEvent}
data={entry}
selected={isSelected}
hasCursor={hasCursor}
@@ -1,8 +1,8 @@
import { useCallback } from 'react';
import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types';
import { GetRundownCached, OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types';
import { calculateDuration, getCueCandidate } from 'ontime-utils';
import { RUNDOWN_TABLE } from '../../common/api/apiConstants';
import { RUNDOWN } from '../../common/api/apiConstants';
import { useEventAction } from '../../common/hooks/useEventAction';
import { ontimeQueryClient } from '../../common/queryClient';
import { useAppMode } from '../../common/stores/appModeStore';
@@ -19,6 +19,7 @@ export type EventItemActions = 'set-cursor' | 'event' | 'delay' | 'block' | 'del
interface RundownEntryProps {
type: SupportedEvent;
isPast: boolean;
isFirstEvent: boolean;
data: OntimeRundownEntry;
selected: boolean;
hasCursor: boolean;
@@ -31,8 +32,19 @@ interface RundownEntryProps {
}
export default function RundownEntry(props: RundownEntryProps) {
const { isPast, data, selected, hasCursor, next, previousEnd, previousEventId, playback, isRolling, disableEdit } =
props;
const {
isPast,
data,
selected,
hasCursor,
next,
previousEnd,
previousEventId,
playback,
isRolling,
disableEdit,
isFirstEvent,
} = props;
const { emitError } = useEmitLog();
const { addEvent, updateEvent, deleteEvent, swapEvents } = useEventAction();
@@ -100,7 +112,8 @@ export default function RundownEntry(props: RundownEntryProps) {
}
case 'clone': {
const newEvent = cloneEvent(data as OntimeEvent, data.id);
newEvent.cue = getCueCandidate(ontimeQueryClient.getQueryData(RUNDOWN_TABLE) || [], data.id);
const rundown = ontimeQueryClient.getQueryData<GetRundownCached>(RUNDOWN)?.rundown ?? [];
newEvent.cue = getCueCandidate(rundown, data.id);
addEvent(newEvent);
break;
}
@@ -175,6 +188,7 @@ export default function RundownEntry(props: RundownEntryProps) {
isRolling={isRolling}
actionHandler={actionHandler}
disableEdit={disableEdit}
isFirstEvent={isFirstEvent}
/>
);
} else if (data.type === SupportedEvent.Block) {
@@ -10,11 +10,11 @@ $skip-opacity: 0.1;
display: grid;
grid-template-areas:
"binder ... ... ..."
"binder pb-actions times actions"
"binder pb-actions title next"
"binder pb-actions estatus estatus"
"binder ... ... ...";
'binder ... ... ...'
'binder pb-actions times actions'
'binder pb-actions title next-ind'
'binder pb-actions estatus estatus'
'binder ... ... ...';
grid-template-columns: $block-binder-width auto 1fr auto;
grid-template-rows: 0.25rem 2.25rem 2.25rem 2.25rem 0.25rem;
@@ -53,8 +53,18 @@ $skip-opacity: 0.1;
outline: 1px solid $block-cursor-color;
}
/* we stop the eventActions from having opacity to fix issue with dropdown drawing order */
&.past:not(.skip) {
opacity: 0.6;
.delayNote,
.statusElements,
.eventTitle,
.eventNote,
.eventTimers,
.eventStatus,
.playbackActions,
.binder {
opacity: 0.4;
}
}
&.skip {
@@ -160,8 +170,8 @@ $skip-opacity: 0.1;
grid-area: estatus;
display: grid;
grid-template-areas:
"notes status"
"progb progb";
'notes status'
'progb progb';
gap: 2px;
grid-template-rows: auto 0.25rem;
align-items: center;
@@ -180,9 +190,8 @@ $skip-opacity: 0.1;
overflow-y: hidden;
}
.nextTag {
grid-area: next;
grid-area: next-ind;
font-size: 1rem;
color: $orange-500;
letter-spacing: 0.03px;
@@ -190,6 +199,31 @@ $skip-opacity: 0.1;
text-align: right;
}
.indicators {
grid-area: next-ind;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 0.5rem;
.indicator {
background-color: transparent;
margin: 0.4rem;
margin-right: 0;
border-radius: 0.7rem;
width: 0.7rem;
height: 0.7rem;
}
.indicator.delay {
background-color: $ontime-delay;
}
.indicator.nextDay,
.indicator.overlap,
.indicator.spacing {
background-color: $gray-600;
}
}
.eventStatus {
grid-area: status;
display: flex;
@@ -198,7 +232,6 @@ $skip-opacity: 0.1;
gap: 0.5rem;
color: var(--status-color-override, $gray-500);
.statusIcon {
width: 1rem;
height: 1rem;
@@ -50,6 +50,7 @@ interface EventBlockProps {
},
) => void;
disableEdit: boolean;
isFirstEvent: boolean;
}
export default function EventBlock(props: EventBlockProps) {
@@ -76,6 +77,7 @@ export default function EventBlock(props: EventBlockProps) {
isRolling,
actionHandler,
disableEdit,
isFirstEvent,
} = props;
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
const moveCursorTo = useAppMode((state) => state.setCursor);
@@ -210,6 +212,7 @@ export default function EventBlock(props: EventBlockProps) {
isRolling={isRolling}
actionHandler={actionHandler}
disableEdit={disableEdit}
isFirstEvent={isFirstEvent}
/>
)}
</div>
@@ -11,9 +11,11 @@ import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward'
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { IoTime } from '@react-icons/all-files/io5/IoTime';
import { EndAction, Playback, TimerType } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
import { useAppMode } from '../../../common/stores/appModeStore';
import { millisToDelayString } from '../../../common/utils/dateConfig';
import { tooltipDelayMid } from '../../../ontimeConfig';
import EditableBlockTitle from '../common/EditableBlockTitle';
import { EventItemActions } from '../RundownEntry';
@@ -53,6 +55,7 @@ interface EventBlockInnerProps {
isRolling: boolean;
actionHandler: (action: EventItemActions, payload?: any) => void;
disableEdit: boolean;
isFirstEvent: boolean;
}
const EventBlockInner = (props: EventBlockInnerProps) => {
@@ -76,6 +79,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
isRolling,
actionHandler,
disableEdit,
isFirstEvent,
} = props;
const [renderInner, setRenderInner] = useState(false);
@@ -103,6 +107,19 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
playBtnStyles._hover = {};
}
const delayedStart = Math.max(0, timeStart + delay);
const newTime = millisToString(delayedStart);
const delayTime = delay !== 0 ? millisToDelayString(delay) : null;
const overlap = previousEnd - timeStart;
const overlapTime = !isFirstEvent
? overlap > 0
? `Overlapping ${millisToDelayString(overlap)}`
: overlap < 0
? `Spacing ${millisToDelayString(overlap)}`
: null
: null;
return !renderInner ? null : (
<>
<EventBlockTimers
@@ -114,10 +131,35 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
previousEnd={previousEnd}
/>
<EditableBlockTitle title={title} eventId={eventId} placeholder='Event title' className={style.eventTitle} />
{next && (
{next ? (
<Tooltip label='Next event' {...tooltipProps}>
<span className={style.nextTag}>UP NEXT</span>
</Tooltip>
) : (
<span className={style.indicators}>
{delayTime && (
<Tooltip
label={
<div>
{delayTime} <br />
New Time: {newTime}
</div>
}
>
<div className={`${style.indicator} ${style.delay}`} />
</Tooltip>
)}
{overlapTime && (
<Tooltip label={overlapTime}>
<div className={`${style.indicator} ${overlap > 0 ? style.overlap : style.spacing}`} />
</Tooltip>
)}
{timeStart > timeEnd && (
<Tooltip label='Start time is later than end'>
<div className={`${style.indicator} ${style.nextDay}`} />
</Tooltip>
)}
</span>
)}
<EventBlockPlayback
eventId={eventId}
@@ -13,7 +13,7 @@ import { EventItemActions } from '../../RundownEntry';
interface BlockActionMenuProps {
enableDelete?: boolean;
showClone?: boolean;
actionHandler: (action: EventItemActions, payload?: unknown) => void;
actionHandler: (action: EventItemActions, payload?: any) => void;
className?: string;
}
@@ -1,11 +1,10 @@
import { memo, useCallback, useState } from 'react';
import { memo } from 'react';
import { OntimeEvent } from 'ontime-types';
import { calculateDuration, millisToString } from 'ontime-utils';
import { calculateDuration, dayInMs, millisToString } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { useEventAction } from '../../../../common/hooks/useEventAction';
import { millisToDelayString } from '../../../../common/utils/dateConfig';
import { TimeEntryField, validateEntry } from '../../../../common/utils/timesManager';
import style from '../EventBlock.module.scss';
@@ -24,15 +23,13 @@ const EventBlockTimers = (props: EventBlockTimerProps) => {
const { eventId, timeStart, timeEnd, duration, delay, previousEnd } = props;
const { updateEvent } = useEventAction();
const [warning, setWarnings] = useState({ start: '', end: '', duration: '' });
const handleSubmit = (field: TimeActions, value: number) => {
const newEventData: Partial<OntimeEvent> = { id: eventId };
switch (field) {
case 'durationOverride': {
// duration defines timeEnd
newEventData.duration = value;
newEventData.timeEnd = timeStart + value;
newEventData.timeEnd = timeStart + ((value as number) % dayInMs);
break;
}
case 'timeStart': {
@@ -49,21 +46,6 @@ const EventBlockTimers = (props: EventBlockTimerProps) => {
updateEvent(newEventData);
};
/**
* @description Validates a time input against its pair
* @param {string} entry - field to validate: timeStart, timeEnd, durationOverride
* @param {number} val - field value
* @return {boolean}
*/
const handleValidation = useCallback(
(field: TimeEntryField, value: number) => {
const valid = validateEntry(field, value, timeStart, timeEnd);
setWarnings((prev) => ({ ...prev, ...valid.warnings }));
return valid.value;
},
[timeEnd, timeStart],
);
const delayedStart = Math.max(0, timeStart + delay);
const newTime = millisToString(delayedStart);
const delayTime = delay !== 0 ? millisToDelayString(delay) : null;
@@ -73,32 +55,26 @@ const EventBlockTimers = (props: EventBlockTimerProps) => {
<TimeInput
name='timeStart'
submitHandler={handleSubmit}
validationHandler={handleValidation}
time={timeStart}
delay={delay}
placeholder='Start'
previousEnd={previousEnd}
warning={warning.start}
/>
<TimeInput
name='timeEnd'
submitHandler={handleSubmit}
validationHandler={handleValidation}
time={timeEnd}
delay={delay}
placeholder='End'
previousEnd={previousEnd}
warning={warning.end}
/>
<TimeInput
name='durationOverride'
submitHandler={handleSubmit}
validationHandler={handleValidation}
time={duration}
delay={0}
placeholder='Duration'
previousEnd={previousEnd}
warning={warning.duration}
/>
{delayTime && (
<div className={style.delayNote}>
@@ -1,16 +1,43 @@
/* eslint-disable react/display-name */
import { ComponentType, useMemo } from 'react';
import { SupportedEvent } from 'ontime-types';
import { TimeManagerType } from 'common/models/TimeManager.type';
import { Message, OntimeEvent, ProjectData, Settings, SupportedEvent, TimerMessage, ViewSettings } from 'ontime-types';
import { useStore } from 'zustand';
import useProjectData from '../../common/hooks-query/useProjectData';
import useRundown from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import useViewSettings from '../../common/hooks-query/useViewSettings';
import { runtime } from '../../common/stores/runtime';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
const withData = <P extends object>(Component: ComponentType<P>) => {
return (props: Partial<P>) => {
type WithDataProps = {
isMirrored: boolean;
pres: TimerMessage;
publ: Message;
lower: Message;
external: Message;
eventNow: OntimeEvent | null;
publicEventNow: OntimeEvent | null;
eventNext: OntimeEvent | null;
publicEventNext: OntimeEvent | null;
time: TimeManagerType;
events: OntimeEvent[];
backstageEvents: OntimeEvent[];
selectedId: string | null;
publicSelectedId: string | null;
nextId: string | null;
general: ProjectData;
viewSettings: ViewSettings;
settings: Settings | undefined;
onAir: boolean;
};
function getDisplayName(Component: React.ComponentType<any>): string {
return Component.displayName || Component.name || 'Component';
}
const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
const WithDataComponent = (props: P) => {
// persisted app state
const isMirrored = useViewOptionsStore((state) => state.mirror);
@@ -18,6 +45,7 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
const { data: rundownData } = useRundown();
const { data: project } = useProjectData();
const { data: viewSettings } = useViewSettings();
const { data: settings } = useSettings();
const publicEvents = useMemo(() => {
if (Array.isArray(rundownData)) {
@@ -27,12 +55,12 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
}, [rundownData]);
// websocket data
const data = useStore(runtime);
const {
timer,
publicMessage,
timerMessage,
lowerMessage,
externalMessage,
playback,
onAir,
eventNext,
@@ -40,7 +68,7 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
publicEventNow,
eventNow,
loaded,
} = data;
} = useStore(runtime);
const publicSelectedId = loaded.selectedPublicEventId;
const selectedId = loaded.selectedEventId;
const nextId = loaded.nextEventId;
@@ -68,6 +96,7 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
pres={timerMessage}
publ={publicMessage}
lower={lowerMessage}
external={externalMessage}
eventNow={eventNow}
publicEventNow={publicEventNow}
eventNext={eventNext}
@@ -78,12 +107,16 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
selectedId={selectedId}
publicSelectedId={publicSelectedId}
viewSettings={viewSettings}
settings={settings}
nextId={nextId}
general={project}
onAir={onAir}
/>
);
};
WithDataComponent.displayName = `WithData(${getDisplayName(Component)})`;
return WithDataComponent;
};
export default withData;
@@ -60,6 +60,7 @@
font-weight: 600;
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
line-height: 0.95em;
}
.message {
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import QRCode from 'react-qr-code';
import { AnimatePresence, motion } from 'framer-motion';
import { Message, OntimeEvent, ProjectData, SupportedEvent, ViewSettings } from 'ontime-types';
import { Message, OntimeEvent, ProjectData, Settings, SupportedEvent, ViewSettings } from 'ontime-types';
import { formatDisplay } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/apiConstants';
@@ -11,13 +11,14 @@ import Schedule from '../../../common/components/schedule/Schedule';
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
import TitleCard from '../../../common/components/title-card/TitleCard';
import { BACKSTAGE_OPTIONS } from '../../../common/components/view-params-editor/constants';
import { getBackstageOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { formatTime } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import { titleVariants } from '../common/animation';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import './Backstage.scss';
@@ -36,10 +37,12 @@ interface BackstageProps {
selectedId: string | null;
general: ProjectData;
viewSettings: ViewSettings;
settings: Settings | undefined;
}
export default function Backstage(props: BackstageProps) {
const { isMirrored, publ, eventNow, eventNext, time, backstageEvents, selectedId, general, viewSettings } = props;
const { isMirrored, publ, eventNow, eventNext, time, backstageEvents, selectedId, general, viewSettings, settings } =
props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation();
const [blinkClass, setBlinkClass] = useState(false);
@@ -88,16 +91,17 @@ export default function Backstage(props: BackstageProps) {
}
const totalTime = (time.duration ?? 0) + (time.addedTime ?? 0);
const backstageOptions = getBackstageOptions(settings?.timeFormat ?? '24');
return (
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
<NavigationMenu />
<ViewParamsEditor paramFields={BACKSTAGE_OPTIONS} />
<ViewParamsEditor paramFields={backstageOptions} />
<div className='project-header'>
{general.title}
<div className='clock-container'>
<div className='label'>{getLocalizedString('common.time_now')}</div>
<div className='time'>{clock}</div>
<SuperscriptTime time={clock} className='time' />
</div>
</div>
@@ -128,12 +132,16 @@ export default function Backstage(props: BackstageProps) {
<div className='timer-group'>
<div className='aux-timers'>
<div className='aux-timers__label'>{getLocalizedString('common.started_at')}</div>
<div className='aux-timers__value'>{startedAt}</div>
<SuperscriptTime time={startedAt} className='aux-timers__value' />
</div>
<div className='timer-gap' />
<div className='aux-timers'>
<div className='aux-timers__label'>{getLocalizedString('common.expected_finish')}</div>
<div className='aux-timers__value'>{expectedFinish}</div>
{isNegative ? (
<div className='aux-timers__value'>{expectedFinish}</div>
) : (
<SuperscriptTime time={expectedFinish} className='aux-timers__value' />
)}
</div>
<div className='timer-gap' />
<div className='aux-timers'>
@@ -1,15 +1,16 @@
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { ViewSettings } from 'ontime-types';
import { Settings, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { CLOCK_OPTIONS } from '../../../common/components/view-params-editor/constants';
import { getClockOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { OverridableOptions } from '../../../common/models/View.types';
import { formatTime } from '../../../common/utils/time';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import './Clock.scss';
@@ -17,6 +18,7 @@ interface ClockProps {
isMirrored: boolean;
time: TimeManagerType;
viewSettings: ViewSettings;
settings: Settings | undefined;
}
const formatOptions = {
@@ -25,7 +27,7 @@ const formatOptions = {
};
export default function Clock(props: ClockProps) {
const { isMirrored, time, viewSettings } = props;
const { isMirrored, time, viewSettings, settings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [searchParams] = useSearchParams();
@@ -123,6 +125,8 @@ export default function Clock(props: ClockProps) {
const clock = formatTime(time.clock, formatOptions);
const clean = clock.replace('/:/g', '');
const clockOptions = getClockOptions(settings?.timeFormat ?? '24');
return (
<div
className={`clock-view ${isMirrored ? 'mirror' : ''}`}
@@ -134,8 +138,9 @@ export default function Clock(props: ClockProps) {
data-testid='clock-view'
>
<NavigationMenu />
<ViewParamsEditor paramFields={CLOCK_OPTIONS} />
<div
<ViewParamsEditor paramFields={clockOptions} />
<SuperscriptTime
time={clock}
className='clock'
style={{
color: userOptions.textColour,
@@ -145,9 +150,7 @@ export default function Clock(props: ClockProps) {
left: userOptions.left,
backgroundColor: userOptions.textBackground,
}}
>
{clock}
</div>
/>
</div>
);
}
@@ -0,0 +1,4 @@
sup.period {
top: -1em;
font-size: 0.4em;
}
@@ -0,0 +1,23 @@
import { CSSProperties } from 'react';
import './SuperscriptTime.scss';
interface SuperscriptTimeProps {
time: string;
className?: string;
style?: CSSProperties;
}
export default function SuperscriptTime(props: SuperscriptTimeProps) {
const { time, className, style } = props;
// we assume anything after space is a period tag
const [timeString, period] = time.split(' ');
return (
<div className={className} style={style}>
{timeString}
{period && <sup className='period'>{period}</sup>}
</div>
);
}
@@ -68,6 +68,7 @@
font-size: clamp(32px, 3.5vw, 50px);
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
line-height: 0.95em;
}
}
@@ -135,6 +136,7 @@
font-size: clamp(32px, 3.5vw, 50px);
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
line-height: 0.95em;
&--delayed {
color: $delay-color;

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