mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-31 13:08:00 +00:00
Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a63fa68d39 | |||
| 20503de6fd | |||
| 0335da9786 | |||
| 9f1a64f53c | |||
| 975356f4f4 | |||
| 1b448835cd | |||
| cbf3d566b9 | |||
| 6c20cb5e99 | |||
| cfc3aab893 | |||
| b7c48f38a9 | |||
| dcb6bfa62d | |||
| 0abc4de3cd | |||
| f750ed3cdc | |||
| f026c1c1e3 | |||
| ce176b01a2 | |||
| 337dd7892a | |||
| b7daf2926a | |||
| 81a07099ae | |||
| 8f88ca5053 | |||
| 82a885dc44 | |||
| c4be814de5 | |||
| 9f3186e249 | |||
| 5159554697 | |||
| cf690aff08 | |||
| 48c4e5f429 | |||
| ad0da6def4 | |||
| a15eb0db4c | |||
| 70e8fbf327 | |||
| 1044dfac01 | |||
| b5e5c113e2 | |||
| cbcff5e7ab | |||
| 83cb2f82f5 | |||
| 837fd53dfd | |||
| 4962288a53 | |||
| 75a35e8537 | |||
| 6e839fcefb | |||
| 052a8e4ccb | |||
| 969c2bad14 | |||
| 9717222491 | |||
| 081c0bdac4 | |||
| 8b99e91883 | |||
| 4af2e688d1 | |||
| cf8c8d4b2e | |||
| 9e63261b35 | |||
| b1838a5e9a | |||
| 9d7b5568e4 | |||
| 657aed1244 | |||
| 13c49f3652 | |||
| 86b4ddadb5 | |||
| e9f806b88a | |||
| 4f672670ed | |||
| 8742b790f3 | |||
| d41cd78f2e | |||
| 715600a514 | |||
| b99cf5f255 | |||
| 9a9a0eb7cf | |||
| 4bf2422ebc | |||
| add769fe1e | |||
| 3af4c64ff3 | |||
| f405e65c55 | |||
| 0e32b3a3af | |||
| c2fa115946 | |||
| edb48a2fc7 | |||
| 6f7846bbf7 | |||
| 48ebd31c54 | |||
| 6d1a911ab8 | |||
| ee1753ef29 |
@@ -0,0 +1,53 @@
|
||||
name: Ontime CLI build
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [ "*" ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build_cli:
|
||||
permissions:
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.18.2
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build project packages
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
run: pnpm build
|
||||
|
||||
- name: Copy server
|
||||
run: mkdir -p apps/cli/server && cp apps/server/dist/index.cjs apps/cli/server/index.cjs
|
||||
|
||||
- name: Copy client
|
||||
run: cp -R apps/client/build apps/cli/client
|
||||
|
||||
- name: Copy external
|
||||
run: cp -R apps/server/src/external apps/cli/external
|
||||
|
||||
- name: Publish to NPM
|
||||
run: pnpm publish --access public --no-git-checks
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
working-directory: ./apps/cli
|
||||
@@ -81,6 +81,9 @@ From the project root, run the following commands
|
||||
|
||||
The build distribution assets will be at `.apps/electron/dist`
|
||||
|
||||
Note: The MacOS build will only work in CI, locally it will fail due to notarisation issues.
|
||||
Use the `turbo dist-mac:local` command to build a MacOS distribution locally.
|
||||
|
||||
## DOCKER
|
||||
|
||||
Ontime provides a docker-compose file to aid with building and running docker images.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"parserOptions": {
|
||||
"sourceType": "module"
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended"
|
||||
],
|
||||
"plugins": [],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-var-requires": "off"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
server
|
||||
external
|
||||
client
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"endOfLine": "lf",
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"printWidth": 120
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# Ontime CLI
|
||||
|
||||

|
||||
> Ontime is an application for managing rundown and event timers.
|
||||
|
||||
Congratulations! You got this far into Ontime's rabbit hole and want to manage your installation.
|
||||
|
||||
The CLI tool is a fast and lightweight way of installing Ontime and is perfect for self-hosting and headless installs.
|
||||
|
||||
## Getting started
|
||||
|
||||
### Prerequisites
|
||||
You will need to have installed the correct version of node.js
|
||||
Please check [the app engines declaration](https://github.com/cpvalente/ontime/blob/master/package.json) for the correct version.
|
||||
|
||||
### Running Ontime
|
||||
To get Ontime running, all you need to do is install it in your system using your package manager of choice:
|
||||
|
||||
Install globally in your system
|
||||
```bash
|
||||
npm install -g @getontime/cli
|
||||
```
|
||||
|
||||
... and run using the installed script
|
||||
```bash
|
||||
ontime
|
||||
```
|
||||
|
||||
Or install and run ontime (the installation here is temporary for the duration of the session)
|
||||
```bash
|
||||
npx install @getontime/cli
|
||||
```
|
||||
|
||||
## Links
|
||||
- [Ontime's repository](https://github.com/cpvalente/ontime)
|
||||
- [Ontime's documentation](https://docs.getontime.no/)
|
||||
- [Ontime's website](https://getontime.no/)
|
||||
|
||||
## Sponsoring
|
||||
You can help the development of this project or say thank you with a one time donation. \
|
||||
See the [terms of donations](https://github.com/cpvalente/ontime/blob/master/SPONSOR.md)
|
||||
|
||||
[](https://github.com/sponsors/cpvalente)
|
||||
[](https://www.buymeacoffee.com/cpvalente)
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// NOTE: for now the following needs to be in place: ./server/index.cjs, ./client, ./external
|
||||
|
||||
const ontimeServer = require('./server/index.cjs');
|
||||
const { initAssets, startServer, startIntegrations, shutdown } = ontimeServer;
|
||||
|
||||
async function startOntime() {
|
||||
await initAssets();
|
||||
|
||||
await startServer();
|
||||
|
||||
await startIntegrations();
|
||||
}
|
||||
|
||||
startOntime();
|
||||
|
||||
process.on(['SIGHUP', 'SIGINT', 'SIGTERM'], () => {
|
||||
shutdown();
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "3.3.3",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
"keywords": [
|
||||
"lighdev",
|
||||
"ontime",
|
||||
"timer"
|
||||
],
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "main.js",
|
||||
"bin": {
|
||||
"ontime": "main.js"
|
||||
},
|
||||
"files": [
|
||||
"client",
|
||||
"external",
|
||||
"server"
|
||||
],
|
||||
"devDependencies": {
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"prettier": "^3.0.3"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "3.1.0",
|
||||
"version": "3.3.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -70,13 +70,13 @@
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@types/testing-library__jest-dom": "^5.14.5",
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"@typescript-eslint/eslint-plugin": "^v7.12.0",
|
||||
"@typescript-eslint/parser": "^7.12.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-jest": "^27.6.0",
|
||||
"eslint-plugin-prettier": "^5.0.1",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-jest": "^28.6.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-react": "^7.32.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-simple-import-sort": "^8.0.0",
|
||||
@@ -84,7 +84,7 @@
|
||||
"jsdom": "^21.1.0",
|
||||
"ontime-types": "workspace:*",
|
||||
"ontime-utils": "workspace:*",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier": "^3.3.1",
|
||||
"sass": "^1.57.1",
|
||||
"typescript": "^5.4.3",
|
||||
"vite": "^5.2.11",
|
||||
|
||||
@@ -4,16 +4,15 @@ import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
|
||||
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
|
||||
import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverlay';
|
||||
import { AppContextProvider } from './common/context/AppContext';
|
||||
import { ontimeQueryClient } from './common/queryClient';
|
||||
import { socketClientName } from './common/stores/connectionName';
|
||||
import { connectSocket } from './common/utils/socket';
|
||||
import theme from './theme/theme';
|
||||
import { TranslationProvider } from './translation/TranslationProvider';
|
||||
import AppRouter from './AppRouter';
|
||||
|
||||
const preferredClientName = socketClientName.getState().name;
|
||||
connectSocket(preferredClientName);
|
||||
connectSocket();
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -24,11 +23,15 @@ function App() {
|
||||
<div className='App'>
|
||||
<ErrorBoundary>
|
||||
<TranslationProvider>
|
||||
<IdentifyOverlay />
|
||||
<AppRouter />
|
||||
</TranslationProvider>
|
||||
</ErrorBoundary>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</div>
|
||||
<ErrorBoundary>
|
||||
<div id='identify-portal' />
|
||||
</ErrorBoundary>
|
||||
</BrowserRouter>
|
||||
</AppContextProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { useClientPath } from './common/hooks/useClientPath';
|
||||
import Log from './features/log/Log';
|
||||
import withPreset from './features/PresetWrapper';
|
||||
import withData from './features/viewers/ViewWrapper';
|
||||
@@ -34,6 +35,9 @@ const TimerControl = lazy(() => import('./features/control/playback/TimerControl
|
||||
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
|
||||
|
||||
export default function AppRouter() {
|
||||
// handle client path changes
|
||||
useClientPath();
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Routes>
|
||||
|
||||
@@ -11,6 +11,7 @@ export const RUNTIME = ['runtimeStore'];
|
||||
export const SHEET_STATE = ['sheetState'];
|
||||
export const URL_PRESETS = ['urlpresets'];
|
||||
export const VIEW_SETTINGS = ['viewSettings'];
|
||||
export const CLIENT_LIST = ['clientList'];
|
||||
|
||||
// resolve location
|
||||
const location = window.location;
|
||||
|
||||
@@ -11,8 +11,8 @@ const dbPath = `${apiEntryUrl}/db`;
|
||||
/**
|
||||
* HTTP request to the current DB
|
||||
*/
|
||||
async function getDb(fileName?: string): Promise<AxiosResponse<DatabaseModel>> {
|
||||
return axios.post(`${dbPath}/download/`, { fileName });
|
||||
async function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
|
||||
return axios.post(`${dbPath}/download/`, { filename });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,7 +123,7 @@ export async function renameProject(filename: string, newFilename: string): Prom
|
||||
const url = `${dbPath}/${filename}/rename`;
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
const res = await axios.put(decodedUrl, {
|
||||
newFilename,
|
||||
filename: newFilename,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
.buttonSection {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $element-spacing;
|
||||
margin-top: -$element-spacing;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftAddon,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { setClientRemote } from '../../hooks/useSocket';
|
||||
|
||||
import style from './ClientModal.module.scss';
|
||||
|
||||
interface RedirectClientModalProps {
|
||||
id: string;
|
||||
name?: string;
|
||||
path?: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function RedirectClientModal(props: RedirectClientModalProps) {
|
||||
const { id, isOpen, name = '', path: currentPath = '', onClose } = props;
|
||||
const [path, setPath] = useState(currentPath);
|
||||
|
||||
const { setRedirect } = setClientRemote;
|
||||
|
||||
const handleRedirect = () => {
|
||||
if (path !== currentPath && path !== '') {
|
||||
setRedirect({ target: id, redirect: path });
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const host = `${window.location.origin}/`;
|
||||
const canSubmit = path !== currentPath && path !== '';
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Redirect: {name}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<InputGroup variant='ontime-filled' size='md'>
|
||||
<InputLeftAddon>{host}</InputLeftAddon>
|
||||
<Input placeholder='minimal?key=0000ffff' value={path} onChange={(event) => setPath(event.target.value)} />
|
||||
</InputGroup>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<div className={style.buttonSection}>
|
||||
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { setClientRemote } from '../../hooks/useSocket';
|
||||
|
||||
import style from './ClientModal.module.scss';
|
||||
|
||||
interface RenameClientModalProps {
|
||||
id: string;
|
||||
name?: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function RenameClientModal(props: RenameClientModalProps) {
|
||||
const { id, name: currentName = '', isOpen, onClose } = props;
|
||||
const [name, setName] = useState(currentName);
|
||||
|
||||
const { setClientName } = setClientRemote;
|
||||
|
||||
const handleRename = () => {
|
||||
if (name !== currentName && name !== '') {
|
||||
setClientName({ target: id, rename: name });
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const canSubmit = name !== currentName && name !== '';
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Rename: {currentName}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<Input
|
||||
variant='ontime-filled'
|
||||
size='md'
|
||||
placeholder='new name'
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<div className={style.buttonSection}>
|
||||
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
border: 10px solid $ui-white;
|
||||
background-color: $ui-black;
|
||||
|
||||
color: $ui-white;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
text-align: center;
|
||||
z-index: 100;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
margin-top: -10vh;
|
||||
line-height: 1em;
|
||||
font-size: 12vw;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.message {
|
||||
color: $label-gray;
|
||||
line-height: 2em;
|
||||
font-size: 3vw;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||
|
||||
import { setClientRemote } from '../../hooks/useSocket';
|
||||
import { useClientStore } from '../../stores/clientStore';
|
||||
|
||||
import style from './IdentifyOverlay.module.scss';
|
||||
|
||||
export default function IdentifyOverlay() {
|
||||
const clients = useClientStore((store) => store.clients);
|
||||
const id = useClientStore((store) => store.id);
|
||||
const showOverlay = clients[id]?.identify;
|
||||
|
||||
if (!showOverlay) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const portalRoot = document.getElementById('identify-portal');
|
||||
|
||||
if (!portalRoot) {
|
||||
return null;
|
||||
}
|
||||
return createPortal(<Overlay />, portalRoot);
|
||||
}
|
||||
|
||||
function Overlay() {
|
||||
const clients = useClientStore((store) => store.clients);
|
||||
const id = useClientStore((store) => store.id);
|
||||
const name = useClientStore((store) => store.name);
|
||||
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const { setIdentify } = setClientRemote;
|
||||
const showOverlay = clients[id]?.identify;
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
setIdentify({ target: id, identify: false });
|
||||
}, [id, setIdentify]);
|
||||
|
||||
// start a timer that will close the overlay after some time
|
||||
useEffect(() => {
|
||||
if (showOverlay) {
|
||||
timerRef.current = setTimeout(handleClose, MILLIS_PER_MINUTE);
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [showOverlay, id, setIdentify, handleClose]);
|
||||
|
||||
console.log('here2');
|
||||
return (
|
||||
<div className={style.overlay} data-testid='identify-overlay' onClick={handleClose}>
|
||||
<div className={style.name}>{name}</div>
|
||||
<div className={style.message}>Click to close</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+28
-21
@@ -7,10 +7,6 @@ $progress-bar-br: 3px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: $progress-bar-size;
|
||||
border-radius: $progress-bar-br;
|
||||
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
&--hidden {
|
||||
display: none;
|
||||
@@ -18,31 +14,42 @@ $progress-bar-br: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.multiprogress-bar__bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--progress-bar-br, $progress-bar-br);
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.multiprogress-bar__indicator {
|
||||
position: absolute;
|
||||
height: inherit;
|
||||
background-color: black;
|
||||
inset: 0;
|
||||
margin: -1px;
|
||||
margin-left: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.multiprogress-bar__indicator-bar {
|
||||
background-color: var(--background-color-override, $ui-black);
|
||||
opacity: 0.8;
|
||||
transition: 1s linear;
|
||||
transition-property: width;
|
||||
right: 0;
|
||||
|
||||
.multiprogress-bar--ignore-css-override & {
|
||||
background-color: $ui-black;
|
||||
}
|
||||
}
|
||||
|
||||
.multiprogress-bar__bg-normal {
|
||||
position: absolute;
|
||||
height: inherit;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.multiprogress-bar__bg-warning {
|
||||
position: absolute;
|
||||
height: inherit;
|
||||
right: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.multiprogress-bar__bg-danger {
|
||||
position: absolute;
|
||||
height: inherit;
|
||||
right: 0;
|
||||
}
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
+33
-13
@@ -13,31 +13,51 @@ interface MultiPartProgressBar {
|
||||
danger?: MaybeNumber;
|
||||
dangerColor: string;
|
||||
hidden?: boolean;
|
||||
ignoreCssOverride?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
||||
const { now, complete, normalColor, warning, warningColor, danger, dangerColor, hidden, className = '' } = props;
|
||||
const {
|
||||
now,
|
||||
complete,
|
||||
normalColor,
|
||||
warning,
|
||||
warningColor,
|
||||
danger,
|
||||
dangerColor,
|
||||
hidden,
|
||||
ignoreCssOverride,
|
||||
className = '',
|
||||
} = props;
|
||||
|
||||
const percentRemaining = complete === 0 ? 0 : 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
|
||||
|
||||
const dangerWidth = danger ? clamp((danger / complete) * 100, 0, 100) : 0;
|
||||
const warningWidth = warning ? clamp((warning / complete) * 100, 0, 100) : 0;
|
||||
const warningWidth = warning ? clamp((warning / complete) * 100 - dangerWidth, 0, 100) : 0;
|
||||
|
||||
return (
|
||||
<div className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${className}`}>
|
||||
<div
|
||||
className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${
|
||||
ignoreCssOverride ? 'multiprogress-bar--ignore-css-override' : ''
|
||||
} ${className}`}
|
||||
>
|
||||
{now !== null && (
|
||||
<>
|
||||
<div className='multiprogress-bar__bg-normal' style={{ backgroundColor: normalColor }} />
|
||||
<div
|
||||
className='multiprogress-bar__bg-warning'
|
||||
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
|
||||
/>
|
||||
<div
|
||||
className='multiprogress-bar__bg-danger'
|
||||
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
|
||||
/>
|
||||
<div className='multiprogress-bar__indicator' style={{ width: `${percentRemaining}%` }} />
|
||||
<div className='multiprogress-bar__bg'>
|
||||
<div className='multiprogress-bar__bg-normal' style={{ backgroundColor: normalColor }} />
|
||||
<div
|
||||
className='multiprogress-bar__bg-warning'
|
||||
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
|
||||
/>
|
||||
<div
|
||||
className='multiprogress-bar__bg-danger'
|
||||
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
|
||||
/>
|
||||
</div>
|
||||
<div className='multiprogress-bar__indicator'>
|
||||
<div className='multiprogress-bar__indicator-bar' style={{ width: `${percentRemaining}%` }} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -19,10 +19,10 @@ import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
||||
|
||||
import { navigatorConstants } from '../../../viewerConfig';
|
||||
import useClickOutside from '../../hooks/useClickOutside';
|
||||
import { useClientStore } from '../../stores/clientStore';
|
||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||
import { isKeyEnter } from '../../utils/keyEvent';
|
||||
|
||||
import RenameClientModal from './rename-client-modal/RenameClientModal';
|
||||
import { RenameClientModal } from '../client-modal/RenameClientModal';
|
||||
|
||||
import style from './NavigationMenu.module.scss';
|
||||
|
||||
@@ -34,8 +34,10 @@ interface NavigationMenuProps {
|
||||
function NavigationMenu(props: NavigationMenuProps) {
|
||||
const { isOpen, onClose } = props;
|
||||
|
||||
const { isOpen: isRenameOpen, onOpen: onRenameOpen, onClose: onRenameClose } = useDisclosure();
|
||||
const id = useClientStore((store) => store.id);
|
||||
const name = useClientStore((store) => store.name);
|
||||
|
||||
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
|
||||
const { fullscreen, toggle } = useFullscreen();
|
||||
const { toggleMirror } = useViewOptionsStore();
|
||||
|
||||
@@ -45,7 +47,7 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
|
||||
return createPortal(
|
||||
<div id='navigation-menu-portal' ref={menuRef}>
|
||||
<RenameClientModal isOpen={isRenameOpen} onClose={onRenameClose} />
|
||||
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
|
||||
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent>
|
||||
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { setClientName } from '../../../hooks/useSocket';
|
||||
import { useSocketClientName } from '../../../stores/connectionName';
|
||||
|
||||
interface RenameClientModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function RenameClientModal({ isOpen, onClose }: RenameClientModalProps) {
|
||||
const { name: clientName, persistName } = useSocketClientName();
|
||||
const [newName, setNewName] = useState(clientName);
|
||||
|
||||
useEffect(() => {
|
||||
setNewName(clientName);
|
||||
}, [isOpen, clientName]);
|
||||
|
||||
const handleRename = async () => {
|
||||
if (newName) {
|
||||
setClientName(newName);
|
||||
persistName(newName);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size='sm'
|
||||
closeOnOverlayClick={false}
|
||||
motionPreset='slideInBottom'
|
||||
scrollBehavior='inside'
|
||||
preserveScrollBarGap
|
||||
variant='ontime'
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Rename client</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<Input
|
||||
placeholder='Connection must have a name'
|
||||
defaultValue={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
variant='ontime-filled'
|
||||
/>
|
||||
<Button
|
||||
isDisabled={newName === clientName || !newName}
|
||||
onClick={handleRename}
|
||||
width='100%'
|
||||
variant='ontime-filled'
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,37 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
.title-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.inline {
|
||||
display: flex;
|
||||
.title-card__title,
|
||||
.title-card__secondary {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.title-card__title {
|
||||
font-weight: 600;
|
||||
font-size: clamp(32px, 3.5vw, 50px);
|
||||
font-size: clamp(1.5rem, 3vw, 3rem);
|
||||
color: var(--color-override, $viewer-color);
|
||||
line-height: 1.1em;
|
||||
line-height: 1.2em;
|
||||
}
|
||||
|
||||
.title-card__secondary {
|
||||
font-size: clamp(1rem, 2vw, 2.25rem);
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
line-height: 1.2em;
|
||||
}
|
||||
|
||||
.title-card__label {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 0.5rem;
|
||||
font-size: clamp(1rem, 1.5vw, 1.5rem);
|
||||
font-weight: 400;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
margin-left: auto;
|
||||
text-transform: uppercase;
|
||||
@@ -28,13 +40,3 @@
|
||||
color: var(--accent-color-override, $accent-color);
|
||||
}
|
||||
}
|
||||
|
||||
.title-card__secondary {
|
||||
font-size: clamp(1.5rem, 2vw, 2.25rem);
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
line-height: 1.1em;
|
||||
|
||||
&::after {
|
||||
content: '\200b';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
import { ForwardedRef, forwardRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
|
||||
import './TitleCard.scss';
|
||||
|
||||
interface TitleCardProps {
|
||||
label: 'now' | 'next';
|
||||
title: string;
|
||||
label?: 'now' | 'next';
|
||||
secondary?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function TitleCard(props: TitleCardProps) {
|
||||
const { label, title, secondary } = props;
|
||||
const TitleCard = forwardRef((props: TitleCardProps, ref: ForwardedRef<HTMLDivElement>) => {
|
||||
const { label, title, secondary, className = '' } = props;
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
const accent = label === 'now';
|
||||
|
||||
return (
|
||||
<div className='title-card'>
|
||||
<div className='inline'>
|
||||
<span className='title-card__title'>{title}</span>
|
||||
<span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}>
|
||||
{getLocalizedString(`common.${label}`)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`title-card ${className}`} ref={ref}>
|
||||
<span className='title-card__title'>{title}</span>
|
||||
<span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}>
|
||||
{label && getLocalizedString(`common.${label}`)}
|
||||
</span>
|
||||
<div className='title-card__secondary'>{secondary}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
TitleCard.displayName = 'TitleCard';
|
||||
export default TitleCard;
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Input, InputGroup, InputLeftElement, Select, Switch } from '@chakra-ui/react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItemOption,
|
||||
MenuList,
|
||||
MenuOptionGroup,
|
||||
Select,
|
||||
Switch,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
|
||||
@@ -34,6 +47,10 @@ export default function ParamInput(props: EditFormInputProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'multi-option') {
|
||||
return <MultiOption paramField={paramField} />;
|
||||
}
|
||||
|
||||
if (type === 'boolean') {
|
||||
const defaultCheckedValue = isStringBoolean(searchParams.get(id)) || defaultValue;
|
||||
|
||||
@@ -70,3 +87,47 @@ export default function ParamInput(props: EditFormInputProps) {
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditFormMultiOptionProps {
|
||||
paramField: ParamField & { type: 'multi-option' };
|
||||
}
|
||||
|
||||
function MultiOption(props: EditFormMultiOptionProps) {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { paramField } = props;
|
||||
const { id, defaultValue } = paramField;
|
||||
|
||||
const optionFromParams = (searchParams.get(id) ?? '').toLocaleLowerCase();
|
||||
const defaultOptionValue = optionFromParams || defaultValue?.toLocaleLowerCase() || '';
|
||||
|
||||
const [paramState, setParamState] = useState<string>(defaultOptionValue);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input name={id} hidden readOnly value={paramState} />
|
||||
<Menu isLazy closeOnSelect={false} variant='ontime-on-dark'>
|
||||
<MenuButton as={Button} variant='ontime-subtle-white' position='relative' width='fit-content' fontWeight={400}>
|
||||
{paramField.title}
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
<MenuOptionGroup
|
||||
type='checkbox'
|
||||
value={paramState.split('_')}
|
||||
onChange={(value) => {
|
||||
setParamState(typeof value === 'object' ? value.filter((v) => v !== '').join('_') : value);
|
||||
}}
|
||||
>
|
||||
{Object.values(paramField.values).map((option) => {
|
||||
const { value, label } = option;
|
||||
return (
|
||||
<MenuItemOption value={value} key={value}>
|
||||
{label}
|
||||
</MenuItemOption>
|
||||
);
|
||||
})}
|
||||
</MenuOptionGroup>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { CustomFields } from 'ontime-types';
|
||||
|
||||
import { capitaliseFirstLetter } from '../../../features/viewers/common/viewUtils';
|
||||
|
||||
import { ParamField } from './types';
|
||||
import { type ParamField } from './types';
|
||||
|
||||
const makeOptionsFromCustomFields = (customFields: CustomFields, additionalOptions?: Record<string, string>) => {
|
||||
const customFieldOptions = Object.keys(customFields).reduce((acc, key) => {
|
||||
return { ...acc, [`custom-${key}`]: `Custom: ${capitaliseFirstLetter(key)}` };
|
||||
const customFieldOptions = Object.entries(customFields).reduce((acc, [key, value]) => {
|
||||
return { ...acc, [`custom-${key}`]: `Custom: ${value.label}` };
|
||||
}, additionalOptions ?? {});
|
||||
return customFieldOptions;
|
||||
};
|
||||
@@ -111,6 +109,7 @@ export const getClockOptions = (timeFormat: string): ParamField[] => [
|
||||
];
|
||||
|
||||
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
|
||||
const mainOptions = makeOptionsFromCustomFields(customFields, { title: 'Title' });
|
||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
|
||||
return [
|
||||
getTimeOption(timeFormat),
|
||||
@@ -123,6 +122,14 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'main',
|
||||
title: 'Main text',
|
||||
description: 'Select the data source for the main text',
|
||||
type: 'option',
|
||||
values: mainOptions,
|
||||
defaultValue: 'Title',
|
||||
},
|
||||
{
|
||||
id: 'secondary-src',
|
||||
title: 'Secondary text',
|
||||
@@ -455,8 +462,8 @@ export const getStudioClockOptions = (timeFormat: string): ParamField[] => [
|
||||
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ParamField[] => {
|
||||
const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
|
||||
|
||||
const customFieldSelect = Object.keys(customFields).reduce((acc, key) => {
|
||||
return { ...acc, [key]: `Custom: ${capitaliseFirstLetter(key)}` };
|
||||
const customFieldSelect = Object.entries(customFields).reduce((acc, [key, field]) => {
|
||||
return { ...acc, [key]: { value: key, label: field.label, colour: field.colour } };
|
||||
}, {});
|
||||
|
||||
return [
|
||||
@@ -488,9 +495,8 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
|
||||
id: 'subscribe',
|
||||
title: 'Highlight Field',
|
||||
description: 'Choose a custom field to highlight',
|
||||
type: 'option',
|
||||
type: 'multi-option',
|
||||
values: customFieldSelect,
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
id: 'shouldEdit',
|
||||
|
||||
@@ -9,8 +9,15 @@ type OptionsField = {
|
||||
values: Record<string, string>;
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
type MultiOptionsField = {
|
||||
type: 'multi-option';
|
||||
values: Record<string, { value: string; label: string; colour: string }>;
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
|
||||
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
|
||||
type BooleanField = { type: 'boolean'; defaultValue: boolean };
|
||||
|
||||
export type ParamField = BaseField & (StringField | BooleanField | NumberField | OptionsField);
|
||||
export type ParamField = BaseField & (StringField | BooleanField | NumberField | OptionsField | MultiOptionsField);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useClientStore } from '../stores/clientStore';
|
||||
import { socketSendJson } from '../utils/socket';
|
||||
|
||||
export const useClientPath = () => {
|
||||
const navigate = useNavigate();
|
||||
const { pathname, search } = useLocation();
|
||||
const redirect = useClientStore((store) => store.redirect);
|
||||
const setRedirect = useClientStore((store) => store.setRedirect);
|
||||
|
||||
// notify of client path changes
|
||||
useEffect(() => {
|
||||
//remove leading '/' from path
|
||||
const fullPath = (pathname.startsWith('/') ? pathname.slice(1) : pathname) + search;
|
||||
socketSendJson('set-client-path', fullPath);
|
||||
}, [pathname, search]);
|
||||
|
||||
// navigate to new path when received from server
|
||||
useEffect(() => {
|
||||
if (redirect === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// clear redirect
|
||||
setRedirect('');
|
||||
|
||||
// navigate if there is a path change
|
||||
if (redirect !== pathname + search) {
|
||||
navigate(redirect);
|
||||
}
|
||||
}, [navigate, pathname, redirect, search, setRedirect]);
|
||||
};
|
||||
@@ -3,6 +3,12 @@ import { RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
|
||||
import { useRuntimeStore } from '../stores/runtime';
|
||||
import { socketSendJson } from '../utils/socket';
|
||||
|
||||
export const setClientRemote = {
|
||||
setIdentify: (payload: { target: string; identify: boolean }) => socketSendJson('client', payload),
|
||||
setRedirect: (payload: { target: string; redirect: string }) => socketSendJson('client', payload),
|
||||
setClientName: (payload: { target: string; rename: string }) => socketSendJson('client', payload),
|
||||
};
|
||||
|
||||
export const useRundownEditor = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
playback: state.timer.playback,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { ClientList } from 'ontime-types';
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface ClientStore {
|
||||
name?: string;
|
||||
setName: (newValue: string) => void;
|
||||
|
||||
id: string;
|
||||
setId: (newValue: string) => void;
|
||||
|
||||
redirect: string;
|
||||
setRedirect: (newValue: string) => void;
|
||||
|
||||
clients: ClientList;
|
||||
setClients: (clients: ClientList) => void;
|
||||
}
|
||||
|
||||
const clientNameKey = 'ontime-client-name';
|
||||
|
||||
function persistNameInStorage(newValue: string) {
|
||||
localStorage.setItem(clientNameKey, newValue);
|
||||
}
|
||||
|
||||
export const useClientStore = create<ClientStore>((set) => ({
|
||||
name: localStorage.getItem(clientNameKey) ?? undefined,
|
||||
setName: (name: string) =>
|
||||
set(() => {
|
||||
persistNameInStorage(name);
|
||||
return { name };
|
||||
}),
|
||||
|
||||
id: '',
|
||||
setId: (id) => set({ id }),
|
||||
|
||||
redirect: '',
|
||||
setRedirect: (redirect: string) => set({ redirect }),
|
||||
|
||||
clients: {},
|
||||
setClients: (clients: ClientList) => set({ clients }),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Allows getting client name (outside react)
|
||||
*/
|
||||
export function getClientName(): string | undefined {
|
||||
return useClientStore.getState().name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows updating current client name (outside react)
|
||||
*/
|
||||
export function setClientName(name: string): void {
|
||||
useClientStore.getState().setName(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows updating current client name (outside react)
|
||||
*/
|
||||
export function setClientId(id: string): void {
|
||||
useClientStore.getState().setId(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows getting client name (outside react)
|
||||
*/
|
||||
export function getClientId(): string | undefined {
|
||||
return useClientStore.getState().id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows updating redirect (outside react)
|
||||
*/
|
||||
export function setClientRedirect(path: string): void {
|
||||
useClientStore.getState().setRedirect(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows setting clients (outside react)
|
||||
*/
|
||||
export function setClients(clients: ClientList): void {
|
||||
return useClientStore.getState().setClients(clients);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { useStore } from 'zustand';
|
||||
import { createStore } from 'zustand/vanilla';
|
||||
|
||||
interface SocketClientNameState {
|
||||
name?: string;
|
||||
setName: (newValue: string) => void;
|
||||
persistName: (newValue: string) => void;
|
||||
}
|
||||
|
||||
const clientNameKey = 'ontime-client-name';
|
||||
|
||||
function persistKeyToStorage(newValue: string) {
|
||||
localStorage.setItem(clientNameKey, newValue);
|
||||
}
|
||||
|
||||
export const socketClientName = createStore<SocketClientNameState>((set) => ({
|
||||
name: localStorage.getItem(clientNameKey) ?? undefined,
|
||||
setName: (newValue: string) => set(() => ({ name: newValue })),
|
||||
persistName: (newValue: string) =>
|
||||
set(() => {
|
||||
persistKeyToStorage(newValue);
|
||||
return { name: newValue };
|
||||
}),
|
||||
}));
|
||||
|
||||
export const useSocketClientName = () => useStore(socketClientName);
|
||||
@@ -1,21 +1,31 @@
|
||||
import { Log, RuntimeStore } from 'ontime-types';
|
||||
|
||||
import { isProduction, RUNTIME, websocketUrl } from '../api/constants';
|
||||
import { CLIENT_LIST, isProduction, RUNTIME, websocketUrl } from '../api/constants';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
import { socketClientName } from '../stores/connectionName';
|
||||
import {
|
||||
getClientId,
|
||||
getClientName,
|
||||
setClientId,
|
||||
setClientName,
|
||||
setClientRedirect,
|
||||
setClients,
|
||||
} from '../stores/clientStore';
|
||||
import { addLog } from '../stores/logger';
|
||||
import { patchRuntime, runtimeStore } from '../stores/runtime';
|
||||
|
||||
export let websocket: WebSocket | null = null;
|
||||
let reconnectTimeout: NodeJS.Timeout | null = null;
|
||||
const reconnectInterval = 1000;
|
||||
|
||||
export let shouldReconnect = true;
|
||||
export let hasConnected = false;
|
||||
export let reconnectAttempts = 0;
|
||||
|
||||
export const connectSocket = (preferredClientName?: string) => {
|
||||
export const connectSocket = () => {
|
||||
websocket = new WebSocket(websocketUrl);
|
||||
|
||||
const preferredClientName = getClientName();
|
||||
|
||||
websocket.onopen = () => {
|
||||
clearTimeout(reconnectTimeout as NodeJS.Timeout);
|
||||
hasConnected = true;
|
||||
@@ -24,6 +34,9 @@ export const connectSocket = (preferredClientName?: string) => {
|
||||
if (preferredClientName) {
|
||||
socketSendJson('set-client-name', preferredClientName);
|
||||
}
|
||||
|
||||
socketSendJson('set-client-type', 'ontime');
|
||||
socketSendJson('set-client-path', location.pathname);
|
||||
};
|
||||
|
||||
websocket.onclose = () => {
|
||||
@@ -54,10 +67,48 @@ export const connectSocket = (preferredClientName?: string) => {
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'client-name': {
|
||||
socketClientName.getState().setName(payload);
|
||||
case 'client-id': {
|
||||
if (typeof payload === 'string') {
|
||||
setClientId(payload);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'client-name': {
|
||||
if (typeof payload === 'string') {
|
||||
setClientName(payload);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'client-rename': {
|
||||
if (typeof payload === 'object') {
|
||||
const id = getClientId();
|
||||
if (payload.target && payload.target === id) {
|
||||
setClientName(payload.name);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'client-redirect': {
|
||||
if (typeof payload === 'object') {
|
||||
const id = getClientId();
|
||||
if (payload.target && payload.target === id) {
|
||||
setClientRedirect(payload.path);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'client-list': {
|
||||
setClients(payload);
|
||||
if (!isProduction) {
|
||||
ontimeQueryClient.setQueryData(CLIENT_LIST, payload);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ontime-log': {
|
||||
addLog(payload as Log);
|
||||
break;
|
||||
|
||||
@@ -3,10 +3,11 @@ import { ErrorBoundary } from '@sentry/react';
|
||||
import { useKeyDown } from '../../common/hooks/useKeyDown';
|
||||
|
||||
import AboutPanel from './panel/about-panel/AboutPanel';
|
||||
import ClientControlPanel from './panel/client-control-panel/ClientControlPanel';
|
||||
import FeatureSettingsPanel from './panel/feature-settings-panel/FeatureSettingsPanel';
|
||||
import GeneralPanel from './panel/general-panel/GeneralPanel';
|
||||
import IntegrationsPanel from './panel/integrations-panel/IntegrationsPanel';
|
||||
import LogPanel from './panel/log-panel/LogPanel';
|
||||
import NetworkLogPanel from './panel/network-panel/NetworkLogPanel';
|
||||
import ProjectPanel from './panel/project-panel/ProjectPanel';
|
||||
import ShutdownPanel from './panel/shutdown-panel/ShutdownPanel';
|
||||
import SourcesPanel from './panel/sources-panel/SourcesPanel';
|
||||
@@ -30,8 +31,9 @@ export default function AppSettings() {
|
||||
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
|
||||
{panel === 'sources' && <SourcesPanel />}
|
||||
{panel === 'integrations' && <IntegrationsPanel location={location} />}
|
||||
{panel === 'client_control' && <ClientControlPanel />}
|
||||
{panel === 'about' && <AboutPanel />}
|
||||
{panel === 'log' && <LogPanel />}
|
||||
{panel === 'network' && <NetworkLogPanel location={location} />}
|
||||
{panel === 'shutdown' && <ShutdownPanel />}
|
||||
</PanelContent>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -73,12 +73,12 @@ $inner-padding: 1rem;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
box-shadow: 0 1px $white-10;
|
||||
background-color: $gray-1350;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 400;
|
||||
color: $gray-400;
|
||||
background-color: $gray-1350;
|
||||
white-space: nowrap;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
display: flex;
|
||||
gap: $element-spacing;
|
||||
}
|
||||
|
||||
.halfWidth {
|
||||
width: 50%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pathList {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.badgeList {
|
||||
width: 100%;
|
||||
* {
|
||||
margin-right: $element-spacing;
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import ClientList from './ClientList';
|
||||
|
||||
export default function ClientControlPanel() {
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>Manage clients</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<ClientList />
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useState } from 'react';
|
||||
import { Badge, Button, useDisclosure } from '@chakra-ui/react';
|
||||
|
||||
import { RedirectClientModal } from '../../../../common/components/client-modal/RedirectClientModal';
|
||||
import { RenameClientModal } from '../../../../common/components/client-modal/RenameClientModal';
|
||||
import { setClientRemote } from '../../../../common/hooks/useSocket';
|
||||
import { useClientStore } from '../../../../common/stores/clientStore';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import style from './ClientControlPanel.module.scss';
|
||||
|
||||
export default function ClientList() {
|
||||
const id = useClientStore((store) => store.id);
|
||||
const clients = useClientStore((store) => store.clients);
|
||||
const { isOpen: isOpenRedirect, onOpen: onOpenRedirect, onClose: onCloseRedirect } = useDisclosure();
|
||||
const { isOpen: isOpenRename, onOpen: onOpenRename, onClose: onCloseRename } = useDisclosure();
|
||||
const { setIdentify } = setClientRemote;
|
||||
|
||||
const [targetId, setTargetId] = useState('');
|
||||
|
||||
const openRename = (targetId: string) => {
|
||||
setTargetId(targetId);
|
||||
onOpenRename();
|
||||
};
|
||||
|
||||
const openRedirect = (targetId: string) => {
|
||||
setTargetId(targetId);
|
||||
onOpenRedirect();
|
||||
};
|
||||
|
||||
const ontimeClients = Object.entries(clients).filter(([_, { type }]) => type === 'ontime');
|
||||
const otherClients = Object.entries(clients).filter(([_, { type }]) => type !== 'ontime');
|
||||
|
||||
const targetClient = clients[targetId];
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpenRedirect && (
|
||||
<RedirectClientModal
|
||||
id={targetId}
|
||||
name={targetClient?.name}
|
||||
path={targetClient?.path}
|
||||
isOpen={isOpenRedirect}
|
||||
onClose={onCloseRedirect}
|
||||
/>
|
||||
)}
|
||||
{isOpenRename && (
|
||||
<RenameClientModal id={targetId} name={targetClient?.name} isOpen={isOpenRename} onClose={onCloseRename} />
|
||||
)}
|
||||
<Panel.Section>
|
||||
<Panel.Title>Ontime Clients ({ontimeClients.length})</Panel.Title>
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td className={style.fullWidth}>Client Name (Connection ID)</td>
|
||||
<td className={style.fullWidth}>Path</td>
|
||||
<td />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ontimeClients.map(([key, client]) => {
|
||||
const { identify, name, path } = client;
|
||||
const isCurrent = id === key;
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td className={style.badgeList}>
|
||||
<Badge variant='outline' size='xs'>
|
||||
{key}
|
||||
</Badge>
|
||||
{isCurrent && (
|
||||
<Badge variant='outline' colorScheme='yellow' size='xs'>
|
||||
self
|
||||
</Badge>
|
||||
)}
|
||||
{name}
|
||||
</td>
|
||||
{isCurrent ? <td /> : <td className={style.pathList}>{path}</td>}
|
||||
<td className={style.actionButtons}>
|
||||
<Button
|
||||
size='xs'
|
||||
className={`${identify ? style.blink : ''}`}
|
||||
isDisabled={isCurrent}
|
||||
variant={identify ? 'ontime-filled' : 'ontime-subtle'}
|
||||
data-testid={isCurrent ? '' : 'not-self-identify'}
|
||||
onClick={() => {
|
||||
setIdentify({ target: key, identify: !identify });
|
||||
}}
|
||||
>
|
||||
Identify
|
||||
</Button>
|
||||
<Button
|
||||
size='xs'
|
||||
variant='ontime-subtle'
|
||||
data-testid={isCurrent ? '' : 'not-self-rename'}
|
||||
onClick={() => openRename(key)}
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size='xs'
|
||||
variant='ontime-subtle'
|
||||
isDisabled={isCurrent}
|
||||
data-testid={isCurrent ? '' : 'not-self-redirect'}
|
||||
onClick={() => openRedirect(key)}
|
||||
>
|
||||
Redirect
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Section>
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Panel.Title>Other Clients ({otherClients.length})</Panel.Title>
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td className={style.halfWidth}>Client Name (Connection ID)</td>
|
||||
<td className={style.halfWidth}>Client type</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{otherClients.map(([key, client]) => {
|
||||
const { name, type } = client;
|
||||
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td className={style.badgeList}>
|
||||
<Badge variant='outline' size='sx'>
|
||||
{key}
|
||||
</Badge>
|
||||
{name}
|
||||
</td>
|
||||
<td>{type}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -67,7 +67,7 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
||||
return (
|
||||
<form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}>
|
||||
<div className={style.column}>
|
||||
<Panel.Description>Label</Panel.Description>
|
||||
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
|
||||
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
|
||||
<Input
|
||||
{...register('label', {
|
||||
|
||||
@@ -153,6 +153,7 @@ export default function GeneralPanelForm() {
|
||||
<option value='pt'>Portuguese</option>
|
||||
<option value='es'>Spanish</option>
|
||||
<option value='sv'>Swedish</option>
|
||||
<option value='pl'>Polish</option>
|
||||
</Select>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
|
||||
@@ -14,4 +14,6 @@ export const cycles: CycleLabel[] = [
|
||||
{ id: 5, label: 'Every second', value: 'onClock' },
|
||||
{ id: 5, label: 'On Timer Update', value: 'onUpdate' },
|
||||
{ id: 6, label: 'On Finish', value: 'onFinish' },
|
||||
{ id: 7, label: 'On Warning', value: 'onWarning' },
|
||||
{ id: 8, label: 'On Danger', value: 'onDanger' },
|
||||
];
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import LogExport from './LogExport';
|
||||
import InfoNif from './NetworkInterfaces';
|
||||
|
||||
export default function LogPanel() {
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Log</Panel.Header>
|
||||
<Panel.Section>
|
||||
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
|
||||
</Panel.Section>
|
||||
<InfoNif />
|
||||
<LogExport />
|
||||
</>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { handleLinks } from '../../../../common/utils/linkUtils';
|
||||
import Log from '../../../log/Log';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import style from './LogExport.module.scss';
|
||||
import style from './NetworkLogExport.module.scss';
|
||||
|
||||
export default function LogExport() {
|
||||
const extract = (event: MouseEvent) => {
|
||||
@@ -0,0 +1,28 @@
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { PanelBaseProps } from '../../settingsStore';
|
||||
import ClientControlPanel from '../client-control-panel/ClientControlPanel';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import InfoNif from './NetworkInterfaces';
|
||||
import LogExport from './NetworkLogExport';
|
||||
|
||||
export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
||||
const clientsRef = useScrollIntoView<HTMLDivElement>('clients', location);
|
||||
const logRef = useScrollIntoView<HTMLDivElement>('log', location);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Network</Panel.Header>
|
||||
<Panel.Section>
|
||||
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
|
||||
</Panel.Section>
|
||||
<InfoNif />
|
||||
<div ref={logRef}>
|
||||
<LogExport />
|
||||
</div>
|
||||
<div ref={clientsRef}>
|
||||
<ClientControlPanel />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -50,7 +50,8 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
||||
const handleSubmitCreate = async (values: ProjectCreateFormValues) => {
|
||||
try {
|
||||
setError(null);
|
||||
const filename = values.title?.trim();
|
||||
|
||||
const filename = values.title ?? 'untitled';
|
||||
|
||||
await createProject({
|
||||
...values,
|
||||
|
||||
@@ -2,8 +2,6 @@ import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Button, Input } from '@chakra-ui/react';
|
||||
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import style from './ProjectPanel.module.scss';
|
||||
|
||||
export type ProjectFormValues = {
|
||||
@@ -15,10 +13,9 @@ interface ProjectFormProps {
|
||||
filename: string;
|
||||
onCancel: () => void;
|
||||
onSubmit: (values: ProjectFormValues) => Promise<void>;
|
||||
submitError: string | null;
|
||||
}
|
||||
|
||||
export default function ProjectForm({ action, filename, onSubmit, onCancel, submitError }: ProjectFormProps) {
|
||||
export default function ProjectForm({ action, filename, onSubmit, onCancel }: ProjectFormProps) {
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
@@ -37,34 +34,31 @@ export default function ProjectForm({ action, filename, onSubmit, onCancel, subm
|
||||
}, [setFocus]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={style.form}>
|
||||
<Input
|
||||
className={style.formInput}
|
||||
id='filename'
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={style.form}>
|
||||
<Input
|
||||
className={style.formInput}
|
||||
id='filename'
|
||||
size='sm'
|
||||
type='text'
|
||||
variant='ontime-filled'
|
||||
placeholder='Enter new name'
|
||||
autoComplete='off'
|
||||
{...register('filename', { required: true })}
|
||||
/>
|
||||
<div className={style.actionButtons}>
|
||||
<Button onClick={onCancel} size='sm' variant='ontime-ghosted' disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
type='text'
|
||||
variant='ontime-filled'
|
||||
placeholder='Enter new name'
|
||||
autoComplete='off'
|
||||
{...register('filename')}
|
||||
/>
|
||||
<div className={style.actionButtons}>
|
||||
<Button onClick={onCancel} size='sm' variant='ontime-ghosted' disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
isDisabled={!isDirty || !isValid || isSubmitting}
|
||||
type='submit'
|
||||
className={style.saveButton}
|
||||
>
|
||||
{action}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{submitError && <Panel.Error>{submitError}</Panel.Error>}
|
||||
</>
|
||||
isDisabled={!isDirty || !isValid || isSubmitting}
|
||||
type='submit'
|
||||
className={style.saveButton}
|
||||
>
|
||||
{action}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
renameProject,
|
||||
} from '../../../../common/api/db';
|
||||
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import ProjectForm, { ProjectFormValues } from './ProjectForm';
|
||||
|
||||
@@ -40,36 +41,53 @@ export default function ProjectListItem({
|
||||
onToggleEditMode,
|
||||
}: ProjectListItemProps) {
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmitRename = async (values: ProjectFormValues) => {
|
||||
try {
|
||||
const handleSubmitAction = (actionType: 'rename' | 'duplicate') => {
|
||||
return async (values: ProjectFormValues) => {
|
||||
setLoading(true);
|
||||
setSubmitError(null);
|
||||
|
||||
if (!values.filename) {
|
||||
setSubmitError('Filename cannot be blank');
|
||||
return;
|
||||
try {
|
||||
if (!values.filename) {
|
||||
setSubmitError('Filename cannot be blank');
|
||||
return;
|
||||
}
|
||||
const action = actionType === 'rename' ? renameProject : duplicateProject;
|
||||
await action(filename, values.filename);
|
||||
await onRefetch();
|
||||
onSubmit();
|
||||
} catch (error) {
|
||||
setSubmitError(maybeAxiosError(error));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
await renameProject(filename, values.filename);
|
||||
};
|
||||
};
|
||||
|
||||
const handleLoad = async (filename: string) => {
|
||||
setLoading(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
await loadProject(filename);
|
||||
await onRefetch();
|
||||
onSubmit();
|
||||
await invalidateAllCaches();
|
||||
} catch (error) {
|
||||
setSubmitError(maybeAxiosError(error));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitDuplicate = async (values: ProjectFormValues) => {
|
||||
const handleDelete = async (filename: string) => {
|
||||
setLoading(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
setSubmitError(null);
|
||||
|
||||
if (!values.filename) {
|
||||
setSubmitError('Filename cannot be blank');
|
||||
return;
|
||||
}
|
||||
await duplicateProject(filename, values.filename);
|
||||
await deleteProject(filename);
|
||||
await onRefetch();
|
||||
onSubmit();
|
||||
} catch (error) {
|
||||
setSubmitError(maybeAxiosError(error));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -86,50 +104,55 @@ export default function ProjectListItem({
|
||||
const classes = current && !isCurrentlyBeingEdited ? style.current : undefined;
|
||||
|
||||
return (
|
||||
<tr key={filename} className={classes}>
|
||||
{isCurrentlyBeingEdited ? (
|
||||
<td colSpan={99}>
|
||||
<ProjectForm
|
||||
action={editingMode}
|
||||
filename={filename}
|
||||
onSubmit={editingMode === 'duplicate' ? handleSubmitDuplicate : handleSubmitRename}
|
||||
onCancel={handleCancel}
|
||||
submitError={submitError}
|
||||
/>
|
||||
</td>
|
||||
) : (
|
||||
<>
|
||||
<td className={style.containCell}>{filename}</td>
|
||||
<td>{new Date(updatedAt).toLocaleString()}</td>
|
||||
<td className={style.actionButton}>
|
||||
<ActionMenu
|
||||
current={current}
|
||||
<>
|
||||
{submitError && (
|
||||
<tr key='filename-error'>
|
||||
<td colSpan={99}>
|
||||
<Panel.Error>{submitError}</Panel.Error>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr key={filename} className={classes}>
|
||||
{isCurrentlyBeingEdited ? (
|
||||
<td colSpan={99}>
|
||||
<ProjectForm
|
||||
action={editingMode}
|
||||
filename={filename}
|
||||
onChangeEditMode={handleToggleEditMode}
|
||||
onRefetch={onRefetch}
|
||||
onSubmit={editingMode === 'duplicate' ? handleSubmitAction('duplicate') : handleSubmitAction('rename')}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
) : (
|
||||
<>
|
||||
<td className={style.containCell}>{filename}</td>
|
||||
<td>{new Date(updatedAt).toLocaleString()}</td>
|
||||
<td className={style.actionButton}>
|
||||
<ActionMenu
|
||||
current={current}
|
||||
filename={filename}
|
||||
onChangeEditMode={handleToggleEditMode}
|
||||
onDelete={handleDelete}
|
||||
onLoad={handleLoad}
|
||||
isDisabled={loading}
|
||||
/>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionMenu({
|
||||
current,
|
||||
filename,
|
||||
onChangeEditMode,
|
||||
onRefetch,
|
||||
}: {
|
||||
interface ActionMenuProps {
|
||||
current?: boolean;
|
||||
filename: string;
|
||||
isDisabled: boolean;
|
||||
onChangeEditMode: (editMode: EditMode, filename: string) => void;
|
||||
onRefetch: () => Promise<void>;
|
||||
}) {
|
||||
const handleLoad = async () => {
|
||||
await loadProject(filename);
|
||||
await invalidateAllCaches();
|
||||
};
|
||||
onDelete: (filename: string) => void;
|
||||
onLoad: (filename: string) => void;
|
||||
}
|
||||
function ActionMenu(props: ActionMenuProps) {
|
||||
const { current, filename, isDisabled, onChangeEditMode, onDelete, onLoad } = props;
|
||||
|
||||
const handleRename = () => {
|
||||
onChangeEditMode('rename', filename);
|
||||
@@ -139,11 +162,6 @@ function ActionMenu({
|
||||
onChangeEditMode('duplicate', filename);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
await deleteProject(filename);
|
||||
await onRefetch();
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
await downloadProject(filename);
|
||||
};
|
||||
@@ -161,16 +179,17 @@ function ActionMenu({
|
||||
color='#e2e2e2' // $gray-200
|
||||
variant='ontime-ghosted'
|
||||
size='sm'
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
<MenuList>
|
||||
<MenuItem onClick={handleLoad} isDisabled={current}>
|
||||
<MenuItem onClick={() => onLoad(filename)} isDisabled={current}>
|
||||
Load
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleRename}>Rename</MenuItem>
|
||||
<MenuItem onClick={handleDuplicate}>Duplicate</MenuItem>
|
||||
<MenuItem onClick={handleDownload}>Download</MenuItem>
|
||||
<MenuItem onClick={handleExportCSV}>Export CSV Rundown</MenuItem>
|
||||
<MenuItem isDisabled={current} onClick={handleDelete}>
|
||||
<MenuItem isDisabled={current} onClick={() => onDelete(filename)}>
|
||||
Delete
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.uploadSection,
|
||||
.successSection {
|
||||
.finishSection {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
padding: 3rem 1rem;
|
||||
@@ -15,12 +15,18 @@
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.successSection {
|
||||
color: $green-500;
|
||||
.finishSection {
|
||||
font-size: 1.5rem;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
.error {
|
||||
color: $red-500;
|
||||
}
|
||||
.success {
|
||||
color: $green-500;
|
||||
}
|
||||
}
|
||||
|
||||
.buttonRow {
|
||||
|
||||
@@ -134,11 +134,20 @@ export default function SourcesPanel() {
|
||||
await exportRundown(sheetId, importMap);
|
||||
};
|
||||
|
||||
const resetFlow = () => {
|
||||
setImportFlow('none');
|
||||
setRundown(null);
|
||||
setHasFile('none');
|
||||
setWorksheets(null);
|
||||
setCustomFields(null);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const isExcelFlow = importFlow === 'excel';
|
||||
const isGSheetFlow = importFlow === 'gsheet';
|
||||
const isAuthenticated = authenticationStatus === 'authenticated';
|
||||
const showInput = importFlow === 'none';
|
||||
const showSuccess = importFlow === 'finished';
|
||||
const showCompleted = importFlow === 'finished';
|
||||
const showAuth = isGSheetFlow && !isAuthenticated;
|
||||
const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile === 'done');
|
||||
const showReview = rundown !== null && customFields !== null;
|
||||
@@ -189,10 +198,18 @@ export default function SourcesPanel() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{showSuccess && (
|
||||
<div className={style.successSection}>
|
||||
<span>Import successful</span>
|
||||
<Button variant='ontime-filled' size='sm' onClick={() => setImportFlow('none')}>
|
||||
{showCompleted && (
|
||||
<div className={style.finishSection}>
|
||||
{error ? (
|
||||
<span key='finish__error' className={style.error}>
|
||||
Import failed
|
||||
</span>
|
||||
) : (
|
||||
<span key='finish__success' className={style.success}>
|
||||
Import successful
|
||||
</span>
|
||||
)}
|
||||
<Button variant='ontime-filled' size='sm' onClick={resetFlow}>
|
||||
Return
|
||||
</Button>
|
||||
</div>
|
||||
@@ -200,6 +217,7 @@ export default function SourcesPanel() {
|
||||
{showAuth && <GSheetSetup onCancel={cancelGSheetFlow} />}
|
||||
{showImportMap && !showReview && (
|
||||
<ImportMapForm
|
||||
hasErrors={Boolean(error)}
|
||||
isSpreadsheet={isExcelFlow}
|
||||
onCancel={cancelImportMap}
|
||||
onSubmitExport={handleSubmitExport}
|
||||
|
||||
+4
-3
@@ -15,14 +15,15 @@ import { convertToImportMap, getPersistedOptions, NamedImportMap, persistImportM
|
||||
import style from '../SourcesPanel.module.scss';
|
||||
|
||||
interface ImportMapFormProps {
|
||||
isSpreadsheet?: boolean;
|
||||
hasErrors: boolean;
|
||||
isSpreadsheet: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmitExport: (importMap: ImportMap) => Promise<void>;
|
||||
onSubmitImport: (importMap: ImportMap) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function ImportMapForm(props: ImportMapFormProps) {
|
||||
const { isSpreadsheet, onCancel, onSubmitExport, onSubmitImport } = props;
|
||||
const { hasErrors, isSpreadsheet, onCancel, onSubmitExport, onSubmitImport } = props;
|
||||
const namedImportMap = getPersistedOptions();
|
||||
const { revoke } = useGoogleSheet();
|
||||
const {
|
||||
@@ -78,7 +79,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
|
||||
const isLoading = Boolean(loading);
|
||||
const canSubmitSpreadsheet = isSpreadsheet && !isLoading;
|
||||
const canSubmitGSheet = !isLoading;
|
||||
const canSubmit = isValid && (canSubmitSpreadsheet || canSubmitGSheet);
|
||||
const canSubmit = !hasErrors && isValid && (canSubmitSpreadsheet || canSubmitGSheet);
|
||||
|
||||
return (
|
||||
<Panel.Section as='form' id='import-map'>
|
||||
|
||||
@@ -50,7 +50,21 @@ export const settingPanels: Readonly<SettingsOption[]> = [
|
||||
{ id: 'integrations__http', label: 'HTTP settings' },
|
||||
],
|
||||
},
|
||||
{ id: 'log', label: 'Log', split: true },
|
||||
{
|
||||
id: 'network',
|
||||
label: 'Network',
|
||||
split: true,
|
||||
secondary: [
|
||||
{
|
||||
id: 'network__log',
|
||||
label: 'Application log',
|
||||
},
|
||||
{
|
||||
id: 'network__clients',
|
||||
label: 'Manage clients',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
label: 'About',
|
||||
|
||||
@@ -43,8 +43,8 @@ export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProp
|
||||
const finish = millisToString(expectedFinish);
|
||||
|
||||
const isRolling = playback === Playback.Roll;
|
||||
const isWaiting = timer.secondaryTimer !== null && timer.secondaryTimer > 0 && timer.current === null;
|
||||
const isOvertime = timer.phase === TimerPhase.Negative;
|
||||
const isWaiting = timer.phase === TimerPhase.Pending;
|
||||
const isOvertime = timer.phase === TimerPhase.Overtime;
|
||||
const hasAddedTime = Boolean(timer.addedTime);
|
||||
|
||||
const rollLabel = isRolling ? 'Roll mode active' : '';
|
||||
|
||||
@@ -47,11 +47,13 @@ $table-header-font-size: calc(1rem - 3px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background-color: $gray-1300;
|
||||
|
||||
font-size: $table-header-font-size;
|
||||
color: $gray-700;
|
||||
font-weight: 400;
|
||||
|
||||
th {
|
||||
background-color: $gray-1300;
|
||||
}
|
||||
}
|
||||
|
||||
.eventRow {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import Color from 'color';
|
||||
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
|
||||
@@ -13,7 +12,7 @@ import DelayRow from './cuesheet-table-elements/DelayRow';
|
||||
import EventRow from './cuesheet-table-elements/EventRow';
|
||||
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
|
||||
import { useCuesheetSettings } from './store/CuesheetSettings';
|
||||
import { initialColumnOrder } from './cuesheetCols';
|
||||
import useColumnManager from './useColumnManager';
|
||||
|
||||
import style from './Cuesheet.module.scss';
|
||||
|
||||
@@ -27,23 +26,20 @@ interface CuesheetProps {
|
||||
export default function Cuesheet({ data, columns, handleUpdate, selectedId }: CuesheetProps) {
|
||||
const { followSelected, showSettings, showDelayBlock, showPrevious, showIndexColumn } = useCuesheetSettings();
|
||||
|
||||
const [columnVisibility, setColumnVisibility] = useLocalStorage({ key: 'table-hidden', defaultValue: {} });
|
||||
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
|
||||
key: 'table-order',
|
||||
defaultValue: initialColumnOrder,
|
||||
});
|
||||
const [columnSizing, setColumnSizing] = useLocalStorage({ key: 'table-sizes', defaultValue: {} });
|
||||
const {
|
||||
columnVisibility,
|
||||
columnOrder,
|
||||
columnSizing,
|
||||
resetColumnOrder,
|
||||
setColumnVisibility,
|
||||
saveColumnOrder,
|
||||
setColumnSizing,
|
||||
} = useColumnManager(columns);
|
||||
|
||||
const selectedRef = useRef<HTMLTableRowElement | null>(null);
|
||||
const tableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected });
|
||||
|
||||
// keep column order in sync with columns
|
||||
useEffect(() => {
|
||||
const order = columns.map((col) => col.id as string);
|
||||
saveColumnOrder(order);
|
||||
}, [columns, saveColumnOrder]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
@@ -61,10 +57,6 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const resetColumnOrder = () => {
|
||||
saveColumnOrder(initialColumnOrder);
|
||||
};
|
||||
|
||||
const setAllVisible = () => {
|
||||
table.toggleAllColumnsVisible(true);
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function CuesheetProgress() {
|
||||
danger={timeDanger}
|
||||
dangerColor={data!.dangerColor}
|
||||
className={styles.progressOverride}
|
||||
ignoreCssOverride
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -117,5 +117,3 @@ export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<Ontim
|
||||
...dynamicCustomFields,
|
||||
];
|
||||
}
|
||||
|
||||
export const initialColumnOrder: string[] = makeCuesheetColumns({}).map((column) => column.id as string);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { OntimeRundownEntry } from 'ontime-types';
|
||||
|
||||
export default function useColumnManager(columns: ColumnDef<OntimeRundownEntry>[]) {
|
||||
const [columnVisibility, setColumnVisibility] = useLocalStorage({ key: 'table-hidden', defaultValue: {} });
|
||||
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
|
||||
key: 'table-order',
|
||||
defaultValue: columns.map((col) => col.id as string),
|
||||
});
|
||||
const [columnSizing, setColumnSizing] = useLocalStorage({ key: 'table-sizes', defaultValue: {} });
|
||||
|
||||
// if the columns change, we update the dataset
|
||||
useEffect(() => {
|
||||
let shouldReplace = false;
|
||||
const newColumns: string[] = [];
|
||||
|
||||
// iterate through columns to see if there are new ids
|
||||
columns.forEach((column) => {
|
||||
const columnnId = column.id as string;
|
||||
if (!shouldReplace && !columnOrder.includes(columnnId)) {
|
||||
shouldReplace = true;
|
||||
}
|
||||
newColumns.push(columnnId);
|
||||
});
|
||||
|
||||
if (shouldReplace) {
|
||||
saveColumnOrder(newColumns);
|
||||
}
|
||||
}, [columnOrder, columns, saveColumnOrder]);
|
||||
|
||||
const resetColumnOrder = useCallback(() => {
|
||||
saveColumnOrder(columns.map((col) => col.id as string));
|
||||
}, [columns, saveColumnOrder]);
|
||||
|
||||
return {
|
||||
columnVisibility,
|
||||
columnOrder,
|
||||
columnSizing,
|
||||
resetColumnOrder,
|
||||
setColumnVisibility,
|
||||
saveColumnOrder,
|
||||
setColumnSizing,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { CustomField, CustomFields, isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types';
|
||||
import { isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types';
|
||||
import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils';
|
||||
|
||||
import Empty from '../../common/components/state/Empty';
|
||||
@@ -27,11 +27,9 @@ import style from './Operator.module.scss';
|
||||
|
||||
const selectedOffset = 50;
|
||||
|
||||
export type Subscribed = { id: string; label: string; colour: string; value: string }[];
|
||||
type TitleFields = Pick<OntimeEvent, 'title'>;
|
||||
export type EditEvent = Pick<OntimeEvent, 'id' | 'cue'> & { fieldLabel?: string; fieldValue: string };
|
||||
export type PartialEdit = EditEvent & {
|
||||
field: keyof CustomFields;
|
||||
};
|
||||
export type EditEvent = Pick<OntimeEvent, 'id' | 'cue'> & { subscriptions: Subscribed };
|
||||
|
||||
export default function Operator() {
|
||||
const { data, status } = useRundown();
|
||||
@@ -45,7 +43,7 @@ export default function Operator() {
|
||||
const { data: settings } = useSettings();
|
||||
|
||||
const [showEditPrompt, setShowEditPrompt] = useState(false);
|
||||
const [editEvent, setEditEvent] = useState<PartialEdit | null>(null);
|
||||
const [editEvent, setEditEvent] = useState<EditEvent | null>(null);
|
||||
|
||||
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
||||
const selectedRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -102,16 +100,9 @@ export default function Operator() {
|
||||
debouncedHandleScroll();
|
||||
};
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(event: EditEvent) => {
|
||||
const field = searchParams.get('subscribe') as keyof CustomField | null;
|
||||
|
||||
if (field) {
|
||||
setEditEvent({ ...event, field });
|
||||
}
|
||||
},
|
||||
[searchParams],
|
||||
);
|
||||
const handleEdit = useCallback((event: EditEvent) => {
|
||||
setEditEvent({ ...event });
|
||||
}, []);
|
||||
|
||||
const missingData = !data || !customFields || !projectData;
|
||||
const isLoading = status === 'pending' || customFieldStatus === 'pending' || projectDataStatus === 'pending';
|
||||
@@ -122,8 +113,13 @@ export default function Operator() {
|
||||
|
||||
// get fields which the user subscribed to
|
||||
const shouldEdit = searchParams.get('shouldEdit');
|
||||
const subscribe = searchParams.get('subscribe') as keyof CustomFields;
|
||||
const canEdit = shouldEdit && subscribe;
|
||||
|
||||
const subscriptions = (searchParams.get('subscribe') ?? '')
|
||||
.toLocaleLowerCase()
|
||||
.split('_')
|
||||
.filter((value) => Object.hasOwn(customFields, value));
|
||||
|
||||
const canEdit = shouldEdit && subscriptions;
|
||||
|
||||
const main = searchParams.get('main') as keyof TitleFields | null;
|
||||
const secondary = searchParams.get('secondary');
|
||||
@@ -171,9 +167,14 @@ export default function Operator() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mainField = main ? entry?.[main] || entry.title : entry.title;
|
||||
const mainField = main ? getPropertyValue(entry, main) ?? '' : entry.title;
|
||||
const secondaryField = getPropertyValue(entry, secondary) ?? '';
|
||||
const subscribedData = entry.custom[subscribe];
|
||||
const subscribedData = subscriptions
|
||||
? subscriptions.map((id) => {
|
||||
const { label, colour } = customFields[id];
|
||||
return { id, label, colour, value: entry.custom[id] };
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<OperatorEvent
|
||||
@@ -189,7 +190,6 @@ export default function Operator() {
|
||||
delay={entry.delay}
|
||||
isSelected={isSelected}
|
||||
subscribed={subscribedData}
|
||||
subscribeLabel={subscribe}
|
||||
isPast={isPast}
|
||||
selectedRef={isSelected ? selectedRef : undefined}
|
||||
onLongPress={canEdit ? handleEdit : () => undefined}
|
||||
|
||||
@@ -1,48 +1,69 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Button, Textarea } from '@chakra-ui/react';
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import type { PartialEdit } from '../Operator';
|
||||
import type { EditEvent } from '../Operator';
|
||||
|
||||
import style from './EditModal.module.scss';
|
||||
|
||||
interface EditModalProps {
|
||||
event: PartialEdit;
|
||||
event: EditEvent;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function EditModal(props: EditModalProps) {
|
||||
const { event, onClose } = props;
|
||||
|
||||
const { updateCustomField } = useEventAction();
|
||||
const { updateEvent } = useEventAction();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement[]>(new Array<HTMLTextAreaElement>());
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!inputRef.current) return;
|
||||
setLoading(true);
|
||||
const newValue = inputRef.current?.value;
|
||||
if (newValue === undefined) {
|
||||
return;
|
||||
|
||||
const patchObject: Partial<OntimeEvent> = { id: event.id };
|
||||
|
||||
inputRef.current.forEach((element) => {
|
||||
if (element.dataset.field && element.defaultValue != element.value) {
|
||||
if (patchObject.custom) {
|
||||
patchObject.custom[element.dataset.field] = element.value;
|
||||
} else {
|
||||
Object.assign(patchObject, { custom: { [element.dataset.field]: element.value } });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (patchObject.custom) {
|
||||
await updateEvent(patchObject);
|
||||
}
|
||||
|
||||
await updateCustomField(event.id, event.field, newValue);
|
||||
setLoading(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const fieldLabel = event?.fieldLabel ?? event.field;
|
||||
|
||||
return (
|
||||
<div className={style.editModal}>
|
||||
<div>{`Editing field ${fieldLabel} in cue ${event.cue}`}</div>
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
variant='ontime-filled'
|
||||
placeholder={`Add value for ${fieldLabel} field`}
|
||||
defaultValue={event.fieldValue}
|
||||
isDisabled={loading}
|
||||
resize='none'
|
||||
/>
|
||||
<div>{`Editing fields in cue ${event.cue}`}</div>
|
||||
{event.subscriptions.map((field) => {
|
||||
return (
|
||||
<div key={field.label}>
|
||||
<label>{field.label}</label>
|
||||
<Textarea
|
||||
ref={(element) => {
|
||||
if (element) inputRef.current.push(element);
|
||||
}}
|
||||
variant='ontime-filled'
|
||||
placeholder={`Add value for ${field.label} field`}
|
||||
defaultValue={field.value}
|
||||
data-field={field.id}
|
||||
isDisabled={loading}
|
||||
resize='none'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className={style.buttonRow}>
|
||||
<Button variant='ontime-subtle' onClick={onClose} isDisabled={loading}>
|
||||
Cancel
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
grid-template-rows: auto auto auto;
|
||||
column-gap: 0.5rem;
|
||||
grid-template-areas:
|
||||
"binder main schedule"
|
||||
"binder secondary running"
|
||||
"binder fields fields";
|
||||
'binder main schedule'
|
||||
'binder secondary running'
|
||||
'binder fields fields';
|
||||
|
||||
&.subscribed {
|
||||
background-color: $gray-1250;
|
||||
@@ -65,12 +65,15 @@
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0.5px;
|
||||
color: $ui-white;
|
||||
|
||||
@include ellipsis-text;
|
||||
}
|
||||
|
||||
.secondaryField {
|
||||
grid-area: secondary;
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: 0.5px;
|
||||
@include ellipsis-text;
|
||||
}
|
||||
|
||||
.schedule {
|
||||
@@ -93,16 +96,27 @@
|
||||
font-weight: 400;
|
||||
color: $ui-black;
|
||||
margin: 0.25rem 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
|
||||
.field {
|
||||
font-weight: 600;
|
||||
padding: 0 0.25rem;
|
||||
background-color: var(--operator-highlight-override, $orange-600);
|
||||
margin-right: 0.5rem;
|
||||
padding-inline: 0.25rem;
|
||||
}
|
||||
|
||||
.noColour {
|
||||
outline: 0.15rem solid var(--operator-highlight-override, $ui-white);
|
||||
outline-offset: -0.15rem;
|
||||
padding-right: 0.3rem;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: $orange-500;
|
||||
padding-inline: 0.25rem;
|
||||
background-color: $gray-1250;
|
||||
color: var(--operator-highlight-override, $ui-white);
|
||||
margin-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTimer } from '../../../common/hooks/useSocket';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import ClockTime from '../../viewers/common/clock-time/ClockTime';
|
||||
import RunningTime from '../../viewers/common/running-time/RunningTime';
|
||||
import type { EditEvent } from '../Operator';
|
||||
import type { EditEvent, Subscribed } from '../Operator';
|
||||
|
||||
import style from './OperatorEvent.module.scss';
|
||||
|
||||
@@ -21,8 +21,7 @@ interface OperatorEventProps {
|
||||
duration: number;
|
||||
delay?: number;
|
||||
isSelected: boolean;
|
||||
subscribed?: string;
|
||||
subscribeLabel: string;
|
||||
subscribed: Subscribed | null;
|
||||
isPast: boolean;
|
||||
selectedRef?: RefObject<HTMLDivElement>;
|
||||
onLongPress: (event: EditEvent) => void;
|
||||
@@ -47,7 +46,6 @@ function OperatorEvent(props: OperatorEventProps) {
|
||||
delay,
|
||||
isSelected,
|
||||
subscribed,
|
||||
subscribeLabel: subscribedAlias,
|
||||
isPast,
|
||||
selectedRef,
|
||||
onLongPress,
|
||||
@@ -56,7 +54,9 @@ function OperatorEvent(props: OperatorEventProps) {
|
||||
const handleLongPress = (event?: SyntheticEvent) => {
|
||||
// we dont have an event out of useLongPress
|
||||
event?.preventDefault();
|
||||
onLongPress({ id, cue, fieldLabel: subscribedAlias, fieldValue: subscribed ?? '' });
|
||||
if (subscribed) {
|
||||
onLongPress({ id, cue, subscriptions: subscribed });
|
||||
}
|
||||
};
|
||||
|
||||
const mouseHandlers = useLongPress(handleLongPress, { threshold: 800 });
|
||||
@@ -89,12 +89,22 @@ function OperatorEvent(props: OperatorEventProps) {
|
||||
</span>
|
||||
|
||||
<div className={style.fields}>
|
||||
{subscribed && (
|
||||
<>
|
||||
<span className={style.field}>{subscribedAlias}</span>
|
||||
<span className={style.value}>{subscribed}</span>
|
||||
</>
|
||||
)}
|
||||
{subscribed &&
|
||||
subscribed
|
||||
.filter((field) => field.value)
|
||||
.map((field) => {
|
||||
const fieldClasses = cx([style.field, !field.colour ? style.noColour : null]);
|
||||
return (
|
||||
<div key={field.id}>
|
||||
<span className={fieldClasses} style={{ backgroundColor: field.colour }}>
|
||||
{field.label}
|
||||
</span>
|
||||
<span className={style.value} style={{ color: field.colour }}>
|
||||
{field.value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -105,5 +105,5 @@
|
||||
}
|
||||
|
||||
.progressOverride {
|
||||
border-radius: 0;
|
||||
--progress-bar-br: 0;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export default function StatusBarProgress(props: StatusBarProgressProps) {
|
||||
danger={timeDanger}
|
||||
dangerColor={viewSettings.dangerColor}
|
||||
className={styles.progressOverride}
|
||||
ignoreCssOverride
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,14 +6,14 @@ import { EditorUpdateFields } from '../EventEditor';
|
||||
|
||||
import style from '../EventEditor.module.scss';
|
||||
|
||||
interface CountedTextInputProps extends InputProps {
|
||||
interface EventTextInputProps extends InputProps {
|
||||
field: EditorUpdateFields;
|
||||
label: string;
|
||||
initialValue: string;
|
||||
submitHandler: (field: EditorUpdateFields, value: string) => void;
|
||||
}
|
||||
|
||||
export default function EventTextInput(props: CountedTextInputProps) {
|
||||
export default function EventTextInput(props: EventTextInputProps) {
|
||||
const { field, label, initialValue, submitHandler, maxLength } = props;
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
|
||||
@@ -34,7 +34,7 @@ export default function EventTextInput(props: CountedTextInputProps) {
|
||||
variant='ontime-filled'
|
||||
data-testid='input-textfield'
|
||||
value={value}
|
||||
maxLength={maxLength || 50}
|
||||
maxLength={maxLength || 100}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
|
||||
@@ -25,6 +25,8 @@ import { getPropertyValue } from '../common/viewUtils';
|
||||
|
||||
import './Backstage.scss';
|
||||
|
||||
export const MotionTitleCard = motion(TitleCard);
|
||||
|
||||
interface BackstageProps {
|
||||
customFields: CustomFields;
|
||||
isMirrored: boolean;
|
||||
@@ -124,7 +126,7 @@ export default function Backstage(props: BackstageProps) {
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
>
|
||||
<TitleCard label='now' title={eventNow.title} secondary={secondaryTextNow} />
|
||||
<TitleCard title={eventNow.title} secondary={secondaryTextNow} />
|
||||
<div className='timer-group'>
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>{getLocalizedString('common.started_at')}</div>
|
||||
@@ -151,16 +153,17 @@ export default function Backstage(props: BackstageProps) {
|
||||
|
||||
<AnimatePresence>
|
||||
{eventNext && (
|
||||
<motion.div
|
||||
<MotionTitleCard
|
||||
className='event next'
|
||||
key='next'
|
||||
variants={titleVariants}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
>
|
||||
<TitleCard label='next' title={eventNext.title} secondary={secondaryTextNext} />
|
||||
</motion.div>
|
||||
label='next'
|
||||
title={eventNext.title}
|
||||
secondary={secondaryTextNext}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
@@ -53,10 +53,6 @@ export function getPropertyValue(event: OntimeEvent | null, property: MaybeStrin
|
||||
return event[property as keyof OntimeEvent] as string;
|
||||
}
|
||||
|
||||
export function capitaliseFirstLetter(string: string) {
|
||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||
}
|
||||
|
||||
type FormattingOptions = {
|
||||
removeSeconds: boolean;
|
||||
removeLeadingZero: boolean;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { OntimeEvent, OntimeRundownEntry, Playback, Settings, SupportedEvent, ViewSettings } from 'ontime-types';
|
||||
import { millisToString, removeLeadingZero } from 'ontime-utils';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/constants';
|
||||
import { getCountdownOptions } from '../../../common/components/view-params-editor/constants';
|
||||
@@ -12,6 +11,7 @@ import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||
import { getFormattedTimer, isStringBoolean } from '../common/viewUtils';
|
||||
|
||||
import { fetchTimerData, TimerMessage } from './countdown.helpers';
|
||||
import CountdownSelect from './CountdownSelect';
|
||||
@@ -84,24 +84,17 @@ export default function Countdown(props: CountdownProps) {
|
||||
const standby = time.playback !== Playback.Play && time.playback !== Playback.Roll && selectedId === follow?.id;
|
||||
const finished = time.playback === Playback.Play && (time.current ?? 0) < 0 && time.startedAt;
|
||||
const isRunningFinished = finished && runningMessage === TimerMessage.running;
|
||||
const isSelected = runningMessage === TimerMessage.running;
|
||||
const delayedTimerStyles = delay > 0 ? 'aux-timers__value--delayed' : '';
|
||||
|
||||
const clock = formatTime(time.clock);
|
||||
const startTime = follow === null ? '...' : formatTime(follow.timeStart + delay);
|
||||
const endTime = follow === null ? '...' : formatTime(follow.timeEnd + delay);
|
||||
|
||||
const formatTimer = (): string => {
|
||||
if (runningMessage === TimerMessage.ended) {
|
||||
return formatTime(runningTimer, { format12: 'hh:mm a', format24: 'HH:mm' });
|
||||
}
|
||||
let formattedTime = millisToString(isSelected ? runningTimer : runningTimer + delay);
|
||||
if (isSelected || runningMessage === TimerMessage.waiting) {
|
||||
formattedTime = removeLeadingZero(formattedTime);
|
||||
}
|
||||
return formattedTime;
|
||||
};
|
||||
const formattedTimer = formatTimer();
|
||||
const hideSeconds = searchParams.get('hideTimerSeconds');
|
||||
const formattedTimer = getFormattedTimer(runningTimer, time.timerType, getLocalizedString('common.minutes'), {
|
||||
removeSeconds: isStringBoolean(hideSeconds),
|
||||
removeLeadingZero: false,
|
||||
});
|
||||
|
||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||
const timeOption = getCountdownOptions(defaultFormat);
|
||||
|
||||
@@ -72,6 +72,8 @@
|
||||
font-family: var(--lowerThird-font-family-override), Lato, Arial, sans-serif;
|
||||
text-align: var(--lowerThird-text-align-override, left);
|
||||
white-space: nowrap;
|
||||
@include ellipsis-text();
|
||||
|
||||
&::after {
|
||||
content: '\200b';
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
||||
const isPlaying = time.playback !== Playback.Pause;
|
||||
|
||||
const shouldShowModifiers = time.timerType !== TimerType.Clock && time.timerType !== TimerType.CountUp;
|
||||
const finished = time.phase === TimerPhase.Negative;
|
||||
const finished = time.phase === TimerPhase.Overtime;
|
||||
const showEndMessage = finished && viewSettings.endMessage && !hideEndMessage;
|
||||
const showFinished = finished && !userOptions?.hideOvertime && (shouldShowModifiers || showEndMessage);
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ import { getPropertyValue } from '../common/viewUtils';
|
||||
|
||||
import './Public.scss';
|
||||
|
||||
export const MotionTitleCard = motion(TitleCard);
|
||||
|
||||
interface BackstageProps {
|
||||
customFields: CustomFields;
|
||||
isMirrored: boolean;
|
||||
@@ -83,31 +85,33 @@ export default function Public(props: BackstageProps) {
|
||||
<div className='now-container'>
|
||||
<AnimatePresence>
|
||||
{publicEventNow && (
|
||||
<motion.div
|
||||
<MotionTitleCard
|
||||
className='event now'
|
||||
key='now'
|
||||
variants={titleVariants}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
>
|
||||
<TitleCard label='now' title={publicEventNow.title} secondary={secondaryTextNow} />
|
||||
</motion.div>
|
||||
label='now'
|
||||
title={publicEventNow.title}
|
||||
secondary={secondaryTextNow}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{publicEventNext && (
|
||||
<motion.div
|
||||
<MotionTitleCard
|
||||
className='event next'
|
||||
key='next'
|
||||
variants={titleVariants}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
>
|
||||
<TitleCard label='next' title={publicEventNext.title} secondary={secondaryTextNext} />
|
||||
</motion.div>
|
||||
label='next'
|
||||
title={publicEventNext.title}
|
||||
secondary={secondaryTextNext}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
@@ -168,7 +168,6 @@ $orange-active: #f60;
|
||||
display: flex;
|
||||
align-items: start;
|
||||
gap: 0.5em;
|
||||
white-space: nowrap;
|
||||
|
||||
&--now {
|
||||
color: var(--studio-active-label, $red-active);
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function StudioClock(props: StudioClockProps) {
|
||||
// TODO: can we prevent the Flash of Unstyled Content on the 7segment fonts?
|
||||
// deferring rendering seems to affect styling (font and useFitText)
|
||||
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const { fontSize: titleFontSize, ref: titleRef } = useFitText({ maxFontSize: 500 });
|
||||
const { fontSize: titleFontSize, ref: titleRef } = useFitText({ minFontSize: 150, maxFontSize: 500 });
|
||||
|
||||
const activeIndicators = [...Array(12).keys()];
|
||||
const secondsIndicators = [...Array(60).keys()];
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: auto;
|
||||
grid-template-columns: 100%;
|
||||
grid-template-rows: auto 1fr auto auto auto;
|
||||
grid-template-areas:
|
||||
'clock'
|
||||
|
||||
@@ -43,6 +43,8 @@ const titleVariants = {
|
||||
},
|
||||
};
|
||||
|
||||
export const MotionTitleCard = motion(TitleCard);
|
||||
|
||||
interface TimerProps {
|
||||
customFields: CustomFields;
|
||||
eventNext: OntimeEvent | null;
|
||||
@@ -106,12 +108,16 @@ export default function Timer(props: TimerProps) {
|
||||
const secondaryTextNow = getPropertyValue(eventNow, secondarySource);
|
||||
const secondaryTextNext = getPropertyValue(eventNext, secondarySource);
|
||||
|
||||
const main = searchParams.get('main');
|
||||
const mainFieldNow = (main ? getPropertyValue(eventNow, main) : eventNow?.title) ?? '';
|
||||
const mainFieldNext = (main ? getPropertyValue(eventNext, main) : eventNext?.title) ?? '';
|
||||
|
||||
const showOverlay = pres.text !== '' && pres.visible;
|
||||
const isPlaying = time.playback !== Playback.Pause;
|
||||
|
||||
const timerIsTimeOfDay = time.timerType === TimerType.Clock;
|
||||
|
||||
const finished = time.phase === TimerPhase.Negative;
|
||||
const finished = time.phase === TimerPhase.Overtime;
|
||||
const totalTime = (time.duration ?? 0) + (time.addedTime ?? 0);
|
||||
|
||||
const shouldShowModifiers = time.timerType !== TimerType.Clock && time.timerType !== TimerType.CountUp;
|
||||
@@ -207,31 +213,33 @@ export default function Timer(props: TimerProps) {
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{eventNow?.title && (
|
||||
<motion.div
|
||||
<MotionTitleCard
|
||||
className='event now'
|
||||
key='now'
|
||||
variants={titleVariants}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
>
|
||||
<TitleCard label='now' title={eventNow.title} secondary={secondaryTextNow} />
|
||||
</motion.div>
|
||||
label='now'
|
||||
title={mainFieldNow}
|
||||
secondary={secondaryTextNow}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{eventNext?.title && (
|
||||
<motion.div
|
||||
className='event next'
|
||||
<MotionTitleCard
|
||||
key='next'
|
||||
variants={titleVariants}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
>
|
||||
<TitleCard label='next' title={eventNext.title} secondary={secondaryTextNext} />
|
||||
</motion.div>
|
||||
label='next'
|
||||
title={mainFieldNext}
|
||||
secondary={secondaryTextNext}
|
||||
className='event next'
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use "ontimeStyles" as *;
|
||||
@use 'ontimeStyles' as *;
|
||||
|
||||
//////////////////////////////////// general app elements
|
||||
|
||||
@@ -15,3 +15,9 @@
|
||||
color: $ontime-color;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin ellipsis-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ $bg-container-onlight: $gray-100;
|
||||
$box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
|
||||
$box-shadow-l2: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
|
||||
$box-shadow-right: rgba(0, 0, 0, 0.15) 3px 0 3px 0;
|
||||
$large-bottom-drawer-shadow: rgba(0, 0, 0, 0.35) 0 3px 6px 6px;
|
||||
$large-top-drawer-shadow: rgba(0, 0, 0, 0.35) 0 1px 6px 3px;
|
||||
|
||||
$modal-note-color: $gray-700;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { langEs } from './languages/es';
|
||||
import { langFr } from './languages/fr';
|
||||
import { langIt } from './languages/it';
|
||||
import { langNo } from './languages/no';
|
||||
import { langPl } from './languages/pl';
|
||||
import { langPt } from './languages/pt';
|
||||
import { langSv } from './languages/sv';
|
||||
|
||||
@@ -20,6 +21,7 @@ const translationsList = {
|
||||
no: langNo,
|
||||
pt: langPt,
|
||||
sv: langSv,
|
||||
pl: langPl,
|
||||
};
|
||||
|
||||
interface TranslationContextValue {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { TranslationObject } from './en';
|
||||
|
||||
export const langPl: TranslationObject = {
|
||||
'common.end_time': 'Czas zakończenia',
|
||||
'common.expected_finish': 'Zakładany czas zakończenia',
|
||||
'common.minutes': 'min',
|
||||
'common.now': 'Teraz',
|
||||
'common.next': 'Następnie',
|
||||
'common.public_message': 'Wiadomość publiczna',
|
||||
'common.start_time': 'Czas rozpoczęcia',
|
||||
'common.stage_timer': 'Timer Scena',
|
||||
'common.started_at': 'Rozpoczęte o',
|
||||
'common.time_now': 'Aktualny czas',
|
||||
'countdown.ended': 'Zakończone o',
|
||||
'countdown.running': 'Trwa',
|
||||
'countdown.select_event': 'Wybierz event który chcesz śledzić',
|
||||
'countdown.to_start': 'Do rozpoczęcia',
|
||||
'countdown.waiting': 'Oczekiwanie na start',
|
||||
'countdown.overtime': 'ponad czasem',
|
||||
};
|
||||
|
||||
@@ -61,6 +61,7 @@ export default defineConfig({
|
||||
additionalData: `
|
||||
@use './src/theme/ontimeColours' as *;
|
||||
@use './src/theme/ontimeStyles' as *;
|
||||
@use './src/theme/mixins' as *;
|
||||
`,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ module.exports = {
|
||||
shutdownCode: 99,
|
||||
},
|
||||
reactAppUrl: {
|
||||
development: (port = 4001) => `http://localhost:${port}`,
|
||||
development: (port = 3000) => `http://localhost:${port}`,
|
||||
production: (port = 4001) => `http://localhost:${port}`,
|
||||
},
|
||||
server: {
|
||||
|
||||
+40
-9
@@ -30,6 +30,10 @@ let win;
|
||||
let splash;
|
||||
let tray = null;
|
||||
|
||||
/**
|
||||
* Coordinates the node process startup
|
||||
* @returns {number} server port - the port at which the backend has been started at
|
||||
*/
|
||||
async function startBackend() {
|
||||
// in dev mode, we expect both UI and server to be running
|
||||
if (!isProduction) {
|
||||
@@ -41,7 +45,7 @@ async function startBackend() {
|
||||
|
||||
await initAssets();
|
||||
|
||||
const result = await startServer();
|
||||
const result = await startServer(escalateError);
|
||||
loaded = result.message;
|
||||
|
||||
await startIntegrations();
|
||||
@@ -62,6 +66,9 @@ function showNotification(title, text) {
|
||||
}).show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate node service and close electron app
|
||||
*/
|
||||
function appShutdown() {
|
||||
// terminate node service
|
||||
(async () => {
|
||||
@@ -76,16 +83,30 @@ function appShutdown() {
|
||||
app.quit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets Ontime window in focus
|
||||
*/
|
||||
function bringToFront() {
|
||||
win.show();
|
||||
win.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinates the shutdown process
|
||||
*/
|
||||
function askToQuit() {
|
||||
bringToFront();
|
||||
win.send('user-request-shutdown');
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows processes to escalate errors to be shown in electron
|
||||
* @param {string} error
|
||||
*/
|
||||
function escalateError(error) {
|
||||
dialog.showErrorBox('An unrecoverable error occurred', error);
|
||||
}
|
||||
|
||||
// Ensure there isn't another instance of the app running already
|
||||
const lock = app.requestSingleInstanceLock();
|
||||
if (!lock) {
|
||||
@@ -103,6 +124,9 @@ if (!lock) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinates creation of electron windows (splash and main)
|
||||
*/
|
||||
function createWindow() {
|
||||
splash = new BrowserWindow({
|
||||
width: 333,
|
||||
@@ -191,12 +215,16 @@ app.whenReady().then(() => {
|
||||
console.log('ERROR: Ontime failed to start', error);
|
||||
});
|
||||
|
||||
// recreate window if no others open
|
||||
/**
|
||||
* recreate window if no others open
|
||||
*/
|
||||
app.on('activate', () => {
|
||||
win.show();
|
||||
});
|
||||
|
||||
// Hide on close
|
||||
/**
|
||||
* Hide on close
|
||||
*/
|
||||
win.on('close', function (event) {
|
||||
event.preventDefault();
|
||||
if (!isQuitting) {
|
||||
@@ -213,12 +241,11 @@ app.whenReady().then(() => {
|
||||
const trayMenuTemplate = getTrayMenu(bringToFront, askToQuit);
|
||||
const trayContextMenu = Menu.buildFromTemplate(trayMenuTemplate);
|
||||
tray.setContextMenu(trayContextMenu);
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
// unregister shortcuts before quitting
|
||||
/**
|
||||
* Unregister shortcuts before quitting
|
||||
*/
|
||||
app.once('will-quit', () => {
|
||||
globalShortcut.unregisterAll();
|
||||
});
|
||||
@@ -235,7 +262,9 @@ ipcMain.on('shutdown', () => {
|
||||
appShutdown();
|
||||
});
|
||||
|
||||
// Window manipulation
|
||||
/**
|
||||
* Handles requests to set window properties
|
||||
*/
|
||||
ipcMain.on('set-window', (event, arg) => {
|
||||
switch (arg) {
|
||||
case 'show-dev':
|
||||
@@ -246,7 +275,9 @@ ipcMain.on('set-window', (event, arg) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Open links external
|
||||
/**
|
||||
* Handles requests to open external links
|
||||
*/
|
||||
ipcMain.on('send-to-link', (event, arg) => {
|
||||
shell.openExternal(arg);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "3.1.0",
|
||||
"version": "3.3.3",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
@@ -14,17 +14,18 @@
|
||||
"devDependencies": {
|
||||
"electron": "^28.3.3",
|
||||
"electron-builder": "^24.13.3",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"prettier": "^3.0.3"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "",
|
||||
"lint": "eslint . --quiet",
|
||||
"lint-staged": "eslint",
|
||||
"dev:electron": "cross-env NODE_ENV=development electron .",
|
||||
"dev": "cross-env NODE_ENV=development electron .",
|
||||
"dist-win": "electron-builder --publish=never --x64 --win",
|
||||
"dist-mac": "electron-builder --publish=never --mac",
|
||||
"dist-mac:local": "electron-builder --publish=never --mac -c.mac.identity=null",
|
||||
"dist-linux": "electron-builder --publish=never --x64 --linux",
|
||||
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "3.1.0",
|
||||
"version": "3.3.3",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@googleapis/sheets": "^5.0.5",
|
||||
@@ -19,8 +19,8 @@
|
||||
"node-osc": "^9.0.2",
|
||||
"node-xlsx": "^0.23.0",
|
||||
"ontime-utils": "workspace:*",
|
||||
"steno": "^3.1.0",
|
||||
"ts-essentials": "^9.4.1",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"steno": "^4.0.2",
|
||||
"ws": "^8.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -31,18 +31,19 @@
|
||||
"@types/node-osc": "^6.0.2",
|
||||
"@types/websocket": "^1.0.5",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"@typescript-eslint/eslint-plugin": "^v7.12.0",
|
||||
"@typescript-eslint/parser": "^7.12.0",
|
||||
"esbuild": "^0.19.10",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-plugin-prettier": "^5.0.1",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"nodemon": "^2.0.20",
|
||||
"ontime-types": "workspace:*",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier": "^3.3.1",
|
||||
"server-timing": "^3.3.3",
|
||||
"shx": "^0.3.4",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.4.3",
|
||||
"ts-essentials": "^9.4.1",
|
||||
"vitest": "^1.6.0"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* Payload: adds necessary payload for the request to be completed
|
||||
*/
|
||||
|
||||
import { LogOrigin } from 'ontime-types';
|
||||
import { Client, LogOrigin } from 'ontime-types';
|
||||
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
import type { Server } from 'http';
|
||||
@@ -24,6 +24,7 @@ import { IAdapter } from './IAdapter.js';
|
||||
import { eventStore } from '../stores/EventStore.js';
|
||||
import { logger } from '../classes/Logger.js';
|
||||
import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
|
||||
import { generateId } from 'ontime-utils';
|
||||
|
||||
let instance: SocketServer | null = null;
|
||||
|
||||
@@ -31,7 +32,7 @@ export class SocketServer implements IAdapter {
|
||||
private readonly MAX_PAYLOAD = 1024 * 256; // 256Kb
|
||||
|
||||
private wss: WebSocketServer | null;
|
||||
private readonly clientIds: Set<string>;
|
||||
private readonly clients: Map<string, Client>;
|
||||
|
||||
constructor() {
|
||||
if (instance) {
|
||||
@@ -40,7 +41,7 @@ export class SocketServer implements IAdapter {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias -- this logic is used to ensure singleton
|
||||
instance = this;
|
||||
this.clientIds = new Set<string>();
|
||||
this.clients = new Map<string, Client>();
|
||||
this.wss = null;
|
||||
}
|
||||
|
||||
@@ -48,9 +49,32 @@ export class SocketServer implements IAdapter {
|
||||
this.wss = new WebSocketServer({ path: '/ws', server, maxPayload: this.MAX_PAYLOAD });
|
||||
|
||||
this.wss.on('connection', (ws) => {
|
||||
let clientId = getRandomName();
|
||||
this.clientIds.add(clientId);
|
||||
logger.info(LogOrigin.Client, `${this.clientIds.size} Connections with new: ${clientId}`);
|
||||
const clientId = generateId();
|
||||
|
||||
this.clients.set(clientId, {
|
||||
type: 'unknown',
|
||||
identify: false,
|
||||
name: getRandomName(),
|
||||
path: '',
|
||||
});
|
||||
|
||||
logger.info(LogOrigin.Client, `${this.clients.size} Connections with new: ${clientId}`);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'client-id',
|
||||
payload: clientId,
|
||||
}),
|
||||
);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'client-name',
|
||||
payload: this.clients.get(clientId).name,
|
||||
}),
|
||||
);
|
||||
|
||||
this.sendClientList();
|
||||
|
||||
// send store payload on connect
|
||||
ws.send(
|
||||
@@ -60,18 +84,12 @@ export class SocketServer implements IAdapter {
|
||||
}),
|
||||
);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'client-name',
|
||||
payload: clientId,
|
||||
}),
|
||||
);
|
||||
|
||||
ws.on('error', console.error);
|
||||
|
||||
ws.on('close', () => {
|
||||
this.clientIds.delete(clientId);
|
||||
logger.info(LogOrigin.Client, `${this.clientIds.size} Connections with disconnected: ${clientId}`);
|
||||
this.clients.delete(clientId);
|
||||
logger.info(LogOrigin.Client, `${this.clients.size} Connections with disconnected: ${clientId}`);
|
||||
this.sendClientList();
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
@@ -84,26 +102,44 @@ export class SocketServer implements IAdapter {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'client-name',
|
||||
payload: clientId,
|
||||
payload: this.clients.get(clientId).name,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'set-client-type') {
|
||||
if (payload && typeof payload == 'string') {
|
||||
const previousData = this.clients.get(clientId);
|
||||
this.clients.set(clientId, { ...previousData, type: payload });
|
||||
}
|
||||
this.sendClientList();
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'set-client-path') {
|
||||
if (payload && typeof payload == 'string') {
|
||||
const previousData = this.clients.get(clientId);
|
||||
previousData.path = payload;
|
||||
this.clients.set(clientId, previousData);
|
||||
}
|
||||
this.sendClientList();
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'set-client-name') {
|
||||
if (payload) {
|
||||
const previousName = clientId;
|
||||
clientId = payload;
|
||||
this.clientIds.delete(previousName);
|
||||
this.clientIds.add(clientId);
|
||||
logger.info(LogOrigin.Client, `Client ${previousName} renamed to ${clientId}`);
|
||||
const previousData = this.clients.get(clientId);
|
||||
logger.info(LogOrigin.Client, `Client ${previousData.name} renamed to ${payload}`);
|
||||
this.clients.set(clientId, { ...previousData, name: payload });
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'client-name',
|
||||
payload: this.clients.get(clientId).name,
|
||||
}),
|
||||
);
|
||||
}
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'client-name',
|
||||
payload: clientId,
|
||||
}),
|
||||
);
|
||||
this.sendClientList();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -135,17 +171,58 @@ export class SocketServer implements IAdapter {
|
||||
});
|
||||
}
|
||||
|
||||
// message is any serializable value
|
||||
sendAsJson(message: unknown) {
|
||||
this.wss?.clients.forEach((client) => {
|
||||
try {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify(message));
|
||||
}
|
||||
} catch (_) {
|
||||
/** We do not handle this error */
|
||||
}
|
||||
private sendClientList(): void {
|
||||
const payload = Object.fromEntries(this.clients.entries());
|
||||
this.sendAsJson({ type: 'client-list', payload });
|
||||
}
|
||||
|
||||
public getClientList(): string[] {
|
||||
return Array.from(this.clients.keys());
|
||||
}
|
||||
|
||||
public renameClient(target: string, name: string) {
|
||||
const previousData = this.clients.get(target);
|
||||
if (!previousData) {
|
||||
throw new Error(`Client "${target}" not found`);
|
||||
}
|
||||
logger.info(LogOrigin.Client, `Client ${previousData.name} renamed to ${name}`);
|
||||
this.clients.set(target, { ...previousData, name });
|
||||
this.sendAsJson({
|
||||
type: 'client-rename',
|
||||
payload: { name, target },
|
||||
});
|
||||
this.sendClientList();
|
||||
}
|
||||
|
||||
public redirectClient(target: string, path: string) {
|
||||
const previousData = this.clients.get(target);
|
||||
if (!previousData) {
|
||||
throw new Error(`Client "${target}" not found`);
|
||||
}
|
||||
this.sendAsJson({ type: 'client-redirect', payload: { target, path } });
|
||||
}
|
||||
|
||||
public identifyClient(target: string, identify: boolean) {
|
||||
const previousData = this.clients.get(target);
|
||||
if (!previousData) {
|
||||
throw new Error(`Client "${target}" not found`);
|
||||
}
|
||||
this.clients.set(target, { ...previousData, identify });
|
||||
this.sendClientList();
|
||||
}
|
||||
|
||||
// message is any serializable value
|
||||
public sendAsJson(message: unknown) {
|
||||
try {
|
||||
const stringifiedMessage = JSON.stringify(message);
|
||||
this.wss?.clients.forEach((client) => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(stringifiedMessage);
|
||||
}
|
||||
});
|
||||
} catch (_) {
|
||||
/** We do not handle this error */
|
||||
}
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
|
||||
@@ -16,7 +16,6 @@ import { failEmptyObjects } from '../../utils/routerUtils.js';
|
||||
import { resolveDbDirectory, resolveProjectsDirectory } from '../../setup/index.js';
|
||||
|
||||
import * as projectService from '../../services/project-service/ProjectService.js';
|
||||
import { ensureJsonExtension } from '../../utils/fileManagement.js';
|
||||
import { generateUniqueFileName } from '../../utils/generateUniqueFilename.js';
|
||||
import { appStateService } from '../../services/app-state-service/AppStateService.js';
|
||||
import { oscIntegration } from '../../services/integration-service/OscIntegration.js';
|
||||
@@ -54,8 +53,7 @@ export async function patchPartialProjectFile(req: Request, res: Response<Databa
|
||||
*/
|
||||
export async function createProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
|
||||
try {
|
||||
const originalFilename = ensureJsonExtension(req.body.title || 'Untitled');
|
||||
const filename = generateUniqueFileName(resolveProjectsDirectory, originalFilename);
|
||||
const filename = generateUniqueFileName(resolveProjectsDirectory, req.body.filename);
|
||||
const errors = projectService.validateProjectFiles({ newFilename: filename });
|
||||
|
||||
if (errors.length) {
|
||||
@@ -71,7 +69,7 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
|
||||
backstageInfo: req.body?.backstageInfo ?? '',
|
||||
};
|
||||
|
||||
projectService.createProjectFile(filename, newProjectData);
|
||||
await projectService.createProjectFile(filename, newProjectData);
|
||||
|
||||
res.status(200).send({
|
||||
filename,
|
||||
@@ -83,29 +81,18 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function finds the correct project file to download
|
||||
*/
|
||||
function selectProjectFile(fileName?: string) {
|
||||
const projectsDirectory = resolveDbDirectory;
|
||||
const fileToDownload = fileName ? ensureJsonExtension(fileName) : projectService.getProjectTitle();
|
||||
const pathToFile = join(projectsDirectory, fileToDownload);
|
||||
|
||||
return { pathToFile, name: fileToDownload };
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows downloading of a optionally given project files
|
||||
* If no {filename} is provided, loaded file will be served
|
||||
* Allows downloading of project files
|
||||
*/
|
||||
export async function projectDownload(req: Request, res: Response) {
|
||||
const { pathToFile, name } = selectProjectFile(req.body?.fileName);
|
||||
const { filename } = req.body;
|
||||
const pathToFile = join(resolveDbDirectory, filename);
|
||||
|
||||
// Check if the file exists before attempting to download
|
||||
if (!existsSync(pathToFile)) {
|
||||
return res.status(404).send({ message: `Project ${name} not found.` });
|
||||
return res.status(404).send({ message: `Project ${filename} not found.` });
|
||||
}
|
||||
|
||||
res.download(pathToFile, name, (error) => {
|
||||
res.download(pathToFile, filename, (error) => {
|
||||
if (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(500).send({ message });
|
||||
@@ -228,7 +215,7 @@ export async function duplicateProjectFile(req: Request, res: Response<MessageRe
|
||||
*/
|
||||
export async function renameProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) {
|
||||
try {
|
||||
const { newFilename } = req.body;
|
||||
const { filename: newFilename } = req.body;
|
||||
const { filename } = req.params;
|
||||
|
||||
const errors = projectService.validateProjectFiles({ filename, newFilename });
|
||||
|
||||
@@ -14,28 +14,26 @@ import {
|
||||
} from './db.controller.js';
|
||||
import { uploadProjectFile } from './db.middleware.js';
|
||||
import {
|
||||
projectSanitiser,
|
||||
sanitizeProjectFilename,
|
||||
validateDownloadProject,
|
||||
validateLoadProjectFile,
|
||||
validatePatchProjectFile,
|
||||
validateProjectDuplicate,
|
||||
validateProjectRename,
|
||||
validateNewProject,
|
||||
validatePatchProject,
|
||||
validateFilenameBody,
|
||||
validateFilenameParam,
|
||||
validateNewFilenameBody,
|
||||
} from './db.validation.js';
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
router.post('/download', validateDownloadProject, projectDownload);
|
||||
router.post('/download', validateFilenameBody, projectDownload);
|
||||
router.post('/upload', uploadProjectFile, postProjectFile);
|
||||
|
||||
router.patch('/', validatePatchProjectFile, patchPartialProjectFile);
|
||||
router.post('/new', projectSanitiser, createProjectFile);
|
||||
router.patch('/', validatePatchProject, patchPartialProjectFile);
|
||||
router.post('/new', validateFilenameBody, validateNewProject, createProjectFile);
|
||||
|
||||
router.get('/all', listProjects);
|
||||
|
||||
router.post('/load', validateLoadProjectFile, sanitizeProjectFilename, loadProject);
|
||||
router.post('/:filename/duplicate', validateProjectDuplicate, sanitizeProjectFilename, duplicateProjectFile);
|
||||
router.put('/:filename/rename', validateProjectRename, sanitizeProjectFilename, renameProjectFile);
|
||||
router.delete('/:filename', sanitizeProjectFilename, deleteProjectFile);
|
||||
router.post('/load', validateFilenameBody, loadProject);
|
||||
router.post('/:filename/duplicate', validateFilenameParam, validateNewFilenameBody, duplicateProjectFile);
|
||||
router.put('/:filename/rename', validateFilenameParam, validateFilenameBody, renameProjectFile);
|
||||
router.delete('/:filename', validateFilenameParam, deleteProjectFile);
|
||||
|
||||
router.get('/info', getInfo);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { body, validationResult } from 'express-validator';
|
||||
|
||||
import { body, param, validationResult } from 'express-validator';
|
||||
import { ensureJsonExtension } from '../../utils/fileManagement.js';
|
||||
import sanitize from 'sanitize-filename';
|
||||
|
||||
export const projectSanitiser = [
|
||||
/**
|
||||
* @description Validates request for a new project.
|
||||
*/
|
||||
export const validateNewProject = [
|
||||
body('title').optional().isString().trim(),
|
||||
body('description').optional().isString().trim(),
|
||||
body('publicUrl').optional().isString().trim(),
|
||||
@@ -19,18 +22,10 @@ export const projectSanitiser = [
|
||||
},
|
||||
];
|
||||
|
||||
export const sanitizeProjectFilename = (req: Request, _res: Response, next: NextFunction) => {
|
||||
const { filename, newFilename } = req.body;
|
||||
const { filename: projectName } = req.params;
|
||||
|
||||
req.body.filename = ensureJsonExtension(filename);
|
||||
req.body.newFilename = ensureJsonExtension(newFilename);
|
||||
req.params.filename = ensureJsonExtension(projectName);
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
export const validatePatchProjectFile = [
|
||||
/**
|
||||
* @description Validates request for pathing data in the project.
|
||||
*/
|
||||
export const validatePatchProject = [
|
||||
body('rundown').isArray().optional({ nullable: false }),
|
||||
body('project').isObject().optional({ nullable: false }),
|
||||
body('settings').isObject().optional({ nullable: false }),
|
||||
@@ -47,31 +42,18 @@ export const validatePatchProjectFile = [
|
||||
];
|
||||
|
||||
/**
|
||||
* @description Validates the filename for loading a project file.
|
||||
* @description Validates request with newFilename in the body.
|
||||
*/
|
||||
export const validateLoadProjectFile = [
|
||||
body('filename').exists().withMessage('Filename is required').isString().withMessage('Filename must be a string'),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(422).json({ errors: errors.array() });
|
||||
}
|
||||
next();
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* @description Validates the filenames for duplicating a project.
|
||||
*/
|
||||
export const validateProjectDuplicate = [
|
||||
export const validateNewFilenameBody = [
|
||||
body('newFilename')
|
||||
.exists()
|
||||
.withMessage('New project filename is required')
|
||||
.isString()
|
||||
.withMessage('New project filename must be a string')
|
||||
.isLength({ min: 1, max: 255 })
|
||||
.withMessage('New project filename must be between 1 and 255 characters'),
|
||||
.trim()
|
||||
.customSanitizer((input: string) => sanitize(input))
|
||||
.withMessage('Failed to sanitize the filename')
|
||||
.notEmpty()
|
||||
.withMessage('Filename was empty or contained only invalid characters')
|
||||
.customSanitizer((input: string) => ensureJsonExtension(input)),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
@@ -84,16 +66,18 @@ export const validateProjectDuplicate = [
|
||||
];
|
||||
|
||||
/**
|
||||
* @description Validates the filenames for renaming a project.
|
||||
* @description Validates request with filename in the body.
|
||||
*/
|
||||
export const validateProjectRename = [
|
||||
body('newFilename')
|
||||
export const validateFilenameBody = [
|
||||
body('filename')
|
||||
.exists()
|
||||
.withMessage('Duplicate project filename is required')
|
||||
.isString()
|
||||
.withMessage('Duplicate project filename must be a string')
|
||||
.isLength({ min: 1, max: 255 })
|
||||
.withMessage('Duplicate project filename must be between 1 and 255 characters'),
|
||||
.trim()
|
||||
.customSanitizer((input: string) => sanitize(input))
|
||||
.withMessage('Failed to sanitize the filename')
|
||||
.notEmpty()
|
||||
.withMessage('Filename was empty or contained only invalid characters')
|
||||
.customSanitizer((input: string) => ensureJsonExtension(input)),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
@@ -106,10 +90,18 @@ export const validateProjectRename = [
|
||||
];
|
||||
|
||||
/**
|
||||
* @description Validates a download request which can include an optional project name.
|
||||
* @description Validates request with filename in the params.
|
||||
*/
|
||||
export const validateDownloadProject = [
|
||||
body('fileName').isString().optional(),
|
||||
export const validateFilenameParam = [
|
||||
param('filename')
|
||||
.exists()
|
||||
.isString()
|
||||
.trim()
|
||||
.customSanitizer((input: string) => sanitize(input))
|
||||
.withMessage('Failed to sanitize the filename')
|
||||
.notEmpty()
|
||||
.withMessage('Filename was empty or contained only invalid characters')
|
||||
.customSanitizer((input: string) => ensureJsonExtension(input)),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { eventStore } from '../stores/EventStore.js';
|
||||
import * as assert from '../utils/assert.js';
|
||||
import { isEmptyObject } from '../utils/parserUtils.js';
|
||||
import { parseProperty, updateEvent } from './integration.utils.js';
|
||||
import { socket } from '../adapters/WebsocketAdapter.js';
|
||||
import { throttle } from '../utils/throttle.js';
|
||||
import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js';
|
||||
|
||||
@@ -240,6 +241,33 @@ const actionHandlers: Record<string, ActionHandler> = {
|
||||
}
|
||||
throw new Error('No matching method provided');
|
||||
},
|
||||
/* Client */
|
||||
client: (payload) => {
|
||||
assert.isObject(payload);
|
||||
if (!('target' in payload) || typeof payload.target != 'string') {
|
||||
throw new Error('No or invalid client target');
|
||||
}
|
||||
|
||||
if ('rename' in payload && typeof payload.rename == 'string') {
|
||||
const { target, rename } = payload;
|
||||
socket.renameClient(target, rename);
|
||||
return { payload: 'success' };
|
||||
}
|
||||
|
||||
if ('redirect' in payload && typeof payload.redirect == 'string') {
|
||||
const { target, redirect } = payload;
|
||||
socket.redirectClient(target, redirect);
|
||||
return { payload: 'success' };
|
||||
}
|
||||
|
||||
if ('identify' in payload && typeof payload.identify == 'boolean') {
|
||||
const { target, identify } = payload;
|
||||
socket.identifyClient(target, identify);
|
||||
return { payload: 'success' };
|
||||
}
|
||||
|
||||
throw new Error('No matching method provided');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+31
-17
@@ -17,9 +17,9 @@ import {
|
||||
resolveExternalsDirectory,
|
||||
resolveStylesDirectory,
|
||||
resolvedPath,
|
||||
clearUploadfolder,
|
||||
} from './setup/index.js';
|
||||
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
|
||||
import { consoleSuccess, consoleHighlight } from './utils/console.js';
|
||||
|
||||
// Import Routers
|
||||
import { appRouter } from './api-data/index.js';
|
||||
@@ -43,11 +43,17 @@ import { messageService } from './services/message-service/MessageService.js';
|
||||
import { populateDemo } from './setup/loadDemo.js';
|
||||
import { getState } from './stores/runtimeState.js';
|
||||
import { initRundown } from './services/rundown-service/RundownService.js';
|
||||
|
||||
// Utilities
|
||||
import { clearUploadfolder } from './utils/upload.js';
|
||||
import { generateCrashReport } from './utils/generateCrashReport.js';
|
||||
import { getNetworkInterfaces } from './utils/networkInterfaces.js';
|
||||
|
||||
console.log(`Starting Ontime version ${ONTIME_VERSION}`);
|
||||
console.log('\n');
|
||||
consoleHighlight(`Starting Ontime version ${ONTIME_VERSION}`);
|
||||
|
||||
if (!isProduction) {
|
||||
const canLog = isProduction;
|
||||
if (!canLog) {
|
||||
console.log(`Ontime running in ${environment} environment`);
|
||||
console.log(`Ontime directory at ${srcDirectory} `);
|
||||
console.log(`Ontime database at ${resolveDbPath}`);
|
||||
@@ -56,7 +62,7 @@ if (!isProduction) {
|
||||
// Create express APP
|
||||
const app = express();
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
// log more serever timings
|
||||
// log server timings to requests
|
||||
app.use(serverTiming());
|
||||
}
|
||||
app.disable('x-powered-by');
|
||||
@@ -151,18 +157,16 @@ export const initAssets = async () => {
|
||||
|
||||
/**
|
||||
* Starts servers
|
||||
* @return {Promise<string>}
|
||||
*/
|
||||
export const startServer = async () => {
|
||||
export const startServer = async (
|
||||
escalateErrorFn?: (error: string) => void,
|
||||
): Promise<{ message: string; serverPort: number }> => {
|
||||
checkStart(OntimeStartOrder.InitServer);
|
||||
|
||||
const { serverPort } = DataProvider.getSettings();
|
||||
|
||||
const returnMessage = `Ontime is listening on port ${serverPort}`;
|
||||
|
||||
expressServer = http.createServer(app);
|
||||
socket.init(expressServer);
|
||||
logger.info(LogOrigin.Server, returnMessage);
|
||||
|
||||
/**
|
||||
* Module initialises the services and provides initial payload for the store
|
||||
@@ -186,6 +190,9 @@ export const startServer = async () => {
|
||||
},
|
||||
});
|
||||
|
||||
// initialise logging service, escalateErrorFn is only exists in electron
|
||||
logger.init(escalateErrorFn);
|
||||
|
||||
// initialise rundown service
|
||||
const persistedRundown = DataProvider.getRundown();
|
||||
const persistedCustomFields = DataProvider.getCustomFields();
|
||||
@@ -200,7 +207,17 @@ export const startServer = async () => {
|
||||
// eventStore set is a dependency of the services that publish to it
|
||||
messageService.init(eventStore.set.bind(eventStore));
|
||||
|
||||
expressServer.listen(serverPort, '0.0.0.0');
|
||||
expressServer.listen(serverPort, '0.0.0.0', () => {
|
||||
const nif = getNetworkInterfaces();
|
||||
consoleSuccess(`Local: http://localhost:${serverPort}/editor`);
|
||||
for (const key in nif) {
|
||||
const address = nif[key].address;
|
||||
consoleSuccess(`Network: http://${address}:${serverPort}/editor`);
|
||||
}
|
||||
});
|
||||
|
||||
const returnMessage = `Ontime is listening on port ${serverPort}`;
|
||||
logger.info(LogOrigin.Server, returnMessage);
|
||||
|
||||
return { message: returnMessage, serverPort };
|
||||
};
|
||||
@@ -241,7 +258,7 @@ export const startIntegrations = async (config?: { osc: OSCSettings; http: HttpS
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
export const shutdown = async (exitCode = 0) => {
|
||||
console.log(`Ontime shutting down with code ${exitCode}`);
|
||||
consoleHighlight(`Ontime shutting down with code ${exitCode}`);
|
||||
|
||||
// clear the restore file if it was a normal exit
|
||||
// 0 means it was a SIGNAL
|
||||
@@ -251,7 +268,6 @@ export const shutdown = async (exitCode = 0) => {
|
||||
await restoreService.clear();
|
||||
}
|
||||
|
||||
// TODO: Clear token
|
||||
expressServer?.close();
|
||||
runtimeService.shutdown();
|
||||
integrationService.shutdown();
|
||||
@@ -260,19 +276,17 @@ export const shutdown = async (exitCode = 0) => {
|
||||
process.exit(exitCode);
|
||||
};
|
||||
|
||||
process.on('exit', (code) => console.log(`Ontime shutdown with code: ${code}`));
|
||||
process.on('exit', (code) => consoleHighlight(`Ontime shutdown with code: ${code}`));
|
||||
|
||||
process.on('unhandledRejection', async (error) => {
|
||||
console.error('Error: unhandled rejection', error);
|
||||
generateCrashReport(error);
|
||||
logger.error(LogOrigin.Server, `Error: unhandled rejection ${error}`);
|
||||
logger.crash(LogOrigin.Server, `Uncaught exception | ${error}`);
|
||||
await shutdown(1);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', async (error) => {
|
||||
console.error('Error: uncaught exception', error);
|
||||
generateCrashReport(error);
|
||||
logger.error(LogOrigin.Server, `Error: uncaught exception ${error}`);
|
||||
logger.crash(LogOrigin.Server, `Uncaught exception | ${error}`);
|
||||
await shutdown(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,22 +4,33 @@ import { generateId, millisToString } from 'ontime-utils';
|
||||
import { clock } from '../services/Clock.js';
|
||||
import { isProduction } from '../setup/index.js';
|
||||
import { socket } from '../adapters/WebsocketAdapter.js';
|
||||
import { consoleSubdued, consoleError } from '../utils/console.js';
|
||||
|
||||
class Logger {
|
||||
private queue: Log[];
|
||||
private escalateErrorFn: ((error: string) => void) | null;
|
||||
private canLog = false;
|
||||
|
||||
constructor() {
|
||||
this.queue = [];
|
||||
this.escalateErrorFn = null;
|
||||
this.canLog = !isProduction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enabling setup logger after init
|
||||
*/
|
||||
init() {
|
||||
init(escalateErrorFn?: (error: string) => void) {
|
||||
// flush logs from queue
|
||||
this.queue.forEach((log) => {
|
||||
this._push(log);
|
||||
});
|
||||
this.queue = [];
|
||||
|
||||
// we only get this when running in electron
|
||||
if (escalateErrorFn) {
|
||||
this.escalateErrorFn = escalateErrorFn;
|
||||
}
|
||||
}
|
||||
|
||||
private addToQueue(log: Log) {
|
||||
@@ -34,8 +45,12 @@ class Logger {
|
||||
* @param log
|
||||
*/
|
||||
private _push(log: Log) {
|
||||
if (!isProduction) {
|
||||
console.log(`[${log.level}] \t ${log.origin} \t ${log.text}`);
|
||||
if (this.canLog || log.level === LogLevel.Severe) {
|
||||
if (log.level === LogLevel.Severe) {
|
||||
consoleError(`[${log.level}] \t ${log.origin} \t ${log.text}`);
|
||||
} else {
|
||||
consoleSubdued(`[${log.level}] \t ${log.origin} \t ${log.text}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -92,11 +107,20 @@ class Logger {
|
||||
this.emit(LogLevel.Error, origin, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to emit logging message of type SEVERE
|
||||
* @param origin
|
||||
* @param text
|
||||
*/
|
||||
crash(origin: string, text: string) {
|
||||
this.emit(LogLevel.Severe, origin, text);
|
||||
this.escalateErrorFn?.(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown logger
|
||||
*/
|
||||
shutdown() {
|
||||
console.log('Shutting down logger');
|
||||
this.queue = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DatabaseModel, OntimeRundown, Settings, URLPreset } from 'ontime-types';
|
||||
import { DatabaseModel, OntimeRundown, Settings, URLPreset, ViewSettings } from 'ontime-types';
|
||||
import { safeMerge } from '../DataProvider.utils.js';
|
||||
|
||||
describe('safeMerge', () => {
|
||||
@@ -49,7 +49,7 @@ describe('safeMerge', () => {
|
||||
} as DatabaseModel;
|
||||
|
||||
it('returns existing data if new data is not provided', () => {
|
||||
const mergedData = safeMerge(existing, undefined);
|
||||
const mergedData = safeMerge(existing, {});
|
||||
expect(mergedData).toEqual(existing);
|
||||
});
|
||||
|
||||
@@ -136,6 +136,7 @@ describe('safeMerge', () => {
|
||||
rundown: [],
|
||||
project: {
|
||||
title: '',
|
||||
description: '',
|
||||
publicUrl: '',
|
||||
publicInfo: '',
|
||||
backstageUrl: '',
|
||||
@@ -153,8 +154,9 @@ describe('safeMerge', () => {
|
||||
viewSettings: {
|
||||
overrideStyles: false,
|
||||
endMessage: '',
|
||||
},
|
||||
} as ViewSettings,
|
||||
urlPresets: [],
|
||||
customFields: {},
|
||||
osc: {
|
||||
portIn: 8888,
|
||||
portOut: 9999,
|
||||
@@ -163,6 +165,10 @@ describe('safeMerge', () => {
|
||||
enabledOut: false,
|
||||
subscriptions: [],
|
||||
},
|
||||
http: {
|
||||
enabledOut: false,
|
||||
subscriptions: [],
|
||||
},
|
||||
} as DatabaseModel;
|
||||
|
||||
const newData = {
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
/* eslint-disable no-console */
|
||||
import { consoleHighlight, consoleError } from './utils/console.js';
|
||||
import { initAssets, startIntegrations, startServer } from './app.js';
|
||||
|
||||
async function startOntime() {
|
||||
try {
|
||||
console.log('Request: Initialise assets...');
|
||||
console.log('\n');
|
||||
consoleHighlight('Request: Initialise assets...');
|
||||
await initAssets();
|
||||
console.log('Request: Start server...');
|
||||
|
||||
console.log('\n');
|
||||
consoleHighlight('Request: Start server...');
|
||||
await startServer();
|
||||
console.log('Request: Start integrations...');
|
||||
|
||||
console.log('\n');
|
||||
consoleHighlight('Request: Start integrations...');
|
||||
await startIntegrations();
|
||||
} catch (error) {
|
||||
console.log(`Request failed: ${error}`);
|
||||
consoleError(`Request failed: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -978,6 +978,32 @@ describe('getRollTimers()', () => {
|
||||
expect(state).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('loads upcoming event while waiting to roll', () => {
|
||||
const singleEventList: Partial<OntimeEvent>[] = [
|
||||
{
|
||||
id: '1',
|
||||
timeStart: 72000000, // 20:00
|
||||
timeEnd: 72010000, // 20:10
|
||||
isPublic: true,
|
||||
},
|
||||
];
|
||||
const now = 6000; // 00:01
|
||||
const expected = {
|
||||
nowIndex: null,
|
||||
nowId: null,
|
||||
publicIndex: null,
|
||||
nextIndex: 0,
|
||||
publicNextIndex: 0,
|
||||
timeToNext: 72000000 - now,
|
||||
nextEvent: singleEventList[0],
|
||||
nextPublicEvent: singleEventList[0],
|
||||
currentEvent: null,
|
||||
currentPublicEvent: null,
|
||||
};
|
||||
const state = getRollTimers(singleEventList as OntimeEvent[], now);
|
||||
expect(state).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('handles roll that goes over midnight', () => {
|
||||
const singleEventList: Partial<OntimeEvent>[] = [
|
||||
{
|
||||
@@ -1779,7 +1805,7 @@ describe('getTimerPhase()', () => {
|
||||
expect(phase).toBe(TimerPhase.None);
|
||||
});
|
||||
|
||||
it('can be negative', () => {
|
||||
it('can be in overtime', () => {
|
||||
const state = {
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
@@ -1794,7 +1820,7 @@ describe('getTimerPhase()', () => {
|
||||
} as RuntimeState;
|
||||
|
||||
const phase = getTimerPhase(state);
|
||||
expect(phase).toBe(TimerPhase.Negative);
|
||||
expect(phase).toBe(TimerPhase.Overtime);
|
||||
});
|
||||
|
||||
it('can be danger', () => {
|
||||
@@ -1850,4 +1876,84 @@ describe('getTimerPhase()', () => {
|
||||
const phase = getTimerPhase(state);
|
||||
expect(phase).toBe(TimerPhase.Default);
|
||||
});
|
||||
|
||||
it('#1042 identifies waiting to roll', () => {
|
||||
const state = {
|
||||
clock: 55691050,
|
||||
eventNow: null,
|
||||
publicEventNow: null,
|
||||
eventNext: null,
|
||||
publicEventNext: null,
|
||||
runtime: {
|
||||
selectedEventIndex: null,
|
||||
numEvents: 1,
|
||||
offset: null,
|
||||
plannedStart: 55860000,
|
||||
plannedEnd: 55880000,
|
||||
actualStart: null,
|
||||
expectedEnd: null,
|
||||
},
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
current: null,
|
||||
duration: null,
|
||||
elapsed: 0,
|
||||
expectedFinish: null,
|
||||
finishedAt: null,
|
||||
phase: 'none',
|
||||
playback: 'roll',
|
||||
secondaryTimer: 168950,
|
||||
startedAt: null,
|
||||
},
|
||||
_timer: {
|
||||
forceFinish: null,
|
||||
totalDelay: 0,
|
||||
pausedAt: null,
|
||||
secondaryTarget: 55860000,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const phase = getTimerPhase(state);
|
||||
expect(phase).toBe(TimerPhase.Pending);
|
||||
});
|
||||
|
||||
it('#1042 identifies waiting to roll', () => {
|
||||
const state = {
|
||||
clock: 55691050,
|
||||
eventNow: null,
|
||||
publicEventNow: null,
|
||||
eventNext: null,
|
||||
publicEventNext: null,
|
||||
runtime: {
|
||||
selectedEventIndex: null,
|
||||
numEvents: 1,
|
||||
offset: null,
|
||||
plannedStart: 55860000,
|
||||
plannedEnd: 55880000,
|
||||
actualStart: null,
|
||||
expectedEnd: null,
|
||||
},
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
current: null,
|
||||
duration: null,
|
||||
elapsed: 0,
|
||||
expectedFinish: null,
|
||||
finishedAt: null,
|
||||
phase: 'none',
|
||||
playback: 'roll',
|
||||
secondaryTimer: 168950,
|
||||
startedAt: null,
|
||||
},
|
||||
_timer: {
|
||||
forceFinish: null,
|
||||
totalDelay: 0,
|
||||
pausedAt: null,
|
||||
secondaryTarget: 55860000,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const phase = getTimerPhase(state);
|
||||
expect(phase).toBe(TimerPhase.Pending);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,21 +14,24 @@ interface Config {
|
||||
class AppStateService {
|
||||
private config: Low<Config>;
|
||||
private pathToFile: string;
|
||||
private didInit = false;
|
||||
|
||||
constructor(appStatePath: string) {
|
||||
this.pathToFile = appStatePath;
|
||||
const adapter = new JSONFile<Config>(this.pathToFile);
|
||||
this.config = new Low<Config>(adapter, null);
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
private async init() {
|
||||
await this.config.read();
|
||||
await this.config.write();
|
||||
this.didInit = true;
|
||||
}
|
||||
|
||||
async get(): Promise<Config> {
|
||||
if (!this.didInit) {
|
||||
await this.init();
|
||||
}
|
||||
await this.config.read();
|
||||
return this.config.data;
|
||||
}
|
||||
@@ -36,6 +39,10 @@ class AppStateService {
|
||||
async updateDatabaseConfig(filename: string): Promise<void> {
|
||||
if (isTest) return;
|
||||
|
||||
if (!this.didInit) {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
this.config.data.lastLoadedProject = filename;
|
||||
await this.config.write();
|
||||
}
|
||||
|
||||
@@ -116,6 +116,18 @@ describe('parseNestedTemplate() -> stringToOSCArgs()', () => {
|
||||
test: '"string with space and {{not.so.easy}}"',
|
||||
expect: [{ type: 'string', value: 'string with space and data with space' }],
|
||||
},
|
||||
{
|
||||
test: '',
|
||||
expect: [],
|
||||
},
|
||||
{
|
||||
test: ' ',
|
||||
expect: [],
|
||||
},
|
||||
{
|
||||
test: '""',
|
||||
expect: [{ type: 'string', value: '' }],
|
||||
},
|
||||
{
|
||||
test: '"string with space and {{not.so.empty}}"',
|
||||
expect: [{ type: 'string', value: 'string with space and ' }],
|
||||
|
||||
@@ -170,6 +170,19 @@ describe('generate()', () => {
|
||||
expect(initResult.totalDuration).toBe(500 - 100);
|
||||
});
|
||||
|
||||
it('calculates total duration with 0 duration events without causing a next day', () => {
|
||||
const testRundown: OntimeRundown = [
|
||||
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 100, duration: 0 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '2', timeStart: 100, timeEnd: 300 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: 'skipped', skip: true, timeStart: 300, timeEnd: 400 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500 } as OntimeEvent,
|
||||
];
|
||||
|
||||
const initResult = generate(testRundown);
|
||||
expect(initResult.order.length).toBe(4);
|
||||
expect(initResult.totalDuration).toBe(500 - 100);
|
||||
});
|
||||
|
||||
it('calculates total duration across days with gap', () => {
|
||||
const testRundown: OntimeRundown = [
|
||||
{
|
||||
@@ -361,7 +374,7 @@ describe('remove() mutation', () => {
|
||||
];
|
||||
const { newRundown } = remove({ eventIds: ['1', '2', '3'], persistedRundown: testRundown });
|
||||
expect(newRundown.length).toBe(3);
|
||||
expect(newRundown.at(0).id).toBe('4');
|
||||
expect(newRundown.at(0)?.id).toBe('4');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -885,7 +898,7 @@ describe('custom fields', () => {
|
||||
};
|
||||
|
||||
const customField = await editCustomField('sound', { label: 'Sound', type: 'string', colour: 'green' });
|
||||
expect(customFieldChangelog).toStrictEqual({});
|
||||
expect(customFieldChangelog).toStrictEqual(new Map());
|
||||
|
||||
expect(customField).toStrictEqual(expected);
|
||||
});
|
||||
@@ -931,9 +944,15 @@ describe('custom fields', () => {
|
||||
},
|
||||
};
|
||||
|
||||
// We need to flush all scheduled tasks for the generate function to settle
|
||||
vi.useFakeTimers();
|
||||
const customField = await editCustomField('video', { label: 'AV', type: 'string', colour: 'red' });
|
||||
expect(customField).toStrictEqual(expectedAfter);
|
||||
expect(customFieldChangelog).toStrictEqual({ video: 'av' });
|
||||
expect(customFieldChangelog).toStrictEqual(new Map([['video', 'av']]));
|
||||
await editCustomField('av', { label: 'video' });
|
||||
vi.runAllTimers();
|
||||
expect(customFieldChangelog).toStrictEqual(new Map());
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -945,8 +964,8 @@ describe('custom fields', () => {
|
||||
type: 'string',
|
||||
colour: 'blue',
|
||||
},
|
||||
av: {
|
||||
label: 'AV',
|
||||
video: {
|
||||
label: 'video',
|
||||
type: 'string',
|
||||
colour: 'red',
|
||||
},
|
||||
|
||||
@@ -96,7 +96,7 @@ describe('handleCustomField()', () => {
|
||||
label: 'sound',
|
||||
},
|
||||
} as CustomFields;
|
||||
const customFieldChangelog = {};
|
||||
const customFieldChangelog = new Map<string, string>();
|
||||
|
||||
// @ts-expect-error -- partial event for testing
|
||||
const event: OntimeEvent = {
|
||||
@@ -132,9 +132,7 @@ describe('handleCustomField()', () => {
|
||||
},
|
||||
} as CustomFields;
|
||||
|
||||
const customFieldChangelog = {
|
||||
sound: 'video',
|
||||
};
|
||||
const customFieldChangelog = new Map([['sound', 'video']]);
|
||||
|
||||
// @ts-expect-error -- partial event for testing
|
||||
const event: OntimeEvent = {
|
||||
@@ -170,9 +168,7 @@ describe('handleCustomField()', () => {
|
||||
},
|
||||
} as CustomFields;
|
||||
|
||||
const customFieldChangelog = {
|
||||
field1: 'newField1',
|
||||
};
|
||||
const customFieldChangelog = new Map([['field1', 'newField1']]);
|
||||
|
||||
// @ts-expect-error -- partial event for testing
|
||||
const mutableEvent: OntimeEvent = {
|
||||
|
||||
@@ -47,7 +47,7 @@ let links: Record<EventID, EventID> = {};
|
||||
* lighting: lx
|
||||
* }
|
||||
*/
|
||||
export const customFieldChangelog = {};
|
||||
export const customFieldChangelog = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Keep track of which custom fields are used.
|
||||
@@ -87,6 +87,7 @@ export function generate(
|
||||
let daySpan = 0;
|
||||
let previousStart: MaybeNumber = null;
|
||||
let previousEnd: MaybeNumber = null;
|
||||
let previousDuration: MaybeNumber = null;
|
||||
|
||||
for (let i = 0; i < initialRundown.length; i++) {
|
||||
const currentEvent = initialRundown[i];
|
||||
@@ -111,7 +112,8 @@ export function generate(
|
||||
lastEnd = updatedEvent.timeEnd;
|
||||
|
||||
// check if we go over midnight, account for eventual gaps
|
||||
const gapOverMidnight = previousStart !== null && checkIsNextDay(previousStart, updatedEvent.timeStart);
|
||||
const gapOverMidnight =
|
||||
previousStart !== null && checkIsNextDay(previousStart, updatedEvent.timeStart, previousDuration);
|
||||
const durationOverMidnight = updatedEvent.timeStart > updatedEvent.timeEnd;
|
||||
if (gapOverMidnight || durationOverMidnight) {
|
||||
daySpan++;
|
||||
@@ -134,6 +136,7 @@ export function generate(
|
||||
updatedEvent.delay = accumulatedDelay;
|
||||
previousStart = updatedEvent.timeStart;
|
||||
previousEnd = updatedEvent.timeEnd;
|
||||
previousDuration = updatedEvent.duration;
|
||||
}
|
||||
|
||||
order.push(updatedEvent.id);
|
||||
@@ -141,6 +144,7 @@ export function generate(
|
||||
}
|
||||
|
||||
isStale = false;
|
||||
customFieldChangelog.clear();
|
||||
totalDelay = accumulatedDelay;
|
||||
if (lastEnd !== null && firstStart !== null) {
|
||||
totalDuration = getTotalDuration(firstStart, lastEnd, daySpan);
|
||||
@@ -411,6 +415,7 @@ function invalidateIfUsed(label: CustomFieldLabel) {
|
||||
// schedule a non priority cache update
|
||||
setImmediate(() => {
|
||||
generate();
|
||||
DataProvider.setRundown(persistedRundown);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -459,7 +464,7 @@ export const editCustomField = async (key: string, newField: Partial<CustomField
|
||||
}
|
||||
|
||||
const existingField = persistedCustomFields[key];
|
||||
if (existingField.type !== newField.type) {
|
||||
if (newField.type !== undefined && existingField.type !== newField.type) {
|
||||
throw new Error('Change of field type is not allowed');
|
||||
}
|
||||
|
||||
@@ -468,7 +473,7 @@ export const editCustomField = async (key: string, newField: Partial<CustomField
|
||||
|
||||
if (key !== newKey) {
|
||||
delete persistedCustomFields[key];
|
||||
customFieldChangelog[key] = newKey;
|
||||
customFieldChangelog.set(key, newKey);
|
||||
}
|
||||
|
||||
scheduleCustomFieldPersist(persistedCustomFields);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user