mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-12 02:43:50 +00:00
Compare commits
179 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 | |||
| 73afbacdd7 | |||
| d08452d613 | |||
| 4ebe6ede59 | |||
| 09a32e7612 | |||
| d81f5fcfe1 | |||
| 0a6542df76 | |||
| b2eb140ee2 | |||
| e28f692d3d | |||
| dc73e7e637 | |||
| b81e3a4ba5 | |||
| 6fc7b15d23 | |||
| 99dd4b4d5c | |||
| b60b1beeec | |||
| eab916f410 | |||
| 069a003787 | |||
| 9b08080b7a | |||
| 8e7ab54d39 | |||
| 4a16378786 | |||
| e5b30770ce | |||
| 8465b04c89 | |||
| 718a6ad2b7 | |||
| 6ca4bfc363 | |||
| 4859026989 | |||
| 9167a50eda | |||
| 8adca908b3 | |||
| bf99646ad5 | |||
| 308285ce3e | |||
| dd781a32f6 | |||
| c2a6de6195 | |||
| 0ac2ce8e84 | |||
| 3bdd8424eb | |||
| b3a099255f | |||
| 570ff59cfc | |||
| d0b4c6a279 | |||
| e90161aa33 | |||
| e37254107e | |||
| 9d6bc0a137 | |||
| 4cd2dcbab0 | |||
| 8c593b3487 | |||
| ba74622569 | |||
| f76c0d1a6d | |||
| 8e8d3a7824 | |||
| 6c3ae1f914 | |||
| b6507c27a6 | |||
| 21877e4bff | |||
| b8d7324be0 | |||
| 88b8e83494 | |||
| abce0c498c | |||
| 6b2e45e7de | |||
| 5fb30c75c4 | |||
| 636f78e21e | |||
| 1636745fed | |||
| e01ab0c88b | |||
| 0ede753652 | |||
| 1f6f3df0f2 | |||
| a5afb32b8a | |||
| 9c41f8ff6b | |||
| 1edde03bb4 | |||
| 946b6717d2 | |||
| f99ad83049 | |||
| 665496907e | |||
| 2867c8301d | |||
| 84a5b4fe5b | |||
| 86bb739d90 | |||
| 2c8b6180d7 | |||
| 4cc138e3c0 | |||
| d21e1f996f | |||
| 11c1b472a2 | |||
| 891bda6661 | |||
| c65761448d | |||
| 70558c5104 | |||
| c8bf74a719 | |||
| 10bf4b93ee | |||
| f6449a4326 |
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
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v3
|
uses: pnpm/action-setup@v3
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 10
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -56,12 +56,12 @@ jobs:
|
|||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v3
|
uses: pnpm/action-setup@v3
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 10
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -89,12 +89,12 @@ jobs:
|
|||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v3
|
uses: pnpm/action-setup@v3
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 10
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|||||||
@@ -20,13 +20,13 @@ jobs:
|
|||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v3
|
uses: pnpm/action-setup@v3
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 10
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ jobs:
|
|||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v3
|
uses: pnpm/action-setup@v3
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 10
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -73,12 +73,12 @@ jobs:
|
|||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v3
|
uses: pnpm/action-setup@v3
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 10
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
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.
|
The steps below will assume you have locally installed the necessary dependencies.
|
||||||
Other dependencies will be installed as part of the setup
|
Other dependencies will be installed as part of the setup
|
||||||
|
|
||||||
- __node__ (~20)
|
- __node__ (~22)
|
||||||
- __pnpm__ (~9)
|
- __pnpm__ (~10)
|
||||||
- __docker__ (only necessary to run and build docker images)
|
- __docker__ (only necessary to run and build docker images)
|
||||||
|
|
||||||
## LOCAL DEVELOPMENT
|
## 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 PNPM_HOME="/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
RUN npm install -g pnpm@9.5.0
|
RUN npm install -g pnpm@10.11.0
|
||||||
COPY . /app
|
COPY . /app
|
||||||
WORKDIR /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 --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile
|
||||||
RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker
|
RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker
|
||||||
|
|
||||||
FROM node:20-alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
# Set environment variables
|
# Set environment variables
|
||||||
# Environment Variable to signal that we are running production
|
# Environment Variable to signal that we are running production
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "3.11.1",
|
"version": "3.16.1",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "3.11.1",
|
"version": "3.16.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -13,25 +13,28 @@
|
|||||||
"@emotion/styled": "^11.10.6",
|
"@emotion/styled": "^11.10.6",
|
||||||
"@fontsource/open-sans": "^5.0.28",
|
"@fontsource/open-sans": "^5.0.28",
|
||||||
"@mantine/hooks": "^7.13.3",
|
"@mantine/hooks": "^7.13.3",
|
||||||
"@react-icons/all-files": "^4.1.0",
|
|
||||||
"@sentry/react": "^8.43.0",
|
"@sentry/react": "^8.43.0",
|
||||||
|
"@table-nav/react": "^0.0.7",
|
||||||
"@tanstack/react-query": "^5.62.7",
|
"@tanstack/react-query": "^5.62.7",
|
||||||
"@tanstack/react-query-devtools": "^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",
|
"autosize": "^6.0.1",
|
||||||
"axios": "^1.2.0",
|
"axios": "^1.2.0",
|
||||||
"color": "^4.2.3",
|
"color": "^4.2.3",
|
||||||
"csv-stringify": "^6.4.5",
|
"csv-stringify": "^6.4.5",
|
||||||
"framer-motion": "^10.10.0",
|
"framer-motion": "^10.10.0",
|
||||||
|
"prismjs": "^1.29.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-colorful": "^5.6.1",
|
"react-colorful": "^5.6.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-fast-compare": "^3.2.2",
|
"react-fast-compare": "^3.2.2",
|
||||||
"react-hook-form": "^7.53.1",
|
"react-hook-form": "^7.53.1",
|
||||||
|
"react-icons": "5.4.0",
|
||||||
"react-qr-code": "^2.0.12",
|
"react-qr-code": "^2.0.12",
|
||||||
"react-router-dom": "^6.3.0",
|
"react-router-dom": "^6.3.0",
|
||||||
|
"react-simple-code-editor": "^0.14.1",
|
||||||
"web-vitals": "^3.1.1",
|
"web-vitals": "^3.1.1",
|
||||||
"zustand": "^4.5.2"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
||||||
@@ -64,11 +67,12 @@
|
|||||||
"@sentry/vite-plugin": "^2.16.1",
|
"@sentry/vite-plugin": "^2.16.1",
|
||||||
"@tanstack/eslint-plugin-query": "^5.8.4",
|
"@tanstack/eslint-plugin-query": "^5.8.4",
|
||||||
"@types/color": "^3.0.3",
|
"@types/color": "^3.0.3",
|
||||||
|
"@types/prismjs": "^1.26.5",
|
||||||
"@types/react": "^18.0.26",
|
"@types/react": "^18.0.26",
|
||||||
"@types/react-dom": "^18.0.10",
|
"@types/react-dom": "^18.0.10",
|
||||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||||
"@typescript-eslint/parser": "catalog:",
|
"@typescript-eslint/parser": "catalog:",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "4.5.1",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"eslint-config-prettier": "catalog:",
|
"eslint-config-prettier": "catalog:",
|
||||||
"eslint-plugin-jest": "^28.6.0",
|
"eslint-plugin-jest": "^28.6.0",
|
||||||
@@ -82,10 +86,10 @@
|
|||||||
"prettier": "catalog:",
|
"prettier": "catalog:",
|
||||||
"sass": "^1.57.1",
|
"sass": "^1.57.1",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "^5.2.11",
|
"vite": "6.3.1",
|
||||||
"vite-plugin-compression2": "^1.3.3",
|
"vite-plugin-compression2": "1.4.0",
|
||||||
"vite-plugin-svgr": "^4.2.0",
|
"vite-plugin-svgr": "4.3.0",
|
||||||
"vite-tsconfig-paths": "^4.3.1",
|
"vite-tsconfig-paths": "5.1.4",
|
||||||
"vitest": "catalog:"
|
"vitest": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-tim
|
|||||||
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
|
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
|
||||||
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
|
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
|
||||||
|
|
||||||
const Backstage = React.lazy(() => import('./features/viewers/backstage/Backstage'));
|
const Backstage = React.lazy(() => import('./views/backstage/Backstage'));
|
||||||
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
|
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
|
||||||
const Public = React.lazy(() => import('./features/viewers/public/Public'));
|
const Public = React.lazy(() => import('./views/public/Public'));
|
||||||
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
||||||
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
|
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
|
||||||
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
|
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
|
||||||
@@ -158,7 +158,14 @@ export default function AppRouter() {
|
|||||||
{/*/!* Protected Routes *!/*/}
|
{/*/!* Protected Routes *!/*/}
|
||||||
<Route path='/editor' element={<Editor />} />
|
<Route path='/editor' element={<Editor />} />
|
||||||
<Route path='/cuesheet' element={<PCuesheet />} />
|
<Route path='/cuesheet' element={<PCuesheet />} />
|
||||||
<Route path='/op' element={<POperator />} />
|
<Route
|
||||||
|
path='/op'
|
||||||
|
element={
|
||||||
|
<ViewLoader>
|
||||||
|
<POperator />
|
||||||
|
</ViewLoader>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/*/!* Protected Routes - Elements *!/*/}
|
{/*/!* Protected Routes - Elements *!/*/}
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ export const SHEET_STATE = ['sheetState'];
|
|||||||
export const URL_PRESETS = ['urlpresets'];
|
export const URL_PRESETS = ['urlpresets'];
|
||||||
export const VIEW_SETTINGS = ['viewSettings'];
|
export const VIEW_SETTINGS = ['viewSettings'];
|
||||||
export const CLIENT_LIST = ['clientList'];
|
export const CLIENT_LIST = ['clientList'];
|
||||||
|
export const REPORT = ['report'];
|
||||||
|
|
||||||
// API URLs
|
// API URLs
|
||||||
export const apiEntryUrl = `${serverURL}/data`;
|
export const apiEntryUrl = `${serverURL}/data`;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import axios, { AxiosResponse } from 'axios';
|
import axios, { AxiosResponse } from 'axios';
|
||||||
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
|
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
|
||||||
|
|
||||||
import { makeCSV, makeTable } from '../../views/cuesheet/cuesheet.utils';
|
import { makeTable } from '../../views/cuesheet/cuesheet.utils';
|
||||||
|
import { makeCSVFromArrayOfArrays } from '../utils/csv';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
import { createBlob, downloadBlob } from './utils';
|
import { createBlob, downloadBlob } from './utils';
|
||||||
@@ -42,7 +43,7 @@ export async function downloadCSV(fileName: string = 'rundown') {
|
|||||||
const { project, rundown, customFields } = data;
|
const { project, rundown, customFields } = data;
|
||||||
|
|
||||||
const sheetData = makeTable(project, rundown, customFields);
|
const sheetData = makeTable(project, rundown, customFields);
|
||||||
const fileContent = makeCSV(sheetData);
|
const fileContent = makeCSVFromArrayOfArrays(sheetData);
|
||||||
|
|
||||||
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
|
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
|
||||||
downloadBlob(blob, `${name}.csv`);
|
downloadBlob(blob, `${name}.csv`);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { OntimeReport } from 'ontime-types';
|
||||||
|
|
||||||
|
import { ontimeQueryClient } from '../../common/queryClient';
|
||||||
|
|
||||||
|
import { apiEntryUrl, REPORT } from './constants';
|
||||||
|
|
||||||
|
export const reportUrl = `${apiEntryUrl}/report`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to fetch all reports
|
||||||
|
*/
|
||||||
|
export async function fetchReport(): Promise<OntimeReport> {
|
||||||
|
const res = await axios.get(`${reportUrl}/`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteReport(id: string) {
|
||||||
|
await axios.delete(`${reportUrl}/${id}`);
|
||||||
|
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAllReport() {
|
||||||
|
await axios.delete(`${reportUrl}/all`);
|
||||||
|
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
@use "../../../theme/mixins" as *;
|
|
||||||
|
|
||||||
.link {
|
|
||||||
@include action-link;
|
|
||||||
font-size: $inner-section-text-size;
|
|
||||||
}
|
|
||||||
|
|
||||||
.linkIcon {
|
|
||||||
margin-left: $element-inner-spacing;
|
|
||||||
display: inline-block;
|
|
||||||
@include rotate-fourty-five;
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { MouseEvent, ReactNode } from 'react';
|
|
||||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
|
||||||
|
|
||||||
import { openLink } from '../../utils/linkUtils';
|
|
||||||
|
|
||||||
import style from './AppLink.module.scss';
|
|
||||||
|
|
||||||
interface AppLinkProps {
|
|
||||||
href: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AppLink(props: AppLinkProps) {
|
|
||||||
const { href, children } = props;
|
|
||||||
|
|
||||||
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
openLink(href);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a href='#!' target='_blank' rel='noreferrer' className={style.link} onClick={handleClick}>
|
|
||||||
{children} <IoArrowUp className={style.linkIcon} />
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
.subtle {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
height: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
|
||||||
|
background: $gray-1050;
|
||||||
|
color: $blue-400;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 3px;
|
||||||
|
|
||||||
|
&:hover:not(:disabled):not(:active) {
|
||||||
|
background: $gray-1000;
|
||||||
|
color: $blue-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: $gray-1100;
|
||||||
|
border-color: $gray-1250;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: $gray-1050;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { ButtonHTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
|
||||||
|
import style from './IconButton.module.scss';
|
||||||
|
|
||||||
|
export default function IconButton(props: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||||
|
const { className, children, ...buttonProps } = props;
|
||||||
|
return (
|
||||||
|
<button className={cx([style.subtle, className])} {...buttonProps}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
.textEntry {
|
||||||
|
grid-area: input;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: $label-gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inlineEntry {
|
||||||
|
display: grid;
|
||||||
|
grid-template-areas:
|
||||||
|
'label label'
|
||||||
|
'input btn';
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
column-gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redirect {
|
||||||
|
grid-area: btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: $label-gray;
|
||||||
|
}
|
||||||
@@ -1,64 +1,125 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { IoArrowForward } from 'react-icons/io5';
|
||||||
import {
|
import {
|
||||||
Button,
|
IconButton,
|
||||||
Input,
|
Input,
|
||||||
InputGroup,
|
|
||||||
InputLeftAddon,
|
|
||||||
Modal,
|
Modal,
|
||||||
ModalBody,
|
ModalBody,
|
||||||
ModalCloseButton,
|
ModalCloseButton,
|
||||||
ModalContent,
|
ModalContent,
|
||||||
ModalFooter,
|
|
||||||
ModalHeader,
|
ModalHeader,
|
||||||
ModalOverlay,
|
ModalOverlay,
|
||||||
|
Select,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { navigatorConstants } from '../../../viewerConfig';
|
||||||
import { setClientRemote } from '../../hooks/useSocket';
|
import { setClientRemote } from '../../hooks/useSocket';
|
||||||
|
import useUrlPresets from '../../hooks-query/useUrlPresets';
|
||||||
|
import Info from '../info/Info';
|
||||||
|
import AppLink from '../link/app-link/AppLink';
|
||||||
|
|
||||||
|
import style from './RedirectClientModal.module.scss';
|
||||||
|
|
||||||
interface RedirectClientModalProps {
|
interface RedirectClientModalProps {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name: string;
|
||||||
path?: string;
|
currentPath: string;
|
||||||
|
origin: string;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RedirectClientModal(props: RedirectClientModalProps) {
|
export function RedirectClientModal(props: RedirectClientModalProps) {
|
||||||
const { id, isOpen, name = '', path: currentPath = '', onClose } = props;
|
const { id, isOpen, name, currentPath, origin, onClose } = props;
|
||||||
|
const { data } = useUrlPresets();
|
||||||
const [path, setPath] = useState(currentPath);
|
const [path, setPath] = useState(currentPath);
|
||||||
|
const [selected, setSelected] = useState('/');
|
||||||
|
|
||||||
const { setRedirect } = setClientRemote;
|
const { setRedirect } = setClientRemote;
|
||||||
|
|
||||||
const handleRedirect = () => {
|
const handleRedirect = (newPath: string) => {
|
||||||
if (path !== currentPath && path !== '') {
|
if (newPath === '/' || newPath === currentPath) {
|
||||||
setRedirect({ target: id, redirect: path });
|
return;
|
||||||
}
|
}
|
||||||
|
setRedirect({ target: id, redirect: newPath });
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const host = window.location.origin;
|
const enabledPresets = data.filter((preset) => preset.enabled);
|
||||||
const canSubmit = path !== currentPath && path !== '';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
|
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
|
||||||
<ModalOverlay />
|
<ModalOverlay />
|
||||||
<ModalContent>
|
<ModalContent maxWidth='max(480px, 35vw)'>
|
||||||
<ModalHeader>Redirect: {name}</ModalHeader>
|
<ModalHeader>Redirect: {name}</ModalHeader>
|
||||||
<ModalCloseButton />
|
<ModalCloseButton />
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<InputGroup variant='ontime-filled' size='md'>
|
<Info>
|
||||||
<InputLeftAddon>{host}</InputLeftAddon>
|
Remotely redirect the client to a different URL. <br />
|
||||||
<Input placeholder='minimal?key=0000ffff' value={path} onChange={(event) => setPath(event.target.value)} />
|
Either by selecting a URL Preset or entering a custom path.
|
||||||
</InputGroup>
|
<br />
|
||||||
|
<br />
|
||||||
|
<AppLink search='settings=feature_settings__urlpresets'>Manage URL Presets</AppLink>
|
||||||
|
</Info>
|
||||||
|
<div>
|
||||||
|
<span className={style.label}>Select View or URL Preset</span>
|
||||||
|
<div className={style.textEntry}>
|
||||||
|
<Select
|
||||||
|
size='md'
|
||||||
|
variant='ontime'
|
||||||
|
isDisabled={enabledPresets.length === 0}
|
||||||
|
onChange={(event) => setSelected(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value='/'>Select view or preset</option>
|
||||||
|
{navigatorConstants.map((view) => {
|
||||||
|
return (
|
||||||
|
<option key={view.url} value={`/${view.url}`}>
|
||||||
|
{view.label}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{enabledPresets.map((preset) => {
|
||||||
|
return (
|
||||||
|
<option key={preset.pathAndParams} value={preset.pathAndParams}>
|
||||||
|
{`Preset: ${preset.alias}`}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Select>
|
||||||
|
<IconButton
|
||||||
|
variant='ontime-filled'
|
||||||
|
size='md'
|
||||||
|
aria-label='Redirect to preset'
|
||||||
|
className={style.redirect}
|
||||||
|
icon={<IoArrowForward />}
|
||||||
|
isDisabled={enabledPresets.length === 0 || selected === '/'}
|
||||||
|
onClick={() => handleRedirect(selected)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.inlineEntry}>
|
||||||
|
<span className={style.label}>Enter custom path</span>
|
||||||
|
<label className={style.textEntry}>
|
||||||
|
{origin}
|
||||||
|
<Input
|
||||||
|
variant='ontime-filled'
|
||||||
|
size='md'
|
||||||
|
placeholder='eg. /minimal?key=0000ffff'
|
||||||
|
value={path}
|
||||||
|
onChange={(event) => setPath(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<IconButton
|
||||||
|
variant='ontime-filled'
|
||||||
|
size='md'
|
||||||
|
aria-label='Redirect'
|
||||||
|
isDisabled={path === currentPath || path === ''}
|
||||||
|
className={style.redirect}
|
||||||
|
icon={<IoArrowForward />}
|
||||||
|
onClick={() => handleRedirect(path)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
|
||||||
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}>
|
|
||||||
Submit
|
|
||||||
</Button>
|
|
||||||
</ModalFooter>
|
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
|
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
|
||||||
|
|
||||||
import { ReactElement } from 'react';
|
import { ReactElement } from 'react';
|
||||||
|
import { IconType } from 'react-icons';
|
||||||
import { Menu, MenuButton, MenuGroup, MenuList } from '@chakra-ui/react';
|
import { Menu, MenuButton, MenuGroup, MenuList } from '@chakra-ui/react';
|
||||||
import { IconType } from '@react-icons/all-files';
|
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
import { ContextMenuOption } from './ContextMenuOption';
|
import { ContextMenuOption } from './ContextMenuOption';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { PropsWithChildren, useState } from 'react';
|
import { PropsWithChildren, useState } from 'react';
|
||||||
|
import { IoCheckmark } from 'react-icons/io5';
|
||||||
|
import { IoCopy } from 'react-icons/io5';
|
||||||
import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react';
|
import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react';
|
||||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
|
||||||
import { IoCopy } from '@react-icons/all-files/io5/IoCopy';
|
|
||||||
|
|
||||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||||
import { Size } from '../../models/Util.type';
|
import { Size } from '../../models/Util.type';
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
|
import { IoChevronDown, IoChevronUp } from 'react-icons/io5';
|
||||||
import { Tooltip } from '@chakra-ui/react';
|
import { Tooltip } from '@chakra-ui/react';
|
||||||
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
|
|
||||||
import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp';
|
|
||||||
|
|
||||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||||
import { millisToDelayString } from '../../utils/dateConfig';
|
import { millisToDelayString } from '../../utils/dateConfig';
|
||||||
|
|||||||
@@ -26,17 +26,22 @@ class ErrorBoundary extends React.Component {
|
|||||||
error,
|
error,
|
||||||
errorInfo: info,
|
errorInfo: info,
|
||||||
});
|
});
|
||||||
|
this.reportContent = `${error} ${info.componentStack}`;
|
||||||
|
|
||||||
|
const appState = runtimeStore.getState();
|
||||||
|
if (appState.ping < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Sentry.withScope((scope) => {
|
Sentry.withScope((scope) => {
|
||||||
scope.setExtras({
|
scope.setExtras({
|
||||||
error,
|
error,
|
||||||
store: runtimeStore.getState(),
|
store: appState,
|
||||||
hasSocket: { hasConnected, shouldReconnect, reconnectAttempts },
|
hasSocket: { hasConnected, shouldReconnect, reconnectAttempts },
|
||||||
});
|
});
|
||||||
const eventId = Sentry.captureException(error);
|
const eventId = Sentry.captureException(error);
|
||||||
this.setState({ eventId, info });
|
this.setState({ eventId, info });
|
||||||
});
|
});
|
||||||
this.reportContent = `${error} ${info.componentStack}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|||||||
@@ -2,14 +2,19 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $element-spacing;
|
gap: $element-spacing;
|
||||||
padding: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
|
|
||||||
background-color: $gray-1100;
|
background-color: $gray-1200;
|
||||||
border-radius: 2px;
|
border-radius: 3px;
|
||||||
font-size: $inner-section-text-size;
|
font-size: $text-body-size;
|
||||||
|
padding: 1rem;
|
||||||
|
|
||||||
|
.content {
|
||||||
|
color: $gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
|
min-width: 1.5rem;
|
||||||
|
align-self: start;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
color: $info-blue;
|
color: $info-blue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
import { IoAlertCircle } from '@react-icons/all-files/io5/IoAlertCircle';
|
import { IoAlertCircle } from 'react-icons/io5';
|
||||||
|
|
||||||
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
|
||||||
import style from './Info.module.scss';
|
import style from './Info.module.scss';
|
||||||
|
|
||||||
export default function Info({ children }: PropsWithChildren) {
|
interface InfoProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Info(props: PropsWithChildren<InfoProps>) {
|
||||||
|
const { className, children } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.infoLabel}>
|
<div className={cx([style.infoLabel, className])}>
|
||||||
<IoAlertCircle />
|
<IoAlertCircle />
|
||||||
{children}
|
<div>{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IoBan } from '@react-icons/all-files/io5/IoBan';
|
import { IoBan } from 'react-icons/io5';
|
||||||
|
|
||||||
import { cx } from '../../../utils/styleUtils';
|
import { cx } from '../../../utils/styleUtils';
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useController, UseControllerProps } from 'react-hook-form';
|
import { useController, UseControllerProps } from 'react-hook-form';
|
||||||
import { IoEyedrop } from '@react-icons/all-files/io5/IoEyedrop';
|
import { IoEyedrop } from 'react-icons/io5';
|
||||||
import { ViewSettings } from 'ontime-types';
|
import { ViewSettings } from 'ontime-types';
|
||||||
|
|
||||||
import PopoverPicker from '../../../../common/components/input/popover-picker/PopoverPicker';
|
import { debounce } from '../../../utils/debounce';
|
||||||
import { debounce } from '../../../../common/utils/debounce';
|
|
||||||
import { cx, getAccessibleColour } from '../../../utils/styleUtils';
|
import { cx, getAccessibleColour } from '../../../utils/styleUtils';
|
||||||
|
import PopoverPicker from '../popover-picker/PopoverPicker';
|
||||||
|
|
||||||
import style from './SwatchSelect.module.scss';
|
import style from './SwatchSelect.module.scss';
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export default function useReactiveTextInput(
|
|||||||
submitOnCtrlEnter?: boolean;
|
submitOnCtrlEnter?: boolean;
|
||||||
onCancelUpdate?: () => void;
|
onCancelUpdate?: () => void;
|
||||||
allowSubmitSameValue?: boolean;
|
allowSubmitSameValue?: boolean;
|
||||||
|
allowKeyboardNavigation?: boolean;
|
||||||
},
|
},
|
||||||
): UseReactiveTextInputReturn {
|
): UseReactiveTextInputReturn {
|
||||||
const [text, setText] = useState<string>(initialText);
|
const [text, setText] = useState<string>(initialText);
|
||||||
@@ -58,7 +59,14 @@ export default function useReactiveTextInput(
|
|||||||
setText(cleanVal);
|
setText(cleanVal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (options?.allowKeyboardNavigation) {
|
||||||
|
ref.current?.parentElement?.focus(); // Focus on parent element to continue keyboard navigation
|
||||||
|
} else {
|
||||||
|
ref.current?.blur();
|
||||||
|
}
|
||||||
|
}); // Immediate timeout to ensure text is set before blurring
|
||||||
},
|
},
|
||||||
[initialText, options, ref, submitCallback],
|
[initialText, options, ref, submitCallback],
|
||||||
);
|
);
|
||||||
@@ -70,12 +78,25 @@ export default function useReactiveTextInput(
|
|||||||
const handleEscape = useCallback(() => {
|
const handleEscape = useCallback(() => {
|
||||||
// No need to update if it hasn't changed
|
// No need to update if it hasn't changed
|
||||||
setText(initialText);
|
setText(initialText);
|
||||||
|
// force the text to be the initial value
|
||||||
|
if (ref.current) {
|
||||||
|
ref.current.value = initialText;
|
||||||
|
}
|
||||||
options?.onCancelUpdate?.();
|
options?.onCancelUpdate?.();
|
||||||
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
|
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before blurring
|
||||||
}, [initialText, options, ref]);
|
}, [initialText, options, ref]);
|
||||||
|
|
||||||
const keyHandler = useMemo(() => {
|
const keyHandler = useMemo(() => {
|
||||||
const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]];
|
const hotKeys: HotkeyItem[] = [
|
||||||
|
[
|
||||||
|
'Escape',
|
||||||
|
(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
handleEscape();
|
||||||
|
},
|
||||||
|
{ preventDefault: true },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
if (options?.submitOnEnter) {
|
if (options?.submitOnEnter) {
|
||||||
hotKeys.push(['Enter', () => handleSubmit(text)]);
|
hotKeys.push(['Enter', () => handleSubmit(text)]);
|
||||||
@@ -84,7 +105,22 @@ export default function useReactiveTextInput(
|
|||||||
if (options?.submitOnCtrlEnter) {
|
if (options?.submitOnCtrlEnter) {
|
||||||
hotKeys.push(['mod + Enter', () => handleSubmit(text)]);
|
hotKeys.push(['mod + Enter', () => handleSubmit(text)]);
|
||||||
}
|
}
|
||||||
return getHotkeyHandler(hotKeys);
|
|
||||||
|
const hotKeyHandler = getHotkeyHandler(hotKeys);
|
||||||
|
|
||||||
|
return (event: KeyboardEvent<HTMLElement>) => {
|
||||||
|
// allow moving in input field with arrow keys
|
||||||
|
if (
|
||||||
|
event.key === 'ArrowLeft' ||
|
||||||
|
event.key === 'ArrowRight' ||
|
||||||
|
event.key === 'ArrowUp' ||
|
||||||
|
event.key === 'ArrowDown'
|
||||||
|
) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
hotKeyHandler(event);
|
||||||
|
};
|
||||||
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]);
|
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
.link {
|
||||||
|
color: $label-gray;
|
||||||
|
font-size: $text-body-size;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
color: $blue-400;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $blue-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { type PropsWithChildren } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import style from './AppLink.module.scss';
|
||||||
|
|
||||||
|
interface AppLinkProps {
|
||||||
|
search: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component used to navigate to an editor link inside the same window
|
||||||
|
* Handles the path to respect Ontime Clouds base URL
|
||||||
|
*/
|
||||||
|
export default function AppLink(props: PropsWithChildren<AppLinkProps>) {
|
||||||
|
const { search, children } = props;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleClick = () => navigate({ search });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={handleClick} className={style.link}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
+11
-6
@@ -1,8 +1,8 @@
|
|||||||
import { MouseEvent, ReactNode } from 'react';
|
import { MouseEvent, ReactNode } from 'react';
|
||||||
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
|
import { IoOpenOutline } from 'react-icons/io5';
|
||||||
|
|
||||||
import { openLink } from '../../utils/linkUtils';
|
import { openLink } from '../../../utils/linkUtils';
|
||||||
import { cx } from '../../utils/styleUtils';
|
import { cx } from '../../../utils/styleUtils';
|
||||||
|
|
||||||
import style from './ExternalLink.module.scss';
|
import style from './ExternalLink.module.scss';
|
||||||
|
|
||||||
@@ -14,7 +14,6 @@ interface ExternalLinkProps {
|
|||||||
|
|
||||||
export default function ExternalLink(props: ExternalLinkProps) {
|
export default function ExternalLink(props: ExternalLinkProps) {
|
||||||
const { href, inline, children } = props;
|
const { href, inline, children } = props;
|
||||||
const classes = cx([style.link, inline ? style.inline : null]);
|
|
||||||
|
|
||||||
const handleClick = (event: MouseEvent) => {
|
const handleClick = (event: MouseEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -22,8 +21,14 @@ export default function ExternalLink(props: ExternalLinkProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a href='#!' target='_blank' rel='noreferrer' className={classes} onClick={handleClick}>
|
<a
|
||||||
{children} <IoOpenOutline />
|
href='#!'
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer'
|
||||||
|
className={cx([style.link, inline && style.inline])}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
{children} <IoOpenOutline style={{ fontSize: '1em' }} />
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
import { IoApps } from 'react-icons/io5';
|
||||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
import { IoSettingsOutline } from 'react-icons/io5';
|
||||||
|
|
||||||
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
|
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
|
||||||
import { cx } from '../../utils/styleUtils';
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
|
||||||
import style from './NavigationMenu.module.scss';
|
import style from './NavigationMenu.module.scss';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { memo, PropsWithChildren } from 'react';
|
import { memo, PropsWithChildren } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
import { IoArrowUp, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
@@ -11,11 +12,6 @@ import {
|
|||||||
useDisclosure,
|
useDisclosure,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { useFullscreen } from '@mantine/hooks';
|
import { useFullscreen } from '@mantine/hooks';
|
||||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
|
||||||
import { IoContract } from '@react-icons/all-files/io5/IoContract';
|
|
||||||
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
|
|
||||||
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
|
|
||||||
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
|
||||||
|
|
||||||
import { isLocalhost } from '../../../externals';
|
import { isLocalhost } from '../../../externals';
|
||||||
import { navigatorConstants } from '../../../viewerConfig';
|
import { navigatorConstants } from '../../../viewerConfig';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
|
import { IoLockClosedOutline } from 'react-icons/io5';
|
||||||
|
|
||||||
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
|
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
|
||||||
import { cx } from '../../utils/styleUtils';
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
|
||||||
import style from './NavigationMenu.module.scss';
|
import style from './NavigationMenu.module.scss';
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
|
import { IoPause, IoPlay, IoStop } from 'react-icons/io5';
|
||||||
import { Tooltip } from '@chakra-ui/react';
|
import { Tooltip } from '@chakra-ui/react';
|
||||||
import { IoPause } from '@react-icons/all-files/io5/IoPause';
|
|
||||||
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
|
|
||||||
import { IoStop } from '@react-icons/all-files/io5/IoStop';
|
|
||||||
import { Playback } from 'ontime-types';
|
import { Playback } from 'ontime-types';
|
||||||
|
|
||||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||||
|
|||||||
@@ -9,10 +9,6 @@ $progress-bar-br: 3px;
|
|||||||
border-radius: $progress-bar-br;
|
border-radius: $progress-bar-br;
|
||||||
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
|
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
|
||||||
overflow: clip;
|
overflow: clip;
|
||||||
|
|
||||||
&--hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar__indicator {
|
.progress-bar__indicator {
|
||||||
|
|||||||
@@ -7,16 +7,15 @@ import './ProgressBar.scss';
|
|||||||
interface ProgressBarProps {
|
interface ProgressBarProps {
|
||||||
current: MaybeNumber;
|
current: MaybeNumber;
|
||||||
duration: MaybeNumber;
|
duration: MaybeNumber;
|
||||||
hidden?: boolean;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProgressBar(props: ProgressBarProps) {
|
export default function ProgressBar(props: ProgressBarProps) {
|
||||||
const { current, duration, hidden, className = '' } = props;
|
const { current, duration, className } = props;
|
||||||
const progress = getProgress(current, duration);
|
const progress = getProgress(current, duration);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`progress-bar__bg ${hidden ? 'progress-bar__bg--hidden' : ''} ${className}`}>
|
<div className={`progress-bar__bg ${className}`}>
|
||||||
<div className='progress-bar__indicator' style={{ width: `${progress}%` }} />
|
<div className='progress-bar__indicator' style={{ width: `${progress}%` }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { IoCheckmark } from 'react-icons/io5';
|
||||||
import { IconButton, PinInput, PinInputField } from '@chakra-ui/react';
|
import { IconButton, PinInput, PinInputField } from '@chakra-ui/react';
|
||||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
|
||||||
|
|
||||||
import style from './ProtectRoute.module.scss';
|
import style from './ProtectRoute.module.scss';
|
||||||
|
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
@use '../../../theme/viewerDefs' as *;
|
|
||||||
|
|
||||||
.schedule {
|
|
||||||
width: 100%;
|
|
||||||
border-spacing: 50px;
|
|
||||||
|
|
||||||
.entry {
|
|
||||||
font-size: clamp(16px, 1.5vw, 24px);
|
|
||||||
|
|
||||||
.entry-colour {
|
|
||||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
|
||||||
height: clamp(8px, 0.75vw, 12px);
|
|
||||||
width: clamp(8px, 0.75vw, 12px);
|
|
||||||
border-radius: 6px;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-times {
|
|
||||||
font-family: $viewer-font-family;
|
|
||||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
|
||||||
font-weight: 300;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-title {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-secondary {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(:last-child) {
|
|
||||||
padding-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--past {
|
|
||||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
&--now {
|
|
||||||
.entry-title {
|
|
||||||
color: var(--accent-color-override, $accent-color);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.skip {
|
|
||||||
text-decoration: line-through;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-nav {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
|
|
||||||
.schedule-nav__item {
|
|
||||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-left: 8px;
|
|
||||||
|
|
||||||
&--selected {
|
|
||||||
background-color: var(--color-override, $viewer-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { useSchedule } from './ScheduleContext';
|
|
||||||
import ScheduleItem from './ScheduleItem';
|
|
||||||
|
|
||||||
import './Schedule.scss';
|
|
||||||
|
|
||||||
interface ScheduleProps {
|
|
||||||
isProduction?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Schedule({ isProduction, className }: ScheduleProps) {
|
|
||||||
const { paginatedEvents, selectedEventId, isBackstage, scheduleType } = useSchedule();
|
|
||||||
|
|
||||||
// TODO: design a nice placeholder for empty schedules
|
|
||||||
if (paginatedEvents?.length < 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let selectedState: 'past' | 'now' | 'future' = 'past';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ul className={`schedule ${className}`}>
|
|
||||||
{paginatedEvents.map((event) => {
|
|
||||||
if (scheduleType === 'past' || scheduleType === 'future') {
|
|
||||||
selectedState = scheduleType;
|
|
||||||
} else {
|
|
||||||
if (event.id === selectedEventId) {
|
|
||||||
selectedState = 'now';
|
|
||||||
} else if (selectedState === 'now') {
|
|
||||||
selectedState = 'future';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeStart = isProduction ? event.timeStart + (event?.delay ?? 0) : event.timeStart;
|
|
||||||
const timeEnd = isProduction ? event.timeEnd + (event?.delay ?? 0) : event.timeEnd;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScheduleItem
|
|
||||||
key={event.id}
|
|
||||||
selected={selectedState}
|
|
||||||
timeStart={timeStart}
|
|
||||||
timeEnd={timeEnd}
|
|
||||||
title={event.title}
|
|
||||||
colour={isBackstage ? event.colour : ''}
|
|
||||||
backstageEvent={!event.isPublic}
|
|
||||||
skip={event.skip}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import { createContext, PropsWithChildren, useContext, useState } from 'react';
|
|
||||||
import { useSearchParams } from 'react-router-dom';
|
|
||||||
import { OntimeEvent } from 'ontime-types';
|
|
||||||
|
|
||||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
|
||||||
import { useInterval } from '../../hooks/useInterval';
|
|
||||||
|
|
||||||
interface ScheduleContextState {
|
|
||||||
events: OntimeEvent[];
|
|
||||||
paginatedEvents: OntimeEvent[];
|
|
||||||
selectedEventId: string | null;
|
|
||||||
scheduleType: 'past' | 'now' | 'future';
|
|
||||||
numPages: number;
|
|
||||||
visiblePage: number;
|
|
||||||
isBackstage: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ScheduleContext = createContext<ScheduleContextState | undefined>(undefined);
|
|
||||||
|
|
||||||
interface ScheduleProviderProps {
|
|
||||||
events: OntimeEvent[];
|
|
||||||
selectedEventId: string | null;
|
|
||||||
isBackstage?: boolean;
|
|
||||||
time?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const numEventsPerPage = 8;
|
|
||||||
|
|
||||||
export const ScheduleProvider = ({
|
|
||||||
children,
|
|
||||||
events,
|
|
||||||
selectedEventId,
|
|
||||||
isBackstage = false,
|
|
||||||
time = 10,
|
|
||||||
}: PropsWithChildren<ScheduleProviderProps>) => {
|
|
||||||
const [visiblePage, setVisiblePage] = useState(0);
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
|
|
||||||
// look for overrides from views
|
|
||||||
const hidePast = isStringBoolean(searchParams.get('hidePast'));
|
|
||||||
const stopCycle = isStringBoolean(searchParams.get('stopCycle'));
|
|
||||||
const eventsPerPage = Number(searchParams.get('eventsPerPage') ?? numEventsPerPage);
|
|
||||||
|
|
||||||
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
|
|
||||||
|
|
||||||
const viewEvents = [...events];
|
|
||||||
if (hidePast) {
|
|
||||||
// we want to show the event after the next
|
|
||||||
viewEvents.splice(0, selectedEventIndex + 2);
|
|
||||||
selectedEventIndex = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const numPages = Math.ceil(viewEvents.length / eventsPerPage);
|
|
||||||
const eventStart = eventsPerPage * visiblePage;
|
|
||||||
const eventEnd = eventsPerPage * (visiblePage + 1);
|
|
||||||
const paginatedEvents = viewEvents.slice(eventStart, eventEnd);
|
|
||||||
|
|
||||||
const resolveScheduleType = () => {
|
|
||||||
if (selectedEventIndex >= eventStart && selectedEventIndex < eventEnd) {
|
|
||||||
return 'now';
|
|
||||||
}
|
|
||||||
if (selectedEventIndex > eventEnd) {
|
|
||||||
return 'past';
|
|
||||||
}
|
|
||||||
return 'future';
|
|
||||||
};
|
|
||||||
const scheduleType = resolveScheduleType();
|
|
||||||
|
|
||||||
// every SCROLL_TIME go to the next array
|
|
||||||
useInterval(() => {
|
|
||||||
if (stopCycle) {
|
|
||||||
setVisiblePage(0);
|
|
||||||
} else if (events.length > eventsPerPage) {
|
|
||||||
const next = (visiblePage + 1) % numPages;
|
|
||||||
setVisiblePage(next);
|
|
||||||
}
|
|
||||||
}, time * 1000);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScheduleContext.Provider
|
|
||||||
value={{
|
|
||||||
events,
|
|
||||||
paginatedEvents,
|
|
||||||
selectedEventId,
|
|
||||||
scheduleType,
|
|
||||||
numPages,
|
|
||||||
visiblePage,
|
|
||||||
isBackstage,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ScheduleContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useSchedule = () => {
|
|
||||||
const context = useContext(ScheduleContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useSchedule() can only be used inside a ScheduleContext');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import SuperscriptTime from '../../../features/viewers/common/superscript-time/SuperscriptTime';
|
|
||||||
import { formatTime } from '../../utils/time';
|
|
||||||
|
|
||||||
import './Schedule.scss';
|
|
||||||
|
|
||||||
const formatOptions = {
|
|
||||||
format12: 'hh:mm a',
|
|
||||||
format24: 'HH:mm',
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ScheduleItemProps {
|
|
||||||
selected: 'past' | 'now' | 'future';
|
|
||||||
timeStart: number;
|
|
||||||
timeEnd: number;
|
|
||||||
title: string;
|
|
||||||
backstageEvent: boolean;
|
|
||||||
colour: string;
|
|
||||||
skip: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ScheduleItem(props: ScheduleItemProps) {
|
|
||||||
const { selected, timeStart, timeEnd, title, backstageEvent, colour, skip } = props;
|
|
||||||
|
|
||||||
const start = formatTime(timeStart, formatOptions);
|
|
||||||
const end = formatTime(timeEnd, formatOptions);
|
|
||||||
const userColour = colour !== '' ? colour : '';
|
|
||||||
const selectStyle = `entry--${selected}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li className={`entry ${selectStyle} ${skip ? 'skip' : ''}`}>
|
|
||||||
<div className='entry-times'>
|
|
||||||
<span className='entry-colour' style={{ backgroundColor: userColour }} />
|
|
||||||
<div style={{ display: 'flex' }}>
|
|
||||||
<SuperscriptTime time={start} />
|
|
||||||
{' → '}
|
|
||||||
<SuperscriptTime time={end} />
|
|
||||||
{backstageEvent ? '*' : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='entry-title'>{title}</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { useSchedule } from './ScheduleContext';
|
|
||||||
|
|
||||||
import './Schedule.scss';
|
|
||||||
|
|
||||||
interface ScheduleNavProps {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ScheduleNav({ className }: ScheduleNavProps) {
|
|
||||||
const { numPages, visiblePage } = useSchedule();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`schedule-nav ${className}`}>
|
|
||||||
{numPages > 1 &&
|
|
||||||
[...Array(numPages).keys()].map((i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className={i === visiblePage ? 'schedule-nav__item schedule-nav__item--selected' : 'schedule-nav__item'}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
opacity: 0.6;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import { CSSProperties } from 'react';
|
import { CSSProperties } from 'react';
|
||||||
|
|
||||||
import EmptyImage from '../../../assets/images/empty.svg?react';
|
import EmptyImage from '../../../assets/images/empty.svg?react';
|
||||||
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
|
||||||
import style from './Empty.module.scss';
|
import style from './Empty.module.scss';
|
||||||
|
|
||||||
interface EmptyProps {
|
interface EmptyProps {
|
||||||
text?: string;
|
text?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Empty(props: EmptyProps) {
|
export default function Empty(props: EmptyProps) {
|
||||||
const { text, ...rest } = props;
|
const { text, className, ...rest } = props;
|
||||||
return (
|
return (
|
||||||
<div className={style.emptyContainer} {...rest}>
|
<div className={cx([style.emptyContainer, className])} {...rest}>
|
||||||
<EmptyImage className={style.empty} />
|
<EmptyImage className={style.empty} />
|
||||||
{text && <span className={style.text}>{text}</span>}
|
{text && <span className={style.text}>{text}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
@use '../../../theme/viewerDefs' as *;
|
@use '../../../theme/viewerDefs' as *;
|
||||||
|
|
||||||
$title-primary-size: clamp(1.5rem, 3vw, 3rem);
|
|
||||||
$title-secondary-size: clamp(1rem, 2vw, 2.25rem);
|
|
||||||
|
|
||||||
.title-card {
|
.title-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -10,21 +7,23 @@ $title-secondary-size: clamp(1rem, 2vw, 2.25rem);
|
|||||||
}
|
}
|
||||||
|
|
||||||
.title-card__title,
|
.title-card__title,
|
||||||
.title-card__secondary {
|
.title-card__placeholder {
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-card__title {
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: $title-primary-size;
|
font-size: $title-font-size;
|
||||||
color: var(--color-override, $viewer-color);
|
|
||||||
line-height: 1.2em;
|
line-height: 1.2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title-card__title {
|
||||||
|
color: var(--color-override, $viewer-color);
|
||||||
|
padding-right: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-card__placeholder {
|
||||||
|
color: var(--label-color-override, $viewer-label-color);
|
||||||
|
}
|
||||||
|
|
||||||
.title-card__secondary {
|
.title-card__secondary {
|
||||||
font-size: $title-secondary-size;
|
font-size: $base-font-size;
|
||||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||||
line-height: 1.2em;
|
line-height: 1.2em;
|
||||||
}
|
}
|
||||||
@@ -35,7 +34,6 @@ $title-secondary-size: clamp(1rem, 2vw, 2.25rem);
|
|||||||
top: 0.5rem;
|
top: 0.5rem;
|
||||||
font-size: $timer-label-size;
|
font-size: $timer-label-size;
|
||||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||||
margin-left: auto;
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|
||||||
&--accent {
|
&--accent {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ForwardedRef, forwardRef } from 'react';
|
import { ForwardedRef, forwardRef } from 'react';
|
||||||
|
|
||||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||||
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
|
||||||
import './TitleCard.scss';
|
import './TitleCard.scss';
|
||||||
|
|
||||||
@@ -18,9 +19,9 @@ const TitleCard = forwardRef((props: TitleCardProps, ref: ForwardedRef<HTMLDivEl
|
|||||||
const accent = label === 'now';
|
const accent = label === 'now';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`title-card ${className}`} ref={ref}>
|
<div className={cx(['title-card', className])} ref={ref}>
|
||||||
<span className='title-card__title'>{title}</span>
|
<span className='title-card__title'>{title}</span>
|
||||||
<span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}>
|
<span className={cx(['title-card__label', accent && 'title-card__label--accent'])}>
|
||||||
{label && getLocalizedString(`common.${label}`)}
|
{label && getLocalizedString(`common.${label}`)}
|
||||||
</span>
|
</span>
|
||||||
<div className='title-card__secondary'>{secondary}</div>
|
<div className='title-card__secondary'>{secondary}</div>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.viewLogo {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 75px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { projectLogoPath } from '../../api/constants';
|
import { projectLogoPath } from '../../api/constants';
|
||||||
|
|
||||||
|
import './ViewLogo.scss';
|
||||||
|
|
||||||
interface ViewLogoProps {
|
interface ViewLogoProps {
|
||||||
name: string;
|
name: string;
|
||||||
className: string;
|
className: string;
|
||||||
@@ -7,5 +9,11 @@ interface ViewLogoProps {
|
|||||||
|
|
||||||
export default function ViewLogo(props: ViewLogoProps) {
|
export default function ViewLogo(props: ViewLogoProps) {
|
||||||
const { name, className } = props;
|
const { name, className } = props;
|
||||||
return <img src={`${projectLogoPath}/${name}`} className={className} />;
|
|
||||||
|
// we wrap the image in a div to help maintain the aspect ratio
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<img alt='' src={`${projectLogoPath}/${name}`} className='viewLogo' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { IoChevronDown } from 'react-icons/io5';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -13,7 +14,6 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Switch,
|
Switch,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
|
|
||||||
|
|
||||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||||
|
|
||||||
@@ -126,9 +126,9 @@ function MultiOption(props: EditFormMultiOptionProps) {
|
|||||||
onChange={(value) => setParamState(Array.isArray(value) ? value : [value])}
|
onChange={(value) => setParamState(Array.isArray(value) ? value : [value])}
|
||||||
>
|
>
|
||||||
{Object.values(paramField.values).map((option) => {
|
{Object.values(paramField.values).map((option) => {
|
||||||
const { value, label } = option;
|
const { value, label, colour } = option;
|
||||||
return (
|
return (
|
||||||
<MenuItemOption value={value} key={value}>
|
<MenuItemOption value={value} key={value} style={{ borderRight: `8px solid ${colour}` }}>
|
||||||
{label}
|
{label}
|
||||||
</MenuItemOption>
|
</MenuItemOption>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,3 +16,7 @@
|
|||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
padding-right: 0.5rem;
|
padding-right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
useDisclosure,
|
useDisclosure,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
import useViewSettings from '../../hooks-query/useViewSettings';
|
||||||
import Info from '../info/Info';
|
import Info from '../info/Info';
|
||||||
|
|
||||||
import { ViewOption } from './types';
|
import { ViewOption } from './types';
|
||||||
@@ -132,7 +132,9 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
|||||||
</DrawerHeader>
|
</DrawerHeader>
|
||||||
|
|
||||||
<DrawerBody>
|
<DrawerBody>
|
||||||
{viewSettings.overrideStyles && <Info>This view style is being modified by a custom CSS file.</Info>}
|
{viewSettings.overrideStyles && (
|
||||||
|
<Info className={style.info}>This view style is being modified by a custom CSS file.</Info>
|
||||||
|
)}
|
||||||
<form id='edit-params-form' onSubmit={onParamsFormSubmit} className={style.sectionList}>
|
<form id='edit-params-form' onSubmit={onParamsFormSubmit} className={style.sectionList}>
|
||||||
{viewOptions.map((section) => (
|
{viewOptions.map((section) => (
|
||||||
<ViewParamsSection
|
<ViewParamsSection
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { IoChevronDown } from 'react-icons/io5';
|
||||||
import { useLocalStorage } from '@mantine/hooks';
|
import { useLocalStorage } from '@mantine/hooks';
|
||||||
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
|
|
||||||
|
|
||||||
|
import { baseURI } from '../../../externals';
|
||||||
import { cx } from '../../utils/styleUtils';
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
|
||||||
import ParamInput from './ParamInput';
|
import ParamInput from './ParamInput';
|
||||||
@@ -17,7 +18,7 @@ interface ViewParamsSectionProps {
|
|||||||
export default function ViewParamsSection(props: ViewParamsSectionProps) {
|
export default function ViewParamsSection(props: ViewParamsSectionProps) {
|
||||||
const { title, collapsible, options } = props;
|
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 = () => {
|
const handleCollapse = () => {
|
||||||
if (collapsible) {
|
if (collapsible) {
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { CustomFields } from 'ontime-types';
|
||||||
|
|
||||||
|
import { makeOptionsFromCustomFields } from '../constants';
|
||||||
|
|
||||||
|
describe('makeOptionsFromCustomFields', () => {
|
||||||
|
const testCustomFields: CustomFields = {
|
||||||
|
field1: { label: 'Field 1', colour: 'red', type: 'string' },
|
||||||
|
field2: { label: 'Field 2', colour: 'blue', type: 'string' },
|
||||||
|
};
|
||||||
|
|
||||||
|
it('creates a record of keys for the given custom fields', () => {
|
||||||
|
const result = makeOptionsFromCustomFields(testCustomFields);
|
||||||
|
expect(result).toStrictEqual({
|
||||||
|
'custom-field1': 'Custom: Field 1',
|
||||||
|
'custom-field2': 'Custom: Field 2',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends additional data', () => {
|
||||||
|
const additionalData = {
|
||||||
|
test1: 'test1',
|
||||||
|
test2: 'test2',
|
||||||
|
};
|
||||||
|
const result = makeOptionsFromCustomFields(testCustomFields, additionalData);
|
||||||
|
expect(result).toStrictEqual({
|
||||||
|
'custom-field1': 'Custom: Field 1',
|
||||||
|
'custom-field2': 'Custom: Field 2',
|
||||||
|
test1: 'test1',
|
||||||
|
test2: 'test2',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filtersImageTypes', () => {
|
||||||
|
const customFieldsWIthImage: CustomFields = {
|
||||||
|
...testCustomFields,
|
||||||
|
field3: { label: 'Field 3', colour: 'green', type: 'image' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = makeOptionsFromCustomFields(customFieldsWIthImage);
|
||||||
|
expect(result).toStrictEqual({
|
||||||
|
'custom-field1': 'Custom: Field 1',
|
||||||
|
'custom-field2': 'Custom: Field 2',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,17 +1,34 @@
|
|||||||
import { CustomFields } from 'ontime-types';
|
import { CustomFields } from 'ontime-types';
|
||||||
|
|
||||||
import type { ParamField } from './types';
|
import type { MultiselectOptions, ParamField } from './types';
|
||||||
|
|
||||||
export const makeOptionsFromCustomFields = (
|
export const makeOptionsFromCustomFields = (
|
||||||
customFields: CustomFields,
|
customFields: CustomFields,
|
||||||
additionalOptions: Record<string, string> = {},
|
additionalOptions: Readonly<Record<string, string>> = {},
|
||||||
|
filterImageType = true,
|
||||||
) => {
|
) => {
|
||||||
return Object.entries(customFields).reduce((options, [key, value]) => {
|
const options = { ...additionalOptions };
|
||||||
|
for (const [key, value] of Object.entries(customFields)) {
|
||||||
|
if (filterImageType && value.type === 'image') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
options[`custom-${key}`] = `Custom: ${value.label}`;
|
options[`custom-${key}`] = `Custom: ${value.label}`;
|
||||||
return options;
|
}
|
||||||
}, additionalOptions);
|
return options;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function makeCustomFieldSelectOptions(customFields: CustomFields, filterImageType = true): MultiselectOptions {
|
||||||
|
const options: MultiselectOptions = {};
|
||||||
|
for (const [key, value] of Object.entries(customFields)) {
|
||||||
|
if (filterImageType && value.type === 'image') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
options[key] = { value: key, label: value.label, colour: value.colour };
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
export const getTimeOption = (timeFormat: string): ParamField => {
|
export const getTimeOption = (timeFormat: string): ParamField => {
|
||||||
const placeholder = `${timeFormat} (default)`;
|
const placeholder = `${timeFormat} (default)`;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ type OptionsField = {
|
|||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MultiselectOption = { value: string; label: string; colour: string };
|
||||||
|
export type MultiselectOptions = Record<string, MultiselectOption>;
|
||||||
type MultiOptionsField = {
|
type MultiOptionsField = {
|
||||||
type: 'multi-option';
|
type: 'multi-option';
|
||||||
values: Record<string, { value: string; label: string; colour: string }>;
|
values: MultiselectOptions;
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createContext, PropsWithChildren, useCallback, useEffect, useState } from 'react';
|
import { createContext, PropsWithChildren, useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { baseURI } from '../../externals';
|
||||||
import useSettings from '../hooks-query/useSettings';
|
import useSettings from '../hooks-query/useSettings';
|
||||||
|
|
||||||
interface AppContextType {
|
interface AppContextType {
|
||||||
@@ -26,7 +27,7 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === 'pending') return;
|
if (status === 'pending') return;
|
||||||
const previousEditor = sessionStorage.getItem(storageKeys.editor);
|
const previousEditor = sessionStorage.getItem(`${baseURI}${storageKeys.editor}`);
|
||||||
|
|
||||||
if (previousEditor && previousEditor === data.editorKey) {
|
if (previousEditor && previousEditor === data.editorKey) {
|
||||||
setEditorAuth(true);
|
setEditorAuth(true);
|
||||||
@@ -34,7 +35,7 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
|
|||||||
setEditorAuth(data.editorKey == null || data.editorKey === '');
|
setEditorAuth(data.editorKey == null || data.editorKey === '');
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousOperator = sessionStorage.getItem(storageKeys.operator);
|
const previousOperator = sessionStorage.getItem(`${baseURI}${storageKeys.operator}`);
|
||||||
if (previousOperator && previousOperator === data.operatorKey) {
|
if (previousOperator && previousOperator === data.operatorKey) {
|
||||||
setOperatorAuth(true);
|
setOperatorAuth(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -55,14 +56,14 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
|
|||||||
if (permission === 'editor') {
|
if (permission === 'editor') {
|
||||||
const correct = isValid(pin, data.editorKey);
|
const correct = isValid(pin, data.editorKey);
|
||||||
if (correct) {
|
if (correct) {
|
||||||
sessionStorage.setItem(storageKeys.editor, pin);
|
sessionStorage.setItem(`${baseURI}${storageKeys.editor}`, pin);
|
||||||
}
|
}
|
||||||
setEditorAuth(correct);
|
setEditorAuth(correct);
|
||||||
return correct;
|
return correct;
|
||||||
} else if (permission === 'operator') {
|
} else if (permission === 'operator') {
|
||||||
const correct = isValid(pin, data.operatorKey);
|
const correct = isValid(pin, data.operatorKey);
|
||||||
if (correct) {
|
if (correct) {
|
||||||
sessionStorage.setItem(storageKeys.operator, pin);
|
sessionStorage.setItem(`${baseURI}${storageKeys.operator}`, pin);
|
||||||
}
|
}
|
||||||
setOperatorAuth(correct);
|
setOperatorAuth(correct);
|
||||||
return correct;
|
return correct;
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { OntimeReport } from 'ontime-types';
|
||||||
|
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { REPORT } from '../api/constants';
|
||||||
|
import { fetchReport } from '../api/report';
|
||||||
|
|
||||||
|
export default function useReport() {
|
||||||
|
const { data, refetch } = useQuery<OntimeReport>({
|
||||||
|
queryKey: REPORT,
|
||||||
|
queryFn: fetchReport,
|
||||||
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
|
retry: 5,
|
||||||
|
retryDelay: (attempt) => attempt * 2500,
|
||||||
|
networkMode: 'always',
|
||||||
|
staleTime: MILLIS_PER_HOUR,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { data: data ?? {}, refetch };
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
|
import { NormalisedRundown, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types';
|
||||||
|
|
||||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||||
import { RUNDOWN } from '../api/constants';
|
import { RUNDOWN } from '../api/constants';
|
||||||
@@ -11,6 +11,9 @@ import useProjectData from './useProjectData';
|
|||||||
// revision is -1 so that the remote revision is higher
|
// revision is -1 so that the remote revision is higher
|
||||||
const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 };
|
const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalised rundown data
|
||||||
|
*/
|
||||||
export default function useRundown() {
|
export default function useRundown() {
|
||||||
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
|
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
|
||||||
queryKey: RUNDOWN,
|
queryKey: RUNDOWN,
|
||||||
@@ -24,6 +27,10 @@ export default function useRundown() {
|
|||||||
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
|
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides access to a flat rundown
|
||||||
|
* built from the order and rundown fields
|
||||||
|
*/
|
||||||
export function useFlatRundown() {
|
export function useFlatRundown() {
|
||||||
const { data, status } = useRundown();
|
const { data, status } = useRundown();
|
||||||
const { data: projectData } = useProjectData();
|
const { data: projectData } = useProjectData();
|
||||||
@@ -34,7 +41,7 @@ export function useFlatRundown() {
|
|||||||
|
|
||||||
// update data whenever the revision changes
|
// update data whenever the revision changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data.revision !== -1 && data.revision !== prevRevision) {
|
if (data.revision !== -1 || data.revision !== prevRevision) {
|
||||||
const flatRundown = data.order.map((id) => data.rundown[id]);
|
const flatRundown = data.order.map((id) => data.rundown[id]);
|
||||||
setFlatRunDown(flatRundown);
|
setFlatRunDown(flatRundown);
|
||||||
setPrevRevision(data.revision);
|
setPrevRevision(data.revision);
|
||||||
@@ -52,3 +59,15 @@ export function useFlatRundown() {
|
|||||||
|
|
||||||
return { data: flatRunDown, status };
|
return { data: flatRunDown, status };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides access to a partial rundown based on a filter callback
|
||||||
|
*/
|
||||||
|
export function usePartialRundown(cb: (event: OntimeRundownEntry) => boolean) {
|
||||||
|
const { data, status } = useFlatRundown();
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
return data.filter(cb);
|
||||||
|
}, [data, cb]);
|
||||||
|
|
||||||
|
return { data: filteredData, status };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { useShallow } from 'zustand/shallow';
|
||||||
|
|
||||||
import { useClientStore } from '../stores/clientStore';
|
import { useClientStore } from '../stores/clientStore';
|
||||||
import { socketSendJson } from '../utils/socket';
|
import { socketSendJson } from '../utils/socket';
|
||||||
@@ -9,10 +10,12 @@ import { useIsOnline } from './useSocket';
|
|||||||
export const useClientPath = () => {
|
export const useClientPath = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pathname, search } = useLocation();
|
const { pathname, search } = useLocation();
|
||||||
const { redirect, setRedirect } = useClientStore((store) => ({
|
const { redirect, setRedirect } = useClientStore(
|
||||||
redirect: store.redirect,
|
useShallow((store) => ({
|
||||||
setRedirect: store.setRedirect,
|
redirect: store.redirect,
|
||||||
}));
|
setRedirect: store.setRedirect,
|
||||||
|
})),
|
||||||
|
);
|
||||||
const isOnline = useIsOnline();
|
const isOnline = useIsOnline();
|
||||||
|
|
||||||
// notify of client path changes
|
// notify of client path changes
|
||||||
|
|||||||
@@ -55,6 +55,17 @@ export const useEventAction = () => {
|
|||||||
defaultEndAction,
|
defaultEndAction,
|
||||||
} = useEditorSettings();
|
} = useEditorSettings();
|
||||||
|
|
||||||
|
const getEventById = useCallback(
|
||||||
|
(eventId: string) => {
|
||||||
|
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
|
||||||
|
if (!cachedRundown?.rundown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return cachedRundown.rundown[eventId];
|
||||||
|
},
|
||||||
|
[queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calls mutation to add new event
|
* Calls mutation to add new event
|
||||||
* @private
|
* @private
|
||||||
@@ -615,6 +626,7 @@ export const useEventAction = () => {
|
|||||||
batchUpdateEvents,
|
batchUpdateEvents,
|
||||||
deleteEvent,
|
deleteEvent,
|
||||||
deleteAllEvents,
|
deleteAllEvents,
|
||||||
|
getEventById,
|
||||||
reorderEvent,
|
reorderEvent,
|
||||||
swapEvents,
|
swapEvents,
|
||||||
updateEvent,
|
updateEvent,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { throttle } from '../../common/utils/throttle';
|
import { throttle } from '../utils/throttle';
|
||||||
|
|
||||||
|
|
||||||
export const useFadeOutOnInactivity = () => {
|
export const useFadeOutOnInactivity = () => {
|
||||||
const [isMouseMoved, setIsMouseMoved] = useState(false);
|
const [isMouseMoved, setIsMouseMoved] = useState(false);
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description utility hook to around setInterval
|
|
||||||
* @param callback
|
|
||||||
* @param delay
|
|
||||||
*/
|
|
||||||
export const useInterval = (callback, delay) => {
|
|
||||||
const savedCallback = useRef();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
savedCallback.current = callback;
|
|
||||||
}, [callback]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
/**
|
|
||||||
* @description function to be called
|
|
||||||
*/
|
|
||||||
function tick() {
|
|
||||||
savedCallback.current();
|
|
||||||
}
|
|
||||||
if (delay !== null) {
|
|
||||||
const id = setInterval(tick, delay);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}
|
|
||||||
}, [delay]);
|
|
||||||
};
|
|
||||||
@@ -1,75 +1,56 @@
|
|||||||
import { RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
|
import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
|
||||||
|
|
||||||
import { useRuntimeStore } from '../stores/runtime';
|
import { useRuntimeStore } from '../stores/runtime';
|
||||||
import { socketSendJson } from '../utils/socket';
|
import { socketSendJson } from '../utils/socket';
|
||||||
|
|
||||||
|
const createSelector =
|
||||||
|
<T>(selector: (state: RuntimeStore) => T) =>
|
||||||
|
() =>
|
||||||
|
useRuntimeStore(selector);
|
||||||
|
|
||||||
export const setClientRemote = {
|
export const setClientRemote = {
|
||||||
setIdentify: (payload: { target: string; identify: boolean }) => socketSendJson('client', payload),
|
setIdentify: (payload: { target: string; identify: boolean }) => socketSendJson('client', payload),
|
||||||
setRedirect: (payload: { target: string; redirect: string }) => socketSendJson('client', payload),
|
setRedirect: (payload: { target: string; redirect: string }) => socketSendJson('client', payload),
|
||||||
setClientName: (payload: { target: string; rename: string }) => socketSendJson('client', payload),
|
setClientName: (payload: { target: string; rename: string }) => socketSendJson('client', payload),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useRundownEditor = () => {
|
export const useRundownEditor = createSelector((state: RuntimeStore) => ({
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
playback: state.timer.playback,
|
||||||
playback: state.timer.playback,
|
selectedEventId: state.eventNow?.id ?? null,
|
||||||
selectedEventId: state.eventNow?.id ?? null,
|
nextEventId: state.eventNext?.id ?? null,
|
||||||
nextEventId: state.eventNext?.id ?? null,
|
}));
|
||||||
});
|
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
export const useOperator = createSelector((state: RuntimeStore) => ({
|
||||||
};
|
playback: state.timer.playback,
|
||||||
|
selectedEventId: state.eventNow?.id ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
export const useOperator = () => {
|
export const useTimerViewControl = createSelector((state: RuntimeStore) => ({
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
blackout: state.message.timer.blackout,
|
||||||
playback: state.timer.playback,
|
blink: state.message.timer.blink,
|
||||||
selectedEventId: state.eventNow?.id ?? null,
|
secondarySource: state.message.timer.secondarySource,
|
||||||
});
|
}));
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
export const useTimerMessageInput = createSelector((state: RuntimeStore) => ({
|
||||||
};
|
text: state.message.timer.text,
|
||||||
|
visible: state.message.timer.visible,
|
||||||
|
}));
|
||||||
|
|
||||||
export const useTimerViewControl = () => {
|
export const useExternalMessageInput = createSelector((state: RuntimeStore) => ({
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
text: state.message.external,
|
||||||
blackout: state.message.timer.blackout,
|
visible: state.message.timer.secondarySource === 'external',
|
||||||
blink: state.message.timer.blink,
|
}));
|
||||||
secondarySource: state.message.timer.secondarySource,
|
|
||||||
});
|
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
export const useMessagePreview = createSelector((state: RuntimeStore) => ({
|
||||||
};
|
blink: state.message.timer.blink,
|
||||||
|
blackout: state.message.timer.blackout,
|
||||||
export const useTimerMessageInput = () => {
|
phase: state.timer.phase,
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
showAuxTimer: state.message.timer.secondarySource === 'aux',
|
||||||
text: state.message.timer.text,
|
showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external),
|
||||||
visible: state.message.timer.visible,
|
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
|
||||||
});
|
timerType: state.eventNow?.timerType ?? null,
|
||||||
|
countToEnd: state.eventNow?.countToEnd ?? false,
|
||||||
return useRuntimeStore(featureSelector);
|
}));
|
||||||
};
|
|
||||||
|
|
||||||
export const useExternalMessageInput = () => {
|
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
|
||||||
text: state.message.external,
|
|
||||||
visible: state.message.timer.secondarySource === 'external',
|
|
||||||
});
|
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useMessagePreview = () => {
|
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
|
||||||
blink: state.message.timer.blink,
|
|
||||||
blackout: state.message.timer.blackout,
|
|
||||||
phase: state.timer.phase,
|
|
||||||
showAuxTimer: state.message.timer.secondarySource === 'aux',
|
|
||||||
showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external),
|
|
||||||
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
|
|
||||||
timerType: state.eventNow?.timerType ?? null,
|
|
||||||
countToEnd: state.eventNow?.countToEnd ?? false,
|
|
||||||
});
|
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setMessage = {
|
export const setMessage = {
|
||||||
timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }),
|
timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }),
|
||||||
@@ -81,16 +62,12 @@ export const setMessage = {
|
|||||||
socketSendJson('message', { timer: { secondarySource: payload } }),
|
socketSendJson('message', { timer: { secondarySource: payload } }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const usePlaybackControl = () => {
|
export const usePlaybackControl = createSelector((state: RuntimeStore) => ({
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
playback: state.timer.playback,
|
||||||
playback: state.timer.playback,
|
selectedEventIndex: state.runtime.selectedEventIndex,
|
||||||
selectedEventIndex: state.runtime.selectedEventIndex,
|
numEvents: state.runtime.numEvents,
|
||||||
numEvents: state.runtime.numEvents,
|
timerPhase: state.timer.phase,
|
||||||
timerPhase: state.timer.phase,
|
}));
|
||||||
});
|
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setPlayback = {
|
export const setPlayback = {
|
||||||
start: () => socketSendJson('start'),
|
start: () => socketSendJson('start'),
|
||||||
@@ -114,32 +91,20 @@ export const setPlayback = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useInfoPanel = () => {
|
export const useInfoPanel = createSelector((state: RuntimeStore) => ({
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
eventNow: state.eventNow,
|
||||||
eventNow: state.eventNow,
|
eventNext: state.eventNext,
|
||||||
eventNext: state.eventNext,
|
playback: state.timer.playback,
|
||||||
playback: state.timer.playback,
|
selectedEventIndex: state.runtime.selectedEventIndex,
|
||||||
selectedEventIndex: state.runtime.selectedEventIndex,
|
numEvents: state.runtime.numEvents,
|
||||||
numEvents: state.runtime.numEvents,
|
}));
|
||||||
});
|
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
export const useAuxTimerTime = createSelector((state: RuntimeStore) => state.auxtimer1.current);
|
||||||
};
|
|
||||||
|
|
||||||
export const useAuxTimerTime = () => {
|
export const useAuxTimerControl = createSelector((state: RuntimeStore) => ({
|
||||||
const featureSelector = (state: RuntimeStore) => state.auxtimer1.current;
|
playback: state.auxtimer1.playback,
|
||||||
|
direction: state.auxtimer1.direction,
|
||||||
return useRuntimeStore(featureSelector);
|
}));
|
||||||
};
|
|
||||||
|
|
||||||
export const useAuxTimerControl = () => {
|
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
|
||||||
playback: state.auxtimer1.playback,
|
|
||||||
direction: state.auxtimer1.direction,
|
|
||||||
});
|
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setAuxTimer = {
|
export const setAuxTimer = {
|
||||||
start: () => socketSendJson('auxtimer', { '1': SimplePlayback.Start }),
|
start: () => socketSendJson('auxtimer', { '1': SimplePlayback.Start }),
|
||||||
@@ -149,20 +114,13 @@ export const setAuxTimer = {
|
|||||||
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
|
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useSelectedEventId = () => {
|
export const useSelectedEventId = createSelector((state: RuntimeStore) => ({
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
selectedEventId: state.eventNow?.id ?? null,
|
||||||
selectedEventId: state.eventNow?.id ?? null,
|
}));
|
||||||
});
|
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
export const useCurrentBlockId = createSelector((state: RuntimeStore) => ({
|
||||||
};
|
currentBlockId: state.currentBlock.block?.id ?? null,
|
||||||
|
}));
|
||||||
export const useCurrentBlockId = () => {
|
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
|
||||||
currentBlockId: state.currentBlock.block?.id ?? null,
|
|
||||||
});
|
|
||||||
return useRuntimeStore(featureSelector);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setEventPlayback = {
|
export const setEventPlayback = {
|
||||||
loadEvent: (id: string) => socketSendJson('load', { id }),
|
loadEvent: (id: string) => socketSendJson('load', { id }),
|
||||||
@@ -171,81 +129,78 @@ export const setEventPlayback = {
|
|||||||
pause: () => socketSendJson('pause'),
|
pause: () => socketSendJson('pause'),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useTimer = () => {
|
export const useTimer = createSelector((state: RuntimeStore) => ({
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
...state.timer,
|
||||||
...state.timer,
|
}));
|
||||||
});
|
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
export const useClock = createSelector((state: RuntimeStore) => ({
|
||||||
};
|
clock: state.clock,
|
||||||
|
}));
|
||||||
export const useClock = () => {
|
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
|
||||||
clock: state.clock,
|
|
||||||
});
|
|
||||||
return useRuntimeStore(featureSelector);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Used by the progress bar components */
|
/** Used by the progress bar components */
|
||||||
export const useProgressData = () => {
|
export const useProgressData = createSelector((state: RuntimeStore) => ({
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
current: state.timer.current,
|
||||||
current: state.timer.current,
|
duration: state.timer.duration,
|
||||||
duration: state.timer.duration,
|
timeWarning: state.eventNow?.timeWarning ?? null,
|
||||||
timeWarning: state.eventNow?.timeWarning ?? null,
|
timeDanger: state.eventNow?.timeDanger ?? null,
|
||||||
timeDanger: state.eventNow?.timeDanger ?? null,
|
}));
|
||||||
});
|
|
||||||
return useRuntimeStore(featureSelector);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setClientName = (newName: string) => socketSendJson('set-client-name', newName);
|
export const setClientName = (newName: string) => socketSendJson('set-client-name', newName);
|
||||||
|
|
||||||
export const useRuntimeOverview = () => {
|
export const useRuntimeOverview = createSelector((state: RuntimeStore) => ({
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
plannedStart: state.runtime.plannedStart,
|
||||||
plannedStart: state.runtime.plannedStart,
|
actualStart: state.runtime.actualStart,
|
||||||
actualStart: state.runtime.actualStart,
|
plannedEnd: state.runtime.plannedEnd,
|
||||||
plannedEnd: state.runtime.plannedEnd,
|
expectedEnd: state.runtime.expectedEnd,
|
||||||
expectedEnd: state.runtime.expectedEnd,
|
}));
|
||||||
});
|
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) => ({
|
||||||
};
|
playback: state.timer.playback,
|
||||||
|
clock: state.clock,
|
||||||
|
|
||||||
export const useRuntimePlaybackOverview = () => {
|
numEvents: state.runtime.numEvents,
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
selectedEventIndex: state.runtime.selectedEventIndex,
|
||||||
playback: state.timer.playback,
|
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
|
||||||
clock: state.clock,
|
|
||||||
|
|
||||||
numEvents: state.runtime.numEvents,
|
currentBlock: state.currentBlock,
|
||||||
selectedEventIndex: state.runtime.selectedEventIndex,
|
}));
|
||||||
offset: state.runtime.offset,
|
|
||||||
|
|
||||||
currentBlock: state.currentBlock,
|
export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
|
||||||
});
|
clock: state.clock,
|
||||||
|
offset: state.runtime.offset,
|
||||||
|
}));
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
export const useTimeUntilData = createSelector((state: RuntimeStore) => ({
|
||||||
};
|
clock: state.clock,
|
||||||
|
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 useTimelineStatus = () => {
|
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
offset: state.runtime.offset,
|
||||||
clock: state.clock,
|
}));
|
||||||
offset: state.runtime.offset,
|
|
||||||
});
|
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
export const usePing = createSelector((state: RuntimeStore) => ({
|
||||||
};
|
ping: state.ping,
|
||||||
|
}));
|
||||||
export const usePing = () => {
|
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
|
||||||
ping: state.ping,
|
|
||||||
});
|
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** convert ping into a derived value which changes less often */
|
/** convert ping into a derived value which changes less often */
|
||||||
export const useIsOnline = () => {
|
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) => ({
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
isOnline: state.ping > 0,
|
playback: state.timer.playback,
|
||||||
});
|
});
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
return useRuntimeStore(featureSelector);
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ export const projectDataPlaceholder: ProjectData = {
|
|||||||
backstageUrl: '',
|
backstageUrl: '',
|
||||||
backstageInfo: '',
|
backstageInfo: '',
|
||||||
projectLogo: null,
|
projectLogo: null,
|
||||||
|
custom: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
import { baseURI } from '../../externals';
|
||||||
|
|
||||||
export enum AppMode {
|
export enum AppMode {
|
||||||
Run = 'run',
|
Run = 'run',
|
||||||
Edit = 'edit',
|
Edit = 'edit',
|
||||||
Freeze = 'freeze',
|
Freeze = 'freeze',
|
||||||
}
|
}
|
||||||
|
|
||||||
const appModeKey = 'ontime-app-mode';
|
const appModeKey = `${baseURI}ontime-app-mode`;
|
||||||
|
|
||||||
function getModeFromSession() {
|
function getModeFromSession() {
|
||||||
return sessionStorage.getItem(appModeKey) === AppMode.Run ? AppMode.Run : AppMode.Edit;
|
return sessionStorage.getItem(appModeKey) === AppMode.Run ? AppMode.Run : AppMode.Edit;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { ClientList } from 'ontime-types';
|
import { ClientList } from 'ontime-types';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
import { baseURI } from '../../externals';
|
||||||
|
|
||||||
interface ClientStore {
|
interface ClientStore {
|
||||||
name?: string;
|
name?: string;
|
||||||
setName: (newValue: string) => void;
|
setName: (newValue: string) => void;
|
||||||
@@ -15,7 +17,7 @@ interface ClientStore {
|
|||||||
setClients: (clients: ClientList) => void;
|
setClients: (clients: ClientList) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientNameKey = 'ontime-client-name';
|
const clientNameKey = `${baseURI}ontime-client-name`;
|
||||||
|
|
||||||
function persistNameInStorage(newValue: string) {
|
function persistNameInStorage(newValue: string) {
|
||||||
localStorage.setItem(clientNameKey, newValue);
|
localStorage.setItem(clientNameKey, newValue);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
// eslint-disable-next-line simple-import-sort/imports
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
import { booleanFromLocalStorage } from '../utils/localStorage';
|
import { booleanFromLocalStorage } from '../utils/localStorage';
|
||||||
|
import { baseURI } from '../../externals';
|
||||||
|
|
||||||
enum LocalEventKeys {
|
enum LocalEventKeys {
|
||||||
Mirror = 'ontime-view-mirror',
|
Mirror = 'ontime-view-mirror',
|
||||||
@@ -12,11 +14,11 @@ type ViewOptionsStore = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useViewOptionsStore = create<ViewOptionsStore>()((set) => ({
|
export const useViewOptionsStore = create<ViewOptionsStore>()((set) => ({
|
||||||
mirror: booleanFromLocalStorage(LocalEventKeys.Mirror, false),
|
mirror: booleanFromLocalStorage(`${baseURI}${LocalEventKeys.Mirror}`, false),
|
||||||
toggleMirror: (newValue?: boolean) =>
|
toggleMirror: (newValue?: boolean) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const val = typeof newValue === 'undefined' ? !state.mirror : newValue;
|
const val = typeof newValue === 'undefined' ? !state.mirror : newValue;
|
||||||
localStorage.setItem(LocalEventKeys.Mirror, String(val));
|
localStorage.setItem(`${baseURI}${LocalEventKeys.Mirror}`, String(val));
|
||||||
return { mirror: val };
|
return { mirror: val };
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
|
|
||||||
exports[`cx() > ignores falsy values 1`] = `""`;
|
exports[`cx() > ignores falsy values 1`] = `""`;
|
||||||
|
|
||||||
exports[`cx() > merges styles 1`] = `"_test_98a1e0 _another_98a1e0"`;
|
exports[`cx() > merges styles 1`] = `"_test_d5d33f _another_d5d33f"`;
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { makeCSVFromArrayOfArrays } from '../csv';
|
||||||
|
|
||||||
|
describe('makeCSVFromArrayOfArrays()', () => {
|
||||||
|
it('joins an array of arrays with commas and newlines', () => {
|
||||||
|
const testdata = [['field'], ['after newline', 'after comma'], ['', 'after empty']];
|
||||||
|
expect(makeCSVFromArrayOfArrays(testdata)).toMatchInlineSnapshot(`
|
||||||
|
"field
|
||||||
|
after newline,after comma
|
||||||
|
,after empty
|
||||||
|
"
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { EndAction, EventCustomFields, OntimeEvent, SupportedEvent, TimerType } from 'ontime-types';
|
import { EndAction, EventCustomFields, OntimeEvent, SupportedEvent, TimerType, TimeStrategy } from 'ontime-types';
|
||||||
|
|
||||||
import { cloneEvent } from '../eventsManager';
|
import { cloneEvent } from '../eventsManager';
|
||||||
|
|
||||||
describe('cloneEvent()', () => {
|
describe('cloneEvent()', () => {
|
||||||
it('creates a stem from a given event', () => {
|
it('creates a stem from a given event', () => {
|
||||||
const original = {
|
const original: OntimeEvent = {
|
||||||
id: 'unique',
|
id: 'unique',
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
title: 'title',
|
title: 'title',
|
||||||
@@ -14,6 +14,9 @@ describe('cloneEvent()', () => {
|
|||||||
duration: 10,
|
duration: 10,
|
||||||
timeEnd: 10,
|
timeEnd: 10,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
|
linkStart: null,
|
||||||
|
countToEnd: false,
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
isPublic: false,
|
isPublic: false,
|
||||||
skip: false,
|
skip: false,
|
||||||
@@ -21,29 +24,39 @@ describe('cloneEvent()', () => {
|
|||||||
revision: 10,
|
revision: 10,
|
||||||
timeWarning: 120000,
|
timeWarning: 120000,
|
||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
custom: {
|
custom: {
|
||||||
lighting: '3',
|
lighting: '3',
|
||||||
} as EventCustomFields,
|
} as EventCustomFields,
|
||||||
} as OntimeEvent;
|
};
|
||||||
|
|
||||||
const cloned = cloneEvent(original);
|
const cloned = cloneEvent(original);
|
||||||
expect(cloned).not.toBe(original);
|
expect(cloned).not.toBe(original);
|
||||||
// @ts-expect-error -- safeguarding this
|
expect(cloned.custom).not.toBe(original.custom);
|
||||||
expect(cloned?.id).toBe(undefined);
|
|
||||||
expect(cloned.title).toBe(original.title);
|
expect(cloned).toMatchObject({
|
||||||
expect(cloned.note).toBe(original.note);
|
type: SupportedEvent.Event,
|
||||||
expect(cloned.endAction).toBe(original.endAction);
|
title: original.title,
|
||||||
expect(cloned.timerType).toBe(original.timerType);
|
note: original.note,
|
||||||
expect(cloned.timeStart).toBe(original.timeStart);
|
timeStart: original.timeStart,
|
||||||
expect(cloned.timeEnd).toBe(original.timeEnd);
|
duration: original.duration,
|
||||||
expect(cloned.duration).toBe(original.duration);
|
timeEnd: original.timeEnd,
|
||||||
expect(cloned.isPublic).toBe(original.isPublic);
|
timerType: original.timerType,
|
||||||
expect(cloned.skip).toBe(original.skip);
|
timeStrategy: original.timeStrategy,
|
||||||
expect(cloned.colour).toBe(original.colour);
|
countToEnd: original.countToEnd,
|
||||||
expect(cloned.type).toBe(SupportedEvent.Event);
|
linkStart: original.linkStart,
|
||||||
expect(cloned.revision).toBe(0);
|
endAction: original.endAction,
|
||||||
expect(cloned.timeWarning).toBe(original.timeWarning);
|
isPublic: original.isPublic,
|
||||||
expect(cloned.timeDanger).toBe(original.timeDanger);
|
skip: original.skip,
|
||||||
expect(cloned.custom).toStrictEqual({});
|
colour: original.colour,
|
||||||
|
revision: 0,
|
||||||
|
delay: original.delay,
|
||||||
|
dayOffset: original.dayOffset,
|
||||||
|
gap: 0,
|
||||||
|
timeWarning: original.timeWarning,
|
||||||
|
timeDanger: original.timeDanger,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { formatTime, nowInMillis } from '../time';
|
import { OffsetMode } from 'ontime-types';
|
||||||
|
import { dayInMs } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { calculateTimeUntilStart, formatTime, nowInMillis } from '../time';
|
||||||
|
|
||||||
describe('nowInMillis()', () => {
|
describe('nowInMillis()', () => {
|
||||||
it('should return the current time in milliseconds', () => {
|
it('should return the current time in milliseconds', () => {
|
||||||
@@ -38,3 +41,257 @@ describe('formatTime()', () => {
|
|||||||
expect(time).toStrictEqual('-01:00');
|
expect(time).toStrictEqual('-01:00');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('calculateTimeUntilStart()', () => {
|
||||||
|
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 = {
|
||||||
|
dayOffset: 0,
|
||||||
|
delay: 0,
|
||||||
|
currentDay: 0,
|
||||||
|
clock: 100,
|
||||||
|
actualStart: 100,
|
||||||
|
plannedStart: 0,
|
||||||
|
offset: 0,
|
||||||
|
offsetMode: OffsetMode.Relative,
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// the overlap will be pushed out to the expected available time
|
||||||
|
expect(calculateTimeUntilStart({ ...test, timeStart: 5, totalGap: -5 })).toBe(10);
|
||||||
|
|
||||||
|
test.clock = 105;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { stringify } from 'csv-stringify/browser/esm/sync';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Converts an array of arrays to a CSV file
|
||||||
|
* @param {string[][]} arrayOfArrays
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string {
|
||||||
|
return stringify(arrayOfArrays);
|
||||||
|
}
|
||||||
@@ -24,11 +24,11 @@ export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
|
|||||||
skip: event.skip,
|
skip: event.skip,
|
||||||
colour: event.colour,
|
colour: event.colour,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
delay: 0,
|
delay: event.delay, // the events will be collocated, so having the same metadata is a good start
|
||||||
dayOffset: 0,
|
dayOffset: event.dayOffset,
|
||||||
gap: 0,
|
gap: 0,
|
||||||
timeWarning: event.timeWarning,
|
timeWarning: event.timeWarning,
|
||||||
timeDanger: event.timeDanger,
|
timeDanger: event.timeDanger,
|
||||||
custom: {},
|
custom: { ...event.custom },
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Allows lazy evaluation and caching of a functions result
|
||||||
|
*/
|
||||||
|
export function lazyEvaluate<T>(fn: () => T): () => T {
|
||||||
|
let result: T | undefined;
|
||||||
|
return function () {
|
||||||
|
if (result === undefined) {
|
||||||
|
result = fn();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import { baseURI, serverURL } from '../../externals';
|
|||||||
* Open an external URLs: specifically for a electron / browser case
|
* Open an external URLs: specifically for a electron / browser case
|
||||||
* If electron: ask main process to call a new browser window
|
* If electron: ask main process to call a new browser window
|
||||||
* If browser: open in new tab
|
* If browser: open in new tab
|
||||||
* @param url
|
|
||||||
*/
|
*/
|
||||||
export function openLink(url: string) {
|
export function openLink(url: string) {
|
||||||
if (window.process?.type === 'renderer') {
|
if (window.process?.type === 'renderer') {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Log, RundownCached, RuntimeStore } from 'ontime-types';
|
import { Log, RundownCached, RuntimeStore } from 'ontime-types';
|
||||||
|
|
||||||
import { isProduction, websocketUrl } from '../../externals';
|
import { isProduction, websocketUrl } from '../../externals';
|
||||||
import { CLIENT_LIST, CUSTOM_FIELDS, RUNDOWN, RUNTIME } from '../api/constants';
|
import { CLIENT_LIST, CUSTOM_FIELDS, REPORT, RUNDOWN, RUNTIME } from '../api/constants';
|
||||||
import { invalidateAllCaches } from '../api/utils';
|
import { invalidateAllCaches } from '../api/utils';
|
||||||
import { ontimeQueryClient } from '../queryClient';
|
import { ontimeQueryClient } from '../queryClient';
|
||||||
import {
|
import {
|
||||||
@@ -34,20 +34,26 @@ export const connectSocket = () => {
|
|||||||
hasConnected = true;
|
hasConnected = true;
|
||||||
reconnectAttempts = 0;
|
reconnectAttempts = 0;
|
||||||
|
|
||||||
|
socketSendJson('set-client-patch', {
|
||||||
|
type: 'ontime',
|
||||||
|
origin: window.location.origin,
|
||||||
|
path: window.location.pathname + window.location.search,
|
||||||
|
});
|
||||||
|
setOnlineStatus(true);
|
||||||
|
|
||||||
if (preferredClientName) {
|
if (preferredClientName) {
|
||||||
socketSendJson('set-client-name', preferredClientName);
|
socketSendJson('set-client-name', preferredClientName);
|
||||||
}
|
}
|
||||||
|
|
||||||
socketSendJson('set-client-type', 'ontime');
|
|
||||||
setOnlineStatus(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
websocket.onclose = () => {
|
websocket.onclose = () => {
|
||||||
console.warn('WebSocket disconnected');
|
console.warn('WebSocket disconnected');
|
||||||
setOnlineStatus(false);
|
|
||||||
|
|
||||||
if (shouldReconnect) {
|
if (shouldReconnect) {
|
||||||
reconnectTimeout = setTimeout(() => {
|
reconnectTimeout = setTimeout(() => {
|
||||||
|
if (reconnectAttempts > 2) {
|
||||||
|
setOnlineStatus(false);
|
||||||
|
}
|
||||||
console.warn('WebSocket: attempting reconnect');
|
console.warn('WebSocket: attempting reconnect');
|
||||||
if (websocket && websocket.readyState === WebSocket.CLOSED) {
|
if (websocket && websocket.readyState === WebSocket.CLOSED) {
|
||||||
reconnectAttempts += 1;
|
reconnectAttempts += 1;
|
||||||
@@ -78,16 +84,14 @@ export const connectSocket = () => {
|
|||||||
updateDevTools({ ping: offset });
|
updateDevTools({ ping: offset });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'client-id': {
|
case 'client': {
|
||||||
if (typeof payload === 'string') {
|
if (typeof payload === 'object' || payload !== null) {
|
||||||
setClientId(payload);
|
if (payload.clientId && payload.clientName) {
|
||||||
}
|
setClientId(payload.clientId);
|
||||||
break;
|
if (!preferredClientName) {
|
||||||
}
|
setClientName(payload.clientName);
|
||||||
|
}
|
||||||
case 'client-name': {
|
}
|
||||||
if (typeof payload === 'string') {
|
|
||||||
setClientName(payload);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -196,19 +200,23 @@ export const connectSocket = () => {
|
|||||||
}
|
}
|
||||||
case 'ontime-refetch': {
|
case 'ontime-refetch': {
|
||||||
// the refetch message signals that the rundown has changed in the server side
|
// the refetch message signals that the rundown has changed in the server side
|
||||||
const { revision, reload } = payload;
|
const { reload, target } = payload;
|
||||||
const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1;
|
|
||||||
|
|
||||||
if (reload) {
|
if (reload) {
|
||||||
invalidateAllCaches();
|
invalidateAllCaches();
|
||||||
} else if (revision > currentRevision) {
|
} else if (target === 'RUNDOWN') {
|
||||||
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
|
const { revision } = payload;
|
||||||
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
|
const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1;
|
||||||
|
if (revision > currentRevision) {
|
||||||
|
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||||
|
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
|
||||||
|
}
|
||||||
|
} else if (target === 'REPORT') {
|
||||||
|
ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'ontime-flush': {
|
case 'ontime-flush': {
|
||||||
flushBatchUpdates()
|
flushBatchUpdates();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { MaybeNumber, Settings, TimeFormat } from 'ontime-types';
|
import { MaybeNumber, OffsetMode, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
|
||||||
import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
import { dayInMs, formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||||
|
|
||||||
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
|
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
|
||||||
import { APP_SETTINGS } from '../api/constants';
|
import { APP_SETTINGS } from '../api/constants';
|
||||||
|
import { useTimeUntilData } from '../hooks/useSocket';
|
||||||
import { ontimeQueryClient } from '../queryClient';
|
import { ontimeQueryClient } from '../queryClient';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,3 +126,89 @@ export function formatDuration(duration: number, hideSeconds = true): string {
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param totalGap accumulated gap from the current event
|
||||||
|
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export function useTimeUntilStart(
|
||||||
|
// typed like this to make it very clear what the data is
|
||||||
|
data: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'> & {
|
||||||
|
totalGap: number;
|
||||||
|
isLinkedToLoaded: boolean;
|
||||||
|
},
|
||||||
|
): number {
|
||||||
|
const { offset, clock, currentDay, offsetMode, actualStart, plannedStart } = useTimeUntilData();
|
||||||
|
return calculateTimeUntilStart({ ...data, currentDay, clock, offset, offsetMode, actualStart, plannedStart });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param currentDay the day offset of the urrently running event
|
||||||
|
* @param totalGap accumulated gap from the current event
|
||||||
|
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
|
||||||
|
* @param clock
|
||||||
|
* @param offset
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export function calculateTimeUntilStart(
|
||||||
|
data: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'> & {
|
||||||
|
currentDay: number;
|
||||||
|
totalGap: number;
|
||||||
|
isLinkedToLoaded: boolean;
|
||||||
|
clock: number;
|
||||||
|
offset: number;
|
||||||
|
offsetMode: OffsetMode;
|
||||||
|
actualStart: MaybeNumber;
|
||||||
|
plannedStart: MaybeNumber;
|
||||||
|
},
|
||||||
|
): number {
|
||||||
|
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;
|
||||||
|
|
||||||
|
const delayedStart = Math.max(0, timeStart + delay);
|
||||||
|
|
||||||
|
//The normalised start time of this event relative to the currently running event
|
||||||
|
const normalisedTimeStart = delayedStart + relativeDayOffset * dayInMs;
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherwise consume as much of the offset as possible with the gap
|
||||||
|
const offsetTimeUntilBufferedByGaps = offsetTimeUntil - totalGap;
|
||||||
|
return offsetTimeUntilBufferedByGaps;
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ export const buyMeACoffeeUrl = 'https://buymeacoffee.com/cpvalente';
|
|||||||
export const appVersion = version;
|
export const appVersion = version;
|
||||||
export const isProduction = import.meta.env.MODE === 'production';
|
export const isProduction = import.meta.env.MODE === 'production';
|
||||||
export const isDev = !isProduction;
|
export const isDev = !isProduction;
|
||||||
export const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
export const currentHostName = window.location.hostname;
|
||||||
|
export const isLocalhost = currentHostName === 'localhost' || currentHostName === '127.0.0.1';
|
||||||
export const isDockerImage = Boolean(import.meta.env.VITE_IS_DOCKER);
|
export const isDockerImage = Boolean(import.meta.env.VITE_IS_DOCKER);
|
||||||
export const isOntimeCloud = window.location.hostname.includes('cloud.getontime.no');
|
export const isOntimeCloud = currentHostName.includes('cloud.getontime.no');
|
||||||
|
|
||||||
// resolve entrypoint URLs
|
// resolve entrypoint URLs
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
|
import { IoClose } from 'react-icons/io5';
|
||||||
import { Button } from '@chakra-ui/react';
|
import { Button } from '@chakra-ui/react';
|
||||||
import { IoClose } from '@react-icons/all-files/io5/IoClose';
|
|
||||||
|
|
||||||
import style from './PanelContent.module.scss';
|
import style from './PanelContent.module.scss';
|
||||||
|
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ $inner-padding: 1rem;
|
|||||||
color: $muted-gray;
|
color: $muted-gray;
|
||||||
|
|
||||||
td {
|
td {
|
||||||
padding-block: 1rem;
|
padding-block: 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@@ -207,6 +207,9 @@ $inner-padding: 1rem;
|
|||||||
&.end {
|
&.end {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
&.apart {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes animloader {
|
@keyframes animloader {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
|
import { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
|
||||||
|
import { IoAdd } from 'react-icons/io5';
|
||||||
import { Button } from '@chakra-ui/react';
|
import { Button } from '@chakra-ui/react';
|
||||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
|
||||||
|
|
||||||
import { cx } from '../../../common/utils/styleUtils';
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
|
|
||||||
@@ -67,9 +67,17 @@ export function TableEmpty({ label, handleClick }: { label?: string; handleClick
|
|||||||
<tr className={style.empty}>
|
<tr className={style.empty}>
|
||||||
<td colSpan={99}>
|
<td colSpan={99}>
|
||||||
<div>{label ?? 'No data yet'}</div>
|
<div>{label ?? 'No data yet'}</div>
|
||||||
<Button onClick={handleClick} isDisabled={!handleClick} variant='ontime-filled' rightIcon={<IoAdd />} size='sm'>
|
{handleClick && (
|
||||||
New
|
<Button
|
||||||
</Button>
|
onClick={handleClick}
|
||||||
|
isDisabled={!handleClick}
|
||||||
|
variant='ontime-filled'
|
||||||
|
rightIcon={<IoAdd />}
|
||||||
|
size='sm'
|
||||||
|
>
|
||||||
|
New
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -105,8 +113,8 @@ export function BlockQuote({ children }: { children: ReactNode }) {
|
|||||||
return <blockquote className={style.blockquote}>{children}</blockquote>;
|
return <blockquote className={style.blockquote}>{children}</blockquote>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Error({ children }: { children: ReactNode }) {
|
export function Error({ children, className }: { children: ReactNode } & JSX.IntrinsicElements['div']) {
|
||||||
return <div className={style.fieldError}>{children}</div>;
|
return <div className={cx([style.fieldError, className])}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Divider() {
|
export function Divider() {
|
||||||
@@ -128,7 +136,7 @@ type AllowedInlineTags = 'div' | 'td';
|
|||||||
type InlineProps<C extends AllowedInlineTags> = {
|
type InlineProps<C extends AllowedInlineTags> = {
|
||||||
as?: C;
|
as?: C;
|
||||||
relation?: 'inner' | 'component' | 'section';
|
relation?: 'inner' | 'component' | 'section';
|
||||||
align?: 'start' | 'end';
|
align?: 'start' | 'end' | 'apart';
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||||
import {
|
import {
|
||||||
buyMeACoffeeUrl,
|
buyMeACoffeeUrl,
|
||||||
discordUrl,
|
discordUrl,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||||
import useAppVersion from '../../../../common/hooks-query/useAppVersion';
|
import useAppVersion from '../../../../common/hooks-query/useAppVersion';
|
||||||
import { appVersion } from '../../../../externals';
|
import { appVersion } from '../../../../externals';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|||||||
+14
-4
@@ -27,7 +27,8 @@
|
|||||||
.titleSection,
|
.titleSection,
|
||||||
.filterSection,
|
.filterSection,
|
||||||
.oscSection,
|
.oscSection,
|
||||||
.httpSection {
|
.httpSection,
|
||||||
|
.actionSection {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-gap: 0.5rem;
|
grid-gap: 0.5rem;
|
||||||
|
|
||||||
@@ -40,8 +41,10 @@
|
|||||||
.ruleSection,
|
.ruleSection,
|
||||||
.filterSection,
|
.filterSection,
|
||||||
.oscSection,
|
.oscSection,
|
||||||
.httpSection {
|
.httpSection,
|
||||||
label, div {
|
.actionSection {
|
||||||
|
label,
|
||||||
|
div {
|
||||||
// we use the div as non-interactive placeholder for button cells
|
// we use the div as non-interactive placeholder for button cells
|
||||||
// it needs to match the size of the label element
|
// it needs to match the size of the label element
|
||||||
font-size: calc(1rem - 3px);
|
font-size: calc(1rem - 3px);
|
||||||
@@ -51,7 +54,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.titleSection {
|
.titleSection {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -68,6 +70,14 @@
|
|||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actionSection {
|
||||||
|
grid-template-columns: auto 1fr 1fr auto;
|
||||||
|
|
||||||
|
.test {
|
||||||
|
grid-column: -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.outputCard {
|
.outputCard {
|
||||||
border-left: 0.25rem solid $gray-1200;
|
border-left: 0.25rem solid $gray-1200;
|
||||||
padding-left: 0.5rem;
|
padding-left: 0.5rem;
|
||||||
|
|||||||
+151
-104
@@ -1,23 +1,22 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
import { IoAdd, IoTrash } from 'react-icons/io5';
|
||||||
|
import { Button, IconButton, Input, Radio, RadioGroup, Select } from '@chakra-ui/react';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Automation,
|
||||||
AlertDescription,
|
AutomationDTO,
|
||||||
AlertIcon,
|
HTTPOutput,
|
||||||
Button,
|
isHTTPOutput,
|
||||||
IconButton,
|
isOntimeAction,
|
||||||
Input,
|
isOSCOutput,
|
||||||
Radio,
|
OntimeAction,
|
||||||
RadioGroup,
|
OSCOutput,
|
||||||
Select,
|
} from 'ontime-types';
|
||||||
} from '@chakra-ui/react';
|
|
||||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
|
||||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
|
||||||
import { Automation, AutomationDTO, HTTPOutput, isHTTPOutput, isOSCOutput, OSCOutput } from 'ontime-types';
|
|
||||||
|
|
||||||
import { addAutomation, editAutomation, testOutput } from '../../../../common/api/automation';
|
import { addAutomation, editAutomation, testOutput } from '../../../../common/api/automation';
|
||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
import Info from '../../../../common/components/info/Info';
|
||||||
|
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||||
import Tag from '../../../../common/components/tag/Tag';
|
import Tag from '../../../../common/components/tag/Tag';
|
||||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||||
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
|
||||||
@@ -27,10 +26,11 @@ import * as Panel from '../../panel-utils/PanelUtils';
|
|||||||
|
|
||||||
import TemplateInput from './template-input/TemplateInput';
|
import TemplateInput from './template-input/TemplateInput';
|
||||||
import { isAutomation, makeFieldList } from './automationUtils';
|
import { isAutomation, makeFieldList } from './automationUtils';
|
||||||
|
import OntimeActionForm from './OntimeActionForm';
|
||||||
|
|
||||||
import style from './AutomationForm.module.scss';
|
import style from './AutomationForm.module.scss';
|
||||||
|
|
||||||
const integrationsDocsUrl = 'https://docs.getontime.no/api/integrations/#using-variables-in-integrations';
|
const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation';
|
||||||
|
|
||||||
interface AutomationFormProps {
|
interface AutomationFormProps {
|
||||||
automation: Automation | AutomationDTO;
|
automation: Automation | AutomationDTO;
|
||||||
@@ -51,6 +51,7 @@ export default function AutomationForm(props: AutomationFormProps) {
|
|||||||
register,
|
register,
|
||||||
setError,
|
setError,
|
||||||
setFocus,
|
setFocus,
|
||||||
|
setValue,
|
||||||
formState: { errors, isSubmitting, isDirty, isValid },
|
formState: { errors, isSubmitting, isDirty, isValid },
|
||||||
} = useForm<AutomationDTO>({
|
} = useForm<AutomationDTO>({
|
||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
@@ -101,6 +102,11 @@ export default function AutomationForm(props: AutomationFormProps) {
|
|||||||
appendOutput({ type: 'http', url: '' });
|
appendOutput({ type: 'http', url: '' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddnewOntimeAction = () => {
|
||||||
|
// @ts-expect-error -- we dont want to choose an action
|
||||||
|
appendOutput({ type: 'ontime', action: undefined });
|
||||||
|
};
|
||||||
|
|
||||||
const handleTestOSCOutput = async (index: number) => {
|
const handleTestOSCOutput = async (index: number) => {
|
||||||
try {
|
try {
|
||||||
const values = getValues(`outputs.${index}`) as OSCOutput;
|
const values = getValues(`outputs.${index}`) as OSCOutput;
|
||||||
@@ -134,6 +140,19 @@ export default function AutomationForm(props: AutomationFormProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTestOntimeAction = async (index: number) => {
|
||||||
|
try {
|
||||||
|
const values = getValues(`outputs.${index}`) as OntimeAction;
|
||||||
|
// NOTE: there is no meaningful validation to do here, we let the server deal with the data
|
||||||
|
await testOutput({
|
||||||
|
...values,
|
||||||
|
type: 'ontime',
|
||||||
|
});
|
||||||
|
} catch (_error) {
|
||||||
|
/** we dont handle errors here */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onSubmit = async (values: AutomationDTO) => {
|
const onSubmit = async (values: AutomationDTO) => {
|
||||||
if (isAutomation(automation)) {
|
if (isAutomation(automation)) {
|
||||||
await handleEdit(automation.id, { id: automation.id, ...values });
|
await handleEdit(automation.id, { id: automation.id, ...values });
|
||||||
@@ -190,7 +209,7 @@ export default function AutomationForm(props: AutomationFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={style.innerSection}>
|
<div className={style.innerSection}>
|
||||||
<h3>Filters</h3>
|
<h3>Filters (optional)</h3>
|
||||||
<div className={style.ruleSection}>
|
<div className={style.ruleSection}>
|
||||||
<label>
|
<label>
|
||||||
Trigger outputs if
|
Trigger outputs if
|
||||||
@@ -205,74 +224,80 @@ export default function AutomationForm(props: AutomationFormProps) {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{fieldFilters.map((field, index) => (
|
{fieldFilters.map((field, index) => {
|
||||||
<div key={field.id} className={style.filterSection}>
|
const key = `filters.${index}.field.${field.id}`;
|
||||||
<label>
|
return (
|
||||||
Runtime data source
|
<div key={key} className={style.filterSection}>
|
||||||
<Select
|
<label>
|
||||||
{...register(`filters.${index}.field`, { required: { value: true, message: 'Required field' } })}
|
Runtime data source
|
||||||
size='sm'
|
<Select
|
||||||
variant='ontime'
|
{...register(`filters.${index}.field`, { required: { value: true, message: 'Required field' } })}
|
||||||
>
|
size='sm'
|
||||||
<option selected hidden disabled value=''>
|
variant='ontime'
|
||||||
Event field
|
>
|
||||||
</option>
|
<option selected hidden disabled value=''>
|
||||||
{fieldList.map(({ value, label }) => (
|
Event field
|
||||||
<option key={value} value={value}>
|
|
||||||
{label}
|
|
||||||
</option>
|
</option>
|
||||||
))}
|
{fieldList.map(({ value, label }, localIndex) => {
|
||||||
</Select>
|
const key = `filters.${index}.field.${localIndex}`;
|
||||||
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
|
return (
|
||||||
</label>
|
<option key={key} value={value}>
|
||||||
<label>
|
{label}
|
||||||
Matching condition
|
</option>
|
||||||
<Select
|
);
|
||||||
{...register(`filters.${index}.operator`, { required: { value: true, message: 'Required field' } })}
|
})}
|
||||||
size='sm'
|
</Select>
|
||||||
variant='ontime'
|
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
|
||||||
>
|
</label>
|
||||||
<option selected hidden disabled value=''>
|
<label>
|
||||||
Operator
|
Matching condition
|
||||||
</option>
|
<Select
|
||||||
<option value='equals'>equals</option>
|
{...register(`filters.${index}.operator`, { required: { value: true, message: 'Required field' } })}
|
||||||
<option value='not_equals'>not equals</option>
|
size='sm'
|
||||||
<option value='contains'>contains</option>
|
variant='ontime'
|
||||||
{/*
|
>
|
||||||
|
<option selected hidden disabled value=''>
|
||||||
|
Operator
|
||||||
|
</option>
|
||||||
|
<option value='equals'>equals</option>
|
||||||
|
<option value='not_equals'>not equals</option>
|
||||||
|
<option value='contains'>contains</option>
|
||||||
|
{/*
|
||||||
We dont currently offer a data source where these operators would make sense
|
We dont currently offer a data source where these operators would make sense
|
||||||
<option value='greater_than'>greater than</option>
|
<option value='greater_than'>greater than</option>
|
||||||
<option value='less_than'>less than</option>
|
<option value='less_than'>less than</option>
|
||||||
*/}
|
*/}
|
||||||
</Select>
|
</Select>
|
||||||
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
|
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Value to match
|
Value to match
|
||||||
<Input
|
<Input
|
||||||
{...register(`filters.${index}.value`)}
|
{...register(`filters.${index}.value`)}
|
||||||
variant='ontime-filled'
|
variant='ontime-filled'
|
||||||
size='sm'
|
|
||||||
placeholder='<empty / no value>'
|
|
||||||
autoComplete='off'
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div>
|
|
||||||
<span> </span>
|
|
||||||
<div>
|
|
||||||
<IconButton
|
|
||||||
aria-label='Delete'
|
|
||||||
icon={<IoTrash />}
|
|
||||||
variant='ontime-ghosted'
|
|
||||||
size='sm'
|
size='sm'
|
||||||
color='#FA5656' // $red-500
|
placeholder='<empty / no value>'
|
||||||
onClick={() => removeFilter(index)}
|
autoComplete='off'
|
||||||
isDisabled={false}
|
|
||||||
isLoading={false}
|
|
||||||
/>
|
/>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<span> </span>
|
||||||
|
<div>
|
||||||
|
<IconButton
|
||||||
|
aria-label='Delete'
|
||||||
|
icon={<IoTrash />}
|
||||||
|
variant='ontime-ghosted'
|
||||||
|
size='sm'
|
||||||
|
color='#FA5656' // $red-500
|
||||||
|
onClick={() => removeFilter(index)}
|
||||||
|
isDisabled={false}
|
||||||
|
isLoading={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
variant='ontime-subtle'
|
variant='ontime-subtle'
|
||||||
@@ -291,13 +316,10 @@ export default function AutomationForm(props: AutomationFormProps) {
|
|||||||
|
|
||||||
<div className={style.innerColumn}>
|
<div className={style.innerColumn}>
|
||||||
<h3>Outputs</h3>
|
<h3>Outputs</h3>
|
||||||
<Alert status='info' variant='ontime-on-dark-info'>
|
<Info>
|
||||||
<AlertIcon />
|
Automation outputs can be used to send data from Ontime to external software.
|
||||||
<AlertDescription>
|
<ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink>
|
||||||
Automation outputs can be used to send data from Ontime to external software.
|
</Info>
|
||||||
<ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
{fieldOutputs.map((output, index) => {
|
{fieldOutputs.map((output, index) => {
|
||||||
if (isOSCOutput(output)) {
|
if (isOSCOutput(output)) {
|
||||||
@@ -357,13 +379,13 @@ export default function AutomationForm(props: AutomationFormProps) {
|
|||||||
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
|
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Parameters
|
Arguments
|
||||||
<TemplateInput
|
<TemplateInput
|
||||||
{...register(`outputs.${index}.args`)}
|
{...register(`outputs.${index}.args`)}
|
||||||
|
value={output.args}
|
||||||
variant='ontime-filled'
|
variant='ontime-filled'
|
||||||
size='sm'
|
size='sm'
|
||||||
placeholder='1'
|
placeholder='1'
|
||||||
autoComplete='off'
|
|
||||||
/>
|
/>
|
||||||
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
|
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
|
||||||
</label>
|
</label>
|
||||||
@@ -380,8 +402,6 @@ export default function AutomationForm(props: AutomationFormProps) {
|
|||||||
size='sm'
|
size='sm'
|
||||||
onClick={() => removeOutput(index)}
|
onClick={() => removeOutput(index)}
|
||||||
color='#FA5656' // $red-500
|
color='#FA5656' // $red-500
|
||||||
isDisabled={false}
|
|
||||||
isLoading={false}
|
|
||||||
/>
|
/>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
</div>
|
</div>
|
||||||
@@ -429,8 +449,6 @@ export default function AutomationForm(props: AutomationFormProps) {
|
|||||||
size='sm'
|
size='sm'
|
||||||
onClick={() => removeOutput(index)}
|
onClick={() => removeOutput(index)}
|
||||||
color='#FA5656' // $red-500
|
color='#FA5656' // $red-500
|
||||||
isDisabled={false}
|
|
||||||
isLoading={false}
|
|
||||||
/>
|
/>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
</div>
|
</div>
|
||||||
@@ -438,30 +456,59 @@ export default function AutomationForm(props: AutomationFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isOntimeAction(output)) {
|
||||||
|
const rowErrors = errors.outputs?.[index] as
|
||||||
|
| {
|
||||||
|
action?: { message?: string };
|
||||||
|
time?: { message?: string };
|
||||||
|
text?: { message?: string };
|
||||||
|
visible?: { message?: string };
|
||||||
|
secondarySource?: { message?: string };
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
return (
|
||||||
|
<div key={output.id} className={style.outputCard}>
|
||||||
|
<Tag>Ontime action</Tag>
|
||||||
|
<OntimeActionForm
|
||||||
|
value={output.action}
|
||||||
|
index={index}
|
||||||
|
register={register}
|
||||||
|
rowErrors={rowErrors}
|
||||||
|
setValue={setValue}
|
||||||
|
>
|
||||||
|
<span> </span>
|
||||||
|
<Panel.InlineElements relation='inner'>
|
||||||
|
<Button size='sm' variant='ontime-ghosted-white' onClick={() => handleTestOntimeAction(index)}>
|
||||||
|
Test
|
||||||
|
</Button>
|
||||||
|
<IconButton
|
||||||
|
aria-label='Delete'
|
||||||
|
icon={<IoTrash />}
|
||||||
|
variant='ontime-ghosted'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => removeOutput(index)}
|
||||||
|
color='#FA5656' // $red-500
|
||||||
|
/>
|
||||||
|
</Panel.InlineElements>
|
||||||
|
</OntimeActionForm>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// there should be no other output types
|
// there should be no other output types
|
||||||
return null;
|
return null;
|
||||||
})}
|
})}
|
||||||
<Panel.InlineElements relation='inner'>
|
<Panel.InlineElements relation='inner'>
|
||||||
<Button
|
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleAddNewOSCOutput}>
|
||||||
variant='ontime-subtle'
|
|
||||||
rightIcon={<IoAdd />}
|
|
||||||
size='sm'
|
|
||||||
onClick={handleAddNewOSCOutput}
|
|
||||||
isDisabled={false}
|
|
||||||
isLoading={false}
|
|
||||||
>
|
|
||||||
OSC
|
OSC
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleAddNewHTTPOutput}>
|
||||||
variant='ontime-subtle'
|
|
||||||
rightIcon={<IoAdd />}
|
|
||||||
size='sm'
|
|
||||||
onClick={handleAddNewHTTPOutput}
|
|
||||||
isDisabled={false}
|
|
||||||
isLoading={false}
|
|
||||||
>
|
|
||||||
HTTP
|
HTTP
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleAddnewOntimeAction}>
|
||||||
|
Ontime action
|
||||||
|
</Button>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+13
-10
@@ -1,11 +1,13 @@
|
|||||||
import { Controller, useForm } from 'react-hook-form';
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
import { Alert, AlertDescription, AlertIcon, Button, Input, Switch } from '@chakra-ui/react';
|
import { Button, Input, Switch } from '@chakra-ui/react';
|
||||||
|
|
||||||
import { editAutomationSettings } from '../../../../common/api/automation';
|
import { editAutomationSettings } from '../../../../common/api/automation';
|
||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
import Info from '../../../../common/components/info/Info';
|
||||||
|
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||||
import { isOnlyNumbers } from '../../../../common/utils/regex';
|
import { isOnlyNumbers } from '../../../../common/utils/regex';
|
||||||
|
import { isOntimeCloud } from '../../../../externals';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
const oscApiDocsUrl = 'https://docs.getontime.no/api/protocols/osc/';
|
const oscApiDocsUrl = 'https://docs.getontime.no/api/protocols/osc/';
|
||||||
@@ -75,14 +77,13 @@ export default function AutomationSettingsForm(props: AutomationSettingsProps) {
|
|||||||
<Panel.Divider />
|
<Panel.Divider />
|
||||||
|
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Alert status='info' variant='ontime-on-dark-info'>
|
<Info>
|
||||||
<AlertIcon />
|
<p>Control Ontime and share its data with external systems in your workflow.</p>
|
||||||
<AlertDescription>
|
<p>- Automations allow Ontime to send its data on lifecycle triggers.</p>
|
||||||
Control Ontime and share its data with external systems in your workflow. <br />
|
<p>- OSC Input tells Ontime to listen to messages on the specific port.</p>
|
||||||
- Automations allow Ontime to send its data on lifecycle triggers. <br />- OSC Input tells Ontime to listen
|
<br />
|
||||||
to messages on the specific port. <ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
|
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
|
||||||
</AlertDescription>
|
</Info>
|
||||||
</Alert>
|
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
|
|
||||||
<Panel.Section
|
<Panel.Section
|
||||||
@@ -112,7 +113,9 @@ export default function AutomationSettingsForm(props: AutomationSettingsProps) {
|
|||||||
</Panel.ListGroup>
|
</Panel.ListGroup>
|
||||||
|
|
||||||
<Panel.Title>OSC Input</Panel.Title>
|
<Panel.Title>OSC Input</Panel.Title>
|
||||||
|
|
||||||
<Panel.ListGroup>
|
<Panel.ListGroup>
|
||||||
|
{isOntimeCloud && <Info>For security reasons OSC integrations are not available in the cloud service.</Info>}
|
||||||
<Panel.ListItem>
|
<Panel.ListItem>
|
||||||
<Panel.Field
|
<Panel.Field
|
||||||
title='OSC input'
|
title='OSC input'
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Fragment, useState } from 'react';
|
import { Fragment, useState } from 'react';
|
||||||
|
import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5';
|
||||||
import { Button, IconButton } from '@chakra-ui/react';
|
import { Button, IconButton } from '@chakra-ui/react';
|
||||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
|
||||||
import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
|
|
||||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
|
||||||
import { AutomationDTO, NormalisedAutomation } from 'ontime-types';
|
import { AutomationDTO, NormalisedAutomation } from 'ontime-types';
|
||||||
|
|
||||||
import { deleteAutomation } from '../../../../common/api/automation';
|
import { deleteAutomation } from '../../../../common/api/automation';
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { PropsWithChildren, useState } from 'react';
|
||||||
|
import { UseFormRegister, UseFormSetValue } from 'react-hook-form';
|
||||||
|
import { Input, Select } from '@chakra-ui/react';
|
||||||
|
import { AutomationDTO, OntimeAction } from 'ontime-types';
|
||||||
|
|
||||||
|
import { cx } from '../../../../common/utils/styleUtils';
|
||||||
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
|
import style from './AutomationForm.module.scss';
|
||||||
|
|
||||||
|
interface OntimeActionFormProps {
|
||||||
|
index: number;
|
||||||
|
register: UseFormRegister<AutomationDTO>;
|
||||||
|
rowErrors?: {
|
||||||
|
action?: { message?: string };
|
||||||
|
time?: { message?: string };
|
||||||
|
text?: { message?: string };
|
||||||
|
visible?: { message?: string };
|
||||||
|
secondarySource?: { message?: string };
|
||||||
|
};
|
||||||
|
value: OntimeAction['action'];
|
||||||
|
setValue: UseFormSetValue<AutomationDTO>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OntimeActionForm(props: PropsWithChildren<OntimeActionFormProps>) {
|
||||||
|
const { index, register, setValue, rowErrors, value, children } = props;
|
||||||
|
const [selectedAction, setSelectedAction] = useState<OntimeAction['action']>(value || 'aux-start');
|
||||||
|
|
||||||
|
const updateSelectedAction = (value: string) => {
|
||||||
|
setSelectedAction(value as OntimeAction['action']);
|
||||||
|
setValue(`outputs.${index}.action`, value as OntimeAction['action']);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cx([style.actionSection, selectedAction && style[selectedAction]])}>
|
||||||
|
<input type='hidden' {...register(`outputs.${index}.action`)} value={selectedAction} />
|
||||||
|
<label>
|
||||||
|
Action
|
||||||
|
<Select
|
||||||
|
variant='ontime'
|
||||||
|
size='sm'
|
||||||
|
value={selectedAction}
|
||||||
|
onChange={(event) => updateSelectedAction(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value='aux-start'>Auxiliary timer: start</option>
|
||||||
|
<option value='aux-pause'>Auxiliary timer: pause</option>
|
||||||
|
<option value='aux-stop'>Auxiliary timer: stop</option>
|
||||||
|
<option value='aux-set'>Auxiliary timer: set</option>
|
||||||
|
<option value='message-set'>Timer: timer message</option>
|
||||||
|
<option value='message-secondary'>Timer: timer secondary</option>
|
||||||
|
</Select>
|
||||||
|
<Panel.Error>{rowErrors?.action?.message}</Panel.Error>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{selectedAction === 'aux-set' && (
|
||||||
|
<label>
|
||||||
|
New time
|
||||||
|
<Input
|
||||||
|
{...register(`outputs.${index}.time`, {
|
||||||
|
required: { value: true, message: 'Required field' },
|
||||||
|
})}
|
||||||
|
variant='ontime-filled'
|
||||||
|
size='sm'
|
||||||
|
placeholder='eg: 10m5s'
|
||||||
|
autoComplete='off'
|
||||||
|
/>
|
||||||
|
<Panel.Error>{rowErrors?.time?.message}</Panel.Error>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedAction === 'message-set' && (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
Text (leave empty for no change)
|
||||||
|
<Input
|
||||||
|
{...register(`outputs.${index}.text`)}
|
||||||
|
variant='ontime-filled'
|
||||||
|
size='sm'
|
||||||
|
placeholder='eg: Timer is finished'
|
||||||
|
autoComplete='off'
|
||||||
|
/>
|
||||||
|
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Visibility
|
||||||
|
<Select variant='ontime' size='sm' {...register(`outputs.${index}.visible`)}>
|
||||||
|
<option value=''>Untouched</option>
|
||||||
|
<option value='true'>Show</option>
|
||||||
|
<option value='false'>Hide</option>
|
||||||
|
</Select>
|
||||||
|
<Panel.Error>{rowErrors?.visible?.message}</Panel.Error>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedAction === 'message-secondary' && (
|
||||||
|
<label>
|
||||||
|
Timer secondary source
|
||||||
|
<Select variant='ontime' size='sm' {...register(`outputs.${index}.secondarySource`)}>
|
||||||
|
<option value='aux'>Auxiliary timer</option>
|
||||||
|
<option value='external'>External</option>
|
||||||
|
<option value='null'>None</option>
|
||||||
|
</Select>
|
||||||
|
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className={style.test}>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Fragment, useMemo, useState } from 'react';
|
import { Fragment, useMemo, useState } from 'react';
|
||||||
|
import { IoAdd } from 'react-icons/io5';
|
||||||
import { Button } from '@chakra-ui/react';
|
import { Button } from '@chakra-ui/react';
|
||||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
|
||||||
import { NormalisedAutomation, Trigger } from 'ontime-types';
|
import { NormalisedAutomation, Trigger } from 'ontime-types';
|
||||||
|
|
||||||
import { deleteTrigger } from '../../../../common/api/automation';
|
import { deleteTrigger } from '../../../../common/api/automation';
|
||||||
@@ -82,7 +82,7 @@ export default function TriggersList(props: TriggersListProps) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{!showForm && triggers.length === 0 && (
|
{!showForm && triggers.length === 0 && (
|
||||||
<Panel.TableEmpty
|
<Panel.TableEmpty
|
||||||
label='Create an automation before to attach triggers to'
|
label='Create an automation to attach triggers to'
|
||||||
handleClick={canAdd ? () => setShowForm(true) : undefined}
|
handleClick={canAdd ? () => setShowForm(true) : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { IoPencil, IoTrash, IoWarningOutline } from 'react-icons/io5';
|
||||||
import { IconButton } from '@chakra-ui/react';
|
import { IconButton } from '@chakra-ui/react';
|
||||||
import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
|
|
||||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
|
||||||
import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline';
|
|
||||||
import { NormalisedAutomation, TimerLifeCycle } from 'ontime-types';
|
import { NormalisedAutomation, TimerLifeCycle } from 'ontime-types';
|
||||||
|
|
||||||
import Tag from '../../../../common/components/tag/Tag';
|
import Tag from '../../../../common/components/tag/Tag';
|
||||||
|
|||||||
+13
-13
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { forwardRef, useMemo, useState } from 'react';
|
||||||
import { type InputProps, Input } from '@chakra-ui/react';
|
import { type InputProps, Input } from '@chakra-ui/react';
|
||||||
import { useClickOutside } from '@mantine/hooks';
|
import { mergeRefs, useClickOutside } from '@mantine/hooks';
|
||||||
|
|
||||||
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||||
|
|
||||||
@@ -10,10 +10,10 @@ import style from './TemplateInput.module.scss';
|
|||||||
|
|
||||||
interface TemplateInputProps extends InputProps {}
|
interface TemplateInputProps extends InputProps {}
|
||||||
|
|
||||||
export default function TemplateInput(props: TemplateInputProps) {
|
const TemplateInput = forwardRef(function TemplateInput(props: TemplateInputProps, ref) {
|
||||||
const { value, onChange, ...rest } = props;
|
const { value, onChange, ...rest } = props;
|
||||||
const { data } = useCustomFields();
|
const { data } = useCustomFields();
|
||||||
const ref = useClickOutside(() => setShowSuggestions(false));
|
const localRef = useClickOutside(() => setShowSuggestions(false));
|
||||||
|
|
||||||
const autocompleteList = useMemo(() => {
|
const autocompleteList = useMemo(() => {
|
||||||
return makeAutoCompleteList(data);
|
return makeAutoCompleteList(data);
|
||||||
@@ -31,15 +31,13 @@ export default function TemplateInput(props: TemplateInputProps) {
|
|||||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setInputValue(event.target.value);
|
setInputValue(event.target.value);
|
||||||
|
|
||||||
if (event.target.value.endsWith('{{')) {
|
if (event.target.value.endsWith('{')) {
|
||||||
setShowSuggestions(true);
|
setShowSuggestions(true);
|
||||||
|
setSuggestions(updateSuggestions(event.target.value));
|
||||||
} else if (event.target.value === '' || event.target.value.endsWith('}}')) {
|
} else if (event.target.value === '' || event.target.value.endsWith('}}')) {
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
}
|
} else if (showSuggestions) {
|
||||||
|
setSuggestions(updateSuggestions(event.target.value));
|
||||||
if (showSuggestions) {
|
|
||||||
const suggestions = updateSuggestions(event.target.value);
|
|
||||||
setSuggestions(suggestions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange?.(event);
|
onChange?.(event);
|
||||||
@@ -54,8 +52,8 @@ export default function TemplateInput(props: TemplateInputProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.wrapper} ref={ref}>
|
<div className={style.wrapper} ref={mergeRefs(localRef, ref)}>
|
||||||
<Input {...rest} value={inputValue} onChange={handleInputChange} autoComplete='off' autoCorrect='off' />
|
<Input value={inputValue} {...rest} onChange={handleInputChange} autoComplete='off' autoCorrect='off' />
|
||||||
{showSuggestions && suggestions.length > 0 && (
|
{showSuggestions && suggestions.length > 0 && (
|
||||||
<ul className={style.suggestions}>
|
<ul className={style.suggestions}>
|
||||||
{suggestions.map((suggestion) => (
|
{suggestions.map((suggestion) => (
|
||||||
@@ -67,4 +65,6 @@ export default function TemplateInput(props: TemplateInputProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export default TemplateInput;
|
||||||
|
|||||||
+2
-4
@@ -6,10 +6,8 @@ describe('matchRemaining()', () => {
|
|||||||
expect(matchRemaining('{{{hum', '{{human}}')).toBe('an}}');
|
expect(matchRemaining('{{{hum', '{{human}}')).toBe('an}}');
|
||||||
expect(matchRemaining('send {', '{{human}}')).toBe('{human}}');
|
expect(matchRemaining('send {', '{{human}}')).toBe('{human}}');
|
||||||
|
|
||||||
// we should be able to match the following
|
expect(matchRemaining('{', '{{human}}')).toBe('{human}}');
|
||||||
// however, the current implementation only needs to deal with strings that start with {{
|
expect(matchRemaining('{{', '{{human}}')).toBe('human}}');
|
||||||
// expect(matchRemaining('{', '{{human}}')).toBe('{human}}');
|
|
||||||
// expect(matchRemaining('{{', '{{human}}')).toBe('human}}');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return an empty string if there are no matches or if it is complete', () => {
|
it('should return an empty string if there are no matches or if it is complete', () => {
|
||||||
|
|||||||
+10
@@ -83,6 +83,16 @@ export function matchRemaining(a: string, b: string) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// naive match assuming that a template will start with {{
|
||||||
|
if (a.endsWith('{{') && b.startsWith('{{')) {
|
||||||
|
return b.substring(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// naive match assuming that a template will start with {
|
||||||
|
if (a.endsWith('{') && b.startsWith('{{')) {
|
||||||
|
return b.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < b.length; i++) {
|
for (let i = 0; i < b.length; i++) {
|
||||||
const searchString = b.substring(0, i + 1);
|
const searchString = b.substring(0, i + 1);
|
||||||
if (a.endsWith(searchString)) {
|
if (a.endsWith(searchString)) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Badge, Button, useDisclosure } from '@chakra-ui/react';
|
import { Badge, Button, useDisclosure } from '@chakra-ui/react';
|
||||||
|
import { Client } from 'ontime-types';
|
||||||
|
|
||||||
import { RedirectClientModal } from '../../../../common/components/client-modal/RedirectClientModal';
|
import { RedirectClientModal } from '../../../../common/components/client-modal/RedirectClientModal';
|
||||||
import { RenameClientModal } from '../../../../common/components/client-modal/RenameClientModal';
|
import { RenameClientModal } from '../../../../common/components/client-modal/RenameClientModal';
|
||||||
@@ -31,15 +32,16 @@ export default function ClientList() {
|
|||||||
const ontimeClients = Object.entries(clients).filter(([_, { type }]) => type === 'ontime');
|
const ontimeClients = Object.entries(clients).filter(([_, { type }]) => type === 'ontime');
|
||||||
const otherClients = Object.entries(clients).filter(([_, { type }]) => type !== 'ontime');
|
const otherClients = Object.entries(clients).filter(([_, { type }]) => type !== 'ontime');
|
||||||
|
|
||||||
const targetClient = clients[targetId];
|
const targetClient: Client | undefined = clients[targetId];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isOpenRedirect && (
|
{isOpenRedirect && targetClient !== undefined && (
|
||||||
<RedirectClientModal
|
<RedirectClientModal
|
||||||
id={targetId}
|
id={targetId}
|
||||||
name={targetClient?.name}
|
name={targetClient.name}
|
||||||
path={targetClient?.path}
|
origin={targetClient.origin}
|
||||||
|
currentPath={targetClient.path}
|
||||||
isOpen={isOpenRedirect}
|
isOpen={isOpenRedirect}
|
||||||
onClose={onCloseRedirect}
|
onClose={onCloseRedirect}
|
||||||
/>
|
/>
|
||||||
@@ -52,7 +54,7 @@ export default function ClientList() {
|
|||||||
<Panel.Table>
|
<Panel.Table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<td className={style.halfWidth}>Client Name (Connection ID)</td>
|
<td className={style.halfWidth}>Client Name</td>
|
||||||
<td className={style.fullWidth}>Path</td>
|
<td className={style.fullWidth}>Path</td>
|
||||||
<td />
|
<td />
|
||||||
</tr>
|
</tr>
|
||||||
@@ -64,9 +66,6 @@ export default function ClientList() {
|
|||||||
return (
|
return (
|
||||||
<tr key={key}>
|
<tr key={key}>
|
||||||
<Panel.InlineElements relation='inner' as='td'>
|
<Panel.InlineElements relation='inner' as='td'>
|
||||||
<Badge variant='outline' size='xs'>
|
|
||||||
{key}
|
|
||||||
</Badge>
|
|
||||||
{isCurrent && (
|
{isCurrent && (
|
||||||
<Badge variant='outline' colorScheme='yellow' size='xs'>
|
<Badge variant='outline' colorScheme='yellow' size='xs'>
|
||||||
self
|
self
|
||||||
@@ -119,7 +118,7 @@ export default function ClientList() {
|
|||||||
<Panel.Table>
|
<Panel.Table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<td className={style.halfWidthNoWrap}>Client Name (Connection ID)</td>
|
<td className={style.halfWidthNoWrap}>Client Name</td>
|
||||||
<td className={style.halfWidthNoWrap}>Client type</td>
|
<td className={style.halfWidthNoWrap}>Client type</td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -129,12 +128,7 @@ export default function ClientList() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={key}>
|
<tr key={key}>
|
||||||
<Panel.InlineElements relation='inner' as='td'>
|
<td>{name}</td>
|
||||||
<Badge variant='outline' size='sx'>
|
|
||||||
{key}
|
|
||||||
</Badge>
|
|
||||||
{name}
|
|
||||||
</Panel.InlineElements>
|
|
||||||
<td>{type}</td>
|
<td>{type}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user