mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e2c9ee31ad | |||
| bfd85855d4 | |||
| 20859abda9 | |||
| 1025347c08 | |||
| 43988ea12b | |||
| 722e045b20 | |||
| ad6804019b | |||
| cd40a6e55e | |||
| 08d9e24871 | |||
| 0649678dca | |||
| a00ec2d02a | |||
| 54a74ccc2a | |||
| 6f3ab274bd | |||
| 90870ecfb6 | |||
| b640e0e181 | |||
| c41fe824cf | |||
| 62c8319d70 | |||
| b1d23467a2 | |||
| 9a62daf047 | |||
| b9ab1c6fd7 | |||
| c1054711b0 | |||
| a4d4f29a37 | |||
| 2793aadea0 | |||
| e7cfb7d9d9 | |||
| 256a851c9b | |||
| 4ab9c81cb8 | |||
| 2f13d6c89e | |||
| f1f7bad25e | |||
| a8b52a48f7 | |||
| cd0999b2ab | |||
| b4c60f3f04 | |||
| 5cefad3666 | |||
| 7a6ecd8c34 | |||
| ba96ecfd91 | |||
| 7bed3757f2 | |||
| e2e755b1d2 | |||
| a77d23109d | |||
| 0eb3b8d382 | |||
| 0021185288 | |||
| 94c72ff4f6 | |||
| e4c08dc9b2 | |||
| ec74af0d62 | |||
| b6d72dd082 | |||
| 31c311daf0 | |||
| e9b3cc6090 | |||
| 543b04a097 | |||
| d39b85b6e6 | |||
| bbe107bb2b | |||
| c616240db1 | |||
| 166be66ce3 | |||
| 4180d0a337 | |||
| 78108e316c | |||
| 3bb8b70915 | |||
| 1c4f13a0ed | |||
| 3ca0abad53 | |||
| 030c8f897f | |||
| 68175cfa3b | |||
| fd8f757851 | |||
| 876d111c61 | |||
| 730cb95c04 | |||
| 3c388d4fb5 | |||
| 2e23718d73 | |||
| 89ea8c470b | |||
| 178640bfc4 | |||
| 4ed38340e0 | |||
| 69eb9a5eff | |||
| 351425127a | |||
| 95f2ba37cc | |||
| fe8bbd5003 | |||
| c6e38a2b4a | |||
| 56f18fe71c | |||
| 0b9306de82 | |||
| 3b1e6cf33d | |||
| dda219ebbe | |||
| 8bb771e5c6 | |||
| 8723bcbd33 | |||
| 0dcea4f2d7 | |||
| 696c016c90 | |||
| eed6373dbf | |||
| ac61f257c0 | |||
| 8d8625aa77 | |||
| 1e1aa4bca5 | |||
| 1f0cde6d0d | |||
| c4b66a9ed3 | |||
| 133b3aa819 | |||
| 9566fadbbb | |||
| 62570899e8 | |||
| c1f25f8dba | |||
| 37a91026d8 | |||
| c62ce213f5 | |||
| 529751578d | |||
| dce87c3a81 | |||
| a94b10cb0f | |||
| 6817263123 | |||
| ddd629d7db | |||
| 9d2a3d5a12 | |||
| 4bc271208e | |||
| dbd535dd6c | |||
| 57b7fe214a | |||
| b136e2856a | |||
| 03e68c03a9 | |||
| 44048a889d | |||
| f72db62e08 | |||
| 333f2623a5 | |||
| 3ebb3451e4 |
Binary file not shown.
|
After Width: | Height: | Size: 271 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 270 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 272 KiB |
@@ -14,12 +14,12 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -56,12 +56,12 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -89,12 +89,12 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -20,13 +20,13 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -17,12 +17,12 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -73,12 +73,12 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@ Ontime consists of 3 distinct parts
|
||||
The steps below will assume you have locally installed the necessary dependencies.
|
||||
Other dependencies will be installed as part of the setup
|
||||
|
||||
- __node__ (~20)
|
||||
- __pnpm__ (~9)
|
||||
- __node__ (~22)
|
||||
- __pnpm__ (~10)
|
||||
- __docker__ (only necessary to run and build docker images)
|
||||
|
||||
## LOCAL DEVELOPMENT
|
||||
|
||||
+3
-3
@@ -1,13 +1,13 @@
|
||||
FROM node:20-bullseye AS builder
|
||||
FROM node:22-bullseye AS builder
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN npm install -g pnpm@9.5.0
|
||||
RUN npm install -g pnpm@10.11.0
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile
|
||||
RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker
|
||||
|
||||
FROM node:20-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
# Set environment variables
|
||||
# Environment Variable to signal that we are running production
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "3.14.3",
|
||||
"version": "3.16.1",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "3.14.3",
|
||||
"version": "3.16.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -17,12 +17,13 @@
|
||||
"@table-nav/react": "^0.0.7",
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"@tanstack/react-query-devtools": "^5.62.7",
|
||||
"@tanstack/react-table": "^8.20.5",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.2.0",
|
||||
"color": "^4.2.3",
|
||||
"csv-stringify": "^6.4.5",
|
||||
"framer-motion": "^10.10.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.3.1",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -31,6 +32,7 @@
|
||||
"react-icons": "5.4.0",
|
||||
"react-qr-code": "^2.0.12",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"web-vitals": "^3.1.1",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
@@ -65,11 +67,12 @@
|
||||
"@sentry/vite-plugin": "^2.16.1",
|
||||
"@tanstack/eslint-plugin-query": "^5.8.4",
|
||||
"@types/color": "^3.0.3",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||
"@typescript-eslint/parser": "catalog:",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@vitejs/plugin-react": "4.5.1",
|
||||
"eslint": "catalog:",
|
||||
"eslint-config-prettier": "catalog:",
|
||||
"eslint-plugin-jest": "^28.6.0",
|
||||
@@ -83,10 +86,10 @@
|
||||
"prettier": "catalog:",
|
||||
"sass": "^1.57.1",
|
||||
"typescript": "catalog:",
|
||||
"vite": "^5.2.11",
|
||||
"vite-plugin-compression2": "^1.3.3",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vite-tsconfig-paths": "^4.3.1",
|
||||
"vite": "6.3.1",
|
||||
"vite-plugin-compression2": "1.4.0",
|
||||
"vite-plugin-svgr": "4.3.0",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import axios from 'axios';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const assetsPath = `${apiEntryUrl}/assets`;
|
||||
|
||||
/**
|
||||
* HTTP request to get css contents
|
||||
*/
|
||||
export async function getCSSContents(): Promise<string> {
|
||||
const res = await axios.get(`${assetsPath}/css`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to post css contents
|
||||
*/
|
||||
export async function postCSSContents(css: string): Promise<void> {
|
||||
await axios.post(`${assetsPath}/css`, {
|
||||
css,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to restore default css
|
||||
*/
|
||||
export async function restoreCSSContents(): Promise<string> {
|
||||
const res = await axios.post(`${assetsPath}/css/restore`);
|
||||
return res.data;
|
||||
}
|
||||
@@ -3,8 +3,9 @@
|
||||
font-size: $text-body-size;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
color: $blue-400;
|
||||
|
||||
&:hover {
|
||||
color: $blue-400;
|
||||
color: $blue-500;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IoChevronDown } from 'react-icons/io5';
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
|
||||
import { baseURI } from '../../../externals';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import ParamInput from './ParamInput';
|
||||
@@ -17,7 +18,7 @@ interface ViewParamsSectionProps {
|
||||
export default function ViewParamsSection(props: ViewParamsSectionProps) {
|
||||
const { title, collapsible, options } = props;
|
||||
|
||||
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
|
||||
const [collapsed, setCollapsed] = useLocalStorage({ key: `${baseURI}params-${title}`, defaultValue: false });
|
||||
|
||||
const handleCollapse = () => {
|
||||
if (collapsible) {
|
||||
|
||||
@@ -4,10 +4,10 @@ import type { MultiselectOptions, ParamField } from './types';
|
||||
|
||||
export const makeOptionsFromCustomFields = (
|
||||
customFields: CustomFields,
|
||||
additionalOptions: Record<string, string> = {},
|
||||
additionalOptions: Readonly<Record<string, string>> = {},
|
||||
filterImageType = true,
|
||||
) => {
|
||||
const options = structuredClone(additionalOptions);
|
||||
const options = { ...additionalOptions };
|
||||
for (const [key, value] of Object.entries(customFields)) {
|
||||
if (filterImageType && value.type === 'image') {
|
||||
continue;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, PropsWithChildren, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { baseURI } from '../../externals';
|
||||
import useSettings from '../hooks-query/useSettings';
|
||||
|
||||
interface AppContextType {
|
||||
@@ -26,7 +27,7 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'pending') return;
|
||||
const previousEditor = sessionStorage.getItem(storageKeys.editor);
|
||||
const previousEditor = sessionStorage.getItem(`${baseURI}${storageKeys.editor}`);
|
||||
|
||||
if (previousEditor && previousEditor === data.editorKey) {
|
||||
setEditorAuth(true);
|
||||
@@ -34,7 +35,7 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
|
||||
setEditorAuth(data.editorKey == null || data.editorKey === '');
|
||||
}
|
||||
|
||||
const previousOperator = sessionStorage.getItem(storageKeys.operator);
|
||||
const previousOperator = sessionStorage.getItem(`${baseURI}${storageKeys.operator}`);
|
||||
if (previousOperator && previousOperator === data.operatorKey) {
|
||||
setOperatorAuth(true);
|
||||
} else {
|
||||
@@ -55,14 +56,14 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
|
||||
if (permission === 'editor') {
|
||||
const correct = isValid(pin, data.editorKey);
|
||||
if (correct) {
|
||||
sessionStorage.setItem(storageKeys.editor, pin);
|
||||
sessionStorage.setItem(`${baseURI}${storageKeys.editor}`, pin);
|
||||
}
|
||||
setEditorAuth(correct);
|
||||
return correct;
|
||||
} else if (permission === 'operator') {
|
||||
const correct = isValid(pin, data.operatorKey);
|
||||
if (correct) {
|
||||
sessionStorage.setItem(storageKeys.operator, pin);
|
||||
sessionStorage.setItem(`${baseURI}${storageKeys.operator}`, pin);
|
||||
}
|
||||
setOperatorAuth(correct);
|
||||
return correct;
|
||||
|
||||
@@ -41,7 +41,7 @@ export function useFlatRundown() {
|
||||
|
||||
// update data whenever the revision changes
|
||||
useEffect(() => {
|
||||
if (data.revision !== -1 && data.revision !== prevRevision) {
|
||||
if (data.revision !== -1 || data.revision !== prevRevision) {
|
||||
const flatRundown = data.order.map((id) => data.rundown[id]);
|
||||
setFlatRunDown(flatRundown);
|
||||
setPrevRevision(data.revision);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
|
||||
import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
|
||||
|
||||
import { useRuntimeStore } from '../stores/runtime';
|
||||
import { socketSendJson } from '../utils/socket';
|
||||
@@ -160,7 +160,7 @@ export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) =
|
||||
|
||||
numEvents: state.runtime.numEvents,
|
||||
selectedEventIndex: state.runtime.selectedEventIndex,
|
||||
offset: state.runtime.offset,
|
||||
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
|
||||
|
||||
currentBlock: state.currentBlock,
|
||||
}));
|
||||
@@ -172,8 +172,11 @@ export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
|
||||
|
||||
export const useTimeUntilData = createSelector((state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
offset: state.runtime.offset,
|
||||
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
|
||||
offsetMode: state.runtime.offsetMode,
|
||||
currentDay: state.eventNow?.dayOffset ?? 0, //The day of the currently running event
|
||||
actualStart: state.runtime.actualStart,
|
||||
plannedStart: state.runtime.plannedStart,
|
||||
}));
|
||||
|
||||
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
|
||||
@@ -189,6 +192,12 @@ export const useIsOnline = createSelector((state: RuntimeStore) => ({
|
||||
isOnline: state.ping > 0,
|
||||
}));
|
||||
|
||||
export const useOffsetMode = createSelector((state: RuntimeStore) => ({
|
||||
offsetMode: state.runtime.offsetMode,
|
||||
}));
|
||||
|
||||
export const setOffsetMode = (payload: OffsetMode) => socketSendJson('offsetmode', payload);
|
||||
|
||||
export const usePlayback = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
playback: state.timer.playback,
|
||||
|
||||
@@ -8,4 +8,5 @@ export const projectDataPlaceholder: ProjectData = {
|
||||
backstageUrl: '',
|
||||
backstageInfo: '',
|
||||
projectLogo: null,
|
||||
custom: [],
|
||||
};
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { baseURI } from '../../externals';
|
||||
|
||||
export enum AppMode {
|
||||
Run = 'run',
|
||||
Edit = 'edit',
|
||||
Freeze = 'freeze',
|
||||
}
|
||||
|
||||
const appModeKey = 'ontime-app-mode';
|
||||
const appModeKey = `${baseURI}ontime-app-mode`;
|
||||
|
||||
function getModeFromSession() {
|
||||
return sessionStorage.getItem(appModeKey) === AppMode.Run ? AppMode.Run : AppMode.Edit;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ClientList } from 'ontime-types';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { baseURI } from '../../externals';
|
||||
|
||||
interface ClientStore {
|
||||
name?: string;
|
||||
setName: (newValue: string) => void;
|
||||
@@ -15,7 +17,7 @@ interface ClientStore {
|
||||
setClients: (clients: ClientList) => void;
|
||||
}
|
||||
|
||||
const clientNameKey = 'ontime-client-name';
|
||||
const clientNameKey = `${baseURI}ontime-client-name`;
|
||||
|
||||
function persistNameInStorage(newValue: string) {
|
||||
localStorage.setItem(clientNameKey, newValue);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// eslint-disable-next-line simple-import-sort/imports
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { booleanFromLocalStorage } from '../utils/localStorage';
|
||||
import { baseURI } from '../../externals';
|
||||
|
||||
enum LocalEventKeys {
|
||||
Mirror = 'ontime-view-mirror',
|
||||
@@ -12,11 +14,11 @@ type ViewOptionsStore = {
|
||||
};
|
||||
|
||||
export const useViewOptionsStore = create<ViewOptionsStore>()((set) => ({
|
||||
mirror: booleanFromLocalStorage(LocalEventKeys.Mirror, false),
|
||||
mirror: booleanFromLocalStorage(`${baseURI}${LocalEventKeys.Mirror}`, false),
|
||||
toggleMirror: (newValue?: boolean) =>
|
||||
set((state) => {
|
||||
const val = typeof newValue === 'undefined' ? !state.mirror : newValue;
|
||||
localStorage.setItem(LocalEventKeys.Mirror, String(val));
|
||||
localStorage.setItem(`${baseURI}${LocalEventKeys.Mirror}`, String(val));
|
||||
return { mirror: val };
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
exports[`cx() > ignores falsy values 1`] = `""`;
|
||||
|
||||
exports[`cx() > merges styles 1`] = `"_test_98a1e0 _another_98a1e0"`;
|
||||
exports[`cx() > merges styles 1`] = `"_test_d5d33f _another_d5d33f"`;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { OffsetMode } from 'ontime-types';
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
|
||||
import { calculateTimeUntilStart, formatTime, nowInMillis } from '../time';
|
||||
|
||||
describe('nowInMillis()', () => {
|
||||
@@ -40,82 +43,255 @@ describe('formatTime()', () => {
|
||||
});
|
||||
|
||||
describe('calculateTimeUntilStart()', () => {
|
||||
test('ontime', () => {
|
||||
describe('Absolute offset mode', () => {
|
||||
test('ontime', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
clock: 90,
|
||||
offset: 0,
|
||||
offsetMode: OffsetMode.Absolute,
|
||||
actualStart: null,
|
||||
plannedStart: null,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(10);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
|
||||
});
|
||||
|
||||
test('running behind', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
clock: 90,
|
||||
offset: -20,
|
||||
offsetMode: OffsetMode.Absolute,
|
||||
actualStart: null,
|
||||
plannedStart: null,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(30);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(30);
|
||||
});
|
||||
|
||||
test('running ahead', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
clock: 80,
|
||||
offset: 10,
|
||||
offsetMode: OffsetMode.Absolute,
|
||||
actualStart: null,
|
||||
plannedStart: null,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // <-- when running ahead the unlinked timer stays put
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
|
||||
});
|
||||
|
||||
test('running behind with enough gaps', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 20,
|
||||
clock: 50,
|
||||
offset: -20,
|
||||
offsetMode: OffsetMode.Absolute,
|
||||
actualStart: null,
|
||||
plannedStart: null,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(50); // <-- when gap is enough to compensate for the running behind
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
|
||||
});
|
||||
|
||||
test('running behind with too little gaps', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 10,
|
||||
clock: 50,
|
||||
offset: -20,
|
||||
offsetMode: OffsetMode.Absolute,
|
||||
actualStart: 0,
|
||||
plannedStart: 0,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(60); // <-- when gap is not enough to compensate for the running behind it absorbs at much as possible
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
|
||||
});
|
||||
});
|
||||
|
||||
describe('Relative offset mode', () => {
|
||||
test('basic function', () => {
|
||||
const test = {
|
||||
timeStart: 0,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
clock: 100,
|
||||
actualStart: 100,
|
||||
plannedStart: 0,
|
||||
offset: 0,
|
||||
offsetMode: OffsetMode.Relative,
|
||||
};
|
||||
|
||||
const timeStartEvent2 = 10;
|
||||
const timeStartEvent3 = 20;
|
||||
|
||||
//event 1 is the currently running event
|
||||
|
||||
//event 2
|
||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(10);
|
||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(10);
|
||||
|
||||
//event 3
|
||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(20);
|
||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(20);
|
||||
|
||||
// When clock advances by 5ms, time until start should decrease by 5ms
|
||||
test.clock = 105;
|
||||
|
||||
//event 2
|
||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(5);
|
||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(5);
|
||||
|
||||
//event 3
|
||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(15);
|
||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(15);
|
||||
});
|
||||
|
||||
test('gaps', () => {
|
||||
const test = {
|
||||
timeStart: 20,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 10,
|
||||
clock: 100,
|
||||
actualStart: 100,
|
||||
plannedStart: 0,
|
||||
offset: 0,
|
||||
offsetMode: OffsetMode.Relative,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
|
||||
|
||||
// When clock advances by 5ms, time until start should decrease by 5ms
|
||||
test.clock = 105;
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(15);
|
||||
});
|
||||
|
||||
test('added/remove time', () => {
|
||||
const test = {
|
||||
timeStart: 20,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
clock: 100,
|
||||
actualStart: 100,
|
||||
plannedStart: 0,
|
||||
offset: 0,
|
||||
offsetMode: OffsetMode.Relative,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
|
||||
|
||||
test.offset = 5; // remove 5 with addtime - we are ahead of time
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // unlocked evets will stay on schedule
|
||||
|
||||
test.offset = -5; // add 5 with addtime - we are behind
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(25);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(25);
|
||||
});
|
||||
|
||||
test('next day', () => {
|
||||
const test = {
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
clock: 100,
|
||||
actualStart: 100,
|
||||
plannedStart: 0,
|
||||
offset: 0,
|
||||
offsetMode: OffsetMode.Relative,
|
||||
};
|
||||
|
||||
// this event will start the current day
|
||||
expect(
|
||||
calculateTimeUntilStart({
|
||||
...test,
|
||||
timeStart: 10,
|
||||
dayOffset: 0,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
}),
|
||||
).toBe(10);
|
||||
|
||||
// this event will start the next day
|
||||
// in absolute mode this would start in dayInMs - 100 since the gap would compensate
|
||||
// but in relative mode with and actual start that is 100 offset it starts in dayInMs
|
||||
expect(
|
||||
calculateTimeUntilStart({
|
||||
...test,
|
||||
timeStart: 0,
|
||||
dayOffset: 1,
|
||||
totalGap: dayInMs - 20,
|
||||
isLinkedToLoaded: false,
|
||||
}),
|
||||
).toBe(dayInMs);
|
||||
|
||||
// advancing 100ms
|
||||
test.clock = 200;
|
||||
|
||||
expect(
|
||||
calculateTimeUntilStart({
|
||||
...test,
|
||||
timeStart: 0,
|
||||
dayOffset: 1,
|
||||
totalGap: dayInMs - 20,
|
||||
isLinkedToLoaded: false,
|
||||
}),
|
||||
).toBe(dayInMs - 100);
|
||||
});
|
||||
});
|
||||
|
||||
test('overlap with negative total gap', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
clock: 90,
|
||||
clock: 100,
|
||||
actualStart: 100,
|
||||
plannedStart: 0,
|
||||
offset: 0,
|
||||
offsetMode: OffsetMode.Relative,
|
||||
isLinkedToLoaded: false,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(10);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
|
||||
// the overlap will be pushed out to the expected available time
|
||||
expect(calculateTimeUntilStart({ ...test, timeStart: 5, totalGap: -5 })).toBe(10);
|
||||
|
||||
test.clock = 105;
|
||||
});
|
||||
|
||||
test('running behind', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
clock: 90,
|
||||
offset: -20,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(30);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(30);
|
||||
});
|
||||
|
||||
test('running ahead', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
clock: 80,
|
||||
offset: 10,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // <-- when running ahead the unlinked timer stays put
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
|
||||
});
|
||||
|
||||
test('running behind with enough gaps', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 20,
|
||||
clock: 50,
|
||||
offset: -20,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(50);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
|
||||
});
|
||||
|
||||
test('running behind with to little gaps', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 10,
|
||||
clock: 50,
|
||||
offset: -20,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(60);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
|
||||
});
|
||||
|
||||
//TODO: more indepth testing,
|
||||
// including day offset handling
|
||||
// and more?
|
||||
});
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { MaybeString } from 'ontime-types';
|
||||
|
||||
export default function safeParseNumber(value: MaybeString, defaultValue: number = 0): number {
|
||||
if (!value) return defaultValue;
|
||||
const number = Number(value);
|
||||
if (isNaN(number)) return defaultValue;
|
||||
return number;
|
||||
}
|
||||
@@ -48,10 +48,12 @@ export const connectSocket = () => {
|
||||
|
||||
websocket.onclose = () => {
|
||||
console.warn('WebSocket disconnected');
|
||||
setOnlineStatus(false);
|
||||
|
||||
if (shouldReconnect) {
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
if (reconnectAttempts > 2) {
|
||||
setOnlineStatus(false);
|
||||
}
|
||||
console.warn('WebSocket: attempting reconnect');
|
||||
if (websocket && websocket.readyState === WebSocket.CLOSED) {
|
||||
reconnectAttempts += 1;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MaybeNumber, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
|
||||
import { MaybeNumber, OffsetMode, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
|
||||
import { dayInMs, formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||
|
||||
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
|
||||
@@ -140,8 +140,8 @@ export function useTimeUntilStart(
|
||||
isLinkedToLoaded: boolean;
|
||||
},
|
||||
): number {
|
||||
const { offset, clock, currentDay } = useTimeUntilData();
|
||||
return calculateTimeUntilStart({ ...data, currentDay, clock, offset });
|
||||
const { offset, clock, currentDay, offsetMode, actualStart, plannedStart } = useTimeUntilData();
|
||||
return calculateTimeUntilStart({ ...data, currentDay, clock, offset, offsetMode, actualStart, plannedStart });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,9 +160,24 @@ export function calculateTimeUntilStart(
|
||||
isLinkedToLoaded: boolean;
|
||||
clock: number;
|
||||
offset: number;
|
||||
offsetMode: OffsetMode;
|
||||
actualStart: MaybeNumber;
|
||||
plannedStart: MaybeNumber;
|
||||
},
|
||||
): number {
|
||||
const { timeStart, dayOffset, currentDay, totalGap, isLinkedToLoaded, clock, offset, delay } = data;
|
||||
const {
|
||||
timeStart,
|
||||
dayOffset,
|
||||
currentDay,
|
||||
totalGap,
|
||||
isLinkedToLoaded,
|
||||
clock,
|
||||
offset,
|
||||
delay,
|
||||
offsetMode,
|
||||
actualStart,
|
||||
plannedStart,
|
||||
} = data;
|
||||
|
||||
//How many days from the currently running event to this one
|
||||
const relativeDayOffset = dayOffset - currentDay;
|
||||
@@ -172,20 +187,23 @@ export function calculateTimeUntilStart(
|
||||
//The normalised start time of this event relative to the currently running event
|
||||
const normalisedTimeStart = delayedStart + relativeDayOffset * dayInMs;
|
||||
|
||||
const offsetTimestart = normalisedTimeStart - offset;
|
||||
const offsetTimeUntil = offsetTimestart - clock;
|
||||
let relativeStartOffset = 0;
|
||||
|
||||
if (offsetMode === OffsetMode.Relative) {
|
||||
relativeStartOffset = (actualStart ?? 0) - (plannedStart ?? 0);
|
||||
}
|
||||
|
||||
const scheduledTimeUntil = normalisedTimeStart - clock + relativeStartOffset;
|
||||
|
||||
const offsetTimeUntil = scheduledTimeUntil - offset;
|
||||
|
||||
if (isLinkedToLoaded) {
|
||||
//if we are directly linked back to the loaded event we just follow the offset
|
||||
return offsetTimeUntil;
|
||||
}
|
||||
|
||||
const scheduledTimeUntil = normalisedTimeStart - clock;
|
||||
|
||||
const isAheadOfSchedule = offset >= 0;
|
||||
const gapsCanCompensadeForOffset = totalGap + offset >= 0;
|
||||
|
||||
if (isAheadOfSchedule || gapsCanCompensadeForOffset) {
|
||||
const gapsCanCompensateForOffset = totalGap + offset >= 0;
|
||||
if (gapsCanCompensateForOffset) {
|
||||
// if we are ahead of schedule or the gap can compensate for the amount we are behind then expect to start at the scheduled time
|
||||
return scheduledTimeUntil;
|
||||
}
|
||||
|
||||
@@ -207,6 +207,9 @@ $inner-padding: 1rem;
|
||||
&.end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
&.apart {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes animloader {
|
||||
|
||||
@@ -113,8 +113,8 @@ export function BlockQuote({ children }: { children: ReactNode }) {
|
||||
return <blockquote className={style.blockquote}>{children}</blockquote>;
|
||||
}
|
||||
|
||||
export function Error({ children }: { children: ReactNode }) {
|
||||
return <div className={style.fieldError}>{children}</div>;
|
||||
export function Error({ children, className }: { children: ReactNode } & JSX.IntrinsicElements['div']) {
|
||||
return <div className={cx([style.fieldError, className])}>{children}</div>;
|
||||
}
|
||||
|
||||
export function Divider() {
|
||||
@@ -136,7 +136,7 @@ type AllowedInlineTags = 'div' | 'td';
|
||||
type InlineProps<C extends AllowedInlineTags> = {
|
||||
as?: C;
|
||||
relation?: 'inner' | 'component' | 'section';
|
||||
align?: 'start' | 'end';
|
||||
align?: 'start' | 'end' | 'apart';
|
||||
className?: string;
|
||||
};
|
||||
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ export default function ReportSettings() {
|
||||
})();
|
||||
return (
|
||||
<tr key={entry.index}>
|
||||
<th>{entry.index + 1}</th>
|
||||
<th>{entry.index}</th>
|
||||
<th>{entry.cue}</th>
|
||||
<th>{entry.title}</th>
|
||||
<th className={cx([start && style[start]])}>{formatTime(entry.scheduledStart)}</th>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.wrapper {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { forwardRef, memo, useEffect, useImperativeHandle, useState } from 'react';
|
||||
import Editor from 'react-simple-code-editor';
|
||||
import Prism from 'prismjs/components/prism-core';
|
||||
|
||||
import 'prismjs/components/prism-css';
|
||||
import 'prismjs/themes/prism-tomorrow.min.css';
|
||||
import style from './StyleEditor.module.scss';
|
||||
|
||||
interface CodeEditorProps {
|
||||
language: string;
|
||||
initialValue: string;
|
||||
isDirty: boolean;
|
||||
setIsDirty: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const CodeEditor = forwardRef((props: CodeEditorProps, cssRef) => {
|
||||
const { language, initialValue, isDirty, setIsDirty } = props;
|
||||
|
||||
const [code, setCode] = useState(initialValue);
|
||||
|
||||
const highlight = (code: string) => {
|
||||
const grammar = Prism.languages[language];
|
||||
return grammar ? Prism.highlight(code, grammar, language) : code;
|
||||
};
|
||||
|
||||
const handleChange = (newCode: string) => {
|
||||
setCode(newCode);
|
||||
};
|
||||
|
||||
useImperativeHandle(cssRef, () => {
|
||||
return {
|
||||
getCss: () => code,
|
||||
};
|
||||
});
|
||||
|
||||
// add contents to editor on mount and any change in initialValue
|
||||
useEffect(() => {
|
||||
setCode(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
// handle dirty state on change
|
||||
useEffect(() => {
|
||||
if (initialValue.trim() !== code.trim() && !isDirty && code.length !== 0) {
|
||||
setIsDirty(true);
|
||||
}
|
||||
|
||||
if (initialValue.trim() === code.trim() && isDirty) {
|
||||
setIsDirty(false);
|
||||
}
|
||||
}, [initialValue, code, isDirty, setIsDirty]);
|
||||
|
||||
return (
|
||||
<div className={style.wrapper}>
|
||||
<Editor
|
||||
value={code}
|
||||
padding={15}
|
||||
onValueChange={handleChange}
|
||||
highlight={highlight}
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
minHeight: 500,
|
||||
background: '#2d2d2d', // Background of tomorrow theme
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
CodeEditor.displayName = 'StyleEditor';
|
||||
|
||||
export default memo(CodeEditor);
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
.editorActions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.right {
|
||||
align-self: end;
|
||||
text-align: right
|
||||
}
|
||||
|
||||
.column {
|
||||
align-items: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { lazy, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { getCSSContents, postCSSContents, restoreCSSContents } from '../../../../common/api/assets';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './StyleEditorModal.module.scss';
|
||||
|
||||
const CodeEditor = lazy(() => import('./StyleEditor'));
|
||||
|
||||
interface CodeEditorModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface CSSRef {
|
||||
getCss: () => string;
|
||||
}
|
||||
|
||||
export default function CodeEditorModal(props: CodeEditorModalProps) {
|
||||
const { isOpen, onClose } = props;
|
||||
|
||||
const [css, setCSS] = useState('');
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [saveLoading, setSaveLoading] = useState(false);
|
||||
const [resetLoading, setResetLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const cssRef = useRef<CSSRef>(null);
|
||||
|
||||
const handleRestore = async () => {
|
||||
try {
|
||||
setResetLoading(true);
|
||||
const defaultCss = await restoreCSSContents();
|
||||
setCSS(defaultCss);
|
||||
} catch (_error) {
|
||||
/** no error handling for now */
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaveLoading(true);
|
||||
if (cssRef.current) {
|
||||
await postCSSContents(cssRef.current.getCss());
|
||||
setCSS(cssRef.current.getCss());
|
||||
setIsDirty(false);
|
||||
}
|
||||
} catch (_error) {
|
||||
/** no error handling for now */
|
||||
} finally {
|
||||
setSaveLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clear = () => setCSS('');
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchServerCSS() {
|
||||
// check for isOpen to fetch recent css
|
||||
if (isOpen) {
|
||||
try {
|
||||
const css = await getCSSContents();
|
||||
setCSS(css);
|
||||
} catch (_error) {
|
||||
setError('Failed to load CSS from server');
|
||||
/** no error handling for now */
|
||||
}
|
||||
}
|
||||
}
|
||||
fetchServerCSS();
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} variant='ontime' isCentered>
|
||||
<ModalOverlay />
|
||||
<ModalContent maxWidth='max(800px, 40vw)'>
|
||||
<ModalHeader>Edit CSS override</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<CodeEditor ref={cssRef} initialValue={css} language='css' isDirty={isDirty} setIsDirty={setIsDirty} />
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter className={style.column}>
|
||||
<Info>Invalid CSS will be refused by the browser</Info>
|
||||
{error && <Panel.Error className={style.right}>{`Error: ${error}`}</Panel.Error>}
|
||||
<Panel.InlineElements align='apart' className={style.editorActions}>
|
||||
<Button
|
||||
variant='ontime-ghosted'
|
||||
onClick={handleRestore}
|
||||
isDisabled={saveLoading || resetLoading}
|
||||
isLoading={resetLoading}
|
||||
>
|
||||
Reset to example
|
||||
</Button>
|
||||
<Panel.InlineElements>
|
||||
<Button variant='ontime-ghosted' onClick={clear}>
|
||||
Clear
|
||||
</Button>
|
||||
<Button variant='ontime-subtle' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='ontime-filled'
|
||||
onClick={handleSave}
|
||||
isDisabled={saveLoading || resetLoading || !isDirty}
|
||||
isLoading={saveLoading}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.InlineElements>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { Button, Input, Switch } from '@chakra-ui/react';
|
||||
import { Button, Input, Switch, useDisclosure } from '@chakra-ui/react';
|
||||
import { ViewSettings } from 'ontime-types';
|
||||
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
@@ -14,11 +14,14 @@ import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { isOntimeCloud } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import CodeEditorModal from './StyleEditorModal';
|
||||
|
||||
const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/';
|
||||
|
||||
export default function ViewSettingsForm() {
|
||||
const { data, status, refetch } = useViewSettings();
|
||||
const { data: info, status: infoStatus } = useInfo();
|
||||
const { isOpen: isCodeEditorOpen, onOpen: onCodeEditorOpen, onClose: onCodeEditorClose } = useDisclosure();
|
||||
|
||||
const {
|
||||
control,
|
||||
@@ -103,6 +106,7 @@ export default function ViewSettingsForm() {
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={isLoading} />
|
||||
<Panel.ListGroup>
|
||||
<CodeEditorModal isOpen={isCodeEditorOpen} onClose={onCodeEditorClose} />
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Override CSS styles'
|
||||
@@ -115,6 +119,15 @@ export default function ViewSettingsForm() {
|
||||
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
onClick={onCodeEditorOpen}
|
||||
variant='ontime-subtle'
|
||||
size='sm'
|
||||
isDisabled={!data.overrideStyles}
|
||||
width='fit-content'
|
||||
>
|
||||
Edit CSS override
|
||||
</Button>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
<Panel.ListGroup>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
declare module 'prismjs/components/prism-core' {
|
||||
export * from 'prismjs';
|
||||
}
|
||||
|
||||
declare module 'prismjs/components/prism-css';
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { IoTrash } from 'react-icons/io5';
|
||||
import { Button, Input, Textarea } from '@chakra-ui/react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
@@ -23,6 +24,7 @@ type ProjectCreateFormValues = {
|
||||
publicUrl?: string;
|
||||
backstageInfo?: string;
|
||||
backstageUrl?: string;
|
||||
custom?: { title: string; value: string }[];
|
||||
};
|
||||
|
||||
export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
||||
@@ -34,6 +36,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
control,
|
||||
formState: { isSubmitting, isValid },
|
||||
setFocus,
|
||||
} = useForm<ProjectCreateFormValues>({
|
||||
@@ -44,6 +47,11 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: 'custom',
|
||||
});
|
||||
|
||||
// set focus to first field
|
||||
useEffect(() => {
|
||||
setFocus('title');
|
||||
@@ -59,6 +67,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
||||
...values,
|
||||
filename,
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: PROJECT_LIST });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
@@ -66,6 +75,10 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCustom = () => {
|
||||
append({ title: '', value: '' });
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Section
|
||||
as='form'
|
||||
@@ -151,6 +164,42 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
||||
{...register('backstageUrl')}
|
||||
/>
|
||||
</label>
|
||||
<Panel.Section>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Custom data' description='Add custom data for your project' />
|
||||
<Button variant='ontime-subtle' onClick={handleAddCustom}>
|
||||
+
|
||||
</Button>
|
||||
</Panel.ListItem>
|
||||
{fields.map((field, idx) => (
|
||||
<div key={field.id} className={style.customDataItem}>
|
||||
<Panel.Paragraph>{idx + 1}.</Panel.Paragraph>
|
||||
<label>
|
||||
Title
|
||||
<Input
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder={field.title}
|
||||
autoComplete='off'
|
||||
{...register(`custom.${idx}.title` as const)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Value
|
||||
<Input
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder={field.value}
|
||||
autoComplete='off'
|
||||
{...register(`custom.${idx}.value` as const)}
|
||||
/>
|
||||
</label>
|
||||
<Button variant='ontime-ghosted' onClick={() => remove(idx)}>
|
||||
<IoTrash />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</Panel.Section>
|
||||
</Panel.Section>
|
||||
</Panel.Section>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ChangeEvent, useEffect, useRef } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { IoDownloadOutline, IoTrash } from 'react-icons/io5';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { IoAdd, IoDownloadOutline, IoTrash } from 'react-icons/io5';
|
||||
import { Button, Input, Textarea } from '@chakra-ui/react';
|
||||
import { type ProjectData } from 'ontime-types';
|
||||
|
||||
@@ -25,6 +25,7 @@ export default function ProjectData() {
|
||||
formState: { isSubmitting, isValid, isDirty, errors },
|
||||
setError,
|
||||
watch,
|
||||
control,
|
||||
setValue,
|
||||
} = useForm({
|
||||
defaultValues: data,
|
||||
@@ -32,6 +33,12 @@ export default function ProjectData() {
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
mode: 'onChange',
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: 'custom',
|
||||
});
|
||||
|
||||
// reset form values if data changes
|
||||
@@ -77,6 +84,10 @@ export default function ProjectData() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddCustom = () => {
|
||||
append({ title: '', value: '' });
|
||||
};
|
||||
|
||||
const onSubmit = async (formData: ProjectData) => {
|
||||
try {
|
||||
await postProjectData(formData);
|
||||
@@ -231,6 +242,69 @@ export default function ProjectData() {
|
||||
{...register('backstageUrl')}
|
||||
/>
|
||||
</label>
|
||||
<Panel.Section style={{ marginTop: 0 }}>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Custom data' description='' />
|
||||
<Button leftIcon={<IoAdd />} size='sm' variant='ontime-subtle' onClick={handleAddCustom}>
|
||||
Add
|
||||
</Button>
|
||||
</Panel.ListItem>
|
||||
{fields.length > 0 &&
|
||||
fields.map((field, idx) => {
|
||||
const rowErrors = errors.custom?.[idx] as
|
||||
| {
|
||||
title?: { message?: string };
|
||||
value?: { message?: string };
|
||||
}
|
||||
| undefined;
|
||||
return (
|
||||
<div key={field.id} className={style.customDataItem}>
|
||||
<div>
|
||||
<div className={style.titleRow}>
|
||||
<label>
|
||||
Title
|
||||
<Input
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
defaultValue={field.title}
|
||||
placeholder='Title of your custom data'
|
||||
autoComplete='off'
|
||||
{...register(`custom.${idx}.title`, {
|
||||
required: { value: true, message: 'Field cannot be empty' },
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='ontime-subtle'
|
||||
color='#FA5656' // $red-500
|
||||
onClick={() => remove(idx)}
|
||||
leftIcon={<IoTrash />}
|
||||
>
|
||||
Delete Entry
|
||||
</Button>
|
||||
</div>
|
||||
{rowErrors?.title?.message && <Panel.Error>{rowErrors.title.message}</Panel.Error>}
|
||||
</div>
|
||||
<label>
|
||||
Value
|
||||
<Textarea
|
||||
variant='ontime-filled'
|
||||
resize='none'
|
||||
size='sm'
|
||||
defaultValue={field.value}
|
||||
autoComplete='off'
|
||||
placeholder='Text of your custom data'
|
||||
{...register(`custom.${idx}.value`, {
|
||||
required: { value: true, message: 'Field cannot be empty' },
|
||||
})}
|
||||
/>
|
||||
{rowErrors?.value?.message && <Panel.Error>{rowErrors.value.message}</Panel.Error>}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Panel.Section>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
|
||||
@@ -57,3 +57,18 @@
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.customDataItem {
|
||||
display: contents;
|
||||
width: 100%;
|
||||
|
||||
.titleRow{
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: end;
|
||||
|
||||
label {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ export default function SourcesPanel() {
|
||||
};
|
||||
|
||||
const handleSubmitImportPreview = async (importMap: ImportMap) => {
|
||||
setError(''); // to clear previous error
|
||||
if (importFlow === 'excel') {
|
||||
try {
|
||||
const previewData = await importRundownPreviewExcel(importMap);
|
||||
|
||||
+10
-1
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { IoAdd, IoTrash } from 'react-icons/io5';
|
||||
import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react';
|
||||
@@ -28,6 +28,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
|
||||
control,
|
||||
handleSubmit,
|
||||
register,
|
||||
setValue,
|
||||
formState: { errors, isValid },
|
||||
} = useForm<NamedImportMap>({
|
||||
mode: 'onChange',
|
||||
@@ -45,6 +46,14 @@ export default function ImportMapForm(props: ImportMapFormProps) {
|
||||
|
||||
const [loading, setLoading] = useState<'' | 'export' | 'import'>('');
|
||||
|
||||
// Set first sheet as default worksheet when 'event schedule' sheet is not there
|
||||
useEffect(() => {
|
||||
if (!worksheetNames || worksheetNames.length === 0) return;
|
||||
if (!worksheetNames.includes(namedImportMap.Worksheet)) {
|
||||
setValue('Worksheet', worksheetNames[0], { shouldValidate: true, shouldDirty: true });
|
||||
}
|
||||
}, [worksheetNames, setValue, namedImportMap.Worksheet]);
|
||||
|
||||
const handleExport = async (values: NamedImportMap) => {
|
||||
setLoading('export');
|
||||
const importMap = convertToImportMap(values);
|
||||
|
||||
+4
-2
@@ -1,5 +1,7 @@
|
||||
import { ImportCustom, ImportMap } from 'ontime-utils';
|
||||
|
||||
import { baseURI } from '../../../../../externals';
|
||||
|
||||
export type NamedImportMap = typeof namedImportMap;
|
||||
|
||||
// Record of label and import name
|
||||
@@ -64,11 +66,11 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
|
||||
}
|
||||
|
||||
export function persistImportMap(options: NamedImportMap) {
|
||||
localStorage.setItem('ontime-import-options', JSON.stringify(options));
|
||||
localStorage.setItem(`${baseURI}ontime-import-options`, JSON.stringify(options));
|
||||
}
|
||||
|
||||
function getPersistImportMap(): unknown {
|
||||
const options = localStorage.getItem('ontime-import-options');
|
||||
const options = localStorage.getItem(`${baseURI}ontime-import-options`);
|
||||
if (!options) {
|
||||
throw new Error('no import options found');
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { IoEye, IoEyeOffOutline } from 'react-icons/io5';
|
||||
import { PropsWithChildren, useEffect, useRef, useState } from 'react';
|
||||
import { Input } from '@chakra-ui/react';
|
||||
|
||||
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
|
||||
import style from './InputRow.module.scss';
|
||||
|
||||
@@ -12,50 +10,53 @@ interface InputRowProps {
|
||||
placeholder: string;
|
||||
text: string;
|
||||
visible: boolean;
|
||||
actionHandler: () => void;
|
||||
changeHandler: (newValue: string) => void;
|
||||
}
|
||||
|
||||
export default function InputRow(props: InputRowProps) {
|
||||
const { label, placeholder, text, visible, actionHandler, changeHandler } = props;
|
||||
export default function InputRow(props: PropsWithChildren<InputRowProps>) {
|
||||
const { label, placeholder, text, visible, changeHandler, children } = props;
|
||||
|
||||
const [value, setValue] = useState(text);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const cursorPositionRef = useRef(0);
|
||||
|
||||
// sync cursor position with text
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
if (inputRef.current && inputRef.current !== document.activeElement) {
|
||||
inputRef.current.selectionStart = cursorPositionRef.current;
|
||||
inputRef.current.selectionEnd = cursorPositionRef.current;
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
// synchronise external text
|
||||
useEffect(() => {
|
||||
if (inputRef.current !== document.activeElement) {
|
||||
setValue(text);
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
cursorPositionRef.current = event.target.selectionStart ?? 0;
|
||||
setValue(event.target.value);
|
||||
changeHandler(event.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.inputRow}>
|
||||
<label className={`${style.label} ${visible ? style.active : ''}`}>{label}</label>
|
||||
<label className={cx([style.label, visible ?? style.active])} htmlFor={label}>
|
||||
{label}
|
||||
</label>
|
||||
<div className={style.inputItems}>
|
||||
<Input
|
||||
id={label}
|
||||
ref={inputRef}
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
value={text}
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<TooltipActionBtn
|
||||
clickHandler={actionHandler}
|
||||
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'
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { IoEye, IoEyeOffOutline } from 'react-icons/io5';
|
||||
|
||||
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
|
||||
import { setMessage, useExternalMessageInput, useTimerMessageInput } from '../../../common/hooks/useSocket';
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
|
||||
import InputRow from './InputRow';
|
||||
import TimerControlsPreview from './TimerViewControl';
|
||||
@@ -23,8 +27,17 @@ function TimerMessageInput() {
|
||||
text={text}
|
||||
visible={visible}
|
||||
changeHandler={(newValue) => setMessage.timerText(newValue)}
|
||||
actionHandler={() => setMessage.timerVisible(!visible)}
|
||||
/>
|
||||
>
|
||||
<TooltipActionBtn
|
||||
clickHandler={() => setMessage.timerVisible(!visible)}
|
||||
tooltip={visible ? 'Make invisible' : 'Make visible'}
|
||||
aria-label='Toggle timer message visibility'
|
||||
openDelay={tooltipDelayMid}
|
||||
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
|
||||
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
|
||||
size='sm'
|
||||
/>
|
||||
</InputRow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,7 +59,16 @@ function ExternalInput() {
|
||||
text={text}
|
||||
visible={visible}
|
||||
changeHandler={(newValue) => setMessage.externalText(newValue)}
|
||||
actionHandler={toggleExternal}
|
||||
/>
|
||||
>
|
||||
<TooltipActionBtn
|
||||
clickHandler={toggleExternal}
|
||||
tooltip={visible ? 'Make invisible' : 'Make visible'}
|
||||
aria-label='Toggle external message visibility'
|
||||
openDelay={tooltipDelayMid}
|
||||
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
|
||||
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
|
||||
size='sm'
|
||||
/>
|
||||
</InputRow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { MILLIS_PER_HOUR, MILLIS_PER_SECOND, parseUserTime } from 'ontime-utils'
|
||||
|
||||
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
||||
import { setPlayback } from '../../../../common/hooks/useSocket';
|
||||
import { baseURI } from '../../../../externals';
|
||||
import { tooltipDelayMid } from '../../../../ontimeConfig';
|
||||
import TapButton from '../tap-button/TapButton';
|
||||
|
||||
@@ -17,7 +18,7 @@ interface AddTimeProps {
|
||||
|
||||
export default function AddTime(props: AddTimeProps) {
|
||||
const { playback } = props;
|
||||
const [time, setTime] = useLocalStorage({ key: 'add-time', defaultValue: 300_000 }); // 5 minutes
|
||||
const [time, setTime] = useLocalStorage({ key: `${baseURI}add-time`, defaultValue: 300_000 }); // 5 minutes
|
||||
|
||||
const handleTimeChange = (_field: string, value: string) => {
|
||||
const newTime = parseUserTime(value);
|
||||
|
||||
@@ -45,6 +45,10 @@
|
||||
@include ellipsis-overflow;
|
||||
}
|
||||
|
||||
.offset {
|
||||
color: $muted-gray;
|
||||
}
|
||||
|
||||
.ahead {
|
||||
color: $playback-ahead;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, PropsWithChildren, ReactNode, useMemo } from 'react';
|
||||
import { Playback } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
|
||||
@@ -163,14 +164,14 @@ function ProgressOverview() {
|
||||
}
|
||||
|
||||
function RuntimeOverview() {
|
||||
const { clock, offset } = useRuntimePlaybackOverview();
|
||||
const { clock, offset, playback } = useRuntimePlaybackOverview();
|
||||
|
||||
const offsetText = getOffsetText(offset);
|
||||
const offsetClasses = offset === null ? undefined : offset <= 0 ? style.behind : style.ahead;
|
||||
const offsetClasses = cx([style.offset, playback !== Playback.Stop && (offset < 0 ? style.behind : style.ahead)]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TimeColumn label='Offset' value={offsetText} className={offsetClasses} />
|
||||
<TimeColumn label='Offset' value={offsetText} className={offsetClasses} testId='offset' />
|
||||
<TimeColumn label='Time now' value={formatedTime(clock)} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -10,13 +10,16 @@ interface TimeLayoutProps {
|
||||
muted?: boolean;
|
||||
daySpan?: number;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function TimeColumn({ label, value, muted, className }: TimeLayoutProps) {
|
||||
export function TimeColumn({ label, value, muted, className, testId }: TimeLayoutProps) {
|
||||
return (
|
||||
<div className={style.column}>
|
||||
<span className={style.label}>{label}</span>
|
||||
<span className={cx([style.clock, muted && style.muted, className])}>{value}</span>
|
||||
<span className={cx([style.clock, muted && style.muted, className])} data-testid={testId}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -300,7 +300,8 @@ export default function Rundown({ data }: RundownProps) {
|
||||
isNextDay = checkIsNextDay(entry, lastEvent);
|
||||
if (!isPast) {
|
||||
totalGap += entry.gap;
|
||||
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart !== null;
|
||||
// We also include countToEnd in this test as the behavior of a linked event coming after a countToEnd is simelar to an unlinked event
|
||||
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart !== null && !lastEvent?.countToEnd;
|
||||
}
|
||||
if (isNewLatest(entry, lastEvent)) {
|
||||
// populate previous entry
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { CSSProperties, useCallback } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { CustomFieldLabel, OntimeEvent } from 'ontime-types';
|
||||
|
||||
import AppLink from '../../../common/components/link/app-link/AppLink';
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import * as Editor from '../../editors/editor-utils/EditorUtils';
|
||||
|
||||
import EventEditorImage from './composite/EventEditorImage';
|
||||
import EventCustom from './composite/EventEditorCustom';
|
||||
import EventEditorTimes from './composite/EventEditorTimes';
|
||||
import EventEditorTitles from './composite/EventEditorTitles';
|
||||
import EventTextArea from './composite/EventTextArea';
|
||||
import EventTextInput from './composite/EventTextInput';
|
||||
import EventEditorTriggers from './composite/EventEditorTriggers';
|
||||
import EventEditorEmpty from './EventEditorEmpty';
|
||||
|
||||
import style from './EventEditor.module.scss';
|
||||
@@ -77,52 +75,16 @@ export default function EventEditor(props: EventEditorProps) {
|
||||
<div className={style.column}>
|
||||
<Editor.Title>
|
||||
Custom Fields
|
||||
{isEditor && <AppLink search='settings=feature_settings__custom'>Manage</AppLink>}
|
||||
{isEditor && <AppLink search='settings=feature_settings__custom'>Manage Custom Fields</AppLink>}
|
||||
</Editor.Title>
|
||||
|
||||
{Object.keys(customFields).map((fieldKey) => {
|
||||
const key = `${event.id}-${fieldKey}`;
|
||||
const fieldName = `custom-${fieldKey}`;
|
||||
const initialValue = event.custom[fieldKey] ?? '';
|
||||
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
|
||||
const labelText = customFields[fieldKey].label;
|
||||
|
||||
if (customFields[fieldKey].type === 'string') {
|
||||
return (
|
||||
<EventTextArea
|
||||
key={key}
|
||||
field={fieldName}
|
||||
label={labelText}
|
||||
initialValue={initialValue}
|
||||
submitHandler={handleSubmit}
|
||||
className={style.decorated}
|
||||
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (customFields[fieldKey].type === 'image') {
|
||||
return (
|
||||
<div key={key} className={style.customImage}>
|
||||
<EventTextInput
|
||||
key={key}
|
||||
field={fieldName}
|
||||
label={labelText}
|
||||
initialValue={initialValue}
|
||||
placeholder='Paste image URL'
|
||||
submitHandler={handleSubmit}
|
||||
className={style.decorated}
|
||||
maxLength={255}
|
||||
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
|
||||
/>
|
||||
<EventEditorImage src={initialValue} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// we should have exhausted all types by now
|
||||
return null;
|
||||
})}
|
||||
<EventCustom fields={customFields} handleSubmit={handleSubmit} event={event} />
|
||||
</div>
|
||||
<div className={style.column}>
|
||||
<Editor.Title>
|
||||
Automations
|
||||
{isEditor && <AppLink search='settings=automation__automations'>Manage Automations</AppLink>}
|
||||
</Editor.Title>
|
||||
<EventEditorTriggers triggers={event.triggers} eventId={event.id} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { CSSProperties, Fragment } from 'react';
|
||||
import { CustomFields, OntimeEvent } from 'ontime-types';
|
||||
|
||||
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||
import { EditorUpdateFields } from '../EventEditor';
|
||||
|
||||
import EventEditorImage from './EventEditorImage';
|
||||
import EventTextArea from './EventTextArea';
|
||||
import EventTextInput from './EventTextInput';
|
||||
|
||||
import style from '../EventEditor.module.scss';
|
||||
|
||||
interface EventEditorCustomProps {
|
||||
fields: CustomFields;
|
||||
event: OntimeEvent;
|
||||
handleSubmit: (field: EditorUpdateFields, value: string) => void;
|
||||
}
|
||||
|
||||
export default function EventEditorCustom(props: EventEditorCustomProps) {
|
||||
const { fields: customFields, handleSubmit, event } = props;
|
||||
return (
|
||||
<Fragment>
|
||||
{Object.keys(customFields).map((fieldKey) => {
|
||||
const key = `${event.id}-${fieldKey}`;
|
||||
const fieldName = `custom-${fieldKey}`;
|
||||
const initialValue = event.custom[fieldKey] ?? '';
|
||||
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
|
||||
const labelText = customFields[fieldKey].label;
|
||||
|
||||
if (customFields[fieldKey].type === 'string') {
|
||||
return (
|
||||
<EventTextArea
|
||||
key={key}
|
||||
field={fieldName}
|
||||
label={labelText}
|
||||
initialValue={initialValue}
|
||||
submitHandler={handleSubmit}
|
||||
className={style.decorated}
|
||||
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (customFields[fieldKey].type === 'image') {
|
||||
return (
|
||||
<div key={key} className={style.customImage}>
|
||||
<EventTextInput
|
||||
key={key}
|
||||
field={fieldName}
|
||||
label={labelText}
|
||||
initialValue={initialValue}
|
||||
placeholder='Paste image URL'
|
||||
submitHandler={handleSubmit}
|
||||
className={style.decorated}
|
||||
maxLength={255}
|
||||
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
|
||||
/>
|
||||
<EventEditorImage src={initialValue} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// we should have exhausted all types by now
|
||||
return null;
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
.triggerForm {
|
||||
padding-block: 0.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto auto;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
padding: 0.25rem 0.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
align-items: center;
|
||||
|
||||
&:nth-child(even) {
|
||||
background-color: $white-1;
|
||||
}
|
||||
|
||||
& > span {
|
||||
width: fit-content;
|
||||
}
|
||||
}
|
||||
|
||||
.errorLabel {
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: $green-500;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Fragment, useCallback, useState } from 'react';
|
||||
import { IoAlertCircle, IoCheckmarkCircle, IoTrash } from 'react-icons/io5';
|
||||
import { Button, IconButton, Select, Tooltip } from '@chakra-ui/react';
|
||||
import { TimerLifeCycle, timerLifecycleValues, Trigger } from 'ontime-types';
|
||||
import { generateId } from 'ontime-utils';
|
||||
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import { useEventAction } from '../../../../common/hooks/useEventAction';
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
|
||||
import { eventTriggerOptions } from './eventTrigger.constants';
|
||||
|
||||
import style from './EventEditorTriggers.module.scss';
|
||||
|
||||
interface EventEditorTriggersProps {
|
||||
eventId: string;
|
||||
triggers?: Trigger[];
|
||||
}
|
||||
|
||||
export default function EventEditorTriggers(props: EventEditorTriggersProps) {
|
||||
const { triggers, eventId } = props;
|
||||
const showTriggers = triggers !== undefined && triggers.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showTriggers && <ExistingEventTriggers triggers={triggers} eventId={eventId} />}
|
||||
<EventTriggerForm triggers={triggers} eventId={eventId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface EventTriggerFormProps {
|
||||
eventId: string;
|
||||
triggers?: Trigger[];
|
||||
}
|
||||
|
||||
function EventTriggerForm(props: EventTriggerFormProps) {
|
||||
const { eventId, triggers } = props;
|
||||
const { data: automationSettings } = useAutomationSettings();
|
||||
const { updateEvent } = useEventAction();
|
||||
const [automationId, setAutomationId] = useState<string | undefined>(undefined);
|
||||
const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart);
|
||||
|
||||
const handleSubmit = (triggerLifeCycle: TimerLifeCycle, automationId: string) => {
|
||||
const newTriggers = triggers ?? new Array<Trigger>();
|
||||
const id = generateId();
|
||||
newTriggers.push({ id, title: '', trigger: triggerLifeCycle, automationId });
|
||||
updateEvent({ id: eventId, triggers: newTriggers });
|
||||
};
|
||||
|
||||
const getValidationError = (cycle: TimerLifeCycle, automationId?: string): string | undefined => {
|
||||
if (automationId === undefined) {
|
||||
return 'Select an automation';
|
||||
}
|
||||
if (!Object.keys(automationSettings.automations).includes(automationId)) {
|
||||
return 'This automation does not exist';
|
||||
}
|
||||
if (triggers === undefined) {
|
||||
return;
|
||||
}
|
||||
return Object.values(triggers).some((t) => t.automationId === automationId && t.trigger === cycle)
|
||||
? 'Automation can only be used once'
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const validationError = getValidationError(cycleValue, automationId);
|
||||
|
||||
return (
|
||||
<div className={style.triggerForm}>
|
||||
<Select
|
||||
size='sm'
|
||||
variant='ontime'
|
||||
value={cycleValue}
|
||||
onChange={(e) => setCycleValue(e.target.value as TimerLifeCycle)}
|
||||
defaultValue={TimerLifeCycle.onStart}
|
||||
>
|
||||
<option disabled>Lifecycle Trigger</option>
|
||||
{eventTriggerOptions.map((cycle) => (
|
||||
<option key={cycle} value={cycle}>
|
||||
{cycle}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
size='sm'
|
||||
variant='ontime'
|
||||
value={automationId}
|
||||
defaultValue='«invalid»'
|
||||
onChange={(e) => setAutomationId(e.target.value)}
|
||||
>
|
||||
<option disabled value='«invalid»'>
|
||||
Automation
|
||||
</option>
|
||||
{Object.values(automationSettings.automations).map(({ id, title }) => (
|
||||
<option key={id} value={id}>
|
||||
{title}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
size='sm'
|
||||
isDisabled={validationError !== undefined}
|
||||
onClick={() => automationId && handleSubmit(cycleValue, automationId)}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
{validationError !== undefined ? (
|
||||
<Tooltip label={validationError} shouldWrapChildren>
|
||||
<IoAlertCircle className={style.errorLabel} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<IoCheckmarkCircle className={style.success} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExistingEventTriggersProps {
|
||||
eventId: string;
|
||||
triggers: Trigger[];
|
||||
}
|
||||
|
||||
function ExistingEventTriggers(props: ExistingEventTriggersProps) {
|
||||
const { eventId, triggers } = props;
|
||||
const { updateEvent } = useEventAction();
|
||||
const { data: automationSettings } = useAutomationSettings();
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(triggerId: string) => {
|
||||
const newTriggers = triggers.filter((trigger) => trigger.id !== triggerId);
|
||||
updateEvent({ id: eventId, triggers: newTriggers });
|
||||
},
|
||||
[eventId, triggers, updateEvent],
|
||||
);
|
||||
|
||||
const filteredTriggers: Record<string, Trigger[]> = {};
|
||||
|
||||
// sort triggers out into groups by the Lifecycle they are on
|
||||
timerLifecycleValues.forEach((triggerType) => {
|
||||
const thisTriggerType = triggers.filter((trigger) => trigger.trigger === triggerType);
|
||||
if (thisTriggerType.length) {
|
||||
Object.assign(filteredTriggers, { [triggerType]: thisTriggerType });
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{Object.entries(filteredTriggers).map(([triggerLifeCycle, triggerGroup]) => (
|
||||
<Fragment key={triggerLifeCycle}>
|
||||
{triggerGroup.map((trigger) => {
|
||||
const { id, automationId } = trigger;
|
||||
const automationTitle = automationSettings.automations[automationId]?.title ?? '<MISSING AUTOMATION>';
|
||||
return (
|
||||
<div key={id} className={style.trigger}>
|
||||
<Tag>{triggerLifeCycle}</Tag>
|
||||
<Tag>{automationTitle}</Tag>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='ontime-ghosted'
|
||||
color='#FA5656' // $red-500
|
||||
icon={<IoTrash />}
|
||||
aria-label='Delete entry'
|
||||
onClick={() => handleDelete(id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { TimerLifeCycle } from 'ontime-types';
|
||||
|
||||
export const eventTriggerOptions: TimerLifeCycle[] = [
|
||||
TimerLifeCycle.onLoad,
|
||||
TimerLifeCycle.onStart,
|
||||
TimerLifeCycle.onPause,
|
||||
TimerLifeCycle.onFinish,
|
||||
TimerLifeCycle.onWarning,
|
||||
TimerLifeCycle.onDanger,
|
||||
];
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Button, ButtonGroup } from '@chakra-ui/react';
|
||||
import { OffsetMode } from 'ontime-types';
|
||||
|
||||
import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket';
|
||||
import { AppMode, useAppMode } from '../../../common/stores/appModeStore';
|
||||
|
||||
import RundownMenu from './RundownMenu';
|
||||
@@ -12,6 +14,8 @@ export default function RundownHeader() {
|
||||
const setRunMode = () => setAppMode(AppMode.Run);
|
||||
const setEditMode = () => setAppMode(AppMode.Edit);
|
||||
|
||||
const { offsetMode } = useOffsetMode();
|
||||
|
||||
return (
|
||||
<div className={style.header}>
|
||||
<ButtonGroup isAttached>
|
||||
@@ -22,6 +26,22 @@ export default function RundownHeader() {
|
||||
Edit
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<ButtonGroup isAttached>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={offsetMode === OffsetMode.Absolute ? 'ontime-filled' : 'ontime-subtle'}
|
||||
onClick={() => setOffsetMode(OffsetMode.Absolute)}
|
||||
>
|
||||
Absolute
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={offsetMode === OffsetMode.Relative ? 'ontime-filled' : 'ontime-subtle'}
|
||||
onClick={() => setOffsetMode(OffsetMode.Relative)}
|
||||
>
|
||||
Relative
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<RundownMenu />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,57 +9,9 @@
|
||||
top: 75vh;
|
||||
line-height: normal;
|
||||
|
||||
animation-fill-mode: both;
|
||||
animation-timing-function: ease-out;
|
||||
|
||||
&--pre {
|
||||
animation-name: in;
|
||||
animation-play-state: paused;
|
||||
|
||||
.data-top {
|
||||
animation-play-state: paused;
|
||||
animation-name: top-in;
|
||||
}
|
||||
|
||||
.data-lower {
|
||||
animation-play-state: paused;
|
||||
animation-name: bottom-in;
|
||||
}
|
||||
}
|
||||
|
||||
&--in {
|
||||
animation-name: in;
|
||||
animation-play-state: running;
|
||||
|
||||
.data-top {
|
||||
animation-play-state: running;
|
||||
animation-name: top-in;
|
||||
}
|
||||
|
||||
.data-bottom {
|
||||
animation-play-state: running;
|
||||
animation-name: bottom-in;
|
||||
}
|
||||
}
|
||||
|
||||
&--out {
|
||||
animation-name: out;
|
||||
animation-play-state: running;
|
||||
|
||||
.data-top {
|
||||
animation-play-state: running;
|
||||
animation-name: top-out;
|
||||
}
|
||||
|
||||
.data-bottom {
|
||||
animation-play-state: running;
|
||||
animation-name: bottom-out;
|
||||
}
|
||||
}
|
||||
|
||||
.line {
|
||||
width: 100%;
|
||||
height: var(--bottomThird-line-height-override, 0.5vh);
|
||||
height: var(--lowerThird-line-height-override, 0.5vh);
|
||||
}
|
||||
|
||||
.clip {
|
||||
@@ -70,10 +22,10 @@
|
||||
.data-top {
|
||||
padding: 0 3vw;
|
||||
font-family: var(--lowerThird-font-family-override), Lato, Arial, sans-serif;
|
||||
text-align: var(--lowerThird-text-align-override, left);
|
||||
text-align: var(--lowerThird-text-align-override, right);
|
||||
white-space: nowrap;
|
||||
@include ellipsis-text();
|
||||
|
||||
|
||||
&::after {
|
||||
content: '\200b';
|
||||
}
|
||||
@@ -85,72 +37,24 @@
|
||||
}
|
||||
|
||||
.data-bottom {
|
||||
font-weight: var(--lowerThird-bottom-font-weight-override, normal);
|
||||
font-weight: var(--lowerThird-bottom-font-weight-override, 540);
|
||||
font-style: var(--lowerThird-bottom-font-style-override, normal);
|
||||
}
|
||||
|
||||
&--in {
|
||||
transition-timing-function: cubic-bezier(0.25, 0.5, 0.5, 1);
|
||||
}
|
||||
|
||||
&--out {
|
||||
transition-timing-function: cubic-bezier(0.5, 0, 0.75, 0.5);
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
.data-top {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
.data-bottom {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes in {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0%;
|
||||
}
|
||||
50%,
|
||||
100% {
|
||||
transform: translateX(0%);
|
||||
opacity: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes out {
|
||||
0%,
|
||||
30% {
|
||||
transform: translateX(0%);
|
||||
opacity: 100%;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes top-in {
|
||||
0%,
|
||||
50% {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes top-out {
|
||||
0% {
|
||||
transform: translateY(0%);
|
||||
}
|
||||
50%,
|
||||
100% {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bottom-in {
|
||||
0%,
|
||||
50% {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bottom-out {
|
||||
0% {
|
||||
transform: translateY(0%);
|
||||
}
|
||||
50%,
|
||||
100% {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,204 +1,123 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { CustomFields, OntimeEvent, ViewSettings } from 'ontime-types';
|
||||
import { isPlaybackActive, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/constants';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||
import { getPropertyValue } from '../common/viewUtils';
|
||||
|
||||
import { getLowerThirdOptions } from './lowerThird.options';
|
||||
import { getLowerThirdOptions, useLowerOptions } from './lowerThird.options';
|
||||
|
||||
import './LowerThird.scss';
|
||||
|
||||
type LowerOptions = {
|
||||
width: number;
|
||||
topSrc: string;
|
||||
bottomSrc: string;
|
||||
topColour: string;
|
||||
bottomColour: string;
|
||||
topBg: string;
|
||||
bottomBg: string;
|
||||
topSize: string;
|
||||
bottomSize: string;
|
||||
transition: number;
|
||||
delay: number;
|
||||
key: string;
|
||||
lineColour: string;
|
||||
lineHeight: string;
|
||||
};
|
||||
|
||||
interface LowerProps {
|
||||
customFields: CustomFields;
|
||||
eventNow: OntimeEvent | null;
|
||||
viewSettings: ViewSettings;
|
||||
time: ViewExtendedTimer;
|
||||
}
|
||||
|
||||
const defaultOptions: Readonly<LowerOptions> = {
|
||||
width: 45,
|
||||
topSrc: 'title',
|
||||
bottomSrc: 'lowerMsg',
|
||||
topColour: '000000',
|
||||
bottomColour: '000000',
|
||||
topBg: 'FFF0',
|
||||
bottomBg: 'FFF0',
|
||||
topSize: '65px',
|
||||
bottomSize: '40px',
|
||||
transition: 3,
|
||||
delay: 3,
|
||||
key: 'FFF0',
|
||||
lineColour: 'FF0000',
|
||||
lineHeight: '0.4em',
|
||||
};
|
||||
|
||||
export default function LowerThird(props: LowerProps) {
|
||||
const { customFields, eventNow, viewSettings } = props;
|
||||
const [searchParams] = useSearchParams();
|
||||
const { customFields, eventNow, viewSettings, time } = props;
|
||||
const previousId = useRef<string>();
|
||||
const animationTimeout = useRef<NodeJS.Timeout>();
|
||||
const [playState, setPlayState] = useState<'pre' | 'in' | 'out'>('pre');
|
||||
const [playState, setPlayState] = useState<boolean>(false);
|
||||
const [textValue, setTextValue] = useState<{ top: string; bottom: string }>({ top: '', bottom: '' });
|
||||
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const options = useLowerOptions();
|
||||
const { playback } = time;
|
||||
|
||||
useWindowTitle('Lower Third');
|
||||
|
||||
const options = useMemo(() => {
|
||||
const newOptions = { ...defaultOptions };
|
||||
|
||||
const width = searchParams.get('width');
|
||||
if (width !== null) {
|
||||
newOptions.width = Number(width);
|
||||
}
|
||||
|
||||
const topSrc = searchParams.get('top-src');
|
||||
if (topSrc) {
|
||||
newOptions.topSrc = topSrc;
|
||||
}
|
||||
|
||||
const bottomSrc = searchParams.get('bottom-src');
|
||||
if (bottomSrc) {
|
||||
newOptions.bottomSrc = bottomSrc;
|
||||
}
|
||||
|
||||
const topColour = searchParams.get('top-colour');
|
||||
if (topColour !== null) {
|
||||
newOptions.topColour = topColour;
|
||||
}
|
||||
|
||||
const bottomColour = searchParams.get('bottom-colour');
|
||||
if (bottomColour !== null) {
|
||||
newOptions.bottomColour = bottomColour;
|
||||
}
|
||||
|
||||
const topBg = searchParams.get('top-bg');
|
||||
if (topBg !== null) {
|
||||
newOptions.topBg = topBg;
|
||||
}
|
||||
|
||||
const bottomBg = searchParams.get('bottom-bg');
|
||||
if (bottomBg !== null) {
|
||||
newOptions.bottomBg = bottomBg;
|
||||
}
|
||||
|
||||
const topSize = searchParams.get('top-size');
|
||||
if (topSize !== null) {
|
||||
newOptions.topSize = topSize;
|
||||
}
|
||||
|
||||
const bottomSize = searchParams.get('bottom-size');
|
||||
if (bottomSize && bottomSize != newOptions.bottomSize) {
|
||||
newOptions.bottomSize = bottomSize;
|
||||
}
|
||||
|
||||
const transition = searchParams.get('transition');
|
||||
if (transition !== null) {
|
||||
newOptions.transition = Number(transition);
|
||||
}
|
||||
|
||||
const delay = searchParams.get('delay');
|
||||
if (delay !== null) {
|
||||
newOptions.delay = Number(delay);
|
||||
}
|
||||
|
||||
const key = searchParams.get('key');
|
||||
if (key !== null) {
|
||||
newOptions.key = key;
|
||||
}
|
||||
|
||||
const lineColour = searchParams.get('line-colour');
|
||||
if (lineColour !== null) {
|
||||
newOptions.lineColour = lineColour;
|
||||
}
|
||||
|
||||
return newOptions;
|
||||
}, [searchParams]);
|
||||
|
||||
// on unmount, cancel any ongoing animations
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// if hold is negative then force animate in
|
||||
if (options.hold < 0) {
|
||||
clearTimeout(animationTimeout.current);
|
||||
};
|
||||
}, []);
|
||||
setTextValue({
|
||||
top: getPropertyValue(eventNow, options.topSrc) ?? '',
|
||||
bottom: getPropertyValue(eventNow, options.bottomSrc) ?? '',
|
||||
});
|
||||
setPlayState(true);
|
||||
return;
|
||||
}
|
||||
}, [eventNow, options.bottomSrc, options.hold, options.topSrc]);
|
||||
|
||||
const animateIn = useCallback(() => {
|
||||
// if hold skip
|
||||
if (options.hold < 0) return;
|
||||
|
||||
//clear any pending timeouts
|
||||
clearTimeout(animationTimeout.current);
|
||||
// set the values
|
||||
setTextValue({
|
||||
top: getPropertyValue(eventNow, options.topSrc) ?? '',
|
||||
bottom: getPropertyValue(eventNow, options.bottomSrc) ?? '',
|
||||
});
|
||||
// start animation
|
||||
setPlayState(true);
|
||||
// reschedule out animation, should animate out after the in animation time + hold time
|
||||
setTimeout(() => setPlayState(false), (options.hold + options.transitionIn) * MILLIS_PER_SECOND);
|
||||
}, [eventNow, options.bottomSrc, options.hold, options.topSrc, options.transitionIn]);
|
||||
|
||||
const animateOut = useCallback(() => {
|
||||
if (options.hold < 0) return; // if hold is negative then we never animate out
|
||||
//clear any pending timeouts
|
||||
clearTimeout(animationTimeout.current);
|
||||
// start animation
|
||||
setPlayState(false);
|
||||
}, [options.hold]);
|
||||
|
||||
// check if playback has changed and schedule animations
|
||||
useEffect(() => {
|
||||
if (isPlaybackActive(playback)) {
|
||||
animateIn();
|
||||
} else {
|
||||
animateOut();
|
||||
}
|
||||
}, [animateIn, animateOut, playback]);
|
||||
|
||||
// check if data has changed and schedule animations
|
||||
useEffect(() => {
|
||||
const hasChanged = eventNow?.id !== previousId.current;
|
||||
if (!hasChanged) {
|
||||
return;
|
||||
if (hasChanged) {
|
||||
previousId.current = eventNow?.id;
|
||||
if (eventNow?.id) animateIn();
|
||||
}
|
||||
}, [animateIn, eventNow?.id]);
|
||||
|
||||
previousId.current = eventNow?.id;
|
||||
const animateOutInMs = options.delay * 1000 + options.transition * 1000;
|
||||
const boxDuration = playState ? `${options.transitionIn * 0.5}s` : `${options.transitionOut * 0.5}s`;
|
||||
const boxDelay = playState ? `${options.delay}s` : `${options.transitionOut * 0.5}s`;
|
||||
|
||||
const reschedule = (newState: 'pre' | 'in' | 'out') => {
|
||||
clearTimeout(animationTimeout.current);
|
||||
animationTimeout.current = setTimeout(() => setPlayState(newState), animateOutInMs);
|
||||
};
|
||||
if (eventNow?.id == null) {
|
||||
setPlayState('out');
|
||||
reschedule('pre');
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventNow.id && !previousId.current) {
|
||||
setPlayState('in');
|
||||
reschedule('out');
|
||||
return;
|
||||
}
|
||||
|
||||
if (playState === 'in') {
|
||||
// event has changed, we just reschedule the timeout
|
||||
reschedule('out');
|
||||
return;
|
||||
}
|
||||
setPlayState('in');
|
||||
reschedule('out');
|
||||
}, [eventNow?.id, options.delay, options.transition, playState, previousId]);
|
||||
|
||||
const topText = getPropertyValue(eventNow, options.topSrc) ?? '';
|
||||
const bottomText = getPropertyValue(eventNow, options.bottomSrc) ?? '';
|
||||
|
||||
const transition = `${options.transition}s`;
|
||||
const textDuration = playState ? `${options.transitionIn * 0.5}s` : `${options.transitionOut * 0.5}s`;
|
||||
const textDelay = playState ? `${options.delay + options.transitionIn * 0.5}s` : '0s';
|
||||
|
||||
return (
|
||||
<div className='lower-third' style={{ backgroundColor: `#${options.key}` }}>
|
||||
<ViewParamsEditor viewOptions={getLowerThirdOptions(customFields)} />
|
||||
<div
|
||||
className={`container container--${playState}`}
|
||||
style={{ minWidth: `${options.width}vw`, animationDuration: transition }}
|
||||
className={`container ${playState ? 'container--in' : 'container--out'}`}
|
||||
style={{
|
||||
minWidth: `${options.width}vw`,
|
||||
transitionDuration: boxDuration,
|
||||
transitionDelay: boxDelay,
|
||||
}}
|
||||
>
|
||||
<div className='clip'>
|
||||
<div
|
||||
className='data-top'
|
||||
style={{
|
||||
animationDuration: transition,
|
||||
transitionDuration: textDuration,
|
||||
transitionDelay: textDelay,
|
||||
color: `#${options.topColour}`,
|
||||
backgroundColor: `#${options.topBg}`,
|
||||
fontSize: options.topSize,
|
||||
fontSize: `${options.topSize}em`,
|
||||
}}
|
||||
>
|
||||
{topText}
|
||||
{textValue.top}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -211,13 +130,14 @@ export default function LowerThird(props: LowerProps) {
|
||||
<div
|
||||
className='data-bottom'
|
||||
style={{
|
||||
animationDuration: transition,
|
||||
transitionDuration: textDuration,
|
||||
transitionDelay: textDelay,
|
||||
color: `#${options.bottomColour}`,
|
||||
backgroundColor: `#${options.bottomBg}`,
|
||||
fontSize: options.bottomSize,
|
||||
fontSize: `${options.bottomSize}em`,
|
||||
}}
|
||||
>
|
||||
{bottomText}
|
||||
{textValue.bottom}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { CustomFields } from 'ontime-types';
|
||||
|
||||
import { makeOptionsFromCustomFields, OptionTitle } from '../../../common/components/view-params-editor/constants';
|
||||
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
||||
import safeParseNumber from '../../../common/utils/safeParseNumber';
|
||||
|
||||
export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] => {
|
||||
const topSourceOptions = makeOptionsFromCustomFields(customFields, {
|
||||
@@ -44,18 +47,32 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] =
|
||||
collapsible: true,
|
||||
options: [
|
||||
{
|
||||
id: 'transition',
|
||||
title: 'Transition',
|
||||
description: 'Transition in time in seconds (default 3)',
|
||||
id: 'transition-in',
|
||||
title: 'Transition In',
|
||||
description: 'Transition in time (default 3 seconds)',
|
||||
type: 'number',
|
||||
placeholder: '3 (default)',
|
||||
},
|
||||
{
|
||||
id: 'transition-out',
|
||||
title: 'Transition Out',
|
||||
description: 'Transition out time (default 3 seconds)',
|
||||
type: 'number',
|
||||
placeholder: '3 (default)',
|
||||
},
|
||||
{
|
||||
id: 'hold',
|
||||
title: 'Hold',
|
||||
description: 'Time on screen before transition out. Set to -1 to stop transition (default 3 seconds) ',
|
||||
type: 'number',
|
||||
placeholder: '3 (default)',
|
||||
},
|
||||
{
|
||||
id: 'delay',
|
||||
title: 'Delay',
|
||||
description: 'Delay between transition in and out in seconds (default 3)',
|
||||
description: 'Delay between trigger and transition in (default 0 seconds)',
|
||||
type: 'number',
|
||||
placeholder: '3 (default)',
|
||||
placeholder: '0 (default)',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -69,14 +86,14 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] =
|
||||
title: 'Top Text Size',
|
||||
description: 'Font size of the top text',
|
||||
type: 'string',
|
||||
placeholder: '65px',
|
||||
placeholder: '5em',
|
||||
},
|
||||
{
|
||||
id: 'bottom-size',
|
||||
title: 'Bottom Text Size',
|
||||
description: 'Font size of the bottom text',
|
||||
type: 'string',
|
||||
placeholder: '64px',
|
||||
placeholder: '4em',
|
||||
},
|
||||
{
|
||||
id: 'width',
|
||||
@@ -132,3 +149,73 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] =
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
type LowerOptions = {
|
||||
width: number;
|
||||
topSrc: string;
|
||||
bottomSrc: string;
|
||||
topColour: string;
|
||||
bottomColour: string;
|
||||
topBg: string;
|
||||
bottomBg: string;
|
||||
topSize: number;
|
||||
bottomSize: number;
|
||||
transitionIn: number;
|
||||
transitionOut: number;
|
||||
hold: number;
|
||||
delay: number;
|
||||
key: string;
|
||||
lineColour: string;
|
||||
};
|
||||
|
||||
const defaultOptions: Readonly<LowerOptions> = {
|
||||
width: 45,
|
||||
topSrc: 'title',
|
||||
bottomSrc: 'lowerMsg',
|
||||
topColour: '000000',
|
||||
bottomColour: '000000',
|
||||
topBg: 'FFF0',
|
||||
bottomBg: 'FFF0',
|
||||
topSize: 5,
|
||||
bottomSize: 4,
|
||||
transitionIn: 3,
|
||||
transitionOut: 3,
|
||||
hold: 3,
|
||||
delay: 0,
|
||||
key: 'FFF0',
|
||||
lineColour: 'FF0000',
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility extract the view options from URL Params
|
||||
* the names and fallbacks are manually matched with defaultOptions
|
||||
*/
|
||||
function getOptionsFromParams(searchParams: URLSearchParams): LowerOptions {
|
||||
// we manually make an object that matches the key above
|
||||
return {
|
||||
width: safeParseNumber(searchParams.get('width'), defaultOptions.width),
|
||||
topSrc: searchParams.get('top-src') ?? defaultOptions.topSrc,
|
||||
bottomSrc: searchParams.get('bottom-src') ?? defaultOptions.bottomSrc,
|
||||
topColour: searchParams.get('top-colour') ?? defaultOptions.topColour,
|
||||
bottomColour: searchParams.get('bottom-colour') ?? defaultOptions.bottomColour,
|
||||
topBg: searchParams.get('top-bg') ?? defaultOptions.topBg,
|
||||
bottomBg: searchParams.get('bottom-bg') ?? defaultOptions.bottomBg,
|
||||
topSize: safeParseNumber(searchParams.get('top-size'), defaultOptions.topSize),
|
||||
bottomSize: safeParseNumber(searchParams.get('bottom-size'), defaultOptions.bottomSize),
|
||||
transitionIn: safeParseNumber(searchParams.get('transition-in'), defaultOptions.transitionIn),
|
||||
transitionOut: safeParseNumber(searchParams.get('transition-out'), defaultOptions.transitionOut),
|
||||
hold: safeParseNumber(searchParams.get('hold'), defaultOptions.hold),
|
||||
delay: safeParseNumber(searchParams.get('hold'), defaultOptions.delay),
|
||||
key: searchParams.get('key') ?? defaultOptions.key,
|
||||
lineColour: searchParams.get('line-colour') ?? defaultOptions.lineColour,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook exposes the timer view options
|
||||
*/
|
||||
export function useLowerOptions(): LowerOptions {
|
||||
const [searchParams] = useSearchParams();
|
||||
const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
|
||||
return options;
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ $orange-active: #f60;
|
||||
}
|
||||
|
||||
.studio-timer {
|
||||
font-size: calc(var(--clock-size) / 5);
|
||||
font-size: calc(var(--clock-size) / 6);
|
||||
line-height: 1em;
|
||||
|
||||
color: var(--studio-active, $red-active);
|
||||
|
||||
@@ -140,7 +140,7 @@ export const ScheduleProvider = ({
|
||||
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
|
||||
|
||||
// we want to show the event after the current
|
||||
const viewEvents = events.toSpliced(0, selectedEventIndex + 1);
|
||||
const viewEvents = events.slice(selectedEventIndex + 1);
|
||||
selectedEventIndex = 0;
|
||||
|
||||
return (
|
||||
|
||||
@@ -96,11 +96,12 @@ describe('makeTable()', () => {
|
||||
"Is Public? (x)",
|
||||
"Skip?",
|
||||
"lighting",
|
||||
"Type",
|
||||
],
|
||||
[
|
||||
"00:00:00",
|
||||
"00:00:00",
|
||||
"...",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
@@ -109,6 +110,7 @@ describe('makeTable()', () => {
|
||||
"x",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
],
|
||||
]
|
||||
`);
|
||||
|
||||
@@ -104,7 +104,7 @@ export default function CuesheetTable(props: CuesheetTableProps) {
|
||||
<div ref={tableContainerRef} className={style.cuesheetContainer}>
|
||||
<table className={style.cuesheet} id='cuesheet' {...listeners}>
|
||||
<CuesheetHeader headerGroups={headerGroups} />
|
||||
<CuesheetBody rowModel={rowModel} selectedRef={selectedRef} table={table} columnSizing={columnSizing} />
|
||||
<CuesheetBody rowModel={rowModel} selectedRef={selectedRef} table={table} />
|
||||
</table>
|
||||
</div>
|
||||
<CuesheetTableMenu showModal={showModal} />
|
||||
|
||||
@@ -19,7 +19,10 @@ function BlockRow(props: BlockRowProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const paddingRows = new Array(columnCount - 1).fill(null);
|
||||
// guard the use case where user has hidden all columns
|
||||
const fillColumns = Math.min(columnCount, 1);
|
||||
|
||||
const paddingRows = new Array(fillColumns).fill(null);
|
||||
|
||||
return (
|
||||
<tr className={style.blockRow}>
|
||||
|
||||
+14
-5
@@ -16,16 +16,24 @@ interface CuesheetBodyProps {
|
||||
rowModel: RowModel<OntimeRundownEntry>;
|
||||
selectedRef: MutableRefObject<HTMLTableRowElement | null>;
|
||||
table: Table<OntimeRundownEntry>;
|
||||
columnSizing: Record<string, number>;
|
||||
}
|
||||
|
||||
export default function CuesheetBody(props: CuesheetBodyProps) {
|
||||
const { rowModel, selectedRef, table, columnSizing } = props;
|
||||
const { rowModel, selectedRef, table } = props;
|
||||
|
||||
const { selectedEventId } = useSelectedEventId();
|
||||
const { hideDelays, hidePast } = useCuesheetOptions();
|
||||
|
||||
const getColumnCount = lazyEvaluate(() => table.getVisibleFlatColumns().length);
|
||||
const getVisibleColumns = lazyEvaluate(() => table.getVisibleFlatColumns());
|
||||
const getColumnHash = lazyEvaluate(() => {
|
||||
let columnHash = '';
|
||||
const columns = getVisibleColumns();
|
||||
|
||||
for (let i = 0; i < columns.length; i++) {
|
||||
columnHash += `${columns[i].getIndex()}-${columns[i].getSize()} `;
|
||||
}
|
||||
return columnHash;
|
||||
});
|
||||
|
||||
let eventIndex = 0;
|
||||
// for the first event, it will be past if there is something selected
|
||||
@@ -41,7 +49,7 @@ export default function CuesheetBody(props: CuesheetBodyProps) {
|
||||
}
|
||||
|
||||
if (isOntimeBlock(entry)) {
|
||||
const columnCount = getColumnCount();
|
||||
const columnCount = getVisibleColumns().length;
|
||||
return <BlockRow columnCount={columnCount} key={key} title={entry.title} hidePast={isPast && hidePast} />;
|
||||
}
|
||||
if (isOntimeDelay(entry)) {
|
||||
@@ -58,6 +66,7 @@ export default function CuesheetBody(props: CuesheetBodyProps) {
|
||||
if (isOntimeEvent(entry)) {
|
||||
eventIndex++;
|
||||
const isSelected = key === selectedEventId;
|
||||
const columnHash = getColumnHash();
|
||||
|
||||
if (isPast && hidePast) {
|
||||
return null;
|
||||
@@ -87,7 +96,7 @@ export default function CuesheetBody(props: CuesheetBodyProps) {
|
||||
selectedRef={isSelected ? selectedRef : undefined}
|
||||
rowBgColour={rowBgColour}
|
||||
table={table}
|
||||
columnSizing={columnSizing}
|
||||
columnHash={columnHash}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,20 +23,20 @@ interface EventRowProps {
|
||||
rowBgColour?: string;
|
||||
table: Table<OntimeRundownEntry>;
|
||||
/** hack to force re-rendering of the row when the column sizes change */
|
||||
columnSizing: Record<string, number>;
|
||||
columnHash: string;
|
||||
}
|
||||
|
||||
export default memo(EventRow, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.rowId === nextProps.rowId &&
|
||||
prevProps.event.id === nextProps.event.id &&
|
||||
prevProps.event.revision === nextProps.event.revision &&
|
||||
prevProps.eventIndex === nextProps.eventIndex &&
|
||||
prevProps.rowIndex === nextProps.rowIndex &&
|
||||
prevProps.isPast === nextProps.isPast &&
|
||||
prevProps.selectedRef === nextProps.selectedRef &&
|
||||
prevProps.rowBgColour === nextProps.rowBgColour &&
|
||||
prevProps.table === nextProps.table &&
|
||||
prevProps.columnSizing === nextProps.columnSizing
|
||||
prevProps.columnHash === nextProps.columnHash
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,13 +3,18 @@ import { useLocalStorage } from '@mantine/hooks';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { OntimeRundownEntry } from 'ontime-types';
|
||||
|
||||
import { baseURI } from '../../../externals';
|
||||
|
||||
export default function useColumnManager(columns: ColumnDef<OntimeRundownEntry>[]) {
|
||||
const [columnVisibility, setColumnVisibility] = useLocalStorage({ key: 'table-hidden', defaultValue: {} });
|
||||
const [columnVisibility, setColumnVisibility] = useLocalStorage({ key: `${baseURI}table-hidden`, defaultValue: {} });
|
||||
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
|
||||
key: 'table-order',
|
||||
key: `${baseURI}table-order`,
|
||||
defaultValue: columns.map((col) => col.id as string),
|
||||
});
|
||||
const [columnSizing, setColumnSizing] = useLocalStorage({ key: 'table-sizes', defaultValue: {} });
|
||||
const [columnSizing, setColumnSizing] = useLocalStorage({
|
||||
key: `${baseURI}table-sizes`,
|
||||
defaultValue: {},
|
||||
});
|
||||
|
||||
// if the columns change, we update the dataset
|
||||
useEffect(() => {
|
||||
|
||||
@@ -20,7 +20,7 @@ type CsvHeaderKey = OntimeEntryCommonKeys | keyof CustomFields;
|
||||
|
||||
export const parseField = (field: CsvHeaderKey, data: unknown): string => {
|
||||
if (field === 'timeStart' || field === 'timeEnd' || field === 'duration') {
|
||||
return millisToString(data as MaybeNumber);
|
||||
return millisToString(data as MaybeNumber, { fallback: '' });
|
||||
}
|
||||
|
||||
if (field === 'isPublic' || field === 'skip') {
|
||||
@@ -59,6 +59,7 @@ export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, custo
|
||||
'isPublic',
|
||||
'skip',
|
||||
...customFieldKeys,
|
||||
'type',
|
||||
];
|
||||
|
||||
const fieldTitles = [
|
||||
@@ -73,6 +74,7 @@ export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, custo
|
||||
'Is Public? (x)',
|
||||
'Skip?',
|
||||
...customFieldLabels,
|
||||
'Type',
|
||||
];
|
||||
|
||||
// add header row to data
|
||||
|
||||
@@ -25,7 +25,9 @@
|
||||
flex: 1;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
width: min(calc(100vw - 4rem), 800px);
|
||||
width: min(calc(100vw - 4rem), 960px);
|
||||
|
||||
padding-bottom: 10vh;
|
||||
}
|
||||
|
||||
.info__label {
|
||||
@@ -33,11 +35,15 @@
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
text-transform: uppercase;
|
||||
margin-top: $view-element-gap;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.info__value {
|
||||
white-space: break-spaces;
|
||||
}
|
||||
|
||||
a.info__value {
|
||||
color: $action-text-color;
|
||||
|
||||
&:hover {
|
||||
color: $ontime-color;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import { useTranslation } from '../../translation/TranslationProvider';
|
||||
|
||||
import BackstageInfo from './backstage-info/BackstageInfo';
|
||||
import CustomInfo from './custom-info/CustomInfo';
|
||||
import PublicInfo from './public-info/PublicInfo';
|
||||
import { projectInfoOptions } from './projectInfo.options';
|
||||
|
||||
@@ -66,6 +67,7 @@ export default function ProjectInfo(props: ProjectInfoProps) {
|
||||
)}
|
||||
<BackstageInfo general={general} />
|
||||
<PublicInfo general={general} />
|
||||
<CustomInfo general={general} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Fragment } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { ProjectData } from 'ontime-types';
|
||||
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
|
||||
interface CustomInfoProps {
|
||||
general: ProjectData;
|
||||
}
|
||||
|
||||
export default function CustomInfo(props: CustomInfoProps) {
|
||||
const { general } = props;
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const showCustom = isStringBoolean(searchParams.get('showCustom'));
|
||||
|
||||
if (!showCustom || general.custom === undefined || general.custom.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{general.custom.map((info, idx) => {
|
||||
if (!info.title || !info.value) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Fragment key={`${info.title}-${idx}`}>
|
||||
<div className='info__label'>{info.title}</div>
|
||||
<div className='info__value'>{info.value}</div>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -9,14 +9,21 @@ export const projectInfoOptions: ViewOption[] = [
|
||||
{
|
||||
id: 'showBackstage',
|
||||
title: 'Show backstage Data',
|
||||
description: 'Weather to show fields related to the backstage views',
|
||||
description: 'Whether to show fields related to the backstage views',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'showPublic',
|
||||
title: 'Show Public Data',
|
||||
description: 'Weather to show fields related to the public views',
|
||||
description: 'Whether to show fields related to the public views',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'showCustom',
|
||||
title: 'Show Custom Data',
|
||||
description: 'Whether to show fields related to the custom data',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
|
||||
@@ -70,9 +70,9 @@ export default defineConfig({
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
additionalData: `
|
||||
@use './src/theme/ontimeColours' as *;
|
||||
@use './src/theme/ontimeStyles' as *;
|
||||
@use './src/theme/mixins' as *;
|
||||
@use '@/theme/ontimeColours' as *;
|
||||
@use '@/theme/ontimeStyles' as *;
|
||||
@use '@/theme/mixins' as *;
|
||||
`,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-electron",
|
||||
"version": "3.14.3",
|
||||
"version": "3.16.1",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
@@ -12,8 +12,8 @@
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "src/main.js",
|
||||
"devDependencies": {
|
||||
"electron": "^31.2.0",
|
||||
"electron-builder": "^24.13.3",
|
||||
"electron": "36.3.1",
|
||||
"electron-builder": "26.0.12",
|
||||
"eslint": "catalog:",
|
||||
"eslint-config-prettier": "catalog:",
|
||||
"prettier": "catalog:",
|
||||
@@ -38,9 +38,7 @@
|
||||
"icon": "icon.icns"
|
||||
},
|
||||
"mac": {
|
||||
"notarize": {
|
||||
"teamId": "MDAU6QK6R4"
|
||||
},
|
||||
"notarize": true,
|
||||
"hardenedRuntime": true,
|
||||
"gatekeeperAssess": false,
|
||||
"entitlements": "./entitlements.plist",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "3.14.3",
|
||||
"version": "3.16.1",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@googleapis/sheets": "^5.0.5",
|
||||
@@ -52,6 +52,7 @@
|
||||
"dev": "cross-env NODE_ENV=development tsx watch ./src/index.ts",
|
||||
"dev:inspect": "cross-env NODE_ENV=development tsx watch --inspect ./src/index.ts",
|
||||
"dev:test": "cross-env IS_TEST=true tsx ./src/index.ts",
|
||||
"prebuild": "tsx ./scripts/bundleCss.ts",
|
||||
"build": "node esbuild.electron.js",
|
||||
"build:electron": "node esbuild.electron.js",
|
||||
"build:local": "node esbuild.dev.js",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { existsSync } from 'fs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { defaultCss } from '../src/user/styles/bundledCss';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Script to write contents of bundledCss to override.css
|
||||
*/
|
||||
async function bundleCss() {
|
||||
try {
|
||||
const stylesDir = path.resolve(process.cwd(), 'src', 'user', 'styles');
|
||||
const cssFile = path.resolve(stylesDir, 'override.css');
|
||||
|
||||
if (!existsSync(cssFile)) {
|
||||
throw new Error('File does not exist');
|
||||
}
|
||||
|
||||
await writeFile(cssFile, defaultCss, { encoding: 'utf8' });
|
||||
} catch (error) {
|
||||
console.error('Failed writing to CSS file: ', error);
|
||||
}
|
||||
}
|
||||
|
||||
bundleCss();
|
||||
@@ -5,7 +5,7 @@ import * as dgram from 'node:dgram';
|
||||
|
||||
import { logger } from '../classes/Logger.js';
|
||||
import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
|
||||
import { isOntimeCloud } from '../externals.js';
|
||||
import { isOntimeCloud } from '../setup/environment.js';
|
||||
|
||||
import { integrationPayloadFromPath } from './utils/parse.js';
|
||||
import type { IAdapter } from './IAdapter.js';
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { defaultCss } from '../../user/styles/bundledCss.js';
|
||||
import type { Request, Response } from 'express';
|
||||
import { readCssFile, writeCssFile } from './assets.service.js';
|
||||
|
||||
/**
|
||||
* Exposes the contents of the cssOverride.css file
|
||||
*/
|
||||
export async function getCssOverride(_req: Request, res: Response) {
|
||||
try {
|
||||
const data = await readCssFile();
|
||||
res.status(200).send(data);
|
||||
} catch (error) {
|
||||
res.status(500).send({ message: error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows modifying the cssOverride.css file
|
||||
*/
|
||||
export async function postCssOverride(req: Request, res: Response) {
|
||||
const { css } = req.body;
|
||||
|
||||
try {
|
||||
await writeCssFile(css);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(500).send({ message: error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the default cssOverride.css file
|
||||
*/
|
||||
export async function restoreCss(_req: Request, res: Response) {
|
||||
try {
|
||||
await writeCssFile(defaultCss);
|
||||
res.status(200).send(defaultCss);
|
||||
} catch (error) {
|
||||
res.status(500).send({ message: error });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import express from 'express';
|
||||
|
||||
import { getCssOverride, postCssOverride, restoreCss } from './assets.controller.js';
|
||||
import { validatePostCss } from './assets.validation.js';
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
router.get('/css', getCssOverride);
|
||||
router.post('/css', validatePostCss, postCssOverride);
|
||||
router.post('/css/restore', restoreCss);
|
||||
@@ -0,0 +1,33 @@
|
||||
import { publicFiles } from '../../setup/index.js';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { defaultCss } from '../../user/styles/bundledCss.js';
|
||||
|
||||
/**
|
||||
* Reads the user's css file
|
||||
* @returns css contents in the file
|
||||
*/
|
||||
export async function readCssFile(): Promise<string> {
|
||||
const path = publicFiles.cssOverride;
|
||||
if (!existsSync(path)) {
|
||||
await writeFile(path, defaultCss, { encoding: 'utf8' });
|
||||
}
|
||||
|
||||
const css = await readFile(path, { encoding: 'utf8' });
|
||||
|
||||
return css;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the user's css file
|
||||
* @param css the updated css to write to file
|
||||
*/
|
||||
export async function writeCssFile(css: string) {
|
||||
const path = publicFiles.cssOverride;
|
||||
if (!existsSync(path)) {
|
||||
await writeFile(path, css, { encoding: 'utf8' });
|
||||
return;
|
||||
}
|
||||
|
||||
await writeFile(path, css, { encoding: 'utf8' });
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { body, validationResult } from 'express-validator';
|
||||
|
||||
export const validatePostCss = [
|
||||
body('css').exists().isString().trim(),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||
next();
|
||||
},
|
||||
];
|
||||
@@ -9,12 +9,13 @@ import type {
|
||||
import { deleteAtIndex, generateId } from 'ontime-utils';
|
||||
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { getTimedEvents } from '../../services/rundown-service/rundownUtils.js';
|
||||
|
||||
/**
|
||||
* Gets a copy of the stored automation settings
|
||||
*/
|
||||
export function getAutomationSettings(): AutomationSettings {
|
||||
return structuredClone(getDataProvider().getAutomation());
|
||||
return getDataProvider().getAutomation();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,14 +139,25 @@ export async function deleteAutomation(id: string): Promise<void> {
|
||||
if (!Object.hasOwn(automations, id)) {
|
||||
return;
|
||||
}
|
||||
// prevent deleting a automation that is in use
|
||||
const triggers = getAutomationTriggers();
|
||||
for (let i = 0; i < triggers.length; i++) {
|
||||
const trigger = triggers[i];
|
||||
if (trigger.automationId === id) {
|
||||
throw new Error(`Unable to delete automation used in trigger ${trigger.title}`);
|
||||
}
|
||||
|
||||
// prevent deleting a automation that is in use in triggers
|
||||
const triggers = getAutomationTriggers().filter((trigger) => trigger.automationId === id);
|
||||
if (triggers.length) {
|
||||
throw new Error(
|
||||
`Unable to delete automation used in trigger ${triggers[0].title}${triggers.length > 1 ? ` and ${triggers.length - 1} more` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
// prevent deleting a automation that is in use in events
|
||||
const events = getTimedEvents().filter(
|
||||
(event) => event.triggers && event.triggers.some((trigger) => trigger.automationId === id),
|
||||
);
|
||||
if (events.length) {
|
||||
throw new Error(
|
||||
`Unable to delete automation used in event: ${events[0].id}${events.length > 1 ? ` and ${events.length - 1} more` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
delete automations[id];
|
||||
await saveChanges({ automations });
|
||||
}
|
||||
|
||||
@@ -3,16 +3,16 @@ import {
|
||||
isOntimeAction,
|
||||
isOSCOutput,
|
||||
LogOrigin,
|
||||
TimerLifeCycle,
|
||||
type AutomationFilter,
|
||||
type AutomationOutput,
|
||||
type FilterRule,
|
||||
type TimerLifeCycle,
|
||||
} from 'ontime-types';
|
||||
import { getPropertyFromPath } from 'ontime-utils';
|
||||
|
||||
import { logger } from '../../classes/Logger.js';
|
||||
import { getState, type RuntimeState } from '../../stores/runtimeState.js';
|
||||
import { isOntimeCloud } from '../../externals.js';
|
||||
import { isOntimeCloud } from '../../setup/environment.js';
|
||||
|
||||
import { emitOSC } from './clients/osc.client.js';
|
||||
import { emitHTTP } from './clients/http.client.js';
|
||||
@@ -23,14 +23,21 @@ import { toOntimeAction } from './clients/ontime.client.js';
|
||||
/**
|
||||
* Exposes a method for triggering actions based on a TimerLifeCycle event
|
||||
*/
|
||||
export function triggerAutomations(event: TimerLifeCycle, state: RuntimeState) {
|
||||
export function triggerAutomations(cycle: TimerLifeCycle, state: RuntimeState) {
|
||||
if (!getAutomationsEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const triggers = getAutomationTriggers();
|
||||
const triggerAutomations = triggers.filter((trigger) => trigger.trigger === event);
|
||||
if (triggerAutomations.length === 0) {
|
||||
let triggers = getAutomationTriggers();
|
||||
|
||||
// get triggers from event
|
||||
if (state.eventNow?.triggers) {
|
||||
triggers = triggers.concat(state.eventNow.triggers);
|
||||
}
|
||||
|
||||
// note: there are no onStop triggers in event
|
||||
const filteredTrigger = triggers.filter((trigger) => trigger.trigger === cycle);
|
||||
if (filteredTrigger.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,7 +46,7 @@ export function triggerAutomations(event: TimerLifeCycle, state: RuntimeState) {
|
||||
return;
|
||||
}
|
||||
|
||||
triggerAutomations.forEach((trigger) => {
|
||||
filteredTrigger.forEach((trigger) => {
|
||||
const automation = automations[trigger.automationId];
|
||||
if (!automation || automation.outputs.length === 0) {
|
||||
return;
|
||||
|
||||
@@ -58,6 +58,7 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
|
||||
backstageUrl: req.body?.backstageUrl ?? '',
|
||||
backstageInfo: req.body?.backstageInfo ?? '',
|
||||
projectLogo: req.body?.projectLogo ?? null,
|
||||
custom: req.body?.custom ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -203,7 +204,7 @@ export async function loadProject(req: Request, res: Response<MessageResponse |
|
||||
/**
|
||||
* Loads the demo project
|
||||
*/
|
||||
export async function loadDemo(req: Request, res: Response<MessageResponse | ErrorResponse>) {
|
||||
export async function loadDemo(_req: Request, res: Response<MessageResponse | ErrorResponse>) {
|
||||
try {
|
||||
const projectName = await projectService.loadDemoProject();
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export const validateNewProject = [
|
||||
body('backstageInfo').optional().isString().trim(),
|
||||
body('projectLogo').optional().isString().trim(),
|
||||
body('endMessage').optional().isString().trim(),
|
||||
body('custom').optional().isArray(),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { router as excelRouter } from './excel/excel.router.js';
|
||||
import { router as sessionRouter } from './session/session.router.js';
|
||||
import { router as viewSettingsRouter } from './view-settings/viewSettings.router.js';
|
||||
import { router as reportRouter } from './report/report.router.js';
|
||||
import { router as assetsRouter } from './assets/assets.router.js';
|
||||
|
||||
export const appRouter = express.Router();
|
||||
|
||||
@@ -27,6 +28,7 @@ appRouter.use('/url-presets', urlPresetsRouter);
|
||||
appRouter.use('/session', sessionRouter);
|
||||
appRouter.use('/view-settings', viewSettingsRouter);
|
||||
appRouter.use('/report', reportRouter);
|
||||
appRouter.use('/assets', assetsRouter);
|
||||
|
||||
//we don't want to redirect to react index when using api routes
|
||||
appRouter.all('/*', (_req, res) => {
|
||||
|
||||
@@ -5,11 +5,11 @@ import type { Request, Response } from 'express';
|
||||
|
||||
import { removeUndefined } from '../../utils/parserUtils.js';
|
||||
import { failEmptyObjects } from '../../utils/routerUtils.js';
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { editCurrentProjectData } from '../../services/project-service/ProjectService.js';
|
||||
import * as projectDao from './project.dao.js';
|
||||
|
||||
export function getProjectData(_req: Request, res: Response<ProjectData>) {
|
||||
res.json(getDataProvider().getProjectData());
|
||||
res.json(projectDao.getProjectData());
|
||||
}
|
||||
|
||||
export async function postProjectData(req: Request, res: Response<ProjectData | ErrorResponse>) {
|
||||
@@ -27,6 +27,7 @@ export async function postProjectData(req: Request, res: Response<ProjectData |
|
||||
backstageInfo: req.body?.backstageInfo,
|
||||
endMessage: req.body?.endMessage,
|
||||
projectLogo: req.body?.projectLogo,
|
||||
custom: req.body?.custom,
|
||||
});
|
||||
|
||||
const updatedData = await editCurrentProjectData(newData);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ProjectData } from 'ontime-types';
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
|
||||
/**
|
||||
* Gets a copy of the stored project data
|
||||
*/
|
||||
export function getProjectData(): ProjectData {
|
||||
return structuredClone(getDataProvider().getProjectData());
|
||||
}
|
||||
@@ -10,6 +10,9 @@ export const projectSanitiser = [
|
||||
body('backstageInfo').optional().isString().trim(),
|
||||
body('endMessage').optional().isString().trim(),
|
||||
body('projectLogo').optional({ nullable: true }).isString().trim(),
|
||||
body('custom').optional().isArray(),
|
||||
body('custom.*.title').optional().isString().trim().notEmpty(),
|
||||
body('custom.*.value').optional().isString().trim().notEmpty(),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getNetworkInterfaces } from '../../utils/network.js';
|
||||
import { getTimezoneLabel } from '../../utils/time.js';
|
||||
import { password, routerPrefix } from '../../externals.js';
|
||||
import { hashPassword } from '../../utils/hash.js';
|
||||
import { ONTIME_VERSION } from '../../ONTIME_VERSION.js';
|
||||
|
||||
const startedAt = new Date();
|
||||
|
||||
@@ -28,6 +29,7 @@ export async function getSessionStats(): Promise<SessionStats> {
|
||||
projectName,
|
||||
playback,
|
||||
timezone: getTimezoneLabel(startedAt),
|
||||
version: ONTIME_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getErrorMessage, obfuscate } from 'ontime-utils';
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { isDocker } from '../../externals.js';
|
||||
import { isDocker } from '../../setup/environment.js';
|
||||
import { failEmptyObjects } from '../../utils/routerUtils.js';
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import * as appState from '../../services/app-state-service/AppStateService.js';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MessageState, OntimeEvent, SimpleDirection, SimplePlayback } from 'ontime-types';
|
||||
import { MessageState, OffsetMode, OntimeEvent, SimpleDirection, SimplePlayback } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||
|
||||
import { DeepPartial } from 'ts-essentials';
|
||||
@@ -17,6 +17,7 @@ import { throttle } from '../utils/throttle.js';
|
||||
import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js';
|
||||
|
||||
import { handleLegacyMessageConversion } from './integration.legacy.js';
|
||||
import { coerceEnum } from '../utils/coerceType.js';
|
||||
|
||||
const throttledUpdateEvent = throttle(updateEvent, 20);
|
||||
let lastRequest: Date | null = null;
|
||||
@@ -286,6 +287,11 @@ const actionHandlers: Record<string, ActionHandler> = {
|
||||
|
||||
throw new Error('No matching method provided');
|
||||
},
|
||||
offsetmode: (payload) => {
|
||||
const mode = coerceEnum<OffsetMode>(payload, OffsetMode);
|
||||
runtimeService.setOffsetMode(mode);
|
||||
return { payload: 'success' };
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,8 @@ import cookieParser from 'cookie-parser';
|
||||
|
||||
// import utils
|
||||
import { publicDir, srcDir } from './setup/index.js';
|
||||
import { environment, isProduction, updateRouterPrefix } from './externals.js';
|
||||
import { environment, isProduction } from './setup/environment.js';
|
||||
import { updateRouterPrefix } from './externals.js';
|
||||
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
|
||||
import { consoleSuccess, consoleHighlight, consoleError } from './utils/console.js';
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { generateId, millisToString } from 'ontime-utils';
|
||||
import { clock } from '../services/Clock.js';
|
||||
import { socket } from '../adapters/WebsocketAdapter.js';
|
||||
import { consoleSubdued, consoleError } from '../utils/console.js';
|
||||
import { isProduction } from '../externals.js';
|
||||
import { isProduction } from '../setup/environment.js';
|
||||
|
||||
class Logger {
|
||||
private queue: Log[];
|
||||
|
||||
@@ -14,7 +14,7 @@ import { JSONFilePreset } from 'lowdb/node';
|
||||
|
||||
import { isPath } from '../../utils/fileManagement.js';
|
||||
import { shouldCrashDev } from '../../utils/development.js';
|
||||
import { isTest } from '../../externals.js';
|
||||
import { isTest } from '../../setup/environment.js';
|
||||
|
||||
import { safeMerge } from './DataProvider.utils.js';
|
||||
|
||||
@@ -63,7 +63,7 @@ function getData(): Readonly<DatabaseModel> {
|
||||
}
|
||||
|
||||
async function setProjectData(newData: Partial<ProjectData>): ReadonlyPromise<ProjectData> {
|
||||
db.data.project = { ...db.data.project, ...newData };
|
||||
db.data.project = { ...structuredClone(db.data.project), ...structuredClone(newData) }; // Performing deep copy as we're updating / merging data
|
||||
await persist();
|
||||
return db.data.project;
|
||||
}
|
||||
|
||||
@@ -4,24 +4,27 @@ import { DatabaseModel } from 'ontime-types';
|
||||
* Merges a partial ontime project into a given ontime project
|
||||
*/
|
||||
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>): DatabaseModel {
|
||||
const deepExisting = structuredClone(existing);
|
||||
const deepNewData = structuredClone(newData);
|
||||
|
||||
const {
|
||||
rundown = existing.rundown,
|
||||
rundown = deepExisting.rundown,
|
||||
project = {},
|
||||
settings = {},
|
||||
viewSettings = {},
|
||||
urlPresets = existing.urlPresets,
|
||||
customFields = existing.customFields,
|
||||
automation = existing.automation,
|
||||
} = newData;
|
||||
urlPresets = deepExisting.urlPresets,
|
||||
customFields = deepExisting.customFields,
|
||||
automation = deepExisting.automation,
|
||||
} = deepNewData;
|
||||
|
||||
return {
|
||||
...existing,
|
||||
...deepExisting,
|
||||
rundown,
|
||||
project: { ...existing.project, ...project },
|
||||
settings: { ...existing.settings, ...settings },
|
||||
viewSettings: { ...existing.viewSettings, ...viewSettings },
|
||||
urlPresets: urlPresets ?? existing.urlPresets,
|
||||
customFields: customFields ?? existing.customFields,
|
||||
automation: { ...existing.automation, ...automation },
|
||||
project: { ...deepExisting.project, ...project },
|
||||
settings: { ...deepExisting.settings, ...settings },
|
||||
viewSettings: { ...deepExisting.viewSettings, ...viewSettings },
|
||||
urlPresets: urlPresets ?? deepExisting.urlPresets,
|
||||
customFields: customFields ?? deepExisting.customFields,
|
||||
automation: { ...deepExisting.automation, ...automation },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,12 @@ describe('safeMerge', () => {
|
||||
publicInfo: 'existing backstageInfo',
|
||||
backstageInfo: 'existing backstageInfo',
|
||||
projectLogo: null,
|
||||
custom: [
|
||||
{
|
||||
title: 'existing custom title',
|
||||
value: 'existing custom value',
|
||||
},
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
@@ -62,6 +68,12 @@ describe('safeMerge', () => {
|
||||
project: {
|
||||
title: 'new title',
|
||||
publicInfo: 'new public info',
|
||||
custom: [
|
||||
{
|
||||
title: 'new custom title',
|
||||
value: 'new custom value',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
// @ts-expect-error -- just testing
|
||||
@@ -74,6 +86,12 @@ describe('safeMerge', () => {
|
||||
backstageUrl: 'existing backstageUrl',
|
||||
backstageInfo: 'existing backstageInfo',
|
||||
projectLogo: null,
|
||||
custom: [
|
||||
{
|
||||
title: 'new custom title',
|
||||
value: 'new custom value',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,6 +125,7 @@ describe('safeMerge', () => {
|
||||
backstageUrl: '',
|
||||
backstageInfo: '',
|
||||
projectLogo: null,
|
||||
custom: [],
|
||||
},
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
|
||||
@@ -7,14 +7,6 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { srcFiles } from './setup/index.js';
|
||||
|
||||
// =================================================
|
||||
// resolve running environment
|
||||
const env = process.env.NODE_ENV || 'production';
|
||||
|
||||
export const isTest = Boolean(process.env.IS_TEST);
|
||||
export const environment = isTest ? 'test' : env;
|
||||
export const isDocker = env === 'docker';
|
||||
export const isProduction = isDocker || (env === 'production' && !isTest);
|
||||
export const isOntimeCloud = Boolean(process.env.IS_CLOUD);
|
||||
export const password = process.env.SESSION_PASSWORD;
|
||||
export const routerPrefix = process.env.ROUTER_PREFIX;
|
||||
|
||||
|
||||
@@ -124,6 +124,6 @@ function setSessionCookie(res: Response, token: string) {
|
||||
httpOnly: false, // allow websocket to access cookie
|
||||
secure: true,
|
||||
path: '/', // allow cookie to be accessed from any path
|
||||
sameSite: 'strict',
|
||||
sameSite: 'none', // allow cookies to be sent in cross-origin requests (e.g., iframes)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export const dbModel: DatabaseModel = {
|
||||
backstageUrl: '',
|
||||
backstageInfo: '',
|
||||
projectLogo: null,
|
||||
custom: [],
|
||||
},
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
|
||||
@@ -413,6 +413,7 @@ export const demoDb: DatabaseModel = {
|
||||
backstageUrl: 'www.github.com/cpvalente/ontime',
|
||||
backstageInfo: 'Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal',
|
||||
projectLogo: null,
|
||||
custom: [],
|
||||
},
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user