mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-10 09:53:48 +00:00
Compare commits
110 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 |
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.14.2",
|
"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.14.2",
|
"version": "3.16.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -13,24 +13,26 @@
|
|||||||
"@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",
|
"@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": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
@@ -65,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",
|
||||||
@@ -83,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:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { IoArrowForward } from 'react-icons/io5';
|
||||||
import {
|
import {
|
||||||
IconButton,
|
IconButton,
|
||||||
Input,
|
Input,
|
||||||
@@ -10,7 +11,6 @@ import {
|
|||||||
ModalOverlay,
|
ModalOverlay,
|
||||||
Select,
|
Select,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { IoArrowForward } from '@react-icons/all-files/io5/IoArrowForward';
|
|
||||||
|
|
||||||
import { navigatorConstants } from '../../../viewerConfig';
|
import { navigatorConstants } from '../../../viewerConfig';
|
||||||
import { setClientRemote } from '../../hooks/useSocket';
|
import { setClientRemote } from '../../hooks/useSocket';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
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 { cx } from '../../utils/styleUtils';
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
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 { debounce } from '../../../utils/debounce';
|
import { debounce } from '../../../utils/debounce';
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
font-size: $text-body-size;
|
font-size: $text-body-size;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-underline-offset: 2px;
|
text-underline-offset: 2px;
|
||||||
|
color: $blue-400;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: $blue-400;
|
color: $blue-500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
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';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
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 '../../hooks/useFadeOutOnInactivity';
|
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
|
||||||
import { cx } from '../../utils/styleUtils';
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
|||||||
@@ -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,4 +1,4 @@
|
|||||||
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
|
import { IoLockClosedOutline } from 'react-icons/io5';
|
||||||
|
|
||||||
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
|
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
|
||||||
import { cx } from '../../utils/styleUtils';
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,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';
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ 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,
|
filterImageType = true,
|
||||||
) => {
|
) => {
|
||||||
const options = structuredClone(additionalOptions);
|
const options = { ...additionalOptions };
|
||||||
for (const [key, value] of Object.entries(customFields)) {
|
for (const [key, value] of Object.entries(customFields)) {
|
||||||
if (filterImageType && value.type === 'image') {
|
if (filterImageType && value.type === 'image') {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -41,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);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
|
import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
|
||||||
|
|
||||||
import { useRuntimeStore } from '../stores/runtime';
|
import { useRuntimeStore } from '../stores/runtime';
|
||||||
import { socketSendJson } from '../utils/socket';
|
import { socketSendJson } from '../utils/socket';
|
||||||
@@ -160,7 +160,7 @@ export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) =
|
|||||||
|
|
||||||
numEvents: state.runtime.numEvents,
|
numEvents: state.runtime.numEvents,
|
||||||
selectedEventIndex: state.runtime.selectedEventIndex,
|
selectedEventIndex: state.runtime.selectedEventIndex,
|
||||||
offset: state.runtime.offset,
|
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
|
||||||
|
|
||||||
currentBlock: state.currentBlock,
|
currentBlock: state.currentBlock,
|
||||||
}));
|
}));
|
||||||
@@ -172,8 +172,11 @@ export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
|
|||||||
|
|
||||||
export const useTimeUntilData = createSelector((state: RuntimeStore) => ({
|
export const useTimeUntilData = createSelector((state: RuntimeStore) => ({
|
||||||
clock: state.clock,
|
clock: state.clock,
|
||||||
offset: state.runtime.offset,
|
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
|
||||||
|
offsetMode: state.runtime.offsetMode,
|
||||||
currentDay: state.eventNow?.dayOffset ?? 0, //The day of the currently running event
|
currentDay: state.eventNow?.dayOffset ?? 0, //The day of the currently running event
|
||||||
|
actualStart: state.runtime.actualStart,
|
||||||
|
plannedStart: state.runtime.plannedStart,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
|
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
|
||||||
@@ -189,6 +192,12 @@ export const useIsOnline = createSelector((state: RuntimeStore) => ({
|
|||||||
isOnline: state.ping > 0,
|
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 = () => {
|
export const usePlayback = () => {
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
playback: state.timer.playback,
|
playback: state.timer.playback,
|
||||||
|
|||||||
@@ -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"`;
|
||||||
|
|||||||
@@ -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,3 +1,6 @@
|
|||||||
|
import { OffsetMode } from 'ontime-types';
|
||||||
|
import { dayInMs } from 'ontime-utils';
|
||||||
|
|
||||||
import { calculateTimeUntilStart, formatTime, nowInMillis } from '../time';
|
import { calculateTimeUntilStart, formatTime, nowInMillis } from '../time';
|
||||||
|
|
||||||
describe('nowInMillis()', () => {
|
describe('nowInMillis()', () => {
|
||||||
@@ -40,82 +43,255 @@ describe('formatTime()', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('calculateTimeUntilStart()', () => {
|
describe('calculateTimeUntilStart()', () => {
|
||||||
test('ontime', () => {
|
describe('Absolute offset mode', () => {
|
||||||
|
test('ontime', () => {
|
||||||
|
const test = {
|
||||||
|
timeStart: 100,
|
||||||
|
dayOffset: 0,
|
||||||
|
delay: 0,
|
||||||
|
currentDay: 0,
|
||||||
|
totalGap: 0,
|
||||||
|
clock: 90,
|
||||||
|
offset: 0,
|
||||||
|
offsetMode: OffsetMode.Absolute,
|
||||||
|
actualStart: null,
|
||||||
|
plannedStart: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(10);
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('running behind', () => {
|
||||||
|
const test = {
|
||||||
|
timeStart: 100,
|
||||||
|
dayOffset: 0,
|
||||||
|
delay: 0,
|
||||||
|
currentDay: 0,
|
||||||
|
totalGap: 0,
|
||||||
|
clock: 90,
|
||||||
|
offset: -20,
|
||||||
|
offsetMode: OffsetMode.Absolute,
|
||||||
|
actualStart: null,
|
||||||
|
plannedStart: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(30);
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('running ahead', () => {
|
||||||
|
const test = {
|
||||||
|
timeStart: 100,
|
||||||
|
dayOffset: 0,
|
||||||
|
delay: 0,
|
||||||
|
currentDay: 0,
|
||||||
|
totalGap: 0,
|
||||||
|
clock: 80,
|
||||||
|
offset: 10,
|
||||||
|
offsetMode: OffsetMode.Absolute,
|
||||||
|
actualStart: null,
|
||||||
|
plannedStart: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // <-- when running ahead the unlinked timer stays put
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('running behind with enough gaps', () => {
|
||||||
|
const test = {
|
||||||
|
timeStart: 100,
|
||||||
|
dayOffset: 0,
|
||||||
|
delay: 0,
|
||||||
|
currentDay: 0,
|
||||||
|
totalGap: 20,
|
||||||
|
clock: 50,
|
||||||
|
offset: -20,
|
||||||
|
offsetMode: OffsetMode.Absolute,
|
||||||
|
actualStart: null,
|
||||||
|
plannedStart: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(50); // <-- when gap is enough to compensate for the running behind
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
|
||||||
|
});
|
||||||
|
|
||||||
|
test('running behind with too little gaps', () => {
|
||||||
|
const test = {
|
||||||
|
timeStart: 100,
|
||||||
|
dayOffset: 0,
|
||||||
|
delay: 0,
|
||||||
|
currentDay: 0,
|
||||||
|
totalGap: 10,
|
||||||
|
clock: 50,
|
||||||
|
offset: -20,
|
||||||
|
offsetMode: OffsetMode.Absolute,
|
||||||
|
actualStart: 0,
|
||||||
|
plannedStart: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(60); // <-- when gap is not enough to compensate for the running behind it absorbs at much as possible
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Relative offset mode', () => {
|
||||||
|
test('basic function', () => {
|
||||||
|
const test = {
|
||||||
|
timeStart: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
delay: 0,
|
||||||
|
currentDay: 0,
|
||||||
|
totalGap: 0,
|
||||||
|
clock: 100,
|
||||||
|
actualStart: 100,
|
||||||
|
plannedStart: 0,
|
||||||
|
offset: 0,
|
||||||
|
offsetMode: OffsetMode.Relative,
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeStartEvent2 = 10;
|
||||||
|
const timeStartEvent3 = 20;
|
||||||
|
|
||||||
|
//event 1 is the currently running event
|
||||||
|
|
||||||
|
//event 2
|
||||||
|
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(10);
|
||||||
|
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(10);
|
||||||
|
|
||||||
|
//event 3
|
||||||
|
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(20);
|
||||||
|
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(20);
|
||||||
|
|
||||||
|
// When clock advances by 5ms, time until start should decrease by 5ms
|
||||||
|
test.clock = 105;
|
||||||
|
|
||||||
|
//event 2
|
||||||
|
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(5);
|
||||||
|
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(5);
|
||||||
|
|
||||||
|
//event 3
|
||||||
|
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(15);
|
||||||
|
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gaps', () => {
|
||||||
|
const test = {
|
||||||
|
timeStart: 20,
|
||||||
|
dayOffset: 0,
|
||||||
|
delay: 0,
|
||||||
|
currentDay: 0,
|
||||||
|
totalGap: 10,
|
||||||
|
clock: 100,
|
||||||
|
actualStart: 100,
|
||||||
|
plannedStart: 0,
|
||||||
|
offset: 0,
|
||||||
|
offsetMode: OffsetMode.Relative,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
|
||||||
|
|
||||||
|
// When clock advances by 5ms, time until start should decrease by 5ms
|
||||||
|
test.clock = 105;
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('added/remove time', () => {
|
||||||
|
const test = {
|
||||||
|
timeStart: 20,
|
||||||
|
dayOffset: 0,
|
||||||
|
delay: 0,
|
||||||
|
currentDay: 0,
|
||||||
|
totalGap: 0,
|
||||||
|
clock: 100,
|
||||||
|
actualStart: 100,
|
||||||
|
plannedStart: 0,
|
||||||
|
offset: 0,
|
||||||
|
offsetMode: OffsetMode.Relative,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
|
||||||
|
|
||||||
|
test.offset = 5; // remove 5 with addtime - we are ahead of time
|
||||||
|
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // unlocked evets will stay on schedule
|
||||||
|
|
||||||
|
test.offset = -5; // add 5 with addtime - we are behind
|
||||||
|
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(25);
|
||||||
|
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('next day', () => {
|
||||||
|
const test = {
|
||||||
|
delay: 0,
|
||||||
|
currentDay: 0,
|
||||||
|
clock: 100,
|
||||||
|
actualStart: 100,
|
||||||
|
plannedStart: 0,
|
||||||
|
offset: 0,
|
||||||
|
offsetMode: OffsetMode.Relative,
|
||||||
|
};
|
||||||
|
|
||||||
|
// this event will start the current day
|
||||||
|
expect(
|
||||||
|
calculateTimeUntilStart({
|
||||||
|
...test,
|
||||||
|
timeStart: 10,
|
||||||
|
dayOffset: 0,
|
||||||
|
totalGap: 0,
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
}),
|
||||||
|
).toBe(10);
|
||||||
|
|
||||||
|
// this event will start the next day
|
||||||
|
// in absolute mode this would start in dayInMs - 100 since the gap would compensate
|
||||||
|
// but in relative mode with and actual start that is 100 offset it starts in dayInMs
|
||||||
|
expect(
|
||||||
|
calculateTimeUntilStart({
|
||||||
|
...test,
|
||||||
|
timeStart: 0,
|
||||||
|
dayOffset: 1,
|
||||||
|
totalGap: dayInMs - 20,
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
}),
|
||||||
|
).toBe(dayInMs);
|
||||||
|
|
||||||
|
// advancing 100ms
|
||||||
|
test.clock = 200;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
calculateTimeUntilStart({
|
||||||
|
...test,
|
||||||
|
timeStart: 0,
|
||||||
|
dayOffset: 1,
|
||||||
|
totalGap: dayInMs - 20,
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
}),
|
||||||
|
).toBe(dayInMs - 100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overlap with negative total gap', () => {
|
||||||
const test = {
|
const test = {
|
||||||
timeStart: 100,
|
|
||||||
dayOffset: 0,
|
dayOffset: 0,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
currentDay: 0,
|
currentDay: 0,
|
||||||
totalGap: 0,
|
clock: 100,
|
||||||
clock: 90,
|
actualStart: 100,
|
||||||
|
plannedStart: 0,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
|
offsetMode: OffsetMode.Relative,
|
||||||
|
isLinkedToLoaded: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(10);
|
// the overlap will be pushed out to the expected available time
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
|
expect(calculateTimeUntilStart({ ...test, timeStart: 5, totalGap: -5 })).toBe(10);
|
||||||
|
|
||||||
|
test.clock = 105;
|
||||||
});
|
});
|
||||||
|
|
||||||
test('running behind', () => {
|
|
||||||
const test = {
|
|
||||||
timeStart: 100,
|
|
||||||
dayOffset: 0,
|
|
||||||
delay: 0,
|
|
||||||
currentDay: 0,
|
|
||||||
totalGap: 0,
|
|
||||||
clock: 90,
|
|
||||||
offset: -20,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(30);
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(30);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('running ahead', () => {
|
|
||||||
const test = {
|
|
||||||
timeStart: 100,
|
|
||||||
dayOffset: 0,
|
|
||||||
delay: 0,
|
|
||||||
currentDay: 0,
|
|
||||||
totalGap: 0,
|
|
||||||
clock: 80,
|
|
||||||
offset: 10,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // <-- when running ahead the unlinked timer stays put
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('running behind with enough gaps', () => {
|
|
||||||
const test = {
|
|
||||||
timeStart: 100,
|
|
||||||
dayOffset: 0,
|
|
||||||
delay: 0,
|
|
||||||
currentDay: 0,
|
|
||||||
totalGap: 20,
|
|
||||||
clock: 50,
|
|
||||||
offset: -20,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(50);
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
|
|
||||||
});
|
|
||||||
|
|
||||||
test('running behind with to little gaps', () => {
|
|
||||||
const test = {
|
|
||||||
timeStart: 100,
|
|
||||||
dayOffset: 0,
|
|
||||||
delay: 0,
|
|
||||||
currentDay: 0,
|
|
||||||
totalGap: 10,
|
|
||||||
clock: 50,
|
|
||||||
offset: -20,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(60);
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
|
|
||||||
});
|
|
||||||
|
|
||||||
//TODO: more indepth testing,
|
|
||||||
// including day offset handling
|
|
||||||
// and more?
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,8 @@
|
|||||||
|
import { MaybeString } from 'ontime-types';
|
||||||
|
|
||||||
|
export default function safeParseNumber(value: MaybeString, defaultValue: number = 0): number {
|
||||||
|
if (!value) return defaultValue;
|
||||||
|
const number = Number(value);
|
||||||
|
if (isNaN(number)) return defaultValue;
|
||||||
|
return number;
|
||||||
|
}
|
||||||
@@ -48,10 +48,12 @@ export const connectSocket = () => {
|
|||||||
|
|
||||||
websocket.onclose = () => {
|
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;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MaybeNumber, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
|
import { MaybeNumber, OffsetMode, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
|
||||||
import { dayInMs, formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
import { 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';
|
||||||
@@ -140,8 +140,8 @@ export function useTimeUntilStart(
|
|||||||
isLinkedToLoaded: boolean;
|
isLinkedToLoaded: boolean;
|
||||||
},
|
},
|
||||||
): number {
|
): number {
|
||||||
const { offset, clock, currentDay } = useTimeUntilData();
|
const { offset, clock, currentDay, offsetMode, actualStart, plannedStart } = useTimeUntilData();
|
||||||
return calculateTimeUntilStart({ ...data, currentDay, clock, offset });
|
return calculateTimeUntilStart({ ...data, currentDay, clock, offset, offsetMode, actualStart, plannedStart });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -160,9 +160,24 @@ export function calculateTimeUntilStart(
|
|||||||
isLinkedToLoaded: boolean;
|
isLinkedToLoaded: boolean;
|
||||||
clock: number;
|
clock: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
|
offsetMode: OffsetMode;
|
||||||
|
actualStart: MaybeNumber;
|
||||||
|
plannedStart: MaybeNumber;
|
||||||
},
|
},
|
||||||
): number {
|
): number {
|
||||||
const { timeStart, dayOffset, currentDay, totalGap, isLinkedToLoaded, clock, offset, delay } = data;
|
const {
|
||||||
|
timeStart,
|
||||||
|
dayOffset,
|
||||||
|
currentDay,
|
||||||
|
totalGap,
|
||||||
|
isLinkedToLoaded,
|
||||||
|
clock,
|
||||||
|
offset,
|
||||||
|
delay,
|
||||||
|
offsetMode,
|
||||||
|
actualStart,
|
||||||
|
plannedStart,
|
||||||
|
} = data;
|
||||||
|
|
||||||
//How many days from the currently running event to this one
|
//How many days from the currently running event to this one
|
||||||
const relativeDayOffset = dayOffset - currentDay;
|
const relativeDayOffset = dayOffset - currentDay;
|
||||||
@@ -172,20 +187,23 @@ export function calculateTimeUntilStart(
|
|||||||
//The normalised start time of this event relative to the currently running event
|
//The normalised start time of this event relative to the currently running event
|
||||||
const normalisedTimeStart = delayedStart + relativeDayOffset * dayInMs;
|
const normalisedTimeStart = delayedStart + relativeDayOffset * dayInMs;
|
||||||
|
|
||||||
const offsetTimestart = normalisedTimeStart - offset;
|
let relativeStartOffset = 0;
|
||||||
const offsetTimeUntil = offsetTimestart - clock;
|
|
||||||
|
if (offsetMode === OffsetMode.Relative) {
|
||||||
|
relativeStartOffset = (actualStart ?? 0) - (plannedStart ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduledTimeUntil = normalisedTimeStart - clock + relativeStartOffset;
|
||||||
|
|
||||||
|
const offsetTimeUntil = scheduledTimeUntil - offset;
|
||||||
|
|
||||||
if (isLinkedToLoaded) {
|
if (isLinkedToLoaded) {
|
||||||
//if we are directly linked back to the loaded event we just follow the offset
|
//if we are directly linked back to the loaded event we just follow the offset
|
||||||
return offsetTimeUntil;
|
return offsetTimeUntil;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduledTimeUntil = normalisedTimeStart - clock;
|
const gapsCanCompensateForOffset = totalGap + offset >= 0;
|
||||||
|
if (gapsCanCompensateForOffset) {
|
||||||
const isAheadOfSchedule = offset >= 0;
|
|
||||||
const gapsCanCompensadeForOffset = totalGap + offset >= 0;
|
|
||||||
|
|
||||||
if (isAheadOfSchedule || gapsCanCompensadeForOffset) {
|
|
||||||
// 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
|
// 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;
|
return scheduledTimeUntil;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -113,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() {
|
||||||
@@ -136,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,8 +1,7 @@
|
|||||||
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 { Button, IconButton, Input, Radio, RadioGroup, Select } from '@chakra-ui/react';
|
||||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
|
||||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
|
||||||
import {
|
import {
|
||||||
Automation,
|
Automation,
|
||||||
AutomationDTO,
|
AutomationDTO,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { IoTrashBin } from 'react-icons/io5';
|
||||||
import { Button } from '@chakra-ui/react';
|
import { Button } from '@chakra-ui/react';
|
||||||
import { IoTrashBin } from '@react-icons/all-files/io5/IoTrashBin';
|
|
||||||
|
|
||||||
import { deleteAllReport } from '../../../../common/api/report';
|
import { deleteAllReport } from '../../../../common/api/report';
|
||||||
import { createBlob, downloadBlob } from '../../../../common/api/utils';
|
import { createBlob, downloadBlob } from '../../../../common/api/utils';
|
||||||
|
|||||||
+1
-3
@@ -1,9 +1,7 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
import { IoAdd, IoOpenOutline, IoTrash } from 'react-icons/io5';
|
||||||
import { Button, IconButton, Input, Switch } from '@chakra-ui/react';
|
import { Button, IconButton, Input, Switch } from '@chakra-ui/react';
|
||||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
|
||||||
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
|
|
||||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
|
||||||
import { URLPreset } from 'ontime-types';
|
import { URLPreset } from 'ontime-types';
|
||||||
|
|
||||||
import { postUrlPresets } from '../../../../common/api/urlPresets';
|
import { postUrlPresets } from '../../../../common/api/urlPresets';
|
||||||
|
|||||||
+1
-2
@@ -1,7 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { IoPencil, IoTrash } 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 { CustomField, CustomFieldLabel } from 'ontime-types';
|
import { CustomField, CustomFieldLabel } from 'ontime-types';
|
||||||
|
|
||||||
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
|
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { 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 { CustomField, CustomFieldLabel } from 'ontime-types';
|
import { CustomField, CustomFieldLabel } from 'ontime-types';
|
||||||
|
|
||||||
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
|
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { PropsWithChildren, useState } from 'react';
|
import { PropsWithChildren, useState } from 'react';
|
||||||
import { UseFormRegister } from 'react-hook-form';
|
import { UseFormRegister } from 'react-hook-form';
|
||||||
|
import { IoEyeOutline } from 'react-icons/io5';
|
||||||
import { IconButton, Input, InputGroup, InputRightElement } from '@chakra-ui/react';
|
import { IconButton, Input, InputGroup, InputRightElement } from '@chakra-ui/react';
|
||||||
import { IoEyeOutline } from '@react-icons/all-files/io5/IoEyeOutline';
|
|
||||||
import { Settings } from 'ontime-types';
|
import { Settings } from 'ontime-types';
|
||||||
|
|
||||||
import { isAlphanumeric } from '../../../../common/utils/regex';
|
import { isAlphanumeric } from '../../../../common/utils/regex';
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.wrapper {
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { forwardRef, memo, useEffect, useImperativeHandle, useState } from 'react';
|
||||||
|
import Editor from 'react-simple-code-editor';
|
||||||
|
import Prism from 'prismjs/components/prism-core';
|
||||||
|
|
||||||
|
import 'prismjs/components/prism-css';
|
||||||
|
import 'prismjs/themes/prism-tomorrow.min.css';
|
||||||
|
import style from './StyleEditor.module.scss';
|
||||||
|
|
||||||
|
interface CodeEditorProps {
|
||||||
|
language: string;
|
||||||
|
initialValue: string;
|
||||||
|
isDirty: boolean;
|
||||||
|
setIsDirty: (value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CodeEditor = forwardRef((props: CodeEditorProps, cssRef) => {
|
||||||
|
const { language, initialValue, isDirty, setIsDirty } = props;
|
||||||
|
|
||||||
|
const [code, setCode] = useState(initialValue);
|
||||||
|
|
||||||
|
const highlight = (code: string) => {
|
||||||
|
const grammar = Prism.languages[language];
|
||||||
|
return grammar ? Prism.highlight(code, grammar, language) : code;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (newCode: string) => {
|
||||||
|
setCode(newCode);
|
||||||
|
};
|
||||||
|
|
||||||
|
useImperativeHandle(cssRef, () => {
|
||||||
|
return {
|
||||||
|
getCss: () => code,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// add contents to editor on mount and any change in initialValue
|
||||||
|
useEffect(() => {
|
||||||
|
setCode(initialValue);
|
||||||
|
}, [initialValue]);
|
||||||
|
|
||||||
|
// handle dirty state on change
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialValue.trim() !== code.trim() && !isDirty && code.length !== 0) {
|
||||||
|
setIsDirty(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initialValue.trim() === code.trim() && isDirty) {
|
||||||
|
setIsDirty(false);
|
||||||
|
}
|
||||||
|
}, [initialValue, code, isDirty, setIsDirty]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.wrapper}>
|
||||||
|
<Editor
|
||||||
|
value={code}
|
||||||
|
padding={15}
|
||||||
|
onValueChange={handleChange}
|
||||||
|
highlight={highlight}
|
||||||
|
style={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
minHeight: 500,
|
||||||
|
background: '#2d2d2d', // Background of tomorrow theme
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
CodeEditor.displayName = 'StyleEditor';
|
||||||
|
|
||||||
|
export default memo(CodeEditor);
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
.editorActions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
align-self: end;
|
||||||
|
text-align: right
|
||||||
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
align-items: start;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { lazy, useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalCloseButton,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader,
|
||||||
|
ModalOverlay,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { getCSSContents, postCSSContents, restoreCSSContents } from '../../../../common/api/assets';
|
||||||
|
import Info from '../../../../common/components/info/Info';
|
||||||
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
|
import style from './StyleEditorModal.module.scss';
|
||||||
|
|
||||||
|
const CodeEditor = lazy(() => import('./StyleEditor'));
|
||||||
|
|
||||||
|
interface CodeEditorModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CSSRef {
|
||||||
|
getCss: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CodeEditorModal(props: CodeEditorModalProps) {
|
||||||
|
const { isOpen, onClose } = props;
|
||||||
|
|
||||||
|
const [css, setCSS] = useState('');
|
||||||
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
const [saveLoading, setSaveLoading] = useState(false);
|
||||||
|
const [resetLoading, setResetLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const cssRef = useRef<CSSRef>(null);
|
||||||
|
|
||||||
|
const handleRestore = async () => {
|
||||||
|
try {
|
||||||
|
setResetLoading(true);
|
||||||
|
const defaultCss = await restoreCSSContents();
|
||||||
|
setCSS(defaultCss);
|
||||||
|
} catch (_error) {
|
||||||
|
/** no error handling for now */
|
||||||
|
} finally {
|
||||||
|
setResetLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
setSaveLoading(true);
|
||||||
|
if (cssRef.current) {
|
||||||
|
await postCSSContents(cssRef.current.getCss());
|
||||||
|
setCSS(cssRef.current.getCss());
|
||||||
|
setIsDirty(false);
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
/** no error handling for now */
|
||||||
|
} finally {
|
||||||
|
setSaveLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clear = () => setCSS('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchServerCSS() {
|
||||||
|
// check for isOpen to fetch recent css
|
||||||
|
if (isOpen) {
|
||||||
|
try {
|
||||||
|
const css = await getCSSContents();
|
||||||
|
setCSS(css);
|
||||||
|
} catch (_error) {
|
||||||
|
setError('Failed to load CSS from server');
|
||||||
|
/** no error handling for now */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchServerCSS();
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} variant='ontime' isCentered>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent maxWidth='max(800px, 40vw)'>
|
||||||
|
<ModalHeader>Edit CSS override</ModalHeader>
|
||||||
|
<ModalCloseButton />
|
||||||
|
<ModalBody>
|
||||||
|
<CodeEditor ref={cssRef} initialValue={css} language='css' isDirty={isDirty} setIsDirty={setIsDirty} />
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter className={style.column}>
|
||||||
|
<Info>Invalid CSS will be refused by the browser</Info>
|
||||||
|
{error && <Panel.Error className={style.right}>{`Error: ${error}`}</Panel.Error>}
|
||||||
|
<Panel.InlineElements align='apart' className={style.editorActions}>
|
||||||
|
<Button
|
||||||
|
variant='ontime-ghosted'
|
||||||
|
onClick={handleRestore}
|
||||||
|
isDisabled={saveLoading || resetLoading}
|
||||||
|
isLoading={resetLoading}
|
||||||
|
>
|
||||||
|
Reset to example
|
||||||
|
</Button>
|
||||||
|
<Panel.InlineElements>
|
||||||
|
<Button variant='ontime-ghosted' onClick={clear}>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button variant='ontime-subtle' onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='ontime-filled'
|
||||||
|
onClick={handleSave}
|
||||||
|
isDisabled={saveLoading || resetLoading || !isDirty}
|
||||||
|
isLoading={saveLoading}
|
||||||
|
>
|
||||||
|
Save changes
|
||||||
|
</Button>
|
||||||
|
</Panel.InlineElements>
|
||||||
|
</Panel.InlineElements>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Controller, useForm } from 'react-hook-form';
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
import { Button, Input, Switch } from '@chakra-ui/react';
|
import { Button, Input, Switch, useDisclosure } from '@chakra-ui/react';
|
||||||
import { ViewSettings } from 'ontime-types';
|
import { ViewSettings } from 'ontime-types';
|
||||||
|
|
||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
@@ -14,11 +14,14 @@ import { preventEscape } from '../../../../common/utils/keyEvent';
|
|||||||
import { isOntimeCloud } from '../../../../externals';
|
import { isOntimeCloud } from '../../../../externals';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
|
import CodeEditorModal from './StyleEditorModal';
|
||||||
|
|
||||||
const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/';
|
const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/';
|
||||||
|
|
||||||
export default function ViewSettingsForm() {
|
export default function ViewSettingsForm() {
|
||||||
const { data, status, refetch } = useViewSettings();
|
const { data, status, refetch } = useViewSettings();
|
||||||
const { data: info, status: infoStatus } = useInfo();
|
const { data: info, status: infoStatus } = useInfo();
|
||||||
|
const { isOpen: isCodeEditorOpen, onOpen: onCodeEditorOpen, onClose: onCodeEditorClose } = useDisclosure();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
@@ -103,6 +106,7 @@ export default function ViewSettingsForm() {
|
|||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Panel.Loader isLoading={isLoading} />
|
<Panel.Loader isLoading={isLoading} />
|
||||||
<Panel.ListGroup>
|
<Panel.ListGroup>
|
||||||
|
<CodeEditorModal isOpen={isCodeEditorOpen} onClose={onCodeEditorClose} />
|
||||||
<Panel.ListItem>
|
<Panel.ListItem>
|
||||||
<Panel.Field
|
<Panel.Field
|
||||||
title='Override CSS styles'
|
title='Override CSS styles'
|
||||||
@@ -115,6 +119,15 @@ export default function ViewSettingsForm() {
|
|||||||
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
|
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={onCodeEditorOpen}
|
||||||
|
variant='ontime-subtle'
|
||||||
|
size='sm'
|
||||||
|
isDisabled={!data.overrideStyles}
|
||||||
|
width='fit-content'
|
||||||
|
>
|
||||||
|
Edit CSS override
|
||||||
|
</Button>
|
||||||
</Panel.ListItem>
|
</Panel.ListItem>
|
||||||
</Panel.ListGroup>
|
</Panel.ListGroup>
|
||||||
<Panel.ListGroup>
|
<Panel.ListGroup>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
declare module 'prismjs/components/prism-core' {
|
||||||
|
export * from 'prismjs';
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'prismjs/components/prism-css';
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
import { IoArrowUp } from 'react-icons/io5';
|
||||||
|
|
||||||
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
||||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { MouseEvent } from 'react';
|
import { MouseEvent } from 'react';
|
||||||
|
import { IoArrowUp } from 'react-icons/io5';
|
||||||
import { Button } from '@chakra-ui/react';
|
import { Button } from '@chakra-ui/react';
|
||||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
|
||||||
|
|
||||||
import { handleLinks } from '../../../../common/utils/linkUtils';
|
import { handleLinks } from '../../../../common/utils/linkUtils';
|
||||||
import Log from '../../../log/Log';
|
import Log from '../../../log/Log';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ChangeEvent, useRef, useState } from 'react';
|
import { ChangeEvent, useRef, useState } from 'react';
|
||||||
|
import { IoAdd } from 'react-icons/io5';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Button, Input } from '@chakra-ui/react';
|
import { Button, Input } from '@chakra-ui/react';
|
||||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
|
||||||
|
|
||||||
import { uploadProjectFile } from '../../../../common/api/db';
|
import { uploadProjectFile } from '../../../../common/api/db';
|
||||||
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
|
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
import { IoTrash } from 'react-icons/io5';
|
||||||
import { Button, Input, Textarea } from '@chakra-ui/react';
|
import { Button, Input, Textarea } from '@chakra-ui/react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ type ProjectCreateFormValues = {
|
|||||||
publicUrl?: string;
|
publicUrl?: string;
|
||||||
backstageInfo?: string;
|
backstageInfo?: string;
|
||||||
backstageUrl?: string;
|
backstageUrl?: string;
|
||||||
|
custom?: { title: string; value: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
||||||
@@ -34,6 +36,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
|||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
formState: { isSubmitting, isValid },
|
formState: { isSubmitting, isValid },
|
||||||
setFocus,
|
setFocus,
|
||||||
} = useForm<ProjectCreateFormValues>({
|
} = useForm<ProjectCreateFormValues>({
|
||||||
@@ -44,6 +47,11 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control,
|
||||||
|
name: 'custom',
|
||||||
|
});
|
||||||
|
|
||||||
// set focus to first field
|
// set focus to first field
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFocus('title');
|
setFocus('title');
|
||||||
@@ -59,6 +67,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
|||||||
...values,
|
...values,
|
||||||
filename,
|
filename,
|
||||||
});
|
});
|
||||||
|
|
||||||
await queryClient.invalidateQueries({ queryKey: PROJECT_LIST });
|
await queryClient.invalidateQueries({ queryKey: PROJECT_LIST });
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -66,6 +75,10 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddCustom = () => {
|
||||||
|
append({ title: '', value: '' });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Section
|
<Panel.Section
|
||||||
as='form'
|
as='form'
|
||||||
@@ -151,6 +164,42 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
|||||||
{...register('backstageUrl')}
|
{...register('backstageUrl')}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<Panel.Section>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<Panel.Field title='Custom data' description='Add custom data for your project' />
|
||||||
|
<Button variant='ontime-subtle' onClick={handleAddCustom}>
|
||||||
|
+
|
||||||
|
</Button>
|
||||||
|
</Panel.ListItem>
|
||||||
|
{fields.map((field, idx) => (
|
||||||
|
<div key={field.id} className={style.customDataItem}>
|
||||||
|
<Panel.Paragraph>{idx + 1}.</Panel.Paragraph>
|
||||||
|
<label>
|
||||||
|
Title
|
||||||
|
<Input
|
||||||
|
variant='ontime-filled'
|
||||||
|
size='sm'
|
||||||
|
placeholder={field.title}
|
||||||
|
autoComplete='off'
|
||||||
|
{...register(`custom.${idx}.title` as const)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Value
|
||||||
|
<Input
|
||||||
|
variant='ontime-filled'
|
||||||
|
size='sm'
|
||||||
|
placeholder={field.value}
|
||||||
|
autoComplete='off'
|
||||||
|
{...register(`custom.${idx}.value` as const)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button variant='ontime-ghosted' onClick={() => remove(idx)}>
|
||||||
|
<IoTrash />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Panel.Section>
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { ChangeEvent, useEffect, useRef } from 'react';
|
import { ChangeEvent, useEffect, useRef } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
import { IoAdd, IoDownloadOutline, IoTrash } from 'react-icons/io5';
|
||||||
import { Button, Input, Textarea } from '@chakra-ui/react';
|
import { Button, Input, Textarea } from '@chakra-ui/react';
|
||||||
import { IoDownloadOutline } from '@react-icons/all-files/io5/IoDownloadOutline';
|
|
||||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
|
||||||
import { type ProjectData } from 'ontime-types';
|
import { type ProjectData } from 'ontime-types';
|
||||||
|
|
||||||
import { projectLogoPath } from '../../../../common/api/constants';
|
import { projectLogoPath } from '../../../../common/api/constants';
|
||||||
@@ -26,6 +25,7 @@ export default function ProjectData() {
|
|||||||
formState: { isSubmitting, isValid, isDirty, errors },
|
formState: { isSubmitting, isValid, isDirty, errors },
|
||||||
setError,
|
setError,
|
||||||
watch,
|
watch,
|
||||||
|
control,
|
||||||
setValue,
|
setValue,
|
||||||
} = useForm({
|
} = useForm({
|
||||||
defaultValues: data,
|
defaultValues: data,
|
||||||
@@ -33,6 +33,12 @@ export default function ProjectData() {
|
|||||||
resetOptions: {
|
resetOptions: {
|
||||||
keepDirtyValues: true,
|
keepDirtyValues: true,
|
||||||
},
|
},
|
||||||
|
mode: 'onChange',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control,
|
||||||
|
name: 'custom',
|
||||||
});
|
});
|
||||||
|
|
||||||
// reset form values if data changes
|
// reset form values if data changes
|
||||||
@@ -78,6 +84,10 @@ export default function ProjectData() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddCustom = () => {
|
||||||
|
append({ title: '', value: '' });
|
||||||
|
};
|
||||||
|
|
||||||
const onSubmit = async (formData: ProjectData) => {
|
const onSubmit = async (formData: ProjectData) => {
|
||||||
try {
|
try {
|
||||||
await postProjectData(formData);
|
await postProjectData(formData);
|
||||||
@@ -97,7 +107,7 @@ export default function ProjectData() {
|
|||||||
const isLoading = status === 'pending';
|
const isLoading = status === 'pending';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} onKeyDown={(event) => preventEscape(event, onReset)}>
|
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} onKeyDown={(event) => preventEscape(event, onReset)}>
|
||||||
<Panel.Card>
|
<Panel.Card>
|
||||||
<Panel.SubHeader>
|
<Panel.SubHeader>
|
||||||
Project data
|
Project data
|
||||||
@@ -232,6 +242,69 @@ export default function ProjectData() {
|
|||||||
{...register('backstageUrl')}
|
{...register('backstageUrl')}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<Panel.Section style={{ marginTop: 0 }}>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<Panel.Field title='Custom data' description='' />
|
||||||
|
<Button leftIcon={<IoAdd />} size='sm' variant='ontime-subtle' onClick={handleAddCustom}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</Panel.ListItem>
|
||||||
|
{fields.length > 0 &&
|
||||||
|
fields.map((field, idx) => {
|
||||||
|
const rowErrors = errors.custom?.[idx] as
|
||||||
|
| {
|
||||||
|
title?: { message?: string };
|
||||||
|
value?: { message?: string };
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
return (
|
||||||
|
<div key={field.id} className={style.customDataItem}>
|
||||||
|
<div>
|
||||||
|
<div className={style.titleRow}>
|
||||||
|
<label>
|
||||||
|
Title
|
||||||
|
<Input
|
||||||
|
variant='ontime-filled'
|
||||||
|
size='sm'
|
||||||
|
defaultValue={field.title}
|
||||||
|
placeholder='Title of your custom data'
|
||||||
|
autoComplete='off'
|
||||||
|
{...register(`custom.${idx}.title`, {
|
||||||
|
required: { value: true, message: 'Field cannot be empty' },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant='ontime-subtle'
|
||||||
|
color='#FA5656' // $red-500
|
||||||
|
onClick={() => remove(idx)}
|
||||||
|
leftIcon={<IoTrash />}
|
||||||
|
>
|
||||||
|
Delete Entry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{rowErrors?.title?.message && <Panel.Error>{rowErrors.title.message}</Panel.Error>}
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
Value
|
||||||
|
<Textarea
|
||||||
|
variant='ontime-filled'
|
||||||
|
resize='none'
|
||||||
|
size='sm'
|
||||||
|
defaultValue={field.value}
|
||||||
|
autoComplete='off'
|
||||||
|
placeholder='Text of your custom data'
|
||||||
|
{...register(`custom.${idx}.value`, {
|
||||||
|
required: { value: true, message: 'Field cannot be empty' },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{rowErrors?.value?.message && <Panel.Error>{rowErrors.value.message}</Panel.Error>}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Panel.Section>
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
</Panel.Card>
|
</Panel.Card>
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||||
import { IconButton, Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react';
|
import { IconButton, Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react';
|
||||||
import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHorizontal';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
deleteProject,
|
deleteProject,
|
||||||
|
|||||||
@@ -57,3 +57,18 @@
|
|||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.customDataItem {
|
||||||
|
display: contents;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.titleRow{
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: end;
|
||||||
|
|
||||||
|
label {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { ChangeEvent, useEffect, useState } from 'react';
|
import { ChangeEvent, useEffect, useState } from 'react';
|
||||||
|
import { IoCheckmark, IoShieldCheckmarkOutline } from 'react-icons/io5';
|
||||||
import { Button, Input, Spinner } from '@chakra-ui/react';
|
import { Button, Input, Spinner } from '@chakra-ui/react';
|
||||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
|
||||||
import { IoShieldCheckmarkOutline } from '@react-icons/all-files/io5/IoShieldCheckmarkOutline';
|
|
||||||
|
|
||||||
import { getWorksheetNames } from '../../../../common/api/sheets';
|
import { getWorksheetNames } from '../../../../common/api/sheets';
|
||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { ChangeEvent, useRef, useState } from 'react';
|
import { ChangeEvent, useRef, useState } from 'react';
|
||||||
|
import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
|
||||||
import { Button, Input } from '@chakra-ui/react';
|
import { Button, Input } from '@chakra-ui/react';
|
||||||
import { IoCloudOutline } from '@react-icons/all-files/io5/IoCloudOutline';
|
|
||||||
import { IoDownloadOutline } from '@react-icons/all-files/io5/IoDownloadOutline';
|
|
||||||
import { getErrorMessage, ImportMap } from 'ontime-utils';
|
import { getErrorMessage, ImportMap } from 'ontime-utils';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -104,6 +103,7 @@ export default function SourcesPanel() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmitImportPreview = async (importMap: ImportMap) => {
|
const handleSubmitImportPreview = async (importMap: ImportMap) => {
|
||||||
|
setError(''); // to clear previous error
|
||||||
if (importFlow === 'excel') {
|
if (importFlow === 'excel') {
|
||||||
try {
|
try {
|
||||||
const previewData = await importRundownPreviewExcel(importMap);
|
const previewData = await importRundownPreviewExcel(importMap);
|
||||||
|
|||||||
+11
-3
@@ -1,8 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
import { IoAdd, IoTrash } from 'react-icons/io5';
|
||||||
import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react';
|
import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react';
|
||||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
|
||||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
|
||||||
import { ImportMap, isAlphanumericWithSpace } from 'ontime-utils';
|
import { ImportMap, isAlphanumericWithSpace } from 'ontime-utils';
|
||||||
|
|
||||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||||
@@ -29,6 +28,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
|
|||||||
control,
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
register,
|
register,
|
||||||
|
setValue,
|
||||||
formState: { errors, isValid },
|
formState: { errors, isValid },
|
||||||
} = useForm<NamedImportMap>({
|
} = useForm<NamedImportMap>({
|
||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
@@ -46,6 +46,14 @@ export default function ImportMapForm(props: ImportMapFormProps) {
|
|||||||
|
|
||||||
const [loading, setLoading] = useState<'' | 'export' | 'import'>('');
|
const [loading, setLoading] = useState<'' | 'export' | 'import'>('');
|
||||||
|
|
||||||
|
// Set first sheet as default worksheet when 'event schedule' sheet is not there
|
||||||
|
useEffect(() => {
|
||||||
|
if (!worksheetNames || worksheetNames.length === 0) return;
|
||||||
|
if (!worksheetNames.includes(namedImportMap.Worksheet)) {
|
||||||
|
setValue('Worksheet', worksheetNames[0], { shouldValidate: true, shouldDirty: true });
|
||||||
|
}
|
||||||
|
}, [worksheetNames, setValue, namedImportMap.Worksheet]);
|
||||||
|
|
||||||
const handleExport = async (values: NamedImportMap) => {
|
const handleExport = async (values: NamedImportMap) => {
|
||||||
setLoading('export');
|
setLoading('export');
|
||||||
const importMap = convertToImportMap(values);
|
const importMap = convertToImportMap(values);
|
||||||
|
|||||||
+4
-2
@@ -1,5 +1,7 @@
|
|||||||
import { ImportCustom, ImportMap } from 'ontime-utils';
|
import { ImportCustom, ImportMap } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { baseURI } from '../../../../../externals';
|
||||||
|
|
||||||
export type NamedImportMap = typeof namedImportMap;
|
export type NamedImportMap = typeof namedImportMap;
|
||||||
|
|
||||||
// Record of label and import name
|
// Record of label and import name
|
||||||
@@ -64,11 +66,11 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function persistImportMap(options: NamedImportMap) {
|
export function persistImportMap(options: NamedImportMap) {
|
||||||
localStorage.setItem('ontime-import-options', JSON.stringify(options));
|
localStorage.setItem(`${baseURI}ontime-import-options`, JSON.stringify(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPersistImportMap(): unknown {
|
function getPersistImportMap(): unknown {
|
||||||
const options = localStorage.getItem('ontime-import-options');
|
const options = localStorage.getItem(`${baseURI}ontime-import-options`);
|
||||||
if (!options) {
|
if (!options) {
|
||||||
throw new Error('no import options found');
|
throw new Error('no import options found');
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { Fragment } from 'react';
|
import { Fragment } from 'react';
|
||||||
import { IoLink } from '@react-icons/all-files/io5/IoLink';
|
import { IoLink } from 'react-icons/io5';
|
||||||
import { CustomFields, isOntimeBlock, isOntimeEvent, OntimeRundown } from 'ontime-types';
|
import { CustomFields, isOntimeBlock, isOntimeEvent, OntimeRundown } from 'ontime-types';
|
||||||
import { millisToString } from 'ontime-utils';
|
import { millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { PropsWithChildren, useEffect, useRef, useState } from 'react';
|
||||||
import { Input } from '@chakra-ui/react';
|
import { Input } from '@chakra-ui/react';
|
||||||
import { IoEye } from '@react-icons/all-files/io5/IoEye';
|
|
||||||
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
|
|
||||||
|
|
||||||
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
|
||||||
|
|
||||||
import style from './InputRow.module.scss';
|
import style from './InputRow.module.scss';
|
||||||
|
|
||||||
@@ -13,50 +10,53 @@ interface InputRowProps {
|
|||||||
placeholder: string;
|
placeholder: string;
|
||||||
text: string;
|
text: string;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
actionHandler: () => void;
|
|
||||||
changeHandler: (newValue: string) => void;
|
changeHandler: (newValue: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function InputRow(props: InputRowProps) {
|
export default function InputRow(props: PropsWithChildren<InputRowProps>) {
|
||||||
const { label, placeholder, text, visible, actionHandler, changeHandler } = props;
|
const { label, placeholder, text, visible, changeHandler, children } = props;
|
||||||
|
|
||||||
|
const [value, setValue] = useState(text);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const cursorPositionRef = useRef(0);
|
const cursorPositionRef = useRef(0);
|
||||||
|
|
||||||
// sync cursor position with text
|
// sync cursor position with text
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (inputRef.current) {
|
if (inputRef.current && inputRef.current !== document.activeElement) {
|
||||||
inputRef.current.selectionStart = cursorPositionRef.current;
|
inputRef.current.selectionStart = cursorPositionRef.current;
|
||||||
inputRef.current.selectionEnd = cursorPositionRef.current;
|
inputRef.current.selectionEnd = cursorPositionRef.current;
|
||||||
}
|
}
|
||||||
}, [text]);
|
}, [text]);
|
||||||
|
|
||||||
|
// synchronise external text
|
||||||
|
useEffect(() => {
|
||||||
|
if (inputRef.current !== document.activeElement) {
|
||||||
|
setValue(text);
|
||||||
|
}
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
cursorPositionRef.current = event.target.selectionStart ?? 0;
|
cursorPositionRef.current = event.target.selectionStart ?? 0;
|
||||||
|
setValue(event.target.value);
|
||||||
changeHandler(event.target.value);
|
changeHandler(event.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.inputRow}>
|
<div className={style.inputRow}>
|
||||||
<label className={`${style.label} ${visible ? style.active : ''}`}>{label}</label>
|
<label className={cx([style.label, visible ?? style.active])} htmlFor={label}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
<div className={style.inputItems}>
|
<div className={style.inputItems}>
|
||||||
<Input
|
<Input
|
||||||
|
id={label}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
size='sm'
|
size='sm'
|
||||||
variant='ontime-filled'
|
variant='ontime-filled'
|
||||||
value={text}
|
value={value}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
/>
|
/>
|
||||||
<TooltipActionBtn
|
{children}
|
||||||
clickHandler={actionHandler}
|
|
||||||
tooltip={visible ? 'Make invisible' : 'Make visible'}
|
|
||||||
aria-label={`Toggle ${label}`}
|
|
||||||
openDelay={tooltipDelayMid}
|
|
||||||
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
|
|
||||||
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
|
|
||||||
size='sm'
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
import { IoEye, IoEyeOffOutline } from 'react-icons/io5';
|
||||||
|
|
||||||
|
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
|
||||||
import { setMessage, useExternalMessageInput, useTimerMessageInput } from '../../../common/hooks/useSocket';
|
import { setMessage, useExternalMessageInput, useTimerMessageInput } from '../../../common/hooks/useSocket';
|
||||||
|
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||||
|
|
||||||
import InputRow from './InputRow';
|
import InputRow from './InputRow';
|
||||||
import TimerControlsPreview from './TimerViewControl';
|
import TimerControlsPreview from './TimerViewControl';
|
||||||
@@ -23,8 +27,17 @@ function TimerMessageInput() {
|
|||||||
text={text}
|
text={text}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
changeHandler={(newValue) => setMessage.timerText(newValue)}
|
changeHandler={(newValue) => setMessage.timerText(newValue)}
|
||||||
actionHandler={() => setMessage.timerVisible(!visible)}
|
>
|
||||||
/>
|
<TooltipActionBtn
|
||||||
|
clickHandler={() => setMessage.timerVisible(!visible)}
|
||||||
|
tooltip={visible ? 'Make invisible' : 'Make visible'}
|
||||||
|
aria-label='Toggle timer message visibility'
|
||||||
|
openDelay={tooltipDelayMid}
|
||||||
|
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
|
||||||
|
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
|
||||||
|
size='sm'
|
||||||
|
/>
|
||||||
|
</InputRow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +59,16 @@ function ExternalInput() {
|
|||||||
text={text}
|
text={text}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
changeHandler={(newValue) => setMessage.externalText(newValue)}
|
changeHandler={(newValue) => setMessage.externalText(newValue)}
|
||||||
actionHandler={toggleExternal}
|
>
|
||||||
/>
|
<TooltipActionBtn
|
||||||
|
clickHandler={toggleExternal}
|
||||||
|
tooltip={visible ? 'Make invisible' : 'Make visible'}
|
||||||
|
aria-label='Toggle external message visibility'
|
||||||
|
openDelay={tooltipDelayMid}
|
||||||
|
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
|
||||||
|
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
|
||||||
|
size='sm'
|
||||||
|
/>
|
||||||
|
</InputRow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
|
import { IoArrowDown, IoArrowUp, IoBan, IoFlag, IoTime } from 'react-icons/io5';
|
||||||
import { Tooltip } from '@chakra-ui/react';
|
import { Tooltip } from '@chakra-ui/react';
|
||||||
import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown';
|
|
||||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
|
||||||
import { IoBan } from '@react-icons/all-files/io5/IoBan';
|
|
||||||
import { IoFlag } from '@react-icons/all-files/io5/IoFlag';
|
|
||||||
import { IoTime } from '@react-icons/all-files/io5/IoTime';
|
|
||||||
import { TimerPhase, TimerType } from 'ontime-types';
|
import { TimerPhase, TimerType } from 'ontime-types';
|
||||||
|
|
||||||
import { useMessagePreview } from '../../../common/hooks/useSocket';
|
import { useMessagePreview } from '../../../common/hooks/useSocket';
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
|
import { IoAdd, IoRemove } from 'react-icons/io5';
|
||||||
import { Tooltip } from '@chakra-ui/react';
|
import { Tooltip } from '@chakra-ui/react';
|
||||||
import { useLocalStorage } from '@mantine/hooks';
|
import { useLocalStorage } from '@mantine/hooks';
|
||||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
|
||||||
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
|
|
||||||
import { Playback } from 'ontime-types';
|
import { Playback } from 'ontime-types';
|
||||||
import { MILLIS_PER_HOUR, MILLIS_PER_SECOND, parseUserTime } from 'ontime-utils';
|
import { MILLIS_PER_HOUR, MILLIS_PER_SECOND, parseUserTime } from 'ontime-utils';
|
||||||
|
|
||||||
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
||||||
import { setPlayback } from '../../../../common/hooks/useSocket';
|
import { setPlayback } from '../../../../common/hooks/useSocket';
|
||||||
|
import { baseURI } from '../../../../externals';
|
||||||
import { tooltipDelayMid } from '../../../../ontimeConfig';
|
import { tooltipDelayMid } from '../../../../ontimeConfig';
|
||||||
import TapButton from '../tap-button/TapButton';
|
import TapButton from '../tap-button/TapButton';
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ interface AddTimeProps {
|
|||||||
|
|
||||||
export default function AddTime(props: AddTimeProps) {
|
export default function AddTime(props: AddTimeProps) {
|
||||||
const { playback } = props;
|
const { playback } = props;
|
||||||
const [time, setTime] = useLocalStorage({ key: 'add-time', defaultValue: 300_000 }); // 5 minutes
|
const [time, setTime] = useLocalStorage({ key: `${baseURI}add-time`, defaultValue: 300_000 }); // 5 minutes
|
||||||
|
|
||||||
const handleTimeChange = (_field: string, value: string) => {
|
const handleTimeChange = (_field: string, value: string) => {
|
||||||
const newTime = parseUserTime(value);
|
const newTime = parseUserTime(value);
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown';
|
import { IoArrowDown, IoArrowUp, IoPause, IoPlay, IoStop } from 'react-icons/io5';
|
||||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
|
||||||
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, SimpleDirection, SimplePlayback } from 'ontime-types';
|
import { Playback, SimpleDirection, SimplePlayback } from 'ontime-types';
|
||||||
import { parseUserTime } from 'ontime-utils';
|
import { parseUserTime } from 'ontime-utils';
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { IoPause, IoPlay, IoPlaySkipBack, IoPlaySkipForward, IoReload, IoStop, IoTime } 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 { IoPlaySkipBack } from '@react-icons/all-files/io5/IoPlaySkipBack';
|
|
||||||
import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward';
|
|
||||||
import { IoReload } from '@react-icons/all-files/io5/IoReload';
|
|
||||||
import { IoStop } from '@react-icons/all-files/io5/IoStop';
|
|
||||||
import { IoTime } from '@react-icons/all-files/io5/IoTime';
|
|
||||||
import { Playback, TimerPhase } from 'ontime-types';
|
import { Playback, TimerPhase } from 'ontime-types';
|
||||||
import { validatePlayback } from 'ontime-utils';
|
import { validatePlayback } from 'ontime-utils';
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { lazy, useCallback, useEffect } from 'react';
|
import { lazy, useCallback, useEffect } from 'react';
|
||||||
|
import { IoApps, IoClose, IoSettingsOutline } from 'react-icons/io5';
|
||||||
import { IconButton, useDisclosure } from '@chakra-ui/react';
|
import { IconButton, useDisclosure } from '@chakra-ui/react';
|
||||||
import { useHotkeys } from '@mantine/hooks';
|
import { useHotkeys } from '@mantine/hooks';
|
||||||
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
|
||||||
import { IoClose } from '@react-icons/all-files/io5/IoClose';
|
|
||||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
|
||||||
|
|
||||||
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
|
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
|
||||||
import { useElectronListener } from '../../common/hooks/useElectronEvent';
|
import { useElectronListener } from '../../common/hooks/useElectronEvent';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { HTMLAttributes, LabelHTMLAttributes } from 'react';
|
import type { HTMLAttributes, LabelHTMLAttributes } from 'react';
|
||||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
import { IconBaseProps } from 'react-icons';
|
||||||
import { type IconBaseProps } from '@react-icons/all-files/lib';
|
import { IoArrowUp } from 'react-icons/io5';
|
||||||
|
|
||||||
import { cx } from '../../../common/utils/styleUtils';
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
|
import { IoLocate } from 'react-icons/io5';
|
||||||
|
|
||||||
import { cx } from '../../../common/utils/styleUtils';
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,10 @@
|
|||||||
@include ellipsis-overflow;
|
@include ellipsis-overflow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.offset {
|
||||||
|
color: $muted-gray;
|
||||||
|
}
|
||||||
|
|
||||||
.ahead {
|
.ahead {
|
||||||
color: $playback-ahead;
|
color: $playback-ahead;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { memo, PropsWithChildren, ReactNode, useMemo } from 'react';
|
import { memo, PropsWithChildren, ReactNode, useMemo } from 'react';
|
||||||
|
import { Playback } from 'ontime-types';
|
||||||
import { millisToString } from 'ontime-utils';
|
import { millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
|
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
|
||||||
@@ -163,14 +164,14 @@ function ProgressOverview() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RuntimeOverview() {
|
function RuntimeOverview() {
|
||||||
const { clock, offset } = useRuntimePlaybackOverview();
|
const { clock, offset, playback } = useRuntimePlaybackOverview();
|
||||||
|
|
||||||
const offsetText = getOffsetText(offset);
|
const offsetText = getOffsetText(offset);
|
||||||
const offsetClasses = offset === null ? undefined : offset <= 0 ? style.behind : style.ahead;
|
const offsetClasses = cx([style.offset, playback !== Playback.Stop && (offset < 0 ? style.behind : style.ahead)]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TimeColumn label='Offset' value={offsetText} className={offsetClasses} />
|
<TimeColumn label='Offset' value={offsetText} className={offsetClasses} testId='offset' />
|
||||||
<TimeColumn label='Time now' value={formatedTime(clock)} />
|
<TimeColumn label='Time now' value={formatedTime(clock)} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,13 +10,16 @@ interface TimeLayoutProps {
|
|||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
daySpan?: number;
|
daySpan?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
testId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TimeColumn({ label, value, muted, className }: TimeLayoutProps) {
|
export function TimeColumn({ label, value, muted, className, testId }: TimeLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<div className={style.column}>
|
<div className={style.column}>
|
||||||
<span className={style.label}>{label}</span>
|
<span className={style.label}>{label}</span>
|
||||||
<span className={cx([style.clock, muted && style.muted, className])}>{value}</span>
|
<span className={cx([style.clock, muted && style.muted, className])} data-testid={testId}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,7 +300,8 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
isNextDay = checkIsNextDay(entry, lastEvent);
|
isNextDay = checkIsNextDay(entry, lastEvent);
|
||||||
if (!isPast) {
|
if (!isPast) {
|
||||||
totalGap += entry.gap;
|
totalGap += entry.gap;
|
||||||
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart !== null;
|
// We also include countToEnd in this test as the behavior of a linked event coming after a countToEnd is simelar to an unlinked event
|
||||||
|
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart !== null && !lastEvent?.countToEnd;
|
||||||
}
|
}
|
||||||
if (isNewLatest(entry, lastEvent)) {
|
if (isNewLatest(entry, lastEvent)) {
|
||||||
// populate previous entry
|
// populate previous entry
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
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 Empty from '../../common/components/state/Empty';
|
import Empty from '../../common/components/state/Empty';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
|
import { IoReorderTwo } from 'react-icons/io5';
|
||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
|
|
||||||
import { OntimeBlock } from 'ontime-types';
|
import { OntimeBlock } from 'ontime-types';
|
||||||
|
|
||||||
import { cx } from '../../../common/utils/styleUtils';
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { IoTrash } from 'react-icons/io5';
|
||||||
import { IconButton } from '@chakra-ui/react';
|
import { IconButton } from '@chakra-ui/react';
|
||||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
|
||||||
|
|
||||||
import { AppMode, useAppMode } from '../../../common/stores/appModeStore';
|
import { AppMode, useAppMode } from '../../../common/stores/appModeStore';
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { IoCheckmarkDone, IoClose, IoReorderTwo } from 'react-icons/io5';
|
||||||
import { Button } from '@chakra-ui/react';
|
import { Button } from '@chakra-ui/react';
|
||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { IoCheckmarkDone } from '@react-icons/all-files/io5/IoCheckmarkDone';
|
|
||||||
import { IoClose } from '@react-icons/all-files/io5/IoClose';
|
|
||||||
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
|
|
||||||
import { OntimeDelay } from 'ontime-types';
|
import { OntimeDelay } from 'ontime-types';
|
||||||
|
|
||||||
import DelayInput from '../../../common/components/input/delay-input/DelayInput';
|
import DelayInput from '../../../common/components/input/delay-input/DelayInput';
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
IoAdd,
|
||||||
|
IoDuplicateOutline,
|
||||||
|
IoLink,
|
||||||
|
IoPeople,
|
||||||
|
IoPeopleOutline,
|
||||||
|
IoReorderTwo,
|
||||||
|
IoSwapVertical,
|
||||||
|
IoTrash,
|
||||||
|
IoUnlink,
|
||||||
|
} from 'react-icons/io5';
|
||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
|
||||||
import { IoDuplicateOutline } from '@react-icons/all-files/io5/IoDuplicateOutline';
|
|
||||||
import { IoLink } from '@react-icons/all-files/io5/IoLink';
|
|
||||||
import { IoPeople } from '@react-icons/all-files/io5/IoPeople';
|
|
||||||
import { IoPeopleOutline } from '@react-icons/all-files/io5/IoPeopleOutline';
|
|
||||||
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
|
|
||||||
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
|
||||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
|
||||||
import { IoUnlink } from '@react-icons/all-files/io5/IoUnlink';
|
|
||||||
import { EndAction, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
import { EndAction, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||||
|
|
||||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { memo, useEffect, useState } from 'react';
|
import { memo, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
IoArrowDown,
|
||||||
|
IoArrowUp,
|
||||||
|
IoBan,
|
||||||
|
IoFlag,
|
||||||
|
IoPeople,
|
||||||
|
IoPlay,
|
||||||
|
IoPlayForward,
|
||||||
|
IoPlaySkipForward,
|
||||||
|
IoStop,
|
||||||
|
IoTime,
|
||||||
|
} from 'react-icons/io5';
|
||||||
import { Tooltip } from '@chakra-ui/react';
|
import { Tooltip } from '@chakra-ui/react';
|
||||||
import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown';
|
|
||||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
|
||||||
import { IoBan } from '@react-icons/all-files/io5/IoBan';
|
|
||||||
import { IoFlag } from '@react-icons/all-files/io5/IoFlag';
|
|
||||||
import { IoPeople } from '@react-icons/all-files/io5/IoPeople';
|
|
||||||
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
|
|
||||||
import { IoPlayForward } from '@react-icons/all-files/io5/IoPlayForward';
|
|
||||||
import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward';
|
|
||||||
import { IoStop } from '@react-icons/all-files/io5/IoStop';
|
|
||||||
import { IoTime } from '@react-icons/all-files/io5/IoTime';
|
|
||||||
import { EndAction, MaybeString, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
import { EndAction, MaybeString, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||||
|
|
||||||
import { cx } from '../../../common/utils/styleUtils';
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { IoCheckmarkCircle } from 'react-icons/io5';
|
||||||
import { Tooltip } from '@chakra-ui/react';
|
import { Tooltip } from '@chakra-ui/react';
|
||||||
import { IoCheckmarkCircle } from '@react-icons/all-files/io5/IoCheckmarkCircle';
|
|
||||||
import { isPlaybackActive, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
import { isPlaybackActive, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||||
|
|
||||||
import { usePlayback } from '../../../../common/hooks/useSocket';
|
import { usePlayback } from '../../../../common/hooks/useSocket';
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { memo, MouseEvent } from 'react';
|
import { memo, MouseEvent } from 'react';
|
||||||
import { IoPause } from '@react-icons/all-files/io5/IoPause';
|
import { IoPause, IoPlay, IoReload, IoRemoveCircle, IoRemoveCircleOutline } from 'react-icons/io5';
|
||||||
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
|
|
||||||
import { IoReload } from '@react-icons/all-files/io5/IoReload';
|
|
||||||
import { IoRemoveCircle } from '@react-icons/all-files/io5/IoRemoveCircle';
|
|
||||||
import { IoRemoveCircleOutline } from '@react-icons/all-files/io5/IoRemoveCircleOutline';
|
|
||||||
|
|
||||||
import TooltipActionBtn from '../../../../common/components/buttons/TooltipActionBtn';
|
import TooltipActionBtn from '../../../../common/components/buttons/TooltipActionBtn';
|
||||||
import { useEventAction } from '../../../../common/hooks/useEventAction';
|
import { useEventAction } from '../../../../common/hooks/useEventAction';
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import { CSSProperties, useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { CustomFieldLabel, OntimeEvent } from 'ontime-types';
|
import { CustomFieldLabel, OntimeEvent } from 'ontime-types';
|
||||||
|
|
||||||
import AppLink from '../../../common/components/link/app-link/AppLink';
|
import AppLink from '../../../common/components/link/app-link/AppLink';
|
||||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||||
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
||||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
|
||||||
import * as Editor from '../../editors/editor-utils/EditorUtils';
|
import * as Editor from '../../editors/editor-utils/EditorUtils';
|
||||||
|
|
||||||
import EventEditorImage from './composite/EventEditorImage';
|
import EventCustom from './composite/EventEditorCustom';
|
||||||
import EventEditorTimes from './composite/EventEditorTimes';
|
import EventEditorTimes from './composite/EventEditorTimes';
|
||||||
import EventEditorTitles from './composite/EventEditorTitles';
|
import EventEditorTitles from './composite/EventEditorTitles';
|
||||||
import EventTextArea from './composite/EventTextArea';
|
import EventEditorTriggers from './composite/EventEditorTriggers';
|
||||||
import EventTextInput from './composite/EventTextInput';
|
|
||||||
import EventEditorEmpty from './EventEditorEmpty';
|
import EventEditorEmpty from './EventEditorEmpty';
|
||||||
|
|
||||||
import style from './EventEditor.module.scss';
|
import style from './EventEditor.module.scss';
|
||||||
@@ -77,52 +75,16 @@ export default function EventEditor(props: EventEditorProps) {
|
|||||||
<div className={style.column}>
|
<div className={style.column}>
|
||||||
<Editor.Title>
|
<Editor.Title>
|
||||||
Custom Fields
|
Custom Fields
|
||||||
{isEditor && <AppLink search='settings=feature_settings__custom'>Manage</AppLink>}
|
{isEditor && <AppLink search='settings=feature_settings__custom'>Manage Custom Fields</AppLink>}
|
||||||
</Editor.Title>
|
</Editor.Title>
|
||||||
|
<EventCustom fields={customFields} handleSubmit={handleSubmit} event={event} />
|
||||||
{Object.keys(customFields).map((fieldKey) => {
|
</div>
|
||||||
const key = `${event.id}-${fieldKey}`;
|
<div className={style.column}>
|
||||||
const fieldName = `custom-${fieldKey}`;
|
<Editor.Title>
|
||||||
const initialValue = event.custom[fieldKey] ?? '';
|
Automations
|
||||||
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
|
{isEditor && <AppLink search='settings=automation__automations'>Manage Automations</AppLink>}
|
||||||
const labelText = customFields[fieldKey].label;
|
</Editor.Title>
|
||||||
|
<EventEditorTriggers triggers={event.triggers} eventId={event.id} />
|
||||||
if (customFields[fieldKey].type === 'string') {
|
|
||||||
return (
|
|
||||||
<EventTextArea
|
|
||||||
key={key}
|
|
||||||
field={fieldName}
|
|
||||||
label={labelText}
|
|
||||||
initialValue={initialValue}
|
|
||||||
submitHandler={handleSubmit}
|
|
||||||
className={style.decorated}
|
|
||||||
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (customFields[fieldKey].type === 'image') {
|
|
||||||
return (
|
|
||||||
<div key={key} className={style.customImage}>
|
|
||||||
<EventTextInput
|
|
||||||
key={key}
|
|
||||||
field={fieldName}
|
|
||||||
label={labelText}
|
|
||||||
initialValue={initialValue}
|
|
||||||
placeholder='Paste image URL'
|
|
||||||
submitHandler={handleSubmit}
|
|
||||||
className={style.decorated}
|
|
||||||
maxLength={255}
|
|
||||||
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
|
|
||||||
/>
|
|
||||||
<EventEditorImage src={initialValue} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// we should have exhausted all types by now
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { CSSProperties, Fragment } from 'react';
|
||||||
|
import { CustomFields, OntimeEvent } from 'ontime-types';
|
||||||
|
|
||||||
|
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||||
|
import { EditorUpdateFields } from '../EventEditor';
|
||||||
|
|
||||||
|
import EventEditorImage from './EventEditorImage';
|
||||||
|
import EventTextArea from './EventTextArea';
|
||||||
|
import EventTextInput from './EventTextInput';
|
||||||
|
|
||||||
|
import style from '../EventEditor.module.scss';
|
||||||
|
|
||||||
|
interface EventEditorCustomProps {
|
||||||
|
fields: CustomFields;
|
||||||
|
event: OntimeEvent;
|
||||||
|
handleSubmit: (field: EditorUpdateFields, value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventEditorCustom(props: EventEditorCustomProps) {
|
||||||
|
const { fields: customFields, handleSubmit, event } = props;
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
{Object.keys(customFields).map((fieldKey) => {
|
||||||
|
const key = `${event.id}-${fieldKey}`;
|
||||||
|
const fieldName = `custom-${fieldKey}`;
|
||||||
|
const initialValue = event.custom[fieldKey] ?? '';
|
||||||
|
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
|
||||||
|
const labelText = customFields[fieldKey].label;
|
||||||
|
|
||||||
|
if (customFields[fieldKey].type === 'string') {
|
||||||
|
return (
|
||||||
|
<EventTextArea
|
||||||
|
key={key}
|
||||||
|
field={fieldName}
|
||||||
|
label={labelText}
|
||||||
|
initialValue={initialValue}
|
||||||
|
submitHandler={handleSubmit}
|
||||||
|
className={style.decorated}
|
||||||
|
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customFields[fieldKey].type === 'image') {
|
||||||
|
return (
|
||||||
|
<div key={key} className={style.customImage}>
|
||||||
|
<EventTextInput
|
||||||
|
key={key}
|
||||||
|
field={fieldName}
|
||||||
|
label={labelText}
|
||||||
|
initialValue={initialValue}
|
||||||
|
placeholder='Paste image URL'
|
||||||
|
submitHandler={handleSubmit}
|
||||||
|
className={style.decorated}
|
||||||
|
maxLength={255}
|
||||||
|
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
|
||||||
|
/>
|
||||||
|
<EventEditorImage src={initialValue} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// we should have exhausted all types by now
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
import { IoInformationCircle } from 'react-icons/io5';
|
||||||
import { Select, Switch, Tooltip } from '@chakra-ui/react';
|
import { Select, Switch, Tooltip } from '@chakra-ui/react';
|
||||||
import { IoInformationCircle } from '@react-icons/all-files/io5/IoInformationCircle';
|
|
||||||
import { EndAction, MaybeString, TimerType, TimeStrategy } from 'ontime-types';
|
import { EndAction, MaybeString, TimerType, TimeStrategy } from 'ontime-types';
|
||||||
import { millisToString, parseUserTime } from 'ontime-utils';
|
import { millisToString, parseUserTime } from 'ontime-utils';
|
||||||
|
|
||||||
|
|||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
.triggerForm {
|
||||||
|
padding-block: 0.5rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr auto auto;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&:nth-child(even) {
|
||||||
|
background-color: $white-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > span {
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorLabel {
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: $green-500;
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { Fragment, useCallback, useState } from 'react';
|
||||||
|
import { IoAlertCircle, IoCheckmarkCircle, IoTrash } from 'react-icons/io5';
|
||||||
|
import { Button, IconButton, Select, Tooltip } from '@chakra-ui/react';
|
||||||
|
import { TimerLifeCycle, timerLifecycleValues, Trigger } from 'ontime-types';
|
||||||
|
import { generateId } from 'ontime-utils';
|
||||||
|
|
||||||
|
import Tag from '../../../../common/components/tag/Tag';
|
||||||
|
import { useEventAction } from '../../../../common/hooks/useEventAction';
|
||||||
|
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||||
|
|
||||||
|
import { eventTriggerOptions } from './eventTrigger.constants';
|
||||||
|
|
||||||
|
import style from './EventEditorTriggers.module.scss';
|
||||||
|
|
||||||
|
interface EventEditorTriggersProps {
|
||||||
|
eventId: string;
|
||||||
|
triggers?: Trigger[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventEditorTriggers(props: EventEditorTriggersProps) {
|
||||||
|
const { triggers, eventId } = props;
|
||||||
|
const showTriggers = triggers !== undefined && triggers.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showTriggers && <ExistingEventTriggers triggers={triggers} eventId={eventId} />}
|
||||||
|
<EventTriggerForm triggers={triggers} eventId={eventId} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventTriggerFormProps {
|
||||||
|
eventId: string;
|
||||||
|
triggers?: Trigger[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventTriggerForm(props: EventTriggerFormProps) {
|
||||||
|
const { eventId, triggers } = props;
|
||||||
|
const { data: automationSettings } = useAutomationSettings();
|
||||||
|
const { updateEvent } = useEventAction();
|
||||||
|
const [automationId, setAutomationId] = useState<string | undefined>(undefined);
|
||||||
|
const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart);
|
||||||
|
|
||||||
|
const handleSubmit = (triggerLifeCycle: TimerLifeCycle, automationId: string) => {
|
||||||
|
const newTriggers = triggers ?? new Array<Trigger>();
|
||||||
|
const id = generateId();
|
||||||
|
newTriggers.push({ id, title: '', trigger: triggerLifeCycle, automationId });
|
||||||
|
updateEvent({ id: eventId, triggers: newTriggers });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getValidationError = (cycle: TimerLifeCycle, automationId?: string): string | undefined => {
|
||||||
|
if (automationId === undefined) {
|
||||||
|
return 'Select an automation';
|
||||||
|
}
|
||||||
|
if (!Object.keys(automationSettings.automations).includes(automationId)) {
|
||||||
|
return 'This automation does not exist';
|
||||||
|
}
|
||||||
|
if (triggers === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return Object.values(triggers).some((t) => t.automationId === automationId && t.trigger === cycle)
|
||||||
|
? 'Automation can only be used once'
|
||||||
|
: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validationError = getValidationError(cycleValue, automationId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.triggerForm}>
|
||||||
|
<Select
|
||||||
|
size='sm'
|
||||||
|
variant='ontime'
|
||||||
|
value={cycleValue}
|
||||||
|
onChange={(e) => setCycleValue(e.target.value as TimerLifeCycle)}
|
||||||
|
defaultValue={TimerLifeCycle.onStart}
|
||||||
|
>
|
||||||
|
<option disabled>Lifecycle Trigger</option>
|
||||||
|
{eventTriggerOptions.map((cycle) => (
|
||||||
|
<option key={cycle} value={cycle}>
|
||||||
|
{cycle}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
size='sm'
|
||||||
|
variant='ontime'
|
||||||
|
value={automationId}
|
||||||
|
defaultValue='«invalid»'
|
||||||
|
onChange={(e) => setAutomationId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option disabled value='«invalid»'>
|
||||||
|
Automation
|
||||||
|
</option>
|
||||||
|
{Object.values(automationSettings.automations).map(({ id, title }) => (
|
||||||
|
<option key={id} value={id}>
|
||||||
|
{title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant='ontime-subtle'
|
||||||
|
size='sm'
|
||||||
|
isDisabled={validationError !== undefined}
|
||||||
|
onClick={() => automationId && handleSubmit(cycleValue, automationId)}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
{validationError !== undefined ? (
|
||||||
|
<Tooltip label={validationError} shouldWrapChildren>
|
||||||
|
<IoAlertCircle className={style.errorLabel} />
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<IoCheckmarkCircle className={style.success} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExistingEventTriggersProps {
|
||||||
|
eventId: string;
|
||||||
|
triggers: Trigger[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExistingEventTriggers(props: ExistingEventTriggersProps) {
|
||||||
|
const { eventId, triggers } = props;
|
||||||
|
const { updateEvent } = useEventAction();
|
||||||
|
const { data: automationSettings } = useAutomationSettings();
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
(triggerId: string) => {
|
||||||
|
const newTriggers = triggers.filter((trigger) => trigger.id !== triggerId);
|
||||||
|
updateEvent({ id: eventId, triggers: newTriggers });
|
||||||
|
},
|
||||||
|
[eventId, triggers, updateEvent],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredTriggers: Record<string, Trigger[]> = {};
|
||||||
|
|
||||||
|
// sort triggers out into groups by the Lifecycle they are on
|
||||||
|
timerLifecycleValues.forEach((triggerType) => {
|
||||||
|
const thisTriggerType = triggers.filter((trigger) => trigger.trigger === triggerType);
|
||||||
|
if (thisTriggerType.length) {
|
||||||
|
Object.assign(filteredTriggers, { [triggerType]: thisTriggerType });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{Object.entries(filteredTriggers).map(([triggerLifeCycle, triggerGroup]) => (
|
||||||
|
<Fragment key={triggerLifeCycle}>
|
||||||
|
{triggerGroup.map((trigger) => {
|
||||||
|
const { id, automationId } = trigger;
|
||||||
|
const automationTitle = automationSettings.automations[automationId]?.title ?? '<MISSING AUTOMATION>';
|
||||||
|
return (
|
||||||
|
<div key={id} className={style.trigger}>
|
||||||
|
<Tag>{triggerLifeCycle}</Tag>
|
||||||
|
<Tag>{automationTitle}</Tag>
|
||||||
|
<IconButton
|
||||||
|
size='sm'
|
||||||
|
variant='ontime-ghosted'
|
||||||
|
color='#FA5656' // $red-500
|
||||||
|
icon={<IoTrash />}
|
||||||
|
aria-label='Delete entry'
|
||||||
|
onClick={() => handleDelete(id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { TimerLifeCycle } from 'ontime-types';
|
||||||
|
|
||||||
|
export const eventTriggerOptions: TimerLifeCycle[] = [
|
||||||
|
TimerLifeCycle.onLoad,
|
||||||
|
TimerLifeCycle.onStart,
|
||||||
|
TimerLifeCycle.onPause,
|
||||||
|
TimerLifeCycle.onFinish,
|
||||||
|
TimerLifeCycle.onWarning,
|
||||||
|
TimerLifeCycle.onDanger,
|
||||||
|
];
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user