mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-03 06:28:01 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3f18feaae | |||
| 2fa397548a | |||
| dd43d5e20a | |||
| 9fc154955a | |||
| 160ccabebc | |||
| 4974898050 | |||
| ac0d5832b6 | |||
| 2b2bafa8c6 | |||
| fad34eeab2 | |||
| c12bca05aa | |||
| 1e4206fcb2 | |||
| 2751ed3d33 | |||
| cd911aaaa2 | |||
| 1321aa555e | |||
| 12e4eaef67 | |||
| 28c9cb0864 | |||
| afdfae9073 | |||
| 0736ce93c6 | |||
| 52f02f153c | |||
| 3582ce18e1 |
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2020
|
||||
},
|
||||
"env": {
|
||||
"es6": true,
|
||||
"jest": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended"
|
||||
],
|
||||
"rules": {}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 248 KiB |
@@ -0,0 +1,64 @@
|
||||
# This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
|
||||
|
||||
name: ontime_test_CI
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CI: ''
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '14.x'
|
||||
|
||||
# Utils server
|
||||
- name: Utils - Install dependencies
|
||||
run: yarn install
|
||||
working-directory: ./server/utils
|
||||
|
||||
- name: Utils - run link command
|
||||
run: yarn link
|
||||
working-directory: ./server/utils
|
||||
|
||||
# React
|
||||
- name: React - Link to utils
|
||||
run: yarn link ontime-utils
|
||||
working-directory: ./client
|
||||
|
||||
- name: React - Install dependencies
|
||||
run: yarn install
|
||||
working-directory: ./client
|
||||
|
||||
- name: React - Run tests
|
||||
run: yarn test:pipeline
|
||||
working-directory: ./client
|
||||
|
||||
- name: React - Build project
|
||||
run: yarn build
|
||||
working-directory: ./client
|
||||
|
||||
# Node server
|
||||
- name: React - Link to utils
|
||||
run: yarn link ontime-utils
|
||||
working-directory: ./server/src
|
||||
|
||||
- name: Server - Install dependencies
|
||||
run: yarn install
|
||||
working-directory: ./server/src
|
||||
|
||||
# App
|
||||
- name: Electron - Install dependencies
|
||||
run: yarn install
|
||||
working-directory: ./server
|
||||
|
||||
- name: Electron - Run tests
|
||||
run: yarn test
|
||||
working-directory: ./server
|
||||
+8
-4
@@ -23,13 +23,17 @@ dist/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
ontime.code-workspace
|
||||
TODO.md
|
||||
|
||||
# working stuff
|
||||
_SS/
|
||||
.vscode/launch.json
|
||||
.eslintrc.json
|
||||
db backup.json
|
||||
server/src/data/db.json
|
||||
server/src/models/db.json
|
||||
TODO.md
|
||||
|
||||
# vscode stuff
|
||||
.vscode/*
|
||||
ontime.code-workspace
|
||||
|
||||
# webstorm stuff
|
||||
.idea/*
|
||||
Generated
+5
@@ -0,0 +1,5 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="HttpUrlsUsage" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||
<option name="ignoredUrls">
|
||||
<list>
|
||||
<option value="http://localhost" />
|
||||
<option value="http://127.0.0.1" />
|
||||
<option value="http://0.0.0.0" />
|
||||
<option value="http://www.w3.org/" />
|
||||
<option value="http://json-schema.org/draft" />
|
||||
<option value="http://java.sun.com/" />
|
||||
<option value="http://xmlns.jcp.org/" />
|
||||
<option value="http://javafx.com/javafx/" />
|
||||
<option value="http://javafx.com/fxml" />
|
||||
<option value="http://maven.apache.org/xsd/" />
|
||||
<option value="http://maven.apache.org/POM/" />
|
||||
<option value="http://www.springframework.org/schema/" />
|
||||
<option value="http://www.springframework.org/tags" />
|
||||
<option value="http://www.springframework.org/security/tags" />
|
||||
<option value="http://www.thymeleaf.org" />
|
||||
<option value="http://www.jboss.org/j2ee/schema/" />
|
||||
<option value="http://www.jboss.com/xml/ns/" />
|
||||
<option value="http://www.ibm.com/webservices/xsd" />
|
||||
<option value="http://activemq.apache.org/schema/" />
|
||||
<option value="http://schema.cloudfoundry.org/spring/" />
|
||||
<option value="http://schemas.xmlsoap.org/" />
|
||||
<option value="http://cxf.apache.org/schemas/" />
|
||||
<option value="http://primefaces.org/ui" />
|
||||
<option value="http://tiles.apache.org/" />
|
||||
<option value="http://__IP__" />
|
||||
</list>
|
||||
</option>
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true
|
||||
}
|
||||
@@ -21,8 +21,11 @@ From here, any device in the same network with a browser is able to render the v
|
||||
You can then use the ontime logo in the top right corner to select the desired view (event in the lower thirds view, where it is hidden).
|
||||
|
||||
In case of unattended machines or automations, it is possible to use different URL to recall individual views
|
||||
and extend with using the URL aliases feature
|
||||
|
||||
```
|
||||
For the presentation views...
|
||||
-------------------------------------------------------------
|
||||
IP.ADDRESS:4001 > Web server default to presenter timer view
|
||||
IP.ADDRESS:4001/preseter > Presenter / Stage timer view
|
||||
IP.ADDRESS:4001/sm > Stage Manager / Backstage view
|
||||
@@ -30,6 +33,11 @@ IP.ADDRESS:4001/public > Public / Foyer view
|
||||
IP.ADDRESS:4001/pip > Picture in Picture view
|
||||
IP.ADDRESS:4001/lower > Lower Thirds
|
||||
IP.ADDRESS:4001/studio > Studio Clock
|
||||
|
||||
...and for the editor (the control interface, same as the app)
|
||||
-------------------------------------------------------------
|
||||
IP.ADDRESS:4001/studio > Studio Clock
|
||||
|
||||
```
|
||||
|
||||
More documentation available [here](https://cpvalente.gitbook.io/ontime/)
|
||||
@@ -47,6 +55,9 @@ More documentation available [here](https://cpvalente.gitbook.io/ontime/)
|
||||
- [x] Open Sound Control (OSC) Control and Feedback
|
||||
- [x] Roll mode: run independently using the system clock
|
||||
- [x] Import event list from Excel
|
||||
- [x] URL Aliases (define configurable aliases to ease onsite setup)
|
||||
- [x] Logging view
|
||||
- [x] Edit anywhere: run ontime in your local network and use any machine to reach the editor page (same as app)
|
||||
|
||||
## Unopinionated
|
||||
We are not interested in forcing workflows and have made ontime, so it is flexible to whichever way you would like to work.
|
||||
@@ -77,8 +88,6 @@ These will be implemented in a development friendly order unless there is user d
|
||||
- [ ] Companion module
|
||||
- [ ] Lower Third Manager
|
||||
- [ ] Note only event
|
||||
- [ ] URL Aliases (define configurable aliases to ease onsite setup)
|
||||
- [ ] Logging view
|
||||
- [ ] Reach Schedule: way to speedup timer to meet a deadline
|
||||
- [ ] vMix integration
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
],
|
||||
"plugins": ["react", "testing-library", "jest"],
|
||||
"rules": {
|
||||
"jest/no-mocks-import": "warn",
|
||||
"no-useless-concat": "warn",
|
||||
"prefer-template": "warn"
|
||||
}
|
||||
}
|
||||
+13
-16
@@ -3,26 +3,26 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^1.7.1",
|
||||
"@emotion/react": "^11.5.0",
|
||||
"@emotion/styled": "^11.3.0",
|
||||
"@chakra-ui/react": "^1.7.3",
|
||||
"@emotion/react": "^11.7.1",
|
||||
"@emotion/styled": "^11.6.0",
|
||||
"@testing-library/jest-dom": "^5.11.4",
|
||||
"@testing-library/react": "^11.1.0",
|
||||
"@testing-library/user-event": "^12.1.10",
|
||||
"axios": "^0.21.1",
|
||||
"axios": "^0.24.0",
|
||||
"framer-motion": "^4.1.6",
|
||||
"jotai": "^0.16.5",
|
||||
"ontime-utils": "link: ../server/utils/",
|
||||
"react": "17.0.2",
|
||||
"react-beautiful-dnd": "^13.1.0",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-fast-compare": "^3.2.0",
|
||||
"react-icons": "^4.3.1",
|
||||
"react-qr-code": "^2.0.2",
|
||||
"react-query": "^3.13.5",
|
||||
"react-router": "^5.2.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-scripts": "4.0.3",
|
||||
"socket.io-client": "^4.3.2",
|
||||
"react-qr-code": "2.0.3",
|
||||
"react-query": "^3.34.5",
|
||||
"react-router-dom": "^6.2.1",
|
||||
"react-scripts": "5.0.0",
|
||||
"socket.io-client": "4.4.0",
|
||||
"typeface-open-sans": "^1.1.13",
|
||||
"use-fit-text": "^2.4.0",
|
||||
"web-vitals": "^1.0.1"
|
||||
@@ -31,14 +31,9 @@
|
||||
"start": "set BROWSER=none&&react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"test:pipeline": "react-scripts test --watchAll=false --runInBand --detectOpenHandles --forceExit ",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
@@ -52,6 +47,8 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react-hooks": "^7.0.2",
|
||||
"react-test-renderer": "^17.0.2",
|
||||
"sass": "^1.44.0"
|
||||
}
|
||||
}
|
||||
|
||||
+50
-42
@@ -1,18 +1,17 @@
|
||||
import { lazy, Suspense, useCallback, useEffect } from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import './App.css';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import SocketProvider from 'app/context/socketContext';
|
||||
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom';
|
||||
import './App.scss';
|
||||
import withSocket from 'features/viewers/ViewWrapper';
|
||||
import ErrorBoundary from 'common/components/errorBoundary/ErrorBoundary';
|
||||
import ProtectRoute from './common/components/protectRoute/ProtectRoute';
|
||||
import { useFetch } from './app/hooks/useFetch';
|
||||
import { ALIASES } from './app/api/apiConstants';
|
||||
import { getAliases } from './app/api/ontimeApi';
|
||||
|
||||
const Editor = lazy(() => import('features/editors/Editor'));
|
||||
const PresenterView = lazy(() =>
|
||||
import('features/viewers/presenter/PresenterView')
|
||||
);
|
||||
const PresenterSimple = lazy(() =>
|
||||
import('features/viewers/presenter/PresenterSimple')
|
||||
);
|
||||
const StageManager = lazy(() =>
|
||||
import('features/viewers/backstage/StageManager')
|
||||
);
|
||||
@@ -21,18 +20,9 @@ const Lower = lazy(() =>
|
||||
import('features/viewers/production/lower/LowerWrapper')
|
||||
);
|
||||
const Pip = lazy(() => import('features/viewers/production/Pip'));
|
||||
|
||||
const StudioClock = lazy(() => import('features/viewers/studio/StudioClock'));
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
// Seemed to cause issues
|
||||
// broadcastQueryClient({
|
||||
// queryClient,
|
||||
// broadcastChannel: 'ontime',
|
||||
// });
|
||||
|
||||
const SPresenter = withSocket(PresenterView);
|
||||
const SPresenterSimple = withSocket(PresenterSimple);
|
||||
const SStageManager = withSocket(StageManager);
|
||||
const SPublic = withSocket(Public);
|
||||
const SLowerThird = withSocket(Lower);
|
||||
@@ -40,6 +30,10 @@ const SPip = withSocket(Pip);
|
||||
const SStudio = withSocket(StudioClock);
|
||||
|
||||
function App() {
|
||||
const { data } = useFetch(ALIASES, getAliases);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyPress = useCallback((e) => {
|
||||
// check if the alt key is pressed
|
||||
@@ -65,33 +59,47 @@ function App() {
|
||||
};
|
||||
}, [handleKeyPress]);
|
||||
|
||||
// navigate if is alias route
|
||||
useEffect(() => {
|
||||
if (data == null) return;
|
||||
for (const d of data) {
|
||||
if (`/${d.alias}` === location.pathname && d.enabled) {
|
||||
navigate(`/${d.pathAndParams}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [data, location, navigate]);
|
||||
|
||||
return (
|
||||
<SocketProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div className='App'>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={null}>
|
||||
<Switch>
|
||||
<Route exact path='/' component={SPresenter} />
|
||||
<Route exact path='/sm' component={SStageManager} />
|
||||
<Route exact path='/speaker' component={SPresenter} />
|
||||
<Route exact path='/presenter' component={SPresenter} />
|
||||
<Route exact path='/stage' component={SPresenter} />
|
||||
<Route exact path='/presentersimple' component={SPresenterSimple} />
|
||||
<Route exact path='/editor' component={Editor} />
|
||||
<Route exact path='/public' component={SPublic} />
|
||||
<Route exact path='/pip' component={SPip} />
|
||||
<Route exact path='/studio' component={SStudio} />
|
||||
{/* Lower cannot have fallback */}
|
||||
<Route exact path='/lower' component={SLowerThird} />
|
||||
{/* Send to default if nothing found */}
|
||||
<Route component={SPresenter} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
</SocketProvider>
|
||||
<div className='App'>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={null}>
|
||||
<Routes>
|
||||
<Route path='/' element={<SPresenter />} />
|
||||
<Route path='/sm' element={<SStageManager />} />
|
||||
<Route path='/speaker' element={<SPresenter />} />
|
||||
<Route path='/presenter' element={<SPresenter />} />
|
||||
<Route path='/stage' element={<SPresenter />} />
|
||||
<Route path='/public' element={<SPublic />} />
|
||||
<Route path='/pip' element={<SPip />} />
|
||||
<Route path='/studio' element={<SStudio />} />
|
||||
{/*/!* Lower cannot have fallback *!/*/}
|
||||
<Route path='/lower' element={<SLowerThird />} />
|
||||
{/*/!* Protected Routes *!/*/}
|
||||
<Route
|
||||
path='/editor'
|
||||
element={
|
||||
<ProtectRoute>
|
||||
<Editor />
|
||||
</ProtectRoute>
|
||||
}
|
||||
/>
|
||||
{/* Send to default if nothing found */}
|
||||
<Route path='*' element={<SPresenter />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import {QueryClient} from "react-query";
|
||||
|
||||
export const queryClientMock = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
export const NODE_PORT = 4001;
|
||||
export const EVENT_TABLE = 'event';
|
||||
export const ALIASES = 'aliases';
|
||||
export const EVENTS_TABLE = 'events';
|
||||
export const APP_TABLE = 'appinfo';
|
||||
export const OSC_SETTINGS = 'oscSettings';
|
||||
export const APP_SETTINGS = 'appSettings';
|
||||
|
||||
const calculateServer = () => {
|
||||
return window.location.origin.replace(window.location.port, `${NODE_PORT}/`);
|
||||
|
||||
+114
-14
@@ -3,26 +3,128 @@ import { ontimeURL } from './apiConstants';
|
||||
|
||||
export const ontimePlaceholderInfo = {
|
||||
networkInterfaces: [],
|
||||
version: '',
|
||||
serverPort: 4001,
|
||||
oscInPort: '',
|
||||
oscOutPort: '',
|
||||
oscOutIP: '',
|
||||
settings: {
|
||||
version: '',
|
||||
serverPort: 4001,
|
||||
},
|
||||
};
|
||||
|
||||
export const ontimePlaceholderSettings = {
|
||||
pinCode: null,
|
||||
};
|
||||
|
||||
export const eventPlaceholderSettings = {
|
||||
title: '',
|
||||
url: '',
|
||||
publicInfo: '',
|
||||
backstageInfo: '',
|
||||
endMessage: '',
|
||||
};
|
||||
|
||||
export const oscPlaceholderSettings = {
|
||||
port: '',
|
||||
portOut: '',
|
||||
targetIP: '',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
export const httpPlaceholder = {
|
||||
onLoad: {
|
||||
url: '',
|
||||
enabled: false,
|
||||
},
|
||||
onStart: {
|
||||
url: '',
|
||||
enabled: false,
|
||||
},
|
||||
onUpdate: {
|
||||
url: '',
|
||||
enabled: false,
|
||||
},
|
||||
onPause: {
|
||||
url: '',
|
||||
enabled: false,
|
||||
},
|
||||
onStop: {
|
||||
url: '',
|
||||
enabled: false,
|
||||
},
|
||||
onFinish: {
|
||||
url: '',
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const ontimeVars = [
|
||||
{
|
||||
name: '$timer',
|
||||
description: 'Current running timer',
|
||||
},
|
||||
{
|
||||
name: '$title',
|
||||
description: 'Current title',
|
||||
},
|
||||
{
|
||||
name: '$presenter',
|
||||
description: 'Current presenter',
|
||||
},
|
||||
{
|
||||
name: '$subtitle',
|
||||
description: 'Current subtitle',
|
||||
},
|
||||
{
|
||||
name: '$next-title',
|
||||
description: 'Next title',
|
||||
},
|
||||
{
|
||||
name: '$next-presenter',
|
||||
description: 'Next presenter',
|
||||
},
|
||||
{
|
||||
name: '$next-subtitle',
|
||||
description: 'Next subtitle',
|
||||
},
|
||||
];
|
||||
|
||||
export const getSettings = async () => {
|
||||
const res = await axios.get(`${ontimeURL}/settings`);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const postSettings = async (data) => {
|
||||
return await axios.post(`${ontimeURL}/settings`, data);
|
||||
};
|
||||
|
||||
export const getInfo = async () => {
|
||||
const res = await axios.get(ontimeURL + '/info');
|
||||
const res = await axios.get(`${ontimeURL}/info`);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const postInfo = async (data) => {
|
||||
const res = await axios.post(ontimeURL + '/info', data);
|
||||
return res;
|
||||
return await axios.post(`${ontimeURL}/info`, data);
|
||||
};
|
||||
|
||||
export const getAliases = async () => {
|
||||
const res = await axios.get(`${ontimeURL}/aliases`);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const postAliases = async (data) => {
|
||||
return await axios.post(`${ontimeURL}/aliases`, data);
|
||||
};
|
||||
|
||||
export const getOSC = async () => {
|
||||
const res = await axios.get(`${ontimeURL}/osc`);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const postOSC = async (data) => {
|
||||
return await axios.post(`${ontimeURL}/osc`, data);
|
||||
};
|
||||
|
||||
export const downloadEvents = async () => {
|
||||
await axios({
|
||||
url: ontimeURL + '/db',
|
||||
url: `${ontimeURL}/db`,
|
||||
method: 'GET',
|
||||
responseType: 'blob', // important
|
||||
}).then((response) => {
|
||||
@@ -49,15 +151,13 @@ export const uploadEvents = async (file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('userFile', file); // appending file
|
||||
await axios
|
||||
.post(ontimeURL + '/db', formData, {
|
||||
.post(`${ontimeURL}/db`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
.then((res) => console.log(res.data))
|
||||
.catch((err) => console.error(err));
|
||||
});
|
||||
};
|
||||
|
||||
export const uploadEventsWithPath = async (filepath) => {
|
||||
await axios.post(ontimeURL + '/dbpath', { path: filepath });
|
||||
await axios.post(`${ontimeURL}/dbpath`, { path: filepath });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Exported viewer links
|
||||
const speakerLink = 'http://localhost:4001/speaker';
|
||||
const smLink = 'http://localhost:4001/sm';
|
||||
const publicLink = 'http://localhost:4001/public';
|
||||
const pipLink = 'http://localhost:4001/pip';
|
||||
const studioLink = 'http://localhost:4001/studio';
|
||||
|
||||
export const viewerLinks = [
|
||||
{ link: speakerLink, label: 'Speaker Screen' },
|
||||
{ link: smLink, label: 'Backstage Screen' },
|
||||
{ link: publicLink, label: 'Public Screen' },
|
||||
{ link: pipLink, label: 'Picture in Picture' },
|
||||
{ link: studioLink, label: 'Studio Clock' }
|
||||
];
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createContext, useCallback, useEffect, useState } from 'react';
|
||||
import { useFetch } from '../hooks/useFetch';
|
||||
import { APP_SETTINGS } from '../api/apiConstants';
|
||||
import { getSettings } from '../api/ontimeApi';
|
||||
|
||||
export const AppContext = createContext({
|
||||
auth: false,
|
||||
data: {
|
||||
pinCode: null
|
||||
}
|
||||
});
|
||||
|
||||
export const AppContextProvider = (props) => {
|
||||
const [auth, setAuth] = useState(true);
|
||||
const { data } = useFetch(APP_SETTINGS, getSettings);
|
||||
|
||||
useEffect(() => {
|
||||
if (data == null) return;
|
||||
if (data?.pinCode === null || data?.pinCode === '') {
|
||||
setAuth(true);
|
||||
} else {
|
||||
setAuth(false);
|
||||
}
|
||||
},[data])
|
||||
|
||||
const validate = useCallback((pin) => {
|
||||
const correct = pin === data.pinCode;
|
||||
setAuth(correct);
|
||||
return correct;
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={{ auth, validate }}>
|
||||
{props.children}
|
||||
</AppContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useSocket } from './socketContext';
|
||||
import { createContext, useCallback, useEffect, useState } from 'react';
|
||||
import { generateId } from 'ontime-utils/generate_id';
|
||||
import { nowInMillis, stringFromMillis } from 'ontime-utils/time';
|
||||
|
||||
export const LoggingContext = createContext({
|
||||
logData: [],
|
||||
emitInfo: () => undefined,
|
||||
emitWarning: () => undefined,
|
||||
emitError: () => undefined,
|
||||
clearLog: () => undefined
|
||||
});
|
||||
|
||||
export const LoggingProvider = (props) => {
|
||||
const MAX_MESSAGES = 100;
|
||||
const socket = useSocket();
|
||||
const [logData, setLogData] = useState([]);
|
||||
const origin = 'USER';
|
||||
|
||||
// handle incoming messages
|
||||
useEffect(() => {
|
||||
if (socket == null) return;
|
||||
|
||||
// Ask for log data
|
||||
socket.emit('get-logger');
|
||||
|
||||
socket.on('logger', (data) => {
|
||||
setLogData((l) => [data, ...l]);
|
||||
});
|
||||
|
||||
// Clear listener
|
||||
return () => {
|
||||
socket.off('logger');
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
/**
|
||||
* Utility function sends message over socket
|
||||
* @param text
|
||||
* @param level
|
||||
* @private
|
||||
*/
|
||||
const _send = useCallback((text, level) => {
|
||||
if (socket != null) {
|
||||
const m = {
|
||||
id: generateId(),
|
||||
origin,
|
||||
time: stringFromMillis(nowInMillis()),
|
||||
level,
|
||||
text
|
||||
}
|
||||
setLogData((l) => [m, ...l]);
|
||||
socket.emit('logger', m);
|
||||
}
|
||||
if (logData.length > MAX_MESSAGES) {
|
||||
setLogData((l) => l.pop());
|
||||
}
|
||||
},[logData, socket]);
|
||||
|
||||
/**
|
||||
* Sends a message with level INFO
|
||||
* @param text
|
||||
*/
|
||||
const emitInfo = useCallback((text) => {
|
||||
_send(text, 'INFO');
|
||||
}, [_send]);
|
||||
|
||||
/**
|
||||
* Sends a message with level WARN
|
||||
* @param text
|
||||
*/
|
||||
const emitWarning = useCallback((text) => {
|
||||
_send(text, 'WARN');
|
||||
}, [_send]);
|
||||
|
||||
/**
|
||||
* Sends a message with level ERROR
|
||||
* @param text
|
||||
*/
|
||||
const emitError = useCallback((text) => {
|
||||
_send(text, 'ERROR');
|
||||
}, [_send]);
|
||||
|
||||
/**
|
||||
* Clears running log
|
||||
*/
|
||||
const clearLog = useCallback(() => {
|
||||
setLogData([])
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LoggingContext.Provider value = {{ emitInfo, logData, emitWarning, emitError, clearLog }}>
|
||||
{props.children}
|
||||
</LoggingContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export const userConfig = {
|
||||
timerColorOnPause: '#555',
|
||||
timerColorOnRunning: '#FFF',
|
||||
timerColorOnMessage: '#CCC',
|
||||
timerColorOnTimeOver: '#F00',
|
||||
overTimeText: '',
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export const clamp = (num, a, b) =>
|
||||
Math.max(Math.min(num, Math.max(a, b)), Math.min(a, b));
|
||||
@@ -0,0 +1,27 @@
|
||||
import { validateAlias } from '../aliases';
|
||||
|
||||
describe('An alias fails if incorrect', () => {
|
||||
|
||||
const testsToFail = [
|
||||
// no empty
|
||||
'',
|
||||
// no https, http or www
|
||||
'https://www.test.com',
|
||||
'http://www.test.com',
|
||||
'www.test.com',
|
||||
// no hostname
|
||||
'localhost/test',
|
||||
'127.0.0.1/test',
|
||||
'0.0.0.0/test',
|
||||
// no editor
|
||||
'editor',
|
||||
'editor?test'
|
||||
];
|
||||
|
||||
testsToFail.forEach((t) => (
|
||||
test(`${t}`, () => {
|
||||
expect(validateAlias(t).status).toBeFalsy();
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { clamp } from '../math';
|
||||
|
||||
test('Clamps a set of numbers correctly', () => {
|
||||
const testCases = [
|
||||
{ num: 10, min: 0, max: 20, result: 10 },
|
||||
{ num: 0, min: 0, max: 20, result: 0 },
|
||||
{ num: 20, min: 0, max: 20, result: 20 },
|
||||
{ num: 20, min: 0, max: 20, result: 20 },
|
||||
{ num: -20, min: 0, max: 20, result: 0 },
|
||||
{ num: -0, min: 0, max: 20, result: 0 },
|
||||
{ num: -50, min: -30, max: -20, result: -30 },
|
||||
{ num: -50, min: 0, max: 0, result: 0 },
|
||||
{ num: 50.5, min: 0, max: 100, result: 50.5 },
|
||||
{ num: 50, min: 0, max: 20.32, result: 20.32 },
|
||||
{ num: 10, min: 20.32, max: 40, result: 20.32 }
|
||||
];
|
||||
|
||||
testCases.forEach((t) => (
|
||||
expect(clamp(t.num, t.min, t.max)).toBe(t.result)
|
||||
));
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Validates an alias against defined parameters
|
||||
* @param {string} alias
|
||||
* @returns {{message: string, status: boolean}}
|
||||
*/
|
||||
export const validateAlias = (alias) => {
|
||||
|
||||
const valid = { status: true, message: 'ok' };
|
||||
|
||||
if (alias === '' || alias == null) {
|
||||
// cannot be empty
|
||||
valid.status = false;
|
||||
valid.message = 'should not be empty';
|
||||
} else if (alias.includes('http') || alias.includes('https') || alias.includes('www')) {
|
||||
// cannot contain http, https or www
|
||||
valid.status = false;
|
||||
valid.message = 'should not include http, https, www';
|
||||
} else if (alias.includes('127.0.0.1') || alias.includes('localhost') || alias.includes('0.0.0.0')) {
|
||||
// aliases cannot contain hostname
|
||||
valid.status = false;
|
||||
valid.message = 'should not include hostname';
|
||||
} else if (alias.includes('editor')) {
|
||||
// no editor
|
||||
valid.status = false;
|
||||
valid.message = 'No aliases to editor page allowed';
|
||||
}
|
||||
|
||||
return valid;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Clamps a value between a min and a max
|
||||
* @param {number} num - Value to clamp
|
||||
* @param {number} min - min value
|
||||
* @param {number} max - max value
|
||||
* @returns {number}
|
||||
*/
|
||||
export const clamp = (num, min, max) =>
|
||||
Math.max(Math.min(num, Math.max(min, max)), Math.min(min, max));
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,24 @@
|
||||
import PropTypes from "prop-types";
|
||||
import style from "../../../features/info/Info.module.scss";
|
||||
import {Icon} from "@chakra-ui/react";
|
||||
import {FiChevronUp} from "react-icons/fi";
|
||||
|
||||
export default function CollapseBar(props) {
|
||||
const {title = 'Collapse bar', isCollapsed = false, onClick}= props;
|
||||
|
||||
return(
|
||||
<div className={style.header}>
|
||||
{title}
|
||||
<Icon
|
||||
className={isCollapsed ? style.moreCollapsed : style.moreExpanded}
|
||||
as={FiChevronUp}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
CollapseBar.propTypes = {
|
||||
title: PropTypes.string,
|
||||
isCollapsed: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
.header,
|
||||
.header__roll {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
color: #ccc;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.header__roll {
|
||||
color: #2b6cb0;
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import React from 'react';
|
||||
import { LoggingContext } from '../../../app/context/LoggingContext';
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
static contextType = LoggingContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { error: null, errorInfo: null };
|
||||
@@ -17,15 +20,11 @@ class ErrorBoundary extends React.Component {
|
||||
errorInfo: info,
|
||||
});
|
||||
// TODO: Log the error to an error reporting service
|
||||
this.logErrorToServices(error.toString(), info.componentStack);
|
||||
this.context.emitError(error.toString());
|
||||
}
|
||||
|
||||
// A fake logging service.
|
||||
logErrorToServices = console.log;
|
||||
|
||||
render() {
|
||||
if (this.state.errorMessage) {
|
||||
// You can render any custom fallback UI
|
||||
return <p>:/</p>;
|
||||
}
|
||||
return this.props.children;
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import EditableTimer from 'common/input/EditableTimer';
|
||||
import { showWarningToast } from 'common/helpers/toastManager';
|
||||
import { useContext } from 'react';
|
||||
import { LoggingContext } from '../../../app/context/LoggingContext';
|
||||
|
||||
export default function EventTimes(props) {
|
||||
const { actionHandler, delay, timeStart, timeEnd } = props;
|
||||
const { emitWarning } = useContext(LoggingContext);
|
||||
|
||||
const handleValidate = (entry, v) => {
|
||||
// we dont inforce validation here
|
||||
// we dont enforce validation here
|
||||
|
||||
if (v == null || timeStart == null || timeEnd == null) return true;
|
||||
if (timeStart === 0) return true;
|
||||
|
||||
let validate = { value: true, catch: '' };
|
||||
if (entry === 'timeStart' && v > timeEnd)
|
||||
if (entry === 'timeStart' && v > timeEnd) {
|
||||
validate.catch = 'Start time later than end time';
|
||||
else if (entry === 'timeEnd' && v < timeStart)
|
||||
} else if (entry === 'timeEnd' && v < timeStart) {
|
||||
validate.catch = 'End time earlier than start time';
|
||||
}
|
||||
|
||||
if (validate.catch !== '')
|
||||
showWarningToast('Time Input Warning', validate.catch);
|
||||
emitWarning(`Time Input Warning: ${validate.catch}`);
|
||||
return validate.value;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import EditableTimer from 'common/input/EditableTimer';
|
||||
import { showWarningToast } from 'common/helpers/toastManager';
|
||||
import { stringFromMillis } from 'common/utils/dateConfig';
|
||||
import { stringFromMillis } from 'ontime-utils/time';
|
||||
import { useContext } from 'react';
|
||||
import { LoggingContext } from '../../../app/context/LoggingContext';
|
||||
|
||||
const label = {
|
||||
fontSize: '0.75em',
|
||||
@@ -83,6 +84,8 @@ const Times = (props) => {
|
||||
|
||||
export default function EventTimesVertical(props) {
|
||||
const { delay, timeStart, timeEnd, duration } = props;
|
||||
const { emitWarning } = useContext(LoggingContext);
|
||||
|
||||
const handleValidate = (entry, v) => {
|
||||
// we dont enforce validation here
|
||||
|
||||
@@ -90,32 +93,36 @@ export default function EventTimesVertical(props) {
|
||||
if (timeStart === 0) return true;
|
||||
|
||||
let validate = { value: true, catch: '' };
|
||||
if (entry === 'timeStart' && v > timeEnd)
|
||||
if (entry === 'timeStart' && v > timeEnd) {
|
||||
validate.catch = 'Start time later than end time';
|
||||
else if (entry === 'timeEnd' && v < timeStart)
|
||||
} else if (entry === 'timeEnd' && v < timeStart) {
|
||||
validate.catch = 'End time earlier than start time';
|
||||
}
|
||||
|
||||
if (validate.catch !== '')
|
||||
showWarningToast('Time Input Warning', validate.catch);
|
||||
if (validate.catch !== '') {
|
||||
emitWarning(`Time Input Warning: ${validate.catch}`);
|
||||
}
|
||||
return validate.value;
|
||||
};
|
||||
|
||||
return (delay != null) & (delay > 0) ? (
|
||||
<TimesDelayed
|
||||
handleValidate={handleValidate}
|
||||
actionHandler={props.actionHandler}
|
||||
delay={delay}
|
||||
timeStart={timeStart}
|
||||
timeEnd={timeEnd}
|
||||
duration={duration}
|
||||
/>
|
||||
) : (
|
||||
<Times
|
||||
handleValidate={handleValidate}
|
||||
actionHandler={props.actionHandler}
|
||||
timeStart={timeStart}
|
||||
timeEnd={timeEnd}
|
||||
duration={duration}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
(delay != null) && (delay > 0) ? (
|
||||
<TimesDelayed
|
||||
handleValidate={handleValidate}
|
||||
actionHandler={props.actionHandler}
|
||||
delay={delay}
|
||||
timeStart={timeStart}
|
||||
timeEnd={timeEnd}
|
||||
duration={duration}
|
||||
/>
|
||||
) : (
|
||||
<Times
|
||||
handleValidate={handleValidate}
|
||||
actionHandler={props.actionHandler}
|
||||
timeStart={timeStart}
|
||||
timeEnd={timeEnd}
|
||||
duration={duration}
|
||||
/>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { clamp } from 'app/utils';
|
||||
import { clamp } from 'app/utils/math';
|
||||
import styles from './MyProgressBar.module.css';
|
||||
|
||||
export default function MyProgressBar(props) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { Link, Redirect } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Image } from '@chakra-ui/react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
@@ -57,7 +57,6 @@ export default function NavLogo(props) {
|
||||
to='/presenter'
|
||||
className={style.navItem}
|
||||
tabIndex={1}
|
||||
onKeyDownCapture={() => <Redirect push to='/presenter' />}
|
||||
>
|
||||
Presenter
|
||||
</Link>
|
||||
@@ -65,7 +64,6 @@ export default function NavLogo(props) {
|
||||
to='/sm'
|
||||
className={style.navItem}
|
||||
tabIndex={2}
|
||||
onKeyDownCapture={() => <Redirect push to='/sm' />}
|
||||
>
|
||||
Backstage
|
||||
</Link>
|
||||
@@ -73,7 +71,6 @@ export default function NavLogo(props) {
|
||||
to='/public'
|
||||
className={style.navItem}
|
||||
tabIndex={3}
|
||||
onKeyDownCapture={() => <Redirect push to='/public' />}
|
||||
>
|
||||
Public
|
||||
</Link>
|
||||
@@ -81,7 +78,6 @@ export default function NavLogo(props) {
|
||||
to='/lower'
|
||||
className={style.navItem}
|
||||
tabIndex={4}
|
||||
onKeyDownCapture={() => <Redirect push to='/lower' />}
|
||||
>
|
||||
Lower Thirds
|
||||
</Link>
|
||||
@@ -89,7 +85,6 @@ export default function NavLogo(props) {
|
||||
to='/pip'
|
||||
className={style.navItem}
|
||||
tabIndex={4}
|
||||
onKeyDownCapture={() => <Redirect push to='/pip' />}
|
||||
>
|
||||
PIP
|
||||
</Link>
|
||||
@@ -97,7 +92,6 @@ export default function NavLogo(props) {
|
||||
to='/studio'
|
||||
className={style.navItem}
|
||||
tabIndex={5}
|
||||
onKeyDownCapture={() => <Redirect push to='/studio' />}
|
||||
>
|
||||
Studio Clock
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import style from './ProtectRoute.module.scss';
|
||||
import { PinInput, PinInputField } from '@chakra-ui/react';
|
||||
import { IconButton } from '@chakra-ui/button';
|
||||
import { FiCheck } from 'react-icons/fi';
|
||||
import { AppContext } from '../../../app/context/AppContext';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
|
||||
|
||||
export default function ProtectRoute(props) {
|
||||
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||
const [pin, setPin] = useState('');
|
||||
const [failed, setFailed] = useState(false);
|
||||
const { auth, validate } = useContext(AppContext);
|
||||
|
||||
// Set window title
|
||||
useEffect(() => {
|
||||
document.title = 'ontime';
|
||||
}, []);
|
||||
|
||||
const handleValidation = () => {
|
||||
const r = validate(pin);
|
||||
if (!r) {
|
||||
setFailed(true);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isLocal && !auth ? (
|
||||
<div className={style.container}>
|
||||
ontime
|
||||
<div className={failed ? style.pin__failed : style.pin}>
|
||||
<PinInput
|
||||
type='alphanumeric'
|
||||
size='lg'
|
||||
mask
|
||||
onChange={(value) => {
|
||||
setFailed(false);
|
||||
setPin(value);
|
||||
}}
|
||||
>
|
||||
<PinInputField />
|
||||
<PinInputField />
|
||||
<PinInputField />
|
||||
<PinInputField />
|
||||
</PinInput>
|
||||
<IconButton
|
||||
aria-label='Enter'
|
||||
size='lg'
|
||||
isRound
|
||||
icon={<FiCheck />}
|
||||
style={{ fontSize: '1.5em' }}
|
||||
onClick={() => handleValidation()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
props.children
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ProtectRoute.propTypes = {
|
||||
children: PropTypes.node.isRequired
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
@use '../../../styles/main' as *;
|
||||
@use '../../../styles/mixins' as *;
|
||||
|
||||
.container {
|
||||
background: #222;
|
||||
|
||||
display: grid;
|
||||
place-content: center;
|
||||
height: 100vh;
|
||||
padding-bottom: 30vh;
|
||||
|
||||
color: $ontime-pink;
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-weight: 200;
|
||||
text-align: center;
|
||||
font-size: 3vw;
|
||||
}
|
||||
|
||||
.pin,
|
||||
.pin__failed {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 20px;
|
||||
|
||||
input {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.pin__failed {
|
||||
input {
|
||||
animation: colourFade 1.5s ease;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes colourFade {
|
||||
from {
|
||||
background: $ontime-pink;
|
||||
}
|
||||
to {
|
||||
background: rgba($ontime-pink, 0);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { stringFromMillis } from 'common/utils/dateConfig';
|
||||
import { stringFromMillis } from 'ontime-utils/time';
|
||||
import style from './Paginator.module.css';
|
||||
export default function TodayItem(props) {
|
||||
const { selected, timeStart, timeEnd, title, backstageEvent } = props;
|
||||
@@ -19,7 +19,7 @@ export default function TodayItem(props) {
|
||||
}`}
|
||||
>{`${start} · ${end}`}</div>
|
||||
<div className={style.entryTitle}>{title}</div>
|
||||
{backstageEvent && <div className={style.backstageInd}></div>}
|
||||
{backstageEvent && <div className={style.backstageInd}/>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { createStandaloneToast } from '@chakra-ui/react';
|
||||
|
||||
const toast = createStandaloneToast();
|
||||
// const customToast = createStandaloneToast({ theme: yourCustomTheme })
|
||||
|
||||
// error toast
|
||||
export const showErrorToast = (title, description) => {
|
||||
toast({
|
||||
title: title,
|
||||
description: description,
|
||||
position: 'top-left',
|
||||
variant: 'subtle',
|
||||
status: 'error',
|
||||
isClosable: true,
|
||||
});
|
||||
};
|
||||
|
||||
// warning toast
|
||||
export const showWarningToast = (title, description) => {
|
||||
toast({
|
||||
title: title,
|
||||
description: description,
|
||||
position: 'top-left',
|
||||
variant: 'subtle',
|
||||
status: 'warning',
|
||||
isClosable: true,
|
||||
});
|
||||
};
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Editable, EditableInput, EditablePreview } from '@chakra-ui/editable';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import {
|
||||
isTimeString,
|
||||
stringFromMillis,
|
||||
timeStringToMillis,
|
||||
} from '../utils/dateConfig';
|
||||
import { showErrorToast } from '../helpers/toastManager';
|
||||
import { stringFromMillis } from 'ontime-utils/time';
|
||||
import style from './EditableTimer.module.css';
|
||||
import { LoggingContext } from '../../app/context/LoggingContext';
|
||||
|
||||
export default function EditableTimer(props) {
|
||||
const { name, actionHandler, time, delay, validate } = props;
|
||||
const { emitError } = useContext(LoggingContext);
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
// prepare time fields
|
||||
@@ -18,9 +19,9 @@ export default function EditableTimer(props) {
|
||||
try {
|
||||
setValue(stringFromMillis(time + delay));
|
||||
} catch (error) {
|
||||
showErrorToast('Error parsing date', error.text);
|
||||
emitError(`Unable to parse date: ${error.text}`);
|
||||
}
|
||||
}, [time, delay]);
|
||||
}, [time, delay, emitError]);
|
||||
|
||||
const validateValue = (value) => {
|
||||
const success = handleSubmit(value);
|
||||
|
||||
@@ -4,37 +4,7 @@ export const timeFormatSeconds = 'HH:mm:ss';
|
||||
const mts = 1000; // millis to seconds
|
||||
const mtm = 1000 * 60; // millis to minutes
|
||||
const mth = 1000 * 60 * 60; // millis to hours
|
||||
const mtd = 1000 * 60 * 60 * 24; // millis to days
|
||||
|
||||
/**
|
||||
* @description Converts milliseconds to string representing time
|
||||
* @param {number} ms - time in milliseconds
|
||||
* @param {boolean} showSeconds - wether to show the seconds
|
||||
* @param {string} delim - character between HH MM SS
|
||||
* @param {string} ifNull - what to return if value is null
|
||||
* @returns {string} String representing time 00:12:02
|
||||
*/
|
||||
|
||||
// This is shared and tested in backend in time.js
|
||||
export const stringFromMillis = (
|
||||
ms,
|
||||
showSeconds = true,
|
||||
delim = ':',
|
||||
ifNull = '...'
|
||||
) => {
|
||||
if (ms === null || isNaN(ms)) return ifNull;
|
||||
const isNegative = ms < 0 ? '-' : '';
|
||||
const showWith0 = (value) => (value < 10 ? `0${value}` : value);
|
||||
const hours = showWith0(Math.floor(((ms / mth) % 60) % 24));
|
||||
const minutes = showWith0(Math.floor((ms / mtm) % 60));
|
||||
const seconds = showWith0(Math.floor((ms / mts) % 60));
|
||||
|
||||
return showSeconds
|
||||
? `${isNegative}${
|
||||
parseInt(hours) ? `${hours}${delim}` : `00${delim}`
|
||||
}${minutes}${delim}${seconds}`
|
||||
: `${isNegative}${parseInt(hours) ? `${hours}` : '00'}${delim}${minutes}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* another go at simpler string formatting (counters)
|
||||
@@ -79,7 +49,7 @@ export const millisToMinutes = (millis) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Converts timestring to milliseconds
|
||||
* @description Converts timestring to milliseconds
|
||||
* @param {string} string - time string "23:00:12"
|
||||
* @returns {number} Amount in milliseconds
|
||||
*/
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { stringFromMillis } from 'ontime-utils/time';
|
||||
|
||||
/**
|
||||
* @description From a list of events, returns only events of type event with calculated delays
|
||||
* @param {Object[]} events - given events
|
||||
* @returns {Object[]} Filtered events with calculated delays
|
||||
*/
|
||||
import {stringFromMillis} from "./dateConfig";
|
||||
|
||||
export const getEventsWithDelay = (events) => {
|
||||
|
||||
if (events == null) return [];
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Handles link to external URLs: specifically for a electron / browser case
|
||||
* If electron: ask main process to call a new browser window
|
||||
* If browser: open in new tab
|
||||
* @param url
|
||||
*/
|
||||
export default function handleLink(url) {
|
||||
if (window.process?.type === 'renderer') {
|
||||
window.ipcRenderer.send('send-to-link', url);
|
||||
} else {
|
||||
window.open(url);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo } from 'react';
|
||||
import style from './PlaybackControl.module.css';
|
||||
import PropTypes from 'prop-types';
|
||||
import style from './PlaybackControl.module.scss';
|
||||
import StartIconBtn from 'common/components/buttons/StartIconBtn';
|
||||
import PauseIconBtn from 'common/components/buttons/PauseIconBtn';
|
||||
import PrevIconBtn from 'common/components/buttons/PrevIconBtn';
|
||||
@@ -10,70 +11,77 @@ import ReloadIconButton from 'common/components/buttons/ReloadIconBtn';
|
||||
|
||||
const areEqual = (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.playback === nextProps.playback &&
|
||||
prevProps.selectedId === nextProps.selectedId
|
||||
prevProps.playback === nextProps.playback
|
||||
&& prevProps.selectedId === nextProps.selectedId
|
||||
&& prevProps.noEvents === nextProps.noEvents
|
||||
);
|
||||
};
|
||||
|
||||
const Playback = ({ playback, selectedId, playbackControl }) => {
|
||||
const Playback = (props) => {
|
||||
const { playback, selectedId, playbackControl, noEvents } = props;
|
||||
const isRolling = playback === 'roll';
|
||||
|
||||
return (
|
||||
<div className={style.playbackContainer}>
|
||||
<StartIconBtn
|
||||
active={playback === 'start'}
|
||||
clickhandler={() => playbackControl('start')}
|
||||
disabled={!selectedId || isRolling}
|
||||
disabled={!selectedId || isRolling || noEvents}
|
||||
/>
|
||||
<PauseIconBtn
|
||||
active={playback === 'pause'}
|
||||
clickhandler={() => playbackControl('pause')}
|
||||
disabled={!selectedId || isRolling}
|
||||
disabled={!selectedId || isRolling || noEvents || playback !== 'start'}
|
||||
/>
|
||||
<RollIconBtn
|
||||
active={playback === 'roll'}
|
||||
disabled={playback === 'roll'}
|
||||
disabled={playback === 'roll' || noEvents}
|
||||
clickhandler={() => playbackControl('roll')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Transport = ({ playback, selectedId, playbackControl }) => {
|
||||
const Transport = (props) => {
|
||||
const { playback, selectedId, playbackControl, noEvents } = props;
|
||||
const isRolling = playback === 'roll';
|
||||
|
||||
return (
|
||||
<div className={style.playbackContainer}>
|
||||
<PrevIconBtn
|
||||
clickhandler={() => playbackControl('previous')}
|
||||
disabled={playback === 'roll'}
|
||||
disabled={isRolling || noEvents}
|
||||
/>
|
||||
<NextIconBtn
|
||||
clickhandler={() => playbackControl('next')}
|
||||
disabled={playback === 'roll'}
|
||||
disabled={isRolling || noEvents}
|
||||
/>
|
||||
<ReloadIconButton
|
||||
clickhandler={() => playbackControl('reload')}
|
||||
disabled={!selectedId || isRolling}
|
||||
disabled={selectedId == null || isRolling || noEvents}
|
||||
/>
|
||||
<UnloadIconBtn
|
||||
clickhandler={() => playbackControl('unload')}
|
||||
disabled={!selectedId && !isRolling}
|
||||
disabled={(selectedId == null && !isRolling) || noEvents}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PlaybackButtons = (props) => {
|
||||
const { playback, selectedId } = props;
|
||||
const { playback, selectedId, noEvents } = props;
|
||||
return (
|
||||
<>
|
||||
<Playback
|
||||
playback={playback}
|
||||
selectedId={selectedId}
|
||||
noEvents={noEvents}
|
||||
playbackControl={props.playbackControl}
|
||||
/>
|
||||
<Transport
|
||||
playback={playback}
|
||||
selectedId={selectedId}
|
||||
noEvents={noEvents}
|
||||
playbackControl={props.playbackControl}
|
||||
/>
|
||||
</>
|
||||
@@ -81,3 +89,17 @@ const PlaybackButtons = (props) => {
|
||||
};
|
||||
|
||||
export default memo(PlaybackButtons, areEqual);
|
||||
|
||||
PlaybackButtons.propTypes = {
|
||||
playback: PropTypes.string,
|
||||
selectedId: PropTypes.string,
|
||||
playbackControl: PropTypes.func.isRequired,
|
||||
noEvents: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
Transport.propTypes = {
|
||||
playback: PropTypes.string,
|
||||
selectedId: PropTypes.string,
|
||||
playbackControl: PropTypes.func.isRequired,
|
||||
noEvents: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import style from './PlaybackControl.module.css';
|
||||
import style from './PlaybackControl.module.scss';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSocket } from 'app/context/socketContext';
|
||||
import PlaybackButtons from './PlaybackButtons';
|
||||
@@ -15,6 +15,7 @@ export default function PlaybackControl() {
|
||||
secondary: null,
|
||||
});
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [numEvents, setNumEvents] = useState(0);
|
||||
|
||||
const resetTimer = () => {
|
||||
setTimer({
|
||||
@@ -32,6 +33,7 @@ export default function PlaybackControl() {
|
||||
socket.emit('get-timer');
|
||||
socket.emit('get-playstate');
|
||||
socket.emit('get-selected-id');
|
||||
socket.emit('get-numevents');
|
||||
|
||||
// Handle playstate
|
||||
socket.on('playstate', (data) => {
|
||||
@@ -48,11 +50,16 @@ export default function PlaybackControl() {
|
||||
setSelectedId(data);
|
||||
});
|
||||
|
||||
socket.on('numevents', (data) => {
|
||||
setNumEvents(data);
|
||||
});
|
||||
|
||||
// Clear listener
|
||||
return () => {
|
||||
socket.off('playstate');
|
||||
socket.off('timer');
|
||||
socket.off('selected-id');
|
||||
socket.off('numevents');
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
@@ -93,11 +100,13 @@ export default function PlaybackControl() {
|
||||
<PlaybackTimer
|
||||
timer={timer}
|
||||
playback={playback}
|
||||
selectedId={selectedId}
|
||||
handleIncrement={(amount) => socket.emit('increment-timer', amount)}
|
||||
/>
|
||||
<PlaybackButtons
|
||||
playback={playback}
|
||||
selectedId={selectedId}
|
||||
noEvents={numEvents < 1}
|
||||
playbackControl={playbackControl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+4
@@ -97,6 +97,10 @@
|
||||
grid-area: fin;
|
||||
}
|
||||
|
||||
.roll {
|
||||
grid-area: 2 / 2 / 2 / 4 ;
|
||||
}
|
||||
|
||||
.time {
|
||||
color: #ccc;
|
||||
font-size: 1.1em;
|
||||
@@ -1,34 +1,37 @@
|
||||
import style from './PlaybackControl.module.css';
|
||||
import style from './PlaybackControl.module.scss';
|
||||
import Countdown from 'common/components/countdown/Countdown';
|
||||
import { stringFromMillis } from 'common/utils/dateConfig';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { Button } from '@chakra-ui/button';
|
||||
import { memo } from 'react';
|
||||
import { stringFromMillis } from 'ontime-utils/time';
|
||||
import {Tooltip} from '@chakra-ui/react';
|
||||
import {Button} from '@chakra-ui/button';
|
||||
import {memo} from 'react';
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const areEqual = (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.timer.running === nextProps.timer.running &&
|
||||
prevProps.timer.expectedFinish === nextProps.timer.expectedFinish &&
|
||||
prevProps.timer.startedAt === nextProps.timer.startedAt &&
|
||||
prevProps.playback === nextProps.playback &&
|
||||
prevProps.timer.secondary === nextProps.timer.secondary
|
||||
prevProps.timer.running === nextProps.timer.running
|
||||
&& prevProps.timer.expectedFinish === nextProps.timer.expectedFinish
|
||||
&& prevProps.timer.startedAt === nextProps.timer.startedAt
|
||||
&& prevProps.playback === nextProps.playback
|
||||
&& prevProps.timer.secondary === nextProps.timer.secondary
|
||||
&& prevProps.selectedId === nextProps.selectedId
|
||||
);
|
||||
};
|
||||
|
||||
const PlaybackTimer = (props) => {
|
||||
const { timer, playback, handleIncrement } = props;
|
||||
const {timer, playback, handleIncrement, selectedId} = props;
|
||||
const started = stringFromMillis(timer.startedAt, true);
|
||||
const finish = stringFromMillis(timer.expectedFinish, true);
|
||||
const isNegative = timer.running < 0;
|
||||
const isRolling = playback === 'roll';
|
||||
const isWaiting = timer.secondary > 0 && timer.running == null;
|
||||
const disableButtons = (selectedId == null || isRolling);
|
||||
|
||||
const incrementProps = {
|
||||
size: 'sm',
|
||||
width: '2.9em',
|
||||
colorScheme: 'whiteAlpha',
|
||||
variant: 'outline',
|
||||
_focus: { boxShadow: 'none' },
|
||||
_focus: {boxShadow: 'none'},
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -36,12 +39,12 @@ const PlaybackTimer = (props) => {
|
||||
<div className={style.timeContainer}>
|
||||
<div className={style.indicators}>
|
||||
<Tooltip label='Roll mode active'>
|
||||
<div className={isRolling ? style.indRollActive : style.indRoll} />
|
||||
<div className={isRolling ? style.indRollActive : style.indRoll}/>
|
||||
</Tooltip>
|
||||
<div
|
||||
className={isNegative ? style.indNegativeActive : style.indNegative}
|
||||
/>
|
||||
<div className={style.indDelay} />
|
||||
<div className={style.indDelay}/>
|
||||
</div>
|
||||
<div className={style.timer}>
|
||||
<Countdown
|
||||
@@ -51,7 +54,7 @@ const PlaybackTimer = (props) => {
|
||||
/>
|
||||
</div>
|
||||
{isWaiting ? (
|
||||
<div className={style.start}>
|
||||
<div className={style.roll}>
|
||||
<span className={style.rolltag}>Roll: Countdown to start</span>
|
||||
<span className={style.time}>{''}</span>
|
||||
</div>
|
||||
@@ -70,28 +73,28 @@ const PlaybackTimer = (props) => {
|
||||
<div className={style.btn}>
|
||||
<Button
|
||||
{...incrementProps}
|
||||
disabled={isRolling}
|
||||
disabled={disableButtons}
|
||||
onClick={() => handleIncrement(-1)}
|
||||
>
|
||||
-1
|
||||
</Button>
|
||||
<Button
|
||||
{...incrementProps}
|
||||
disabled={isRolling}
|
||||
disabled={disableButtons}
|
||||
onClick={() => handleIncrement(1)}
|
||||
>
|
||||
+1
|
||||
</Button>
|
||||
<Button
|
||||
{...incrementProps}
|
||||
disabled={isRolling}
|
||||
disabled={disableButtons}
|
||||
onClick={() => handleIncrement(-5)}
|
||||
>
|
||||
-5
|
||||
</Button>
|
||||
<Button
|
||||
{...incrementProps}
|
||||
disabled={isRolling}
|
||||
disabled={disableButtons}
|
||||
onClick={() => handleIncrement(5)}
|
||||
>
|
||||
+5
|
||||
@@ -103,3 +106,10 @@ const PlaybackTimer = (props) => {
|
||||
};
|
||||
|
||||
export default memo(PlaybackTimer, areEqual);
|
||||
|
||||
PlaybackTimer.propTypes = {
|
||||
timer: PropTypes.object.isRequired,
|
||||
playback: PropTypes.string,
|
||||
handleIncrement: PropTypes.func.isRequired,
|
||||
selectedId: PropTypes.string,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import SocketProvider from 'app/context/socketContext';
|
||||
import MessageControl from '../MessageControl';
|
||||
|
||||
// need to inject the socket provider to make component
|
||||
// render without failing
|
||||
const MockMessageControl = () => {
|
||||
return (
|
||||
<SocketProvider>
|
||||
<MessageControl />
|
||||
</SocketProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Message Control input blocks', () => {
|
||||
test('Presenter dialog', async () => {
|
||||
// Presenter dialog and button
|
||||
// substring match, ignore case
|
||||
render(<MockMessageControl />);
|
||||
expect(screen.getByPlaceholderText(/presenter/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Public dialog', async () => {
|
||||
// Public dialog and button
|
||||
// substring match, ignore case
|
||||
render(<MockMessageControl />);
|
||||
expect(screen.getByPlaceholderText(/public/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Lower third', async () => {
|
||||
// Lower third dialog and button
|
||||
// substring match, ignore case
|
||||
render(<MockMessageControl />);
|
||||
expect(screen.getByPlaceholderText(/lower third/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import SocketProvider from 'app/context/socketContext';
|
||||
import PlaybackControl from '../PlaybackControl';
|
||||
|
||||
test('check that playback control renders', async () => {
|
||||
// need to inject the socket provider to make component
|
||||
// render without failing
|
||||
render(
|
||||
<SocketProvider>
|
||||
<PlaybackControl />
|
||||
</SocketProvider>
|
||||
);
|
||||
|
||||
// Text labels for times
|
||||
// substring match, ignore case
|
||||
expect(screen.getByText(/started/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/finish/i)).toBeInTheDocument();
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { lazy, useEffect } from 'react';
|
||||
import { Box } from '@chakra-ui/layout';
|
||||
import { useDisclosure } from '@chakra-ui/hooks';
|
||||
import styles from './Editor.module.css';
|
||||
import styles from './Editor.module.scss';
|
||||
import MenuBar from 'features/menu/MenuBar';
|
||||
import ModalManager from 'features/modals/ModalManager';
|
||||
import ErrorBoundary from 'common/components/errorBoundary/ErrorBoundary';
|
||||
import { LoggingProvider } from '../../app/context/LoggingContext';
|
||||
|
||||
const EventListWrapper = lazy(() =>
|
||||
import('features/editors/list/EventListWrapper')
|
||||
@@ -22,13 +23,15 @@ export default function Editor() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalManager isOpen={isOpen} onClose={onClose} />
|
||||
<LoggingProvider>
|
||||
<ErrorBoundary>
|
||||
<ModalManager isOpen={isOpen} onClose={onClose} />
|
||||
</ErrorBoundary>
|
||||
|
||||
<div className={styles.mainContainer}>
|
||||
<Box id='settings' className={styles.settings}>
|
||||
<ErrorBoundary>
|
||||
<MenuBar onOpen={onOpen} />
|
||||
<MenuBar onOpen={onOpen} isOpen={isOpen} />
|
||||
</ErrorBoundary>
|
||||
</Box>
|
||||
|
||||
@@ -68,6 +71,6 @@ export default function Editor() {
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</>
|
||||
</LoggingProvider>
|
||||
);
|
||||
}
|
||||
|
||||
+13
-2
@@ -8,7 +8,7 @@
|
||||
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-columns: 40px 48em auto auto;
|
||||
grid-template-columns: 40px 48em 31em auto;
|
||||
grid-template-areas:
|
||||
'sett even play info'
|
||||
'sett even mess info';
|
||||
@@ -110,12 +110,23 @@ h1 {
|
||||
|
||||
.editor {
|
||||
grid-area: even;
|
||||
|
||||
.content {
|
||||
height: calc(100% - 3em);
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
grid-area: info;
|
||||
min-width: 17em;
|
||||
max-width: 32em;
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100% - 3em);
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.messages {
|
||||
@@ -1,4 +1,4 @@
|
||||
import style from './List.module.css';
|
||||
import style from './List.module.scss';
|
||||
import { createRef, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSocket } from 'app/context/socketContext';
|
||||
import Empty from 'common/state/Empty';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import DelayBlock from './DelayBlock';
|
||||
import BlockBlock from './BlockBlock';
|
||||
import EventBlock from './EventBlock';
|
||||
import { showErrorToast } from 'common/helpers/toastManager';
|
||||
import { memo } from 'react';
|
||||
import { memo, useContext } from 'react';
|
||||
import { LoggingContext } from '../../../app/context/LoggingContext';
|
||||
|
||||
const areEqual = (prevProps, nextProps) => {
|
||||
return (
|
||||
@@ -26,6 +26,7 @@ const EventListItem = (props) => {
|
||||
delay,
|
||||
...rest
|
||||
} = props;
|
||||
const { emitError } = useContext(LoggingContext);
|
||||
|
||||
// Create / delete new events
|
||||
const actionHandler = (action, payload) => {
|
||||
@@ -59,7 +60,7 @@ const EventListItem = (props) => {
|
||||
// request update in parent
|
||||
eventsHandler('patch', newData);
|
||||
} else {
|
||||
showErrorToast('Field Error: ' + field);
|
||||
emitError(`Unknown field: ${field}`);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation, useQueryClient } from 'react-query';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import {
|
||||
fetchAllEvents,
|
||||
requestPatch,
|
||||
@@ -12,16 +12,17 @@ import {
|
||||
} from 'app/api/eventsApi.js';
|
||||
import EventList from './EventList';
|
||||
import EventListMenu from 'features/menu/EventListMenu.jsx';
|
||||
import { showErrorToast } from 'common/helpers/toastManager';
|
||||
import { useFetch } from 'app/hooks/useFetch.js';
|
||||
import Empty from 'common/state/Empty';
|
||||
import { EVENTS_TABLE } from 'app/api/apiConstants';
|
||||
import { BatchOperation } from 'app/context/collapseAtom';
|
||||
import { useAtom } from 'jotai';
|
||||
import { LoggingContext } from '../../../app/context/LoggingContext';
|
||||
|
||||
export default function EventListWrapper() {
|
||||
const [, setCollapsed] = useAtom(BatchOperation);
|
||||
const queryClient = useQueryClient();
|
||||
const { emitError } = useContext(LoggingContext);
|
||||
const { data, status, isError, refetch } = useFetch(
|
||||
EVENTS_TABLE,
|
||||
fetchAllEvents
|
||||
@@ -230,9 +231,9 @@ export default function EventListWrapper() {
|
||||
// Show toasts on errors
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
showErrorToast('Error fetching data');
|
||||
emitError('Error fetching data');
|
||||
}
|
||||
}, [isError]);
|
||||
}, [emitError, isError]);
|
||||
|
||||
// Events API
|
||||
const eventsHandler = useCallback(
|
||||
@@ -242,35 +243,35 @@ export default function EventListWrapper() {
|
||||
try {
|
||||
await addEvent.mutateAsync(payload);
|
||||
} catch (error) {
|
||||
showErrorToast('Error creating event', error.message);
|
||||
emitError(`Error fetching data: ${error.message}`);
|
||||
}
|
||||
break;
|
||||
case 'update':
|
||||
try {
|
||||
await updateEvent.mutateAsync(payload);
|
||||
} catch (error) {
|
||||
showErrorToast('Error updating event', error.message);
|
||||
emitError(`Error updating event: ${error.message}`);
|
||||
}
|
||||
break;
|
||||
case 'patch':
|
||||
try {
|
||||
await patchEvent.mutateAsync(payload);
|
||||
} catch (error) {
|
||||
showErrorToast('Error updating event', error.message);
|
||||
emitError(`Error updating event: ${error.message}`);
|
||||
}
|
||||
break;
|
||||
case 'delete':
|
||||
try {
|
||||
await deleteEvent.mutateAsync(payload);
|
||||
} catch (error) {
|
||||
showErrorToast('Error deleting event', error.message);
|
||||
emitError(`Error deleting event: ${error.message}`);
|
||||
}
|
||||
break;
|
||||
case 'reorder':
|
||||
try {
|
||||
await reorderEvent.mutateAsync(payload);
|
||||
} catch (error) {
|
||||
showErrorToast('Error reordering event', error.message);
|
||||
emitError(`Error re-ordering event: ${error.message}`);
|
||||
}
|
||||
break;
|
||||
case 'applyDelay':
|
||||
@@ -293,13 +294,13 @@ export default function EventListWrapper() {
|
||||
// delete block after, if any
|
||||
if (blockAfter) await deleteEvent.mutateAsync(blockAfter);
|
||||
} catch (error) {
|
||||
showErrorToast('Error applying delay', error.message);
|
||||
emitError(`Error applying delay: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await applyDelay.mutateAsync(payload.id);
|
||||
} catch (error) {
|
||||
showErrorToast('Error applying delay', error.message);
|
||||
emitError(`Error applying delay: ${error.message}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -317,11 +318,11 @@ export default function EventListWrapper() {
|
||||
try {
|
||||
await deleteAllEvents.mutateAsync();
|
||||
} catch (error) {
|
||||
showErrorToast('Error deleting events', error.message);
|
||||
emitError(`Error deleting events: ${error.message}`);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
showErrorToast('Unrecognised request', action);
|
||||
emitError(`Unhandled request: ${action}`);
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
overflow-y: scroll;
|
||||
height: 73vh;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.list {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSocket } from 'app/context/socketContext';
|
||||
import style from './Info.module.css';
|
||||
import style from './Info.module.scss';
|
||||
import InfoTitle from './InfoTitle';
|
||||
import InfoLogger from './InfoLogger';
|
||||
import InfoNif from './InfoNif';
|
||||
@@ -15,11 +15,10 @@ export default function Info() {
|
||||
titleNext: '',
|
||||
subtitleNext: '',
|
||||
presenterNext: '',
|
||||
noteNext: '',
|
||||
noteNext: ''
|
||||
});
|
||||
const [selected, setSelected] = useState('No events');
|
||||
const [playback, setPlayback] = useState(null);
|
||||
const logData = [];
|
||||
|
||||
// handle incoming messages
|
||||
useEffect(() => {
|
||||
@@ -61,20 +60,19 @@ export default function Info() {
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
// TODO: Put this in use effect
|
||||
// prepare data
|
||||
const titlesNow = {
|
||||
title: titles.titleNow,
|
||||
subtitle: titles.subtitleNow,
|
||||
presenter: titles.presenterNow,
|
||||
note: titles.noteNow,
|
||||
note: titles.noteNow
|
||||
};
|
||||
|
||||
const titlesNext = {
|
||||
title: titles.titleNext,
|
||||
subtitle: titles.subtitleNext,
|
||||
presenter: titles.presenterNext,
|
||||
note: titles.noteNext,
|
||||
note: titles.noteNext
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -83,10 +81,10 @@ export default function Info() {
|
||||
<span>{`Running on port 4001`}</span>
|
||||
<span>{selected}</span>
|
||||
</div>
|
||||
{/* <InfoLogger logData={logData} /> */}
|
||||
<InfoNif />
|
||||
<InfoTitle title={'Now'} data={titlesNow} roll={playback === 'roll'} />
|
||||
<InfoTitle title={'Next'} data={titlesNext} roll={playback === 'roll'} />
|
||||
<InfoLogger />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+16
-29
@@ -1,4 +1,7 @@
|
||||
.container {
|
||||
@use '../../styles/main' as *;
|
||||
@use '../../styles/mixins' as *;
|
||||
|
||||
@mixin container {
|
||||
margin-top: 1em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -8,9 +11,13 @@
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.container {
|
||||
@include container;
|
||||
}
|
||||
|
||||
.main {
|
||||
font-size: 0.9em;
|
||||
color: #ff7597;
|
||||
color: $ontime-pink;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -20,17 +27,17 @@
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
color: #ccc;
|
||||
color: $header-gray;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header {
|
||||
color: #ccc;
|
||||
color: $header-gray;
|
||||
}
|
||||
|
||||
.headerRoll {
|
||||
color: #2b6cb0;
|
||||
color: $ontime-roll;
|
||||
}
|
||||
|
||||
.collapsedTitle {
|
||||
@@ -57,7 +64,7 @@
|
||||
|
||||
.label {
|
||||
font-size: 0.9em;
|
||||
color: #aaa;
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
.label::after {
|
||||
@@ -70,18 +77,15 @@
|
||||
}
|
||||
|
||||
.notes {
|
||||
color: #d69e2e;
|
||||
color: $notes-color;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.if {
|
||||
font-size: 0.8em;
|
||||
color: #4bffabcc;
|
||||
background-color: rgba(0, 0, 0, 0.13);
|
||||
border-radius: 2px;
|
||||
padding: 0 0.5em;
|
||||
margin: 0 0.5em;
|
||||
color: $ontime-accent;
|
||||
@include container-bg;
|
||||
}
|
||||
|
||||
ul > li {
|
||||
@@ -89,23 +93,6 @@ ul > li {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.log {
|
||||
overflow-y: scroll;
|
||||
height: 30vh;
|
||||
}
|
||||
|
||||
.info {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.client {
|
||||
color: lightblue;
|
||||
}
|
||||
|
||||
.moreExpanded,
|
||||
.moreCollapsed {
|
||||
cursor: pointer;
|
||||
@@ -1,34 +1,117 @@
|
||||
import { Icon } from '@chakra-ui/react';
|
||||
import { useState } from 'react';
|
||||
import { FiChevronUp } from 'react-icons/fi';
|
||||
import style from './Info.module.css';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import style from './InfoLogger.module.scss';
|
||||
import CollapseBar from "../../common/components/collapseBar/CollapseBar";
|
||||
import { LoggingContext } from '../../app/context/LoggingContext';
|
||||
|
||||
export default function InfoLogger(props) {
|
||||
export default function InfoLogger() {
|
||||
const { logData, clearLog } = useContext(LoggingContext);
|
||||
const [data, setData] = useState([]);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
// Todo: save in local storage
|
||||
const [showClient, setShowClient] = useState(true);
|
||||
const [showServer, setShowServer] = useState(true);
|
||||
const [showRx, setShowRx] = useState(true);
|
||||
const [showTx, setShowTx] = useState(true);
|
||||
const [showPlayback, setShowPlayback] = useState(true);
|
||||
const [showUser, setShowUser] = useState(true);
|
||||
|
||||
const { logData } = props;
|
||||
useEffect(() => {
|
||||
const matchers = [];
|
||||
if (showUser) {
|
||||
matchers.push('USER');
|
||||
}
|
||||
if (showClient) {
|
||||
matchers.push('CLIENT');
|
||||
}
|
||||
if (showServer) {
|
||||
matchers.push('SERVER');
|
||||
}
|
||||
if (showRx) {
|
||||
matchers.push('RX');
|
||||
}
|
||||
if (showTx) {
|
||||
matchers.push('TX');
|
||||
}
|
||||
if (showPlayback) {
|
||||
matchers.push('PLAYBACK');
|
||||
}
|
||||
|
||||
const d = logData.filter((d) => (
|
||||
matchers.some((m) => d.origin === m)
|
||||
))
|
||||
|
||||
setData(d);
|
||||
},[logData, showUser, showClient, showServer, showPlayback, showRx, showTx])
|
||||
|
||||
const disableOthers = (toEnable) => {
|
||||
toEnable === 'USER' ? setShowUser(true) : setShowUser(false);
|
||||
toEnable === 'CLIENT' ? setShowClient(true) : setShowClient(false);
|
||||
toEnable === 'SERVER' ? setShowServer(true) : setShowServer(false);
|
||||
toEnable === 'RX' ? setShowRx(true) : setShowRx(false);
|
||||
toEnable === 'TX' ? setShowTx(true) : setShowTx(false);
|
||||
toEnable === 'PLAYBACK' ? setShowPlayback(true) : setShowPlayback(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.container}>
|
||||
<div className={style.header}>
|
||||
Log
|
||||
<Icon
|
||||
className={collapsed ? style.moreCollapsed : style.moreExpanded}
|
||||
as={FiChevronUp}
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
/>
|
||||
</div>
|
||||
<div className={collapsed ? style.container : style.container__expanded}>
|
||||
<CollapseBar title={'Log'} isCollapsed={collapsed} onClick={() => setCollapsed((c) => !c)}/>
|
||||
{!collapsed && (
|
||||
<ul className={style.log}>
|
||||
<li className={style.info}>10:35:23 [PLAYBACK] Next</li>
|
||||
<li className={style.client}>
|
||||
10:32:10 [CLIENT] New socket client (total: 3)
|
||||
</li>
|
||||
<li className={style.info}>10:28:23 [PLAYBACK] Next</li>
|
||||
<li className={style.info}>10:25:23 [PLAYBACK] Play</li>
|
||||
<li className={style.info}>10:23:13 [SERVER] Server Reconnected</li>
|
||||
<li className={style.error}>10:23:10 [SERVER] Server Disconnected</li>
|
||||
</ul>
|
||||
<>
|
||||
<div className={style.toggleBar}>
|
||||
<div
|
||||
onClick={() => setShowUser((s) => !s)}
|
||||
onAuxClick={() => disableOthers('USER')}
|
||||
className={(showUser) ? style.active : null}>
|
||||
USER
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setShowClient((s) => !s)}
|
||||
onAuxClick={() => disableOthers('CLIENT')}
|
||||
className={(showClient) ? style.active : null}>
|
||||
CLIENT
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setShowServer((s) => !s)}
|
||||
onAuxClick={() => disableOthers('SERVER')}
|
||||
className={(showServer) ? style.active : null}>
|
||||
SERVER
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setShowPlayback((s) => !s)}
|
||||
onAuxClick={() => disableOthers('PLAYBACK')}
|
||||
className={(showPlayback) ? style.active : null}>
|
||||
Playback
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setShowRx((s) => !s)}
|
||||
onAuxClick={() => disableOthers('RX')}
|
||||
className={(showRx) ? style.active : null}>
|
||||
RX
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setShowTx((s) => !s)}
|
||||
onAuxClick={() => disableOthers('TX')}
|
||||
className={(showTx) ? style.active : null}>
|
||||
TX
|
||||
</div>
|
||||
<div
|
||||
onClick={clearLog}
|
||||
className={style.clear}>
|
||||
Clear
|
||||
</div>
|
||||
</div>
|
||||
<ul className={style.log}>
|
||||
{data.map((d) => (
|
||||
<li key={d.id} className={d.level === 'INFO' ? style.info : d.level === 'WARN' ? style.warn : d.level === 'ERROR' ? style.error : ''}>
|
||||
<div
|
||||
className={style.time}
|
||||
>{d.time}</div>
|
||||
<div className={style.origin}>{d.origin}</div>
|
||||
<div className={style.msg}>{d.text}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
@use 'Info.module' as *;
|
||||
@use '../../styles/main' as *;
|
||||
@use '../../styles/mixins' as *;
|
||||
|
||||
.container,
|
||||
.container__expanded{
|
||||
@include container;
|
||||
max-height: 80%;
|
||||
}
|
||||
|
||||
.container__expanded {
|
||||
min-height: 50%;
|
||||
height: 100%
|
||||
}
|
||||
|
||||
.log {
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
font-size: 0.8em;
|
||||
user-select:text;
|
||||
@include container-bg;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
margin-bottom: 2px;
|
||||
|
||||
.time {
|
||||
width: 13%;
|
||||
}
|
||||
.origin {
|
||||
width: 18%;
|
||||
}
|
||||
.msg {
|
||||
width: 70%;
|
||||
}
|
||||
}
|
||||
|
||||
li.info {
|
||||
color: #aaa;
|
||||
}
|
||||
li.warn {
|
||||
color: #dd6b20;
|
||||
}
|
||||
li.error {
|
||||
color: #f00;
|
||||
}
|
||||
|
||||
.entry:hover {
|
||||
color: #ddd;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.info {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.client {
|
||||
color: lightblue;
|
||||
}
|
||||
|
||||
.toggleBar {
|
||||
display: flex;
|
||||
font-size: 0.7em;
|
||||
justify-content: flex-start;
|
||||
gap: 1em;
|
||||
padding: 0.5em 0;
|
||||
font-weight: 600;
|
||||
|
||||
div {
|
||||
padding: 2px 8px;
|
||||
background: #0002;
|
||||
border: 1px solid #fff1;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
div.active {
|
||||
background: $ontime-accent;
|
||||
color: darken($ontime-accent, 70%);
|
||||
}
|
||||
|
||||
.clear {
|
||||
border: 1px solid rgba($ontime-pink, 0.5);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Icon } from '@chakra-ui/react';
|
||||
import { useState } from 'react';
|
||||
import { FiChevronUp } from 'react-icons/fi';
|
||||
import { APP_TABLE } from 'app/api/apiConstants';
|
||||
import { getInfo, ontimePlaceholderInfo } from 'app/api/ontimeApi';
|
||||
import { useFetch } from 'app/hooks/useFetch';
|
||||
import style from './Info.module.css';
|
||||
import style from './Info.module.scss';
|
||||
import CollapseBar from '../../common/components/collapseBar/CollapseBar';
|
||||
import handleLink from '../../common/utils/handleLink';
|
||||
|
||||
export default function InfoNif() {
|
||||
const { data, status } = useFetch(APP_TABLE, getInfo, {
|
||||
@@ -13,25 +13,9 @@ export default function InfoNif() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const baseURL = 'http://__IP__:4001';
|
||||
|
||||
const handleLink = (url) => {
|
||||
if (window.process?.type === 'renderer') {
|
||||
window.ipcRenderer.send('send-to-link', url);
|
||||
} else {
|
||||
window.open(url);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.container}>
|
||||
<div className={style.header}>
|
||||
Network Info
|
||||
<Icon
|
||||
className={collapsed ? style.moreCollapsed : style.moreExpanded}
|
||||
as={FiChevronUp}
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={style.container}>
|
||||
<CollapseBar title={'Network Info'} isCollapsed={collapsed} onClick={() => setCollapsed((c) => !c)}/>
|
||||
{!collapsed && (
|
||||
<div>
|
||||
{status === 'success' && (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Icon } from '@chakra-ui/react';
|
||||
import { useState } from 'react';
|
||||
import { FiChevronUp } from 'react-icons/fi';
|
||||
import style from './Info.module.css';
|
||||
import style from './Info.module.scss';
|
||||
|
||||
export default function InfoTitle(props) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import SocketProvider from 'app/context/socketContext';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import Info from '../Info';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
test('check static info render', async () => {
|
||||
// need to inject the socket provider to make component
|
||||
// render without failing
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SocketProvider>
|
||||
<Info />
|
||||
</SocketProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
// Info titles
|
||||
// substring match, ignore case
|
||||
expect(screen.getByText(/running/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/event/i)).toBeInTheDocument();
|
||||
});
|
||||
@@ -6,13 +6,16 @@ import SettingsIconBtn from './buttons/SettingsIconBtn';
|
||||
import MaxIconBtn from './buttons/MaxIconBtn';
|
||||
import MinIconBtn from './buttons/MinIconBtn';
|
||||
import QuitIconBtn from './buttons/QuitIconBtn';
|
||||
import style from './MenuBar.module.css';
|
||||
import style from './MenuBar.module.scss';
|
||||
import HelpIconBtn from './buttons/HelpIconBtn';
|
||||
import UploadIconBtn from './buttons/UploadIconBtn';
|
||||
import { useRef } from 'react';
|
||||
import { useContext, useRef } from 'react';
|
||||
import { LoggingContext } from '../../app/context/LoggingContext';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export default function MenuBar(props) {
|
||||
const { onOpen } = props;
|
||||
const { isOpen, onOpen } = props;
|
||||
const { emitError } = useContext(LoggingContext);
|
||||
const hiddenFileInput = useRef(null);
|
||||
const queryClient = useQueryClient();
|
||||
const uploaddb = useMutation(uploadEvents, {
|
||||
@@ -31,31 +34,33 @@ export default function MenuBar(props) {
|
||||
}
|
||||
};
|
||||
|
||||
const buttonStyle = {
|
||||
fontSize: '1.5em'
|
||||
};
|
||||
|
||||
const handleUpload = (event) => {
|
||||
const fileUploaded = event.target.files[0];
|
||||
console.log('1', fileUploaded)
|
||||
if (fileUploaded == null) return;
|
||||
console.log(fileUploaded);
|
||||
|
||||
// Limit file size to 1MB
|
||||
if (fileUploaded.size > 1000000) {
|
||||
console.log('Error: File size limit (1MB) exceeded');
|
||||
emitError('Error: File size limit (1MB) exceeded')
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('2', ! fileUploaded.name.endsWith('.xlsx')
|
||||
|| !fileUploaded.name.endsWith('.json'))
|
||||
|
||||
// Check file extension
|
||||
if (fileUploaded.name.endsWith('.xlsx')) {
|
||||
console.log('excel file');
|
||||
} else if (fileUploaded.name.endsWith('.json')) {
|
||||
console.log('json file');
|
||||
if (fileUploaded.name.endsWith('.xlsx') || fileUploaded.name.endsWith('.json')) {
|
||||
try {
|
||||
uploaddb.mutate(fileUploaded);
|
||||
} catch (error) {
|
||||
emitError(`Failed uploading file: ${error}`)
|
||||
}
|
||||
} else {
|
||||
console.log('Error: File type unknown');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
uploaddb.mutate(fileUploaded);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
emitError('Error: File type unknown')
|
||||
}
|
||||
|
||||
// reset input value
|
||||
@@ -95,25 +100,27 @@ export default function MenuBar(props) {
|
||||
<>
|
||||
<QuitIconBtn size='lg' clickhandler={() => handleIPC('shutdown')} />
|
||||
<MaxIconBtn
|
||||
style={{ fontSize: '1.5em' }}
|
||||
style={{ ...buttonStyle }}
|
||||
size='lg'
|
||||
clickhandler={() => handleIPC('max')}
|
||||
/>
|
||||
<MinIconBtn
|
||||
style={{ fontSize: '1.5em' }}
|
||||
style={{ ...buttonStyle }}
|
||||
size='lg'
|
||||
clickhandler={() => handleIPC('min')}
|
||||
/>
|
||||
<div className={style.gap} />
|
||||
<HelpIconBtn
|
||||
style={{ fontSize: '1.5em' }}
|
||||
style={{ ...buttonStyle }}
|
||||
size='lg'
|
||||
clickhandler={() => handleIPC('help')}
|
||||
/>
|
||||
<SettingsIconBtn
|
||||
style={{ fontSize: '1.5em' }}
|
||||
style={{...buttonStyle}}
|
||||
size='lg'
|
||||
className={isOpen ? style.open : ''}
|
||||
clickhandler={onOpen}
|
||||
isRound
|
||||
/>
|
||||
<div className={style.gap} />
|
||||
<input
|
||||
@@ -124,15 +131,21 @@ export default function MenuBar(props) {
|
||||
accept='.json, .xlsx'
|
||||
/>
|
||||
<UploadIconBtn
|
||||
style={{ fontSize: '1.5em' }}
|
||||
style={{ ...buttonStyle }}
|
||||
size='lg'
|
||||
clickhandler={handleClick}
|
||||
/>
|
||||
<DownloadIconBtn
|
||||
style={{ fontSize: '1.5em' }}
|
||||
style={{ ...buttonStyle }}
|
||||
size='lg'
|
||||
clickhandler={handleDownload}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
MenuBar.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onOpen: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.gap {
|
||||
height: 1em;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
@use '../../styles/main' as *;
|
||||
|
||||
.gap {
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.open {
|
||||
background: $light-bg;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import MenuBar from "../MenuBar";
|
||||
import {queryClientMock} from "../../../__mocks__/QueryClient.mock";
|
||||
import {QueryClientProvider} from "react-query";
|
||||
|
||||
const onOpenHandler = jest.fn();
|
||||
const renderInMock = () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClientMock}>
|
||||
<MenuBar onOpen={onOpenHandler} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
};
|
||||
|
||||
test('check that menu bar renders correctly', () => {
|
||||
// need to inject the react query provider
|
||||
renderInMock();
|
||||
|
||||
const nButtons = screen.getAllByRole("button").length;
|
||||
expect(nButtons).toBe(7);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import MenuActionButtons from "../MenuActionButtons";
|
||||
|
||||
const actionHandler = jest.fn();
|
||||
const renderInMock = () => {
|
||||
render(<MenuActionButtons actionHandler={actionHandler} />)
|
||||
};
|
||||
|
||||
test('check that menu bar renders correctly', () => {
|
||||
// need to inject the react query provider
|
||||
renderInMock();
|
||||
|
||||
const b = screen.getByRole('button', {
|
||||
name: /create menu/i
|
||||
})
|
||||
|
||||
expect(b).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import MenuBar from "../MenuBar";
|
||||
import {queryClientMock} from "../../../__mocks__/QueryClient.mock";
|
||||
import {QueryClientProvider} from "react-query";
|
||||
|
||||
const onOpenHandler = jest.fn();
|
||||
const renderInMock = () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClientMock}>
|
||||
<MenuBar onOpen={onOpenHandler} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
};
|
||||
|
||||
test('check that menu bar renders correctly', () => {
|
||||
// need to inject the react query provider
|
||||
renderInMock();
|
||||
|
||||
const nButtons = screen.getAllByRole("button").length;
|
||||
expect(nButtons).toBe(7);
|
||||
});
|
||||
@@ -1,152 +1,321 @@
|
||||
import { IconButton } from '@chakra-ui/button';
|
||||
import { FiPlus, FiMinus } from 'react-icons/fi';
|
||||
import { Button, IconButton } from '@chakra-ui/button';
|
||||
import { FiInfo, FiMinus, FiSun } from 'react-icons/fi';
|
||||
import { ModalBody } from '@chakra-ui/modal';
|
||||
import { Input } from '@chakra-ui/react';
|
||||
import { fetchEvent } from 'app/api/eventApi';
|
||||
import { useState } from 'react';
|
||||
import { getAliases, postAliases } from '../../app/api/ontimeApi';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { useFetch } from 'app/hooks/useFetch';
|
||||
import { EVENT_TABLE } from 'app/api/apiConstants';
|
||||
import style from './Modals.module.css';
|
||||
import { ALIASES } from 'app/api/apiConstants';
|
||||
import style from './Modals.module.scss';
|
||||
import { viewerLinks } from '../../app/appConstants';
|
||||
import { LoggingContext } from '../../app/context/LoggingContext';
|
||||
import { validateAlias } from '../../app/utils/aliases';
|
||||
import { Tooltip } from '@chakra-ui/tooltip';
|
||||
import SubmitContainer from './SubmitContainer';
|
||||
import handleLink from '../../common/utils/handleLink';
|
||||
|
||||
export default function AliasesModal() {
|
||||
const { data, status, isError } = useFetch(EVENT_TABLE, fetchEvent);
|
||||
const { data, status, refetch } = useFetch(ALIASES, getAliases);
|
||||
const { emitError } = useContext(LoggingContext);
|
||||
const [changed, setChanged] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [aliases, setAliases] = useState([]);
|
||||
const host = window.location.host;
|
||||
|
||||
/**
|
||||
* Set formdata from server state
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (data == null) return;
|
||||
if (changed) return;
|
||||
setAliases([...data]);
|
||||
}, [changed, data]);
|
||||
|
||||
/**
|
||||
* Validate and submit data
|
||||
*/
|
||||
const submitHandler = async (event) => {
|
||||
event.preventDefault();
|
||||
// NOTHING HERE YET
|
||||
setSubmitting(true);
|
||||
|
||||
const validatedAliases = [...aliases];
|
||||
let errors = false;
|
||||
for (const alias of validatedAliases) {
|
||||
// validate url
|
||||
const isURLValid = validateAlias(alias.pathAndParams);
|
||||
if (!isURLValid.status) {
|
||||
alias.urlError = isURLValid.message;
|
||||
errors = true;
|
||||
} else {
|
||||
alias.urlError = undefined;
|
||||
}
|
||||
// validate alias
|
||||
const isAliasValid = validateAlias(alias.alias);
|
||||
if (!isAliasValid.status) {
|
||||
alias.aliasError = isAliasValid.message;
|
||||
errors = true;
|
||||
} else {
|
||||
alias.aliasError = undefined;
|
||||
}
|
||||
}
|
||||
setAliases(validatedAliases);
|
||||
|
||||
if (!errors) {
|
||||
await postAliases(aliases);
|
||||
await refetch();
|
||||
setChanged(false);
|
||||
}
|
||||
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
// Hardcoded links for now
|
||||
// it will need dynamic PORT assignment
|
||||
const speakerLink = 'http://localhost:4001/speaker';
|
||||
const smLink = 'http://localhost:4001/sm';
|
||||
const publicLink = 'http://localhost:4001/public';
|
||||
const pipLink = 'http://localhost:4001/pip';
|
||||
/**
|
||||
* Creates a new alias in state with a temporary id
|
||||
*/
|
||||
const addNew = () => {
|
||||
if (aliases.length > 20) {
|
||||
emitError('Maximum amount of aliases reacted (20)');
|
||||
return;
|
||||
}
|
||||
|
||||
const emptyAlias = {
|
||||
id: Math.floor(Math.random() * 1000),
|
||||
enabled: false,
|
||||
alias: '',
|
||||
pathAndParams: '',
|
||||
};
|
||||
setAliases((prevState) => [...prevState, emptyAlias]);
|
||||
setChanged(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes an alias by a given id
|
||||
* @param {string} id - id of alias to delete
|
||||
*/
|
||||
const deleteAlias = (id) => {
|
||||
setAliases((prevState) => [...prevState.filter((a) => a.id !== id)]);
|
||||
setChanged(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets enabled flag to true / false
|
||||
* @param {string} id - object id
|
||||
* @param {boolean} isEnabled - whether to enable / disable flag
|
||||
*/
|
||||
const setEnabled = (id, isEnabled) => {
|
||||
const aliasesState = [...aliases];
|
||||
for (const a of aliasesState) {
|
||||
if (a.id === id) {
|
||||
if (isEnabled) {
|
||||
if (a.alias === '' || a.pathAndParams === '') {
|
||||
emitError('Alias incomplete');
|
||||
break;
|
||||
}
|
||||
|
||||
const isRepeated = aliases.some(
|
||||
(r) => a.alias === r.alias && r.enabled
|
||||
);
|
||||
if (isRepeated) {
|
||||
emitError('There is already an alias with this name');
|
||||
break;
|
||||
}
|
||||
}
|
||||
a.enabled = isEnabled;
|
||||
break;
|
||||
}
|
||||
}
|
||||
setChanged(true);
|
||||
setAliases(aliasesState);
|
||||
};
|
||||
|
||||
/**
|
||||
* Reverts local state equals to server state
|
||||
*/
|
||||
const revert = async () => {
|
||||
setChanged(false);
|
||||
await refetch();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles change of input field in local state
|
||||
* @param {number} index - index of item in array
|
||||
* @param {string} field - object parameter to update
|
||||
* @param {string} value - new object parameter value
|
||||
*/
|
||||
const handleChange = (index, field, value) => {
|
||||
const temp = [...aliases];
|
||||
temp[index][field] = value;
|
||||
setAliases(temp);
|
||||
setChanged(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalBody className={style.modalBody}>
|
||||
<p className={style.notes}>
|
||||
Configure easy to use URL Aliases
|
||||
<br />
|
||||
🔥 Changes take effect on save 🔥
|
||||
</p>
|
||||
<form onSubmit={submitHandler}>
|
||||
<ModalBody className={style.modalBody}>
|
||||
<p className={style.notes}>
|
||||
Configure easy to use URL Aliases
|
||||
<div className={style.modalFields}>
|
||||
<div className={style.hSeparator}>Default URLs</div>
|
||||
<div className={style.blockNotes}>
|
||||
{viewerLinks.map((l) => (
|
||||
<a
|
||||
href={l.link}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className={style.flexNote}
|
||||
key={l.link}
|
||||
onClick={() => handleLink(`${host}/${l.link}`)}
|
||||
>
|
||||
{`${l.label} - ${l.link}`}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<div className={style.hSeparator}>Custom Aliases</div>
|
||||
<div className={style.blockNotes}>
|
||||
<span className={style.inlineFlex}>
|
||||
<FiInfo color='#2b6cb0' fontSize={'2em'} />
|
||||
URL aliases are useful in two main scenarios
|
||||
</span>
|
||||
<span className={style.labelNote}>Complicated URLs</span>
|
||||
<br />
|
||||
!!! Feature is not yet implemented !!!
|
||||
</p>
|
||||
|
||||
<span> Default URLs </span>
|
||||
|
||||
<div className={style.highNotes}>
|
||||
<p className={style.flexNote}>
|
||||
Presenter Screen <br />
|
||||
<a
|
||||
href={speakerLink}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className={style.label}
|
||||
>
|
||||
{speakerLink}
|
||||
</a>
|
||||
</p>
|
||||
<p className={style.flexNote}>
|
||||
Backstage / Stage Manager Screen <br />
|
||||
<a
|
||||
href={smLink}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className={style.label}
|
||||
>
|
||||
{smLink}
|
||||
</a>
|
||||
</p>
|
||||
<p className={style.flexNote}>
|
||||
Public / Foyer Screen <br />
|
||||
<a
|
||||
href={publicLink}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className={style.label}
|
||||
>
|
||||
{publicLink}
|
||||
</a>
|
||||
</p>
|
||||
<p className={style.flexNote}>
|
||||
Picture in Picture Screen <br />
|
||||
<a
|
||||
href={pipLink}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className={style.label}
|
||||
>
|
||||
{pipLink}
|
||||
</a>
|
||||
</p>
|
||||
eg. a lower third url with some custom parameters
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className={style.labelNote} style={{ width: '30%' }}>
|
||||
Alias
|
||||
</td>
|
||||
<td className={style.labelNote}>Page URL</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>mylower</td>
|
||||
<td>lower?bg=ff2&text=f00&size=0.6&transition=5</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<br />
|
||||
<span className={style.labelNote}>
|
||||
URLs to be changed dynamically
|
||||
</span>
|
||||
<br />
|
||||
eg. an unattended screen that you would need to change route from
|
||||
the app
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className={style.labelNote} style={{ width: '30%' }}>
|
||||
Alias
|
||||
</td>
|
||||
<td className={style.labelNote}>Page URL</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>thirdfloor</td>
|
||||
<td>public</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<span> Manage custom aliases</span>
|
||||
<div className={style.modalInline}>
|
||||
<Input
|
||||
size='sm'
|
||||
name='URL'
|
||||
placeholder='A long URL'
|
||||
autoComplete='off'
|
||||
value={'A long URL'}
|
||||
onChange={(event) => {
|
||||
// Nothing here yet
|
||||
}}
|
||||
isDisabled={true}
|
||||
/>
|
||||
<Input
|
||||
size='sm'
|
||||
name='Alias'
|
||||
placeholder='A nice alias'
|
||||
autoComplete='off'
|
||||
value={'A nice alias'}
|
||||
onChange={(event) => {
|
||||
// Nothing here yet
|
||||
}}
|
||||
isDisabled={true}
|
||||
/>
|
||||
<IconButton
|
||||
size='sm'
|
||||
icon={<FiMinus />}
|
||||
colorScheme='red'
|
||||
disabled
|
||||
/>
|
||||
<div
|
||||
className={style.inlineAliasPlaceholder}
|
||||
style={{ padding: '0.5em 0' }}
|
||||
>
|
||||
<span className={style.labelNote}>Alias</span>
|
||||
<span className={style.labelNote}>Page URL</span>
|
||||
</div>
|
||||
<div className={style.separator} />
|
||||
<div className={style.modalInline}>
|
||||
<Input
|
||||
size='sm'
|
||||
name='URL'
|
||||
placeholder='URL'
|
||||
autoComplete='off'
|
||||
value={'URL'}
|
||||
onChange={(event) => {
|
||||
// Nothing here yet
|
||||
}}
|
||||
isDisabled={true}
|
||||
/>
|
||||
<Input
|
||||
size='sm'
|
||||
name='Alias'
|
||||
placeholder='Alias'
|
||||
autoComplete='off'
|
||||
value={'Alias'}
|
||||
onChange={(event) => {
|
||||
// Nothing here yet
|
||||
}}
|
||||
isDisabled={true}
|
||||
/>
|
||||
<IconButton
|
||||
size='sm'
|
||||
icon={<FiPlus />}
|
||||
{aliases.map((alias, index) => (
|
||||
<div key={alias.id}>
|
||||
<div className={style.inlineAlias}>
|
||||
<Input
|
||||
size='sm'
|
||||
variant='flushed'
|
||||
name='Alias'
|
||||
placeholder='URL Alias'
|
||||
autoComplete='off'
|
||||
value={alias.alias}
|
||||
isInvalid={alias.aliasError}
|
||||
onChange={(event) =>
|
||||
handleChange(index, 'alias', event.target.value)
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
size='sm'
|
||||
fontSize={'0.75em'}
|
||||
variant='flushed'
|
||||
name='URL'
|
||||
placeholder='URL (portion after ontime Port)'
|
||||
autoComplete='off'
|
||||
value={alias.pathAndParams}
|
||||
isInvalid={alias.urlError}
|
||||
onChange={(event) =>
|
||||
handleChange(index, 'pathAndParams', event.target.value)
|
||||
}
|
||||
/>
|
||||
<Tooltip label={`Test /${alias.pathAndParams}`} openDelay={500}>
|
||||
<a
|
||||
href='#!'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleLink(`http://${host}/${alias.pathAndParams}`);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label='Enable alias' openDelay={500}>
|
||||
<IconButton
|
||||
size='xs'
|
||||
icon={<FiSun />}
|
||||
colorScheme='blue'
|
||||
variant={alias.enabled ? null : 'outline'}
|
||||
onClick={() => setEnabled(alias.id, !alias.enabled)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label='Delete alias' openDelay={500}>
|
||||
<IconButton
|
||||
size='xs'
|
||||
icon={<FiMinus />}
|
||||
colorScheme='red'
|
||||
onClick={() => deleteAlias(alias.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{alias.aliasError ? (
|
||||
<div
|
||||
className={style.error}
|
||||
>{`Alias error: ${alias.aliasError}`}</div>
|
||||
) : null}
|
||||
{alias.urlError ? (
|
||||
<div
|
||||
className={style.error}
|
||||
>{`URL error: ${alias.urlError}`}</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div
|
||||
className={style.inlineAliasPlaceholder}
|
||||
style={{ padding: '0.5em 0' }}
|
||||
>
|
||||
<Button
|
||||
size='xs'
|
||||
colorScheme='blue'
|
||||
disabled
|
||||
/>
|
||||
variant='outline'
|
||||
onClick={() => addNew()}
|
||||
>
|
||||
Add new
|
||||
</Button>
|
||||
</div>
|
||||
</ModalBody>
|
||||
</div>
|
||||
<SubmitContainer
|
||||
revert={revert}
|
||||
submitting={submitting}
|
||||
changed={changed}
|
||||
status={status}
|
||||
/>
|
||||
</form>
|
||||
</>
|
||||
</ModalBody>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,185 +1,170 @@
|
||||
import { ModalBody } from '@chakra-ui/modal';
|
||||
import { FormLabel, FormControl, Input, Button } from '@chakra-ui/react';
|
||||
import { getInfo, ontimePlaceholderInfo, postInfo } from 'app/api/ontimeApi';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
PinInput,
|
||||
PinInputField,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
getSettings,
|
||||
ontimePlaceholderSettings,
|
||||
postSettings,
|
||||
} from 'app/api/ontimeApi';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { useFetch } from 'app/hooks/useFetch';
|
||||
import { APP_TABLE } from 'app/api/apiConstants';
|
||||
import { showErrorToast } from 'common/helpers/toastManager';
|
||||
import style from './Modals.module.css';
|
||||
import { APP_SETTINGS } from 'app/api/apiConstants';
|
||||
import style from './Modals.module.scss';
|
||||
import { LoggingContext } from '../../app/context/LoggingContext';
|
||||
import { IconButton } from '@chakra-ui/button';
|
||||
import { FiEye } from 'react-icons/fi';
|
||||
import SubmitContainer from './SubmitContainer';
|
||||
import { inputProps } from './modalHelper';
|
||||
|
||||
export default function AppSettingsModal() {
|
||||
const { data, status } = useFetch(APP_TABLE, getInfo);
|
||||
const [formData, setFormData] = useState(ontimePlaceholderInfo);
|
||||
const { data, status, refetch } = useFetch(APP_SETTINGS, getSettings);
|
||||
const { emitError, emitWarning } = useContext(LoggingContext);
|
||||
const [formData, setFormData] = useState(ontimePlaceholderSettings);
|
||||
const [changed, setChanged] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [hidePin, setHidePin] = useState(true);
|
||||
|
||||
/**
|
||||
* Set formdata from server state
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (data == null) return;
|
||||
|
||||
if (changed) return;
|
||||
setFormData({
|
||||
oscInPort: data.oscInPort,
|
||||
oscOutPort: data.oscOutPort,
|
||||
oscOutIP: data.oscOutIP,
|
||||
pinCode: data.pinCode,
|
||||
});
|
||||
}, [data]);
|
||||
}, [changed, data]);
|
||||
|
||||
/**
|
||||
* Validate and submit data
|
||||
*/
|
||||
const submitHandler = async (event) => {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
|
||||
const f = formData;
|
||||
let e = { status: false, message: '' };
|
||||
|
||||
// Validate fields
|
||||
if (f.oscInPort < 1024 || f.oscInPort > 65535) {
|
||||
// Port in incorrect range
|
||||
if (f.pinCode === '' || f.pinCode == null) {
|
||||
e.status = true;
|
||||
e.message += 'OSC IN Port in incorrect range (1024 - 65535)';
|
||||
} else if (f.oscOutPort < 1024 || f.oscOutPort > 65535) {
|
||||
// Port in incorrect range
|
||||
e.message += 'App pin code removed';
|
||||
} else {
|
||||
e.status = true;
|
||||
e.message += 'OSC OUT Port in incorrect range (1024 - 65535)';
|
||||
} else if (f.oscInPort === f.oscOutPort) {
|
||||
// Cant use the same port
|
||||
e.status = true;
|
||||
e.message += 'OSC IN and OUT Ports cant be the same';
|
||||
e.message += 'App pin code added';
|
||||
}
|
||||
|
||||
// set fields with error
|
||||
if (e.status) {
|
||||
showErrorToast('Invalid Input', e.message);
|
||||
return;
|
||||
if (!e.status) {
|
||||
emitError(`Invalid Input: ${e.message}`);
|
||||
} else {
|
||||
await postSettings(formData);
|
||||
await refetch();
|
||||
emitWarning(e.message);
|
||||
setChanged(false);
|
||||
}
|
||||
|
||||
// Post here
|
||||
postInfo(formData);
|
||||
|
||||
setChanged(false);
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={submitHandler}>
|
||||
<ModalBody className={style.modalBody}>
|
||||
{status === 'success' && (
|
||||
<>
|
||||
<p className={style.notes}>
|
||||
Options related to the application
|
||||
<br />
|
||||
!!! Changes take effect after app restart !!!
|
||||
</p>
|
||||
/**
|
||||
* Reverts local state equals to server state
|
||||
*/
|
||||
const revert = async () => {
|
||||
setChanged(false);
|
||||
await refetch();
|
||||
};
|
||||
|
||||
<FormControl id='serverPort'>
|
||||
<FormLabel htmlFor='serverPort'>
|
||||
Viewer Port
|
||||
<span className={style.notes}>Port to access viewers</span>
|
||||
</FormLabel>
|
||||
<Input
|
||||
/**
|
||||
* Handles change of input field in local state
|
||||
* @param {string} field - object parameter to update
|
||||
* @param {string} value - new object parameter value
|
||||
*/
|
||||
const handleChange = (field, value) => {
|
||||
const temp = { ...formData };
|
||||
temp[field] = value;
|
||||
setFormData(temp);
|
||||
setChanged(true);
|
||||
};
|
||||
|
||||
const disableModal = status !== 'success';
|
||||
|
||||
return (
|
||||
<ModalBody className={style.modalBody}>
|
||||
<p className={style.notes}>
|
||||
Options related to the application
|
||||
<br />
|
||||
🔥 Changes take effect after app restart 🔥
|
||||
</p>
|
||||
<form onSubmit={submitHandler}>
|
||||
<div className={style.modalFields}>
|
||||
<div className={style.hSeparator}>General App Settings</div>
|
||||
<div className={style.modalInline}>
|
||||
<FormControl id='serverPort'>
|
||||
<FormLabel htmlFor='serverPort'>
|
||||
Viewer Port
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
Ontime is available at port
|
||||
</span>
|
||||
</FormLabel>
|
||||
<Input
|
||||
{...inputProps}
|
||||
name='title'
|
||||
value={4001}
|
||||
disabled
|
||||
style={{ width: '6em', textAlign: 'center' }}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl id='editorPin'>
|
||||
<FormLabel htmlFor='editorPin'>
|
||||
Editor Pincode
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
Protect the editor with a Pincode
|
||||
</span>
|
||||
</FormLabel>
|
||||
<div className={style.pin}>
|
||||
<PinInput
|
||||
{...inputProps}
|
||||
type='alphanumeric'
|
||||
defaultValue=''
|
||||
value={formData.pinCode}
|
||||
mask={hidePin}
|
||||
isDisabled={disableModal}
|
||||
onChange={(value) => handleChange('pinCode', value)}
|
||||
>
|
||||
<PinInputField />
|
||||
<PinInputField />
|
||||
<PinInputField />
|
||||
<PinInputField />
|
||||
</PinInput>
|
||||
<IconButton
|
||||
size='sm'
|
||||
name='title'
|
||||
placeholder='4001'
|
||||
autoComplete='off'
|
||||
value={4001}
|
||||
readOnly
|
||||
style={{ width: '6em', textAlign: 'center' }}
|
||||
colorScheme='blue'
|
||||
variant='ghost'
|
||||
icon={<FiEye />}
|
||||
aria-label='Editor pin code'
|
||||
onMouseDown={() => setHidePin(false)}
|
||||
onMouseUp={() => setHidePin(true)}
|
||||
isDisabled={disableModal}
|
||||
/>
|
||||
<span className={style.notes}>(Read Only Value)</span>
|
||||
</FormControl>
|
||||
<FormControl id='oscInPort'>
|
||||
<FormLabel htmlFor='oscInPort'>
|
||||
OSC In Port
|
||||
<span className={style.notes}>
|
||||
<br />
|
||||
App Control - Default 8888
|
||||
</span>
|
||||
</FormLabel>
|
||||
<Input
|
||||
size='sm'
|
||||
name='oscInPort'
|
||||
placeholder='8888'
|
||||
autoComplete='off'
|
||||
type='number'
|
||||
value={formData.oscInPort}
|
||||
min='1024'
|
||||
max='65535'
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
oscInPort: parseInt(event.target.value),
|
||||
});
|
||||
}}
|
||||
isDisabled={submitting}
|
||||
style={{ width: '6em', textAlign: 'center' }}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className={style.modalInline}>
|
||||
<FormControl id='oscOutIP' width='auto'>
|
||||
<FormLabel htmlFor='oscOutIP'>
|
||||
OSC Out Target IP
|
||||
<span className={style.notes}>
|
||||
<br />
|
||||
App Feedback - Default 127.0.0.1
|
||||
</span>
|
||||
</FormLabel>
|
||||
<Input
|
||||
size='sm'
|
||||
name='oscOutIP'
|
||||
placeholder='127.0.0.1'
|
||||
autoComplete='off'
|
||||
value={formData.oscOutIP}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
oscOutIP: event.target.value,
|
||||
});
|
||||
}}
|
||||
isDisabled={submitting}
|
||||
style={{ width: '12em', textAlign: 'right' }}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl id='oscOutPort' width='auto'>
|
||||
<FormLabel htmlFor='oscOutPort'>
|
||||
OSC Out Port
|
||||
<span className={style.notes}>
|
||||
<br />
|
||||
Default 9999
|
||||
</span>
|
||||
</FormLabel>
|
||||
<Input
|
||||
size='sm'
|
||||
name='oscOutPort'
|
||||
placeholder='9999'
|
||||
autoComplete='off'
|
||||
type='number'
|
||||
value={formData.oscOutPort}
|
||||
min='1024'
|
||||
max='65535'
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
oscOutPort: parseInt(event.target.value),
|
||||
});
|
||||
}}
|
||||
isDisabled={submitting}
|
||||
style={{ width: '6em', textAlign: 'left' }}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
colorScheme='blue'
|
||||
type='submit'
|
||||
isLoading={submitting}
|
||||
disabled={!changed}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</ModalBody>
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
<SubmitContainer
|
||||
revert={revert}
|
||||
submitting={submitting}
|
||||
changed={changed}
|
||||
status={status}
|
||||
/>
|
||||
</form>
|
||||
</>
|
||||
</ModalBody>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,26 @@
|
||||
import { ModalBody } from '@chakra-ui/modal';
|
||||
import {
|
||||
FormLabel,
|
||||
FormControl,
|
||||
Input,
|
||||
Button,
|
||||
Textarea,
|
||||
} from '@chakra-ui/react';
|
||||
import { FormLabel, Input, Textarea } from '@chakra-ui/react';
|
||||
import { fetchEvent, postEvent } from 'app/api/eventApi';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useFetch } from 'app/hooks/useFetch';
|
||||
import { EVENT_TABLE } from 'app/api/apiConstants';
|
||||
import style from './Modals.module.css';
|
||||
import style from './Modals.module.scss';
|
||||
import { eventPlaceholderSettings } from '../../app/api/ontimeApi';
|
||||
import SubmitContainer from './SubmitContainer';
|
||||
import { inputProps } from './modalHelper';
|
||||
|
||||
export default function SettingsModal() {
|
||||
const { data, status } = useFetch(EVENT_TABLE, fetchEvent);
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
url: '',
|
||||
publicInfo: '',
|
||||
backstageInfo: '',
|
||||
endMessage: '',
|
||||
});
|
||||
const { data, status, refetch } = useFetch(EVENT_TABLE, fetchEvent);
|
||||
const [formData, setFormData] = useState(eventPlaceholderSettings);
|
||||
const [changed, setChanged] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
/**
|
||||
* Set formdata from server state
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (data == null) return;
|
||||
if (changed) return;
|
||||
|
||||
setFormData({
|
||||
title: data.title,
|
||||
@@ -34,8 +29,11 @@ export default function SettingsModal() {
|
||||
backstageInfo: data.backstageInfo,
|
||||
endMessage: data.endMessage,
|
||||
});
|
||||
}, [data]);
|
||||
}, [changed, data]);
|
||||
|
||||
/**
|
||||
* Validate and submit data
|
||||
*/
|
||||
const submitHandler = async (event) => {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
@@ -46,130 +44,128 @@ export default function SettingsModal() {
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Reverts local state equals to server state
|
||||
*/
|
||||
const revert = async () => {
|
||||
setChanged(false);
|
||||
await refetch();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles change of input field in local state
|
||||
* @param {string} field - object parameter to update
|
||||
* @param {string} value - new object parameter value
|
||||
*/
|
||||
const handleChange = (field, value) => {
|
||||
const temp = { ...formData };
|
||||
temp[field] = value;
|
||||
setFormData(temp);
|
||||
setChanged(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalBody className={style.modalBody}>
|
||||
<p className={style.notes}>
|
||||
Options related to the running event
|
||||
<br />
|
||||
Affects rendered views
|
||||
</p>
|
||||
<form onSubmit={submitHandler}>
|
||||
<ModalBody className={style.modalBody}>
|
||||
{status === 'success' && (
|
||||
<>
|
||||
<p className={style.notes}>
|
||||
Options related to the running event
|
||||
<div className={style.modalFields}>
|
||||
<div className={style.hSeparator}>Event Data</div>
|
||||
<div className={style.spacedEntry}>
|
||||
<FormLabel htmlFor='title'>Event Title</FormLabel>
|
||||
<Input
|
||||
{...inputProps}
|
||||
maxLength={35}
|
||||
name='title'
|
||||
placeholder='Event Title'
|
||||
value={formData.title}
|
||||
onChange={(event) => handleChange('title', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.hSeparator}>Additional Screen Info</div>
|
||||
<div className={style.spacedEntry}>
|
||||
<FormLabel htmlFor='url'>
|
||||
Event URL
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
Affect rendered views
|
||||
</p>
|
||||
|
||||
<FormControl id='title'>
|
||||
<FormLabel htmlFor='title'>Event Title</FormLabel>
|
||||
<Input
|
||||
size='sm'
|
||||
maxLength={35}
|
||||
name='title'
|
||||
placeholder='Event Title'
|
||||
autoComplete='off'
|
||||
value={formData.title}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({ ...formData, title: event.target.value });
|
||||
}}
|
||||
isDisabled={submitting}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl id='url'>
|
||||
<FormLabel htmlFor='url'>
|
||||
Event URL
|
||||
<span className={style.notes}>
|
||||
(shown as a QR code in some views)
|
||||
</span>
|
||||
</FormLabel>
|
||||
<Input
|
||||
size='sm'
|
||||
name='url'
|
||||
placeholder='www.onsite.no'
|
||||
autoComplete='off'
|
||||
value={formData.url}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({ ...formData, url: event.target.value });
|
||||
}}
|
||||
isDisabled={submitting}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl id='pubInfo'>
|
||||
<FormLabel htmlFor='pubInfo'>Public Info</FormLabel>
|
||||
<Textarea
|
||||
size='sm'
|
||||
name='pubInfo'
|
||||
placeholder='Information to be shown on public screens'
|
||||
autoComplete='off'
|
||||
value={formData.publicInfo}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
publicInfo: event.target.value,
|
||||
});
|
||||
}}
|
||||
isDisabled={submitting}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl id='backstageInfo'>
|
||||
<FormLabel htmlFor='backstageInfo'>Backstage Info</FormLabel>
|
||||
<Textarea
|
||||
size='sm'
|
||||
name='backstageInfo'
|
||||
placeholder='Information to be shown on backstage screens'
|
||||
autoComplete='off'
|
||||
value={formData.backstageInfo}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
backstageInfo: event.target.value,
|
||||
});
|
||||
}}
|
||||
isDisabled={submitting}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl id='endMessage'>
|
||||
<FormLabel htmlFor='endMessage'>
|
||||
End Message
|
||||
<span className={style.notes}>
|
||||
Shown on presenter view when time is finished
|
||||
</span>
|
||||
</FormLabel>
|
||||
<Input
|
||||
size='sm'
|
||||
maxLength={30}
|
||||
name='endMessage'
|
||||
placeholder='Empty message shows elapsed time'
|
||||
autoComplete='off'
|
||||
value={formData.endMessage}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
endMessage: event.target.value,
|
||||
});
|
||||
}}
|
||||
isDisabled={submitting}
|
||||
/>
|
||||
</FormControl>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
colorScheme='blue'
|
||||
type='submit'
|
||||
isLoading={submitting}
|
||||
disabled={!changed}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</ModalBody>
|
||||
Shown as a QR code in some views
|
||||
</span>
|
||||
</FormLabel>
|
||||
<Input
|
||||
{...inputProps}
|
||||
name='url'
|
||||
placeholder='www.onsite.no'
|
||||
value={formData.url}
|
||||
onChange={(event) => handleChange('url', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.spacedEntry}>
|
||||
<FormLabel htmlFor='pubInfo'>
|
||||
Public Info
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
Information to be shown on public screens
|
||||
</span>
|
||||
</FormLabel>
|
||||
<Textarea
|
||||
{...inputProps}
|
||||
name='pubInfo'
|
||||
placeholder='Information to be shown on public screens'
|
||||
value={formData.publicInfo}
|
||||
onChange={(event) =>
|
||||
handleChange('publicInfo', event.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.spacedEntry}>
|
||||
<FormLabel htmlFor='backstageInfo'>
|
||||
Backstage Info
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
Information to be shown on backstage screens
|
||||
</span>
|
||||
</FormLabel>
|
||||
<Textarea
|
||||
{...inputProps}
|
||||
name='backstageInfo'
|
||||
placeholder='Information to be shown on backstage screens'
|
||||
resize={false}
|
||||
value={formData.backstageInfo}
|
||||
onChange={(event) =>
|
||||
handleChange('backstageInfo', event.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.spacedEntry}>
|
||||
<FormLabel htmlFor='endMessage'>
|
||||
End Message
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
Shown on presenter view when time is finished
|
||||
</span>
|
||||
</FormLabel>
|
||||
<Input
|
||||
{...inputProps}
|
||||
maxLength={30}
|
||||
name='endMessage'
|
||||
placeholder='Empty message shows elapsed time'
|
||||
value={formData.endMessage}
|
||||
onChange={(event) =>
|
||||
handleChange('endMessage', event.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SubmitContainer
|
||||
revert={revert}
|
||||
submitting={submitting}
|
||||
changed={changed}
|
||||
status={status}
|
||||
/>
|
||||
</form>
|
||||
</>
|
||||
</ModalBody>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
import { ModalBody } from '@chakra-ui/modal';
|
||||
import { FormControl, FormLabel, Input, Switch } from '@chakra-ui/react';
|
||||
import {
|
||||
getInfo,
|
||||
httpPlaceholder,
|
||||
ontimeVars,
|
||||
postInfo,
|
||||
} from 'app/api/ontimeApi';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { useFetch } from 'app/hooks/useFetch';
|
||||
import { APP_TABLE } from 'app/api/apiConstants';
|
||||
import style from './Modals.module.scss';
|
||||
import { LoggingContext } from '../../app/context/LoggingContext';
|
||||
import { FiInfo } from 'react-icons/fi';
|
||||
import SubmitContainer from './SubmitContainer';
|
||||
import { inputProps } from './modalHelper';
|
||||
|
||||
export default function IntegrationSettingsModal() {
|
||||
const { data, status, refetch } = useFetch(APP_TABLE, getInfo);
|
||||
const { emitError } = useContext(LoggingContext);
|
||||
const [formData, setFormData] = useState(httpPlaceholder);
|
||||
const [changed, setChanged] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
/**
|
||||
* Set formdata from server state
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (data == null) return;
|
||||
if (changed) return;
|
||||
|
||||
setFormData({
|
||||
onLoad: data?.onLoad,
|
||||
onStart: data?.onStart,
|
||||
onUpdate: data?.onUpdate,
|
||||
onPause: data?.onPause,
|
||||
onStop: data?.onStop,
|
||||
});
|
||||
}, [changed, data]);
|
||||
|
||||
/**
|
||||
* Validate and submit data
|
||||
*/
|
||||
const submitHandler = async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const f = formData;
|
||||
let e = { status: false, message: '' };
|
||||
|
||||
// set fields with error
|
||||
if (e.status) {
|
||||
emitError(`Invalid Input: ${e.message}`);
|
||||
return;
|
||||
} else {
|
||||
await postInfo(f);
|
||||
setChanged(false);
|
||||
}
|
||||
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Reverts local state equals to server state
|
||||
*/
|
||||
const revert = async () => {
|
||||
setChanged(false);
|
||||
await refetch();
|
||||
};
|
||||
|
||||
// Todo: make change handler
|
||||
// Todo: toggle between GET / POST
|
||||
// Todo: add test button
|
||||
// Todo: enabled should be button
|
||||
// Todo: add friendly placeholder to input
|
||||
|
||||
return (
|
||||
<ModalBody className={style.modalBody}>
|
||||
<p className={style.notes}>
|
||||
Integrate with third party over an HTTP API
|
||||
<br />
|
||||
🔥 Changes take effect after app restart 🔥
|
||||
</p>
|
||||
<form onSubmit={submitHandler}>
|
||||
<div className={style.modalFields}>
|
||||
<div className={style.hSeparator}>Ontime event cycle</div>
|
||||
<div className={style.blockNotes}>
|
||||
<span className={style.inlineFlex}>
|
||||
<FiInfo color='#2b6cb0' fontSize={'2em'} />
|
||||
Add HTTP messages that ontime will send during the event cycle
|
||||
</span>
|
||||
<span className={style.labelNote}>
|
||||
You can use variables in the HTTP request URL to send data from
|
||||
ontime
|
||||
</span>
|
||||
<span className={style.emNote}>
|
||||
http://127.0.0.1:8088/API/?setHeadline=
|
||||
<span className={style.labelNoteInline}>$title</span>
|
||||
&setSub=<span className={style.labelNoteInline}>$presenter</span>
|
||||
</span>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className={style.labelNote} style={{ width: '30%' }}>
|
||||
Variable
|
||||
</td>
|
||||
<td className={style.labelNote}>Value</td>
|
||||
</tr>
|
||||
{ontimeVars.map((v) => (
|
||||
<tr>
|
||||
<td className={style.labelNote}>{v.name}</td>
|
||||
<td>{v.description}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className={style.hSeparator}>Send HTTP</div>
|
||||
<FormLabel style={{paddingLeft:'0.5em'}}>
|
||||
On Load
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
When a new event loads
|
||||
</span>
|
||||
</FormLabel>
|
||||
<div className={style.modalInline}>
|
||||
<Input
|
||||
{...inputProps}
|
||||
name='onLoadURL'
|
||||
value={formData?.onLoad?.url}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
onLoad: {
|
||||
...formData.onLoad,
|
||||
url: event.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
colorScheme='green'
|
||||
id='onLoadEnable'
|
||||
value={formData?.onLoad?.enabled}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
onLoad: {
|
||||
...formData.onLoad,
|
||||
enabled: event.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<FormLabel style={{paddingLeft:'0.5em'}}>
|
||||
On Start
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
When an timer starts / resumes{' '}
|
||||
</span>
|
||||
</FormLabel>
|
||||
<div className={style.modalInline}>
|
||||
<Input
|
||||
{...inputProps}
|
||||
name='onStartURL'
|
||||
value={formData?.onStart?.url}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
onStart: {
|
||||
...formData.onStart,
|
||||
url: event.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
colorScheme='green'
|
||||
id='onStartEnable'
|
||||
value={formData?.onStart?.enabled}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
onStart: {
|
||||
...formData.onStart,
|
||||
enabled: event.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<FormLabel style={{paddingLeft:'0.5em'}}>
|
||||
On Update
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
At every clock tick
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl id='onUpdate' className={style.modalInline}>
|
||||
<Input
|
||||
{...inputProps}
|
||||
name='onUpdateURL'
|
||||
value={formData?.onUpdate?.url}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
onUpdate: {
|
||||
...formData.onUpdate,
|
||||
url: event.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
colorScheme='green'
|
||||
id='onUpdateEnable'
|
||||
value={formData?.onUpdate?.enabled}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
onUpdate: {
|
||||
...formData.onUpdate,
|
||||
enabled: event.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel style={{paddingLeft:'0.5em'}}>
|
||||
On Pause
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
When a timer pauses
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl id='onPause' className={style.modalInline}>
|
||||
<Input
|
||||
{...inputProps}
|
||||
name='onPauseURL'
|
||||
value={formData?.onPause?.url}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
onPause: {
|
||||
...formData.onPause,
|
||||
url: event.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
colorScheme='green'
|
||||
id='onPauseEnable'
|
||||
value={formData?.onPause?.enabled}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
onPause: {
|
||||
...formData.onPause,
|
||||
enabled: event.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel style={{paddingLeft:'0.5em'}}>
|
||||
On Stop
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
When an event is unloaded
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl id='onStop' className={style.modalInline}>
|
||||
<Input
|
||||
{...inputProps}
|
||||
name='onStopURL'
|
||||
value={formData?.onStop?.url}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
onStop: {
|
||||
...formData.onStop,
|
||||
url: event.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
colorScheme='green'
|
||||
id='onStopEnable'
|
||||
value={formData?.onStop?.enabled}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
onStop: {
|
||||
...formData.onStop,
|
||||
enabled: event.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel style={{paddingLeft:'0.5em'}}>
|
||||
On Finish
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
When an event is finished
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl id='onFinish' className={style.modalInline}>
|
||||
<Input
|
||||
{...inputProps}
|
||||
name='onFinishURL'
|
||||
value={formData?.onFinish?.url}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
onStop: {
|
||||
...formData.onFinish,
|
||||
url: event.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
colorScheme='green'
|
||||
id='onFinishEnable'
|
||||
value={formData?.onFinish?.enabled}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
onStop: {
|
||||
...formData.onStop,
|
||||
enabled: event.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<SubmitContainer
|
||||
revert={revert}
|
||||
submitting={submitting}
|
||||
changed={changed}
|
||||
status={status}
|
||||
/>
|
||||
</form>
|
||||
</ModalBody>
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
} from '@chakra-ui/modal';
|
||||
import { Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/tabs';
|
||||
import EventSettingsModal from './EventSettingsModal';
|
||||
import AppSettingsModal from './AppSettingsModal';
|
||||
import OscSettingsModal from './OscSettingsModal';
|
||||
import AliasesModal from './AliasesModal';
|
||||
import IntegrationSettingsModal from './IntegrationSettingsModal';
|
||||
import AppSettingsModal from './AppSettingsModal';
|
||||
|
||||
export default function ModalManager(props) {
|
||||
const { isOpen, onClose } = props;
|
||||
@@ -19,6 +21,8 @@ export default function ModalManager(props) {
|
||||
onClose={onClose}
|
||||
closeOnOverlayClick={false}
|
||||
motionPreset={'slideInBottom'}
|
||||
size='xl'
|
||||
scrollBehavior='inside'
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
@@ -27,20 +31,28 @@ export default function ModalManager(props) {
|
||||
|
||||
<Tabs size='sm' isLazy>
|
||||
<TabList>
|
||||
<Tab>Event Settings</Tab>
|
||||
<Tab>Application Settings</Tab>
|
||||
<Tab>URL Aliases</Tab>
|
||||
<Tab style={{ fontSize: '0.9em' }}>App Settings</Tab>
|
||||
<Tab style={{ fontSize: '0.9em' }}>Event Data</Tab>
|
||||
<Tab style={{ fontSize: '0.9em' }}>URL Aliases</Tab>
|
||||
<Tab style={{ fontSize: '0.9em' }}>OSC</Tab>
|
||||
{/*<Tab style={{ fontSize: '0.9em' }}>Integration</Tab>*/}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<EventSettingsModal />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<AppSettingsModal />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<EventSettingsModal />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<AliasesModal />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<OscSettingsModal />
|
||||
</TabPanel>
|
||||
{/*<TabPanel>*/}
|
||||
{/* <IntegrationSettingsModal />*/}
|
||||
{/*</TabPanel>*/}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</ModalContent>
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
.modalBody {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.modalBody > * {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
.modalBody > button {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.notes {
|
||||
font-weight: 400;
|
||||
color: #2b6cb0;
|
||||
}
|
||||
|
||||
p.notes {
|
||||
text-align: center;
|
||||
border-color: #2b6cb055;
|
||||
border-width: 0 2px;
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
span.notes {
|
||||
font-size: 0.8em;
|
||||
padding-left: 0.4em;
|
||||
}
|
||||
|
||||
.modalInline {
|
||||
display: flex;
|
||||
gap: 2em;
|
||||
}
|
||||
|
||||
.highNotes {
|
||||
background-color: #2b6cb022;
|
||||
margin: 1em 0;
|
||||
padding: 0.3em;
|
||||
}
|
||||
|
||||
.flexNote {
|
||||
font-size: 0.9em;
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
|
||||
a::after {
|
||||
content: ' \2197';
|
||||
color: #ff7597;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #ff7597;
|
||||
}
|
||||
|
||||
.separator {
|
||||
border: 1px solid #2b6cb055;
|
||||
width: 50%;
|
||||
margin: 0.5em auto;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
@use '../../styles/main' as *;
|
||||
|
||||
//////////////////////////////////// main
|
||||
|
||||
.modalBody {
|
||||
font-weight: 400;
|
||||
|
||||
.notes {
|
||||
font-weight: 400;
|
||||
color: $light-bg;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 4em;
|
||||
}
|
||||
|
||||
.modalFields {
|
||||
min-height: 45vh;
|
||||
max-height: 45vh;
|
||||
overflow-y: auto;
|
||||
scrollbar-color: rgba($light-bg, 0.35) rgba($light-bg, 0.15);
|
||||
padding-right: 6px;
|
||||
|
||||
label {
|
||||
//font-weight: 400;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.inlineAlias,
|
||||
.inlineAliasPlaceholder {
|
||||
display: grid;
|
||||
grid-template-columns: 20% 1fr 1em 1.5em 1.5em;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.error {
|
||||
font-size: 0.8em;
|
||||
color: $error-red;
|
||||
}
|
||||
|
||||
.inlineAliasPlaceholder {
|
||||
grid-template-columns: 20% 1fr 4em;
|
||||
|
||||
.placeholder {
|
||||
background: $light-text;
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Track */
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba($light-bg, 0.15);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Handle */
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba($light-bg, 0.35);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Handle on hover */
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba($light-bg, 0.45);
|
||||
}
|
||||
|
||||
.modalInline {
|
||||
display: flex;
|
||||
gap: 2em;
|
||||
align-items: center;
|
||||
padding: 0 0.5em 0.5em 0.5em;
|
||||
}
|
||||
|
||||
.spacedEntry {
|
||||
padding: 0 0.5em 0.5em 0.5em;
|
||||
}
|
||||
|
||||
.pin {
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
border-radius: 50%;
|
||||
|
||||
input {
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.submitContainer {
|
||||
margin-top: 2em;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.modalBody > * {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
//////////////////////////////////// notes
|
||||
p {
|
||||
&.notes {
|
||||
text-align: center;
|
||||
border-color: $light-bg-transparent;
|
||||
border-width: 0 2px;
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
&.notes {
|
||||
font-size: 0.9em;
|
||||
padding-left: 0.4em;
|
||||
}
|
||||
}
|
||||
|
||||
.blockNotes {
|
||||
background-color: $bg-gray;
|
||||
margin: 1em 0;
|
||||
padding: 0.5em;
|
||||
font-size: 0.8em;
|
||||
border-radius: 2px;
|
||||
|
||||
table {
|
||||
background-color: #fff;
|
||||
border-left: 4px solid lighten($ontime-pink, 5%);
|
||||
width: 100%;
|
||||
margin: 0.5em 0;
|
||||
border-radius: 2px;
|
||||
|
||||
:first-child {
|
||||
padding-left: 1em;
|
||||
}
|
||||
|
||||
td {
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
|
||||
.noteItem {
|
||||
user-select: text;
|
||||
font-weight: 600;
|
||||
padding-right: 2em;
|
||||
}
|
||||
|
||||
.flexNote {
|
||||
user-select: text;
|
||||
padding-bottom: 0.3em;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.emNote {
|
||||
user-select: text;
|
||||
display: block;
|
||||
background-color: #fffc;
|
||||
}
|
||||
}
|
||||
|
||||
.labelNote {
|
||||
color: $light-bg;
|
||||
padding-right: 1em;
|
||||
}
|
||||
|
||||
.labelNoteInline {
|
||||
color: $light-bg;
|
||||
}
|
||||
|
||||
.inlineFlex {
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
align-items: center;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { ModalBody } from '@chakra-ui/modal';
|
||||
import { FormControl, FormLabel, Input } from '@chakra-ui/react';
|
||||
import { getOSC, oscPlaceholderSettings, postOSC } from 'app/api/ontimeApi';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { useFetch } from 'app/hooks/useFetch';
|
||||
import { OSC_SETTINGS } from 'app/api/apiConstants';
|
||||
import style from './Modals.module.scss';
|
||||
import { LoggingContext } from '../../app/context/LoggingContext';
|
||||
import SubmitContainer from './SubmitContainer';
|
||||
import { inputProps, portInputProps } from './modalHelper';
|
||||
|
||||
|
||||
export default function OscSettingsModal() {
|
||||
const { data, status, refetch } = useFetch(OSC_SETTINGS, getOSC);
|
||||
const { emitError } = useContext(LoggingContext);
|
||||
const [formData, setFormData] = useState(oscPlaceholderSettings);
|
||||
const [changed, setChanged] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
/**
|
||||
* Set formdata from server state
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (data == null) return;
|
||||
if (changed) return;
|
||||
setFormData({ ...data });
|
||||
}, [changed, data]);
|
||||
|
||||
/**
|
||||
* Validate and submit data
|
||||
*/
|
||||
const submitHandler = async (event) => {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
|
||||
const f = formData;
|
||||
let e = { status: false, message: '' };
|
||||
|
||||
// Validate fields
|
||||
if (f.port < 1024 || f.port > 65535) {
|
||||
// Port in incorrect range
|
||||
e.status = true;
|
||||
e.message += 'OSC IN Port in incorrect range (1024 - 65535)';
|
||||
} else if (f.portOut < 1024 || f.portOut > 65535) {
|
||||
// Port in incorrect range
|
||||
e.status = true;
|
||||
e.message += 'OSC OUT Port in incorrect range (1024 - 65535)';
|
||||
} else if (f.port === f.portOut) {
|
||||
// Cant use the same port
|
||||
e.status = true;
|
||||
e.message += 'OSC IN and OUT Ports cant be the same';
|
||||
}
|
||||
|
||||
// set fields with error
|
||||
if (e.status) {
|
||||
emitError(`Invalid Input: ${e.message}`);
|
||||
} else {
|
||||
// Post here
|
||||
await postOSC(formData);
|
||||
setChanged(false);
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Reverts local state equals to server state
|
||||
*/
|
||||
const revert = async () => {
|
||||
setChanged(false);
|
||||
await refetch();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles change of input field in local state
|
||||
* @param {string} field - object parameter to update
|
||||
* @param {(string | number)} value - new object parameter value
|
||||
*/
|
||||
const handleChange = (field, value) => {
|
||||
const temp = { ...formData };
|
||||
temp[field] = value;
|
||||
setFormData(temp);
|
||||
setChanged(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalBody className={style.modalBody}>
|
||||
<p className={style.notes}>
|
||||
Options related to Open Sound Control
|
||||
<br />
|
||||
🔥 Changes take effect after app restart 🔥
|
||||
</p>
|
||||
<form onSubmit={submitHandler}>
|
||||
<div className={style.modalFields}>
|
||||
<div className={style.hSeparator}>OSC Input (control)</div>
|
||||
<div className={style.spacedEntry}>
|
||||
<FormLabel htmlFor='port'>
|
||||
OSC In Port
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
Open port for 3rd party control over OSC - Default 8888
|
||||
</span>
|
||||
</FormLabel>
|
||||
<Input
|
||||
{...portInputProps}
|
||||
name='port'
|
||||
placeholder='8888'
|
||||
value={formData.port}
|
||||
onChange={(event) =>
|
||||
handleChange('port', parseInt(event.target.value))
|
||||
}
|
||||
style={{ width: '6em', textAlign: 'center' }}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.hSeparator}>OSC Output (feedback)</div>
|
||||
<div className={style.modalInline}>
|
||||
<FormControl id='targetIP'>
|
||||
<FormLabel htmlFor='targetIP'>
|
||||
OSC Out Target IP
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
Default 127.0.0.1
|
||||
</span>
|
||||
</FormLabel>
|
||||
<Input
|
||||
{...inputProps}
|
||||
size='sm'
|
||||
name='targetIP'
|
||||
placeholder='127.0.0.1'
|
||||
autoComplete='off'
|
||||
value={formData.targetIP}
|
||||
onChange={(event) =>
|
||||
handleChange('targetIP', event.target.value)
|
||||
}
|
||||
isDisabled={submitting}
|
||||
style={{ width: '12em', textAlign: 'right' }}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl id='portOut'>
|
||||
<FormLabel htmlFor='portOut'>
|
||||
OSC Out Port
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
Default 9999
|
||||
</span>
|
||||
</FormLabel>
|
||||
<Input
|
||||
{...portInputProps}
|
||||
name='portOut'
|
||||
placeholder='9999'
|
||||
value={formData.portOut}
|
||||
onChange={(event) =>
|
||||
handleChange('portOut', parseInt(event.target.value))
|
||||
}
|
||||
style={{ width: '6em', textAlign: 'left' }}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
<SubmitContainer
|
||||
revert={revert}
|
||||
submitting={submitting}
|
||||
changed={changed}
|
||||
status={status}
|
||||
/>
|
||||
</form>
|
||||
</ModalBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import style from './Modals.module.scss';
|
||||
import { Button } from '@chakra-ui/button';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export default function SubmitContainer(props) {
|
||||
const { submitting, changed, revert, status } = props;
|
||||
|
||||
return (
|
||||
<div className={style.submitContainer}>
|
||||
<Button
|
||||
type='submit'
|
||||
isDisabled={submitting || !changed}
|
||||
variant='ghosted'
|
||||
onClick={() => revert()}
|
||||
>
|
||||
Revert
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme='blue'
|
||||
type='submit'
|
||||
isLoading={submitting}
|
||||
disabled={!changed || status !== 'success'}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SubmitContainer.propTypes = {
|
||||
submitting: PropTypes.bool,
|
||||
changed: PropTypes.bool,
|
||||
status: PropTypes.string,
|
||||
revert: PropTypes.func.isRequired,
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
export const inputProps = {
|
||||
size: 'sm',
|
||||
autoComplete: 'off',
|
||||
};
|
||||
|
||||
export const portInputProps = {
|
||||
...inputProps,
|
||||
type: 'number',
|
||||
min: '1024',
|
||||
max: '65535',
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { fetchAllEvents } from 'app/api/eventsApi';
|
||||
import { fetchEvent } from 'app/api/eventApi';
|
||||
import { useSocket } from 'app/context/socketContext';
|
||||
import { stringFromMillis } from 'common/utils/dateConfig';
|
||||
import { stringFromMillis } from 'ontime-utils/time';
|
||||
import { useFetch } from 'app/hooks/useFetch';
|
||||
import { EVENTS_TABLE, EVENT_TABLE } from 'app/api/apiConstants';
|
||||
|
||||
|
||||
@@ -21,10 +21,10 @@ export default function StageManager(props) {
|
||||
useEffect(() => {
|
||||
if (backstageEvents == null) return;
|
||||
|
||||
setFilteredEvents(getEventsWithDelay(backstageEvents));
|
||||
const f = getEventsWithDelay(backstageEvents)
|
||||
|
||||
console.log('hhh', getEventsWithDelay(backstageEvents))
|
||||
}, [backstageEvents]);
|
||||
setFilteredEvents(f);
|
||||
}, [backstageEvents]);
|
||||
|
||||
// Format messages
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import LowerClean from './LowerClean';
|
||||
import LowerLines from './LowerLines';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
const isEqual = require('react-fast-compare');
|
||||
|
||||
const areEqual = (prevProps, nextProps) => {
|
||||
@@ -12,6 +13,7 @@ const areEqual = (prevProps, nextProps) => {
|
||||
|
||||
const Lower = (props) => {
|
||||
const { title } = props;
|
||||
const [searchParams,] = useSearchParams();
|
||||
const [titles, setTitles] = useState({
|
||||
titleNow: '',
|
||||
titleNext: '',
|
||||
@@ -61,61 +63,59 @@ const Lower = (props) => {
|
||||
// eg. http://localhost:3000/lower?bg=ff2&text=f00&size=0.6&transition=5
|
||||
// Check for user options
|
||||
useEffect(() => {
|
||||
// get parameters
|
||||
const params = new URLSearchParams(props.location.search);
|
||||
// create aux
|
||||
let options = {};
|
||||
|
||||
// preset: selector
|
||||
// Should be a number 1-n
|
||||
let p = parseInt(params.get('preset'));
|
||||
let p = parseInt(searchParams.get('preset'));
|
||||
if (!isNaN(p)) setPreset(p);
|
||||
|
||||
// size: multiplier
|
||||
// Should be a number 0.0-n
|
||||
let s = params.get('size');
|
||||
let s = searchParams.get('size');
|
||||
if (s) options.size = s;
|
||||
|
||||
// transitionIn: seconds
|
||||
// Should be a number 0-n
|
||||
let t = parseInt(params.get('transition'));
|
||||
let t = parseInt(searchParams.get('transition'));
|
||||
if (!isNaN(t)) options.transitionIn = t;
|
||||
|
||||
// textColour: string
|
||||
// Should be a hex string '#ffffff'
|
||||
let c = params.get('text');
|
||||
let c = searchParams.get('text');
|
||||
if (c) options.textColour = `#${c}`;
|
||||
|
||||
// bgColour: string
|
||||
// Should be a hex string '#ffffff'
|
||||
let b = params.get('bg');
|
||||
let b = searchParams.get('bg');
|
||||
if (b) options.bgColour = `#${b}`;
|
||||
|
||||
// key: string
|
||||
// Should be a hex string '#00FF00' with key colour
|
||||
let k = params.get('key');
|
||||
let k = searchParams.get('key');
|
||||
if (k) options.keyColour = `#${k}`;
|
||||
|
||||
// fadeOut: seconds
|
||||
// Should be a number 0-n
|
||||
let f = parseInt(params.get('fadeout'));
|
||||
let f = parseInt(searchParams.get('fadeout'));
|
||||
if (!isNaN(f)) options.fadeOut = f;
|
||||
|
||||
// x: pixels
|
||||
// Should be a number 0-n
|
||||
let x = parseInt(params.get('x'));
|
||||
let x = parseInt(searchParams.get('x'));
|
||||
if (!isNaN(x)) options.posX = x;
|
||||
|
||||
// y: pixels
|
||||
// Should be a number 0-n
|
||||
let y = parseInt(params.get('y'));
|
||||
let y = parseInt(searchParams.get('y'));
|
||||
if (!isNaN(y)) options.posY = y;
|
||||
|
||||
setLowerOptions({
|
||||
...options,
|
||||
set: true,
|
||||
});
|
||||
}, [props.location.search]);
|
||||
}, [searchParams]);
|
||||
|
||||
// Defer rendering until we have data ready
|
||||
if (!lowerOptions.set) return null;
|
||||
|
||||
@@ -3,16 +3,20 @@ import useFitText from "use-fit-text";
|
||||
import NavLogo from "../../../common/components/nav/NavLogo";
|
||||
import {useEffect, useState} from "react";
|
||||
import {formatDisplay} from "../../../common/utils/dateConfig";
|
||||
import {formatEventList, trimEventlist} from "../../../common/utils/eventsManager";
|
||||
import {
|
||||
formatEventList,
|
||||
getEventsWithDelay,
|
||||
trimEventlist
|
||||
} from "../../../common/utils/eventsManager";
|
||||
|
||||
export default function StudioClock(props) {
|
||||
const { title, time, backstageEvents, selectedId, nextId, onAir } = props;
|
||||
const { fontSize, ref } = useFitText({maxFontSize:500});
|
||||
const [hoursNow, minutesNow] = time.clockNoSeconds.split(':');
|
||||
const {title, time, backstageEvents, selectedId, nextId, onAir} = props;
|
||||
const {fontSize, ref} = useFitText({maxFontSize: 500});
|
||||
const [, , secondsNow] = time.clock.split(':');
|
||||
const [schedule, setSchedule] = useState([]);
|
||||
|
||||
const hoursIndicators = [...Array(12).keys()];
|
||||
const minutesIndicators = [...Array(60).keys()];
|
||||
const activeIndicators = [...Array(12).keys()];
|
||||
const secondsIndicators = [...Array(60).keys()];
|
||||
const MAX_TITLES = 8;
|
||||
|
||||
// Set window title
|
||||
@@ -25,45 +29,45 @@ export default function StudioClock(props) {
|
||||
useEffect(() => {
|
||||
if (backstageEvents == null) return;
|
||||
|
||||
const events = backstageEvents.filter((e) => e.type === 'event');
|
||||
|
||||
let e = trimEventlist(events, selectedId, MAX_TITLES);
|
||||
e = formatEventList(e, selectedId, nextId);
|
||||
setSchedule(e);
|
||||
const delayed = getEventsWithDelay(backstageEvents);
|
||||
const events = delayed.filter((e) => e.type === 'event');
|
||||
const trimmed = trimEventlist(events, selectedId, MAX_TITLES);
|
||||
const formatted = formatEventList(trimmed, selectedId, nextId);
|
||||
setSchedule(formatted);
|
||||
|
||||
}, [backstageEvents, selectedId, nextId]);
|
||||
|
||||
return (
|
||||
<div className={style.container}>
|
||||
<NavLogo />
|
||||
<NavLogo/>
|
||||
<div className={style.clockContainer}>
|
||||
<div className={style.time}>{time.clockNoSeconds}</div>
|
||||
<div
|
||||
ref={ref}
|
||||
className={style.nextTitle}
|
||||
style={{fontSize, height:'100px', width:'100%', maxWidth:'680px'}}
|
||||
style={{fontSize, height: '100px', width: '100%', maxWidth: '680px'}}
|
||||
>
|
||||
{title.titleNext}
|
||||
</div>
|
||||
<div className={time.running > 0 ? style.nextCountdown: style.nextCountdown__overtime}>
|
||||
{selectedId != null && formatDisplay(time.running)}
|
||||
</div>
|
||||
<div className={time.running > 0 ? style.nextCountdown : style.nextCountdown__overtime}>
|
||||
{selectedId != null && formatDisplay(time.running)}
|
||||
</div>
|
||||
<div className={style.indicators}>
|
||||
{hoursIndicators.map(i => (
|
||||
{activeIndicators.map(i => (
|
||||
<div
|
||||
key = {i}
|
||||
className={i <= hoursNow ? style.hours__active : style.hours}
|
||||
key={i}
|
||||
className={style.hours__active}
|
||||
style={{
|
||||
transform: `rotate(${360/12*i-90}deg) translateX(380px)`
|
||||
transform: `rotate(${360 / 12 * i - 90}deg) translateX(380px)`
|
||||
}}/>
|
||||
)
|
||||
)}
|
||||
{minutesIndicators.map(i => (
|
||||
{secondsIndicators.map(i => (
|
||||
<div
|
||||
key={i}
|
||||
className={i <= minutesNow ? style.min__active : style.min}
|
||||
className={i <= secondsNow ? style.min__active : style.min}
|
||||
style={{
|
||||
transform: `rotate(${360/60*i-90}deg) translateX(415px)`
|
||||
transform: `rotate(${360 / 60 * i - 90}deg) translateX(415px)`
|
||||
}}/>
|
||||
)
|
||||
)}
|
||||
@@ -75,9 +79,9 @@ export default function StudioClock(props) {
|
||||
<ul>
|
||||
{schedule.map((s) => (
|
||||
<li key={s.id} className={s.isNow ? style.now : s.isNext ? style.next : ''}>
|
||||
<div className={s.isNow ? style.decorator__active : s.isNext ? style.decorator__next : style.decorator}/>{`${s.time} ${s.title}`}
|
||||
{`${s.time} ${s.title}`}
|
||||
</li>
|
||||
))
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@font-face {
|
||||
font-family: "digital-clock";
|
||||
src: local('digital-7'), url('./../../../assets/fonts/digital-7.mono.ttf') format('truetype') ;
|
||||
src: local('digital-7'), url('./../../../assets/fonts/digital-7.monoitalic.ttf') format('truetype') ;
|
||||
}
|
||||
|
||||
.container {
|
||||
@@ -11,7 +11,7 @@
|
||||
height: 100vh;
|
||||
padding: 1vw;
|
||||
|
||||
background: #111;
|
||||
background: #000;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1000px 1fr;
|
||||
@@ -26,7 +26,7 @@
|
||||
$size-min: 18px;
|
||||
$half-min: 9px;
|
||||
$red-active: #c53030;
|
||||
$red-idle: darken($red-active, 30%);
|
||||
$red-idle: #300000;
|
||||
$cyan-active: #0ff;
|
||||
$cyan-idle: #0aa;
|
||||
|
||||
@@ -42,13 +42,11 @@
|
||||
font-family: digital-clock, monospace;
|
||||
text-transform: uppercase;
|
||||
|
||||
|
||||
.time {
|
||||
margin-top: 175px;
|
||||
color: $red-active;
|
||||
font-size: 275px;
|
||||
font-size: 300px;
|
||||
line-height: 0.8em;
|
||||
text-shadow: rgb(180,0,0) 0 0 20px;
|
||||
}
|
||||
.nextTitle:after,
|
||||
.nextCountdown:after,
|
||||
@@ -118,17 +116,14 @@
|
||||
font-family: digital-clock, monospace;
|
||||
text-transform: uppercase;
|
||||
|
||||
|
||||
.onAir,
|
||||
.onAir__idle{
|
||||
padding-bottom: 50px;
|
||||
font-size: 190px;
|
||||
font-size: 170px;
|
||||
line-height: 0.9em;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.onAir {
|
||||
color: $red-active;
|
||||
text-shadow: rgb(150,0,0) 0 0 20px;
|
||||
}
|
||||
.onAir__idle {
|
||||
color: $red-idle;
|
||||
@@ -149,29 +144,9 @@
|
||||
}
|
||||
.now {
|
||||
color: $cyan-active;
|
||||
text-shadow: rgb(0,100,100) 0 0 20px;
|
||||
}
|
||||
.next {
|
||||
color: $red-active;
|
||||
text-shadow: rgb(100,0,0) 0 0 20px;
|
||||
}
|
||||
.decorator,
|
||||
.decorator__active,
|
||||
.decorator__next {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background: $red-idle;
|
||||
min-height: $size-hours;
|
||||
max-height: $size-hours;
|
||||
width: $size-hours;
|
||||
}
|
||||
.decorator__active {
|
||||
background: $cyan-active;
|
||||
box-shadow: 0 0 10px 2px rgba(0,255,255,0.25);
|
||||
}
|
||||
.decorator__next{
|
||||
background: $red-active;
|
||||
box-shadow: 0 0 10px 2px rgba(255,0,0,0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-15
@@ -1,29 +1,30 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import './index.css';
|
||||
import './index.scss';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
|
||||
// 1. import Chakra components
|
||||
import { ChakraProvider, extendTheme } from '@chakra-ui/react';
|
||||
|
||||
// 2. Extend the theme to include custom colors, fonts, etc
|
||||
const colors = {
|
||||
// not yet
|
||||
};
|
||||
import { ChakraProvider } from '@chakra-ui/react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { AppContextProvider } from './app/context/AppContext';
|
||||
import SocketProvider from './app/context/socketContext';
|
||||
|
||||
// Load Open Sans typeface
|
||||
require('typeface-open-sans');
|
||||
|
||||
const theme = extendTheme({ colors });
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<ChakraProvider resetCSS theme={theme}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
<ChakraProvider resetCSS>
|
||||
<SocketProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppContextProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</AppContextProvider>
|
||||
</QueryClientProvider>
|
||||
</SocketProvider>
|
||||
</ChakraProvider>
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@use './styles/main';
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
@@ -0,0 +1,54 @@
|
||||
//////////////////////////////////// general app colours
|
||||
|
||||
$ontime-accent: #4bffabcc;
|
||||
$ontime-pink: #ff7597;
|
||||
$ontime-roll: #2b6cb0;
|
||||
|
||||
$notes-color: #d69e2e;
|
||||
|
||||
$header-gray: #ccc;
|
||||
$label-gray: #aaa;
|
||||
$bg-gray: #f4f4f8;
|
||||
|
||||
$light-bg: #2b6cb0;
|
||||
$light-bg-transparent: #2b6cb055;
|
||||
$light-text: #2b6cb022;
|
||||
|
||||
$error-red: #E53E3E;
|
||||
|
||||
//////////////////////////////////// general app element overriders
|
||||
|
||||
// no decoration on lists
|
||||
ul {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
// no resizing on text areas
|
||||
textarea {
|
||||
resize: none !important;
|
||||
}
|
||||
|
||||
// Define style for a link
|
||||
a {
|
||||
&::after {
|
||||
content: ' \2197';
|
||||
color: $ontime-pink;
|
||||
}
|
||||
&:hover {
|
||||
color: $ontime-pink;
|
||||
}
|
||||
}
|
||||
|
||||
// horizontal separator
|
||||
.hSeparator {
|
||||
width: 100%;
|
||||
border-bottom: 1px solid $light-text;
|
||||
margin: 1em auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// inline vertical separator
|
||||
.vSpan {
|
||||
margin: 0 0.5em;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//////////////////////////////////// general app elements
|
||||
|
||||
@mixin container-bg {
|
||||
background-color: rgba(0, 0, 0, 0.13);
|
||||
border-radius: 2px;
|
||||
padding: 0 0.5em;
|
||||
margin: 0 0.5em;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
stats: {
|
||||
// logging: 'warn',
|
||||
// errors: true,
|
||||
logging: 'errors',
|
||||
errors: false,
|
||||
},
|
||||
};
|
||||
+4483
-6154
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"parserOptions": {
|
||||
"sourceType": "module"
|
||||
},
|
||||
"env": {
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:prettier/recommended"
|
||||
],
|
||||
"plugins": [],
|
||||
"rules": {
|
||||
"prettier/prettier": ["error", {
|
||||
"endOfLine": "auto",
|
||||
"singleQuote": true
|
||||
}]
|
||||
}
|
||||
}
|
||||
+17
-6
@@ -118,10 +118,21 @@
|
||||
"app": "ontime",
|
||||
"version": 1,
|
||||
"serverPort": 4001,
|
||||
"oscInPort": 8888,
|
||||
"oscOutPort": 9999,
|
||||
"oscOutIP": "127.0.0.1",
|
||||
"oscEnabled": true,
|
||||
"lock": false
|
||||
}
|
||||
"lock": null,
|
||||
"pinCode": "1234"
|
||||
},
|
||||
"osc": {
|
||||
"port": 8888,
|
||||
"portOut": 9999,
|
||||
"targetIP": "127.0.0.1",
|
||||
"enabled": true
|
||||
},
|
||||
"aliases": [
|
||||
{
|
||||
"id": "0b0b3",
|
||||
"enabled": true,
|
||||
"alias": "test",
|
||||
"pathAndParams": "lower?bg=ff2&text=f00&size=0.6&transition=5"
|
||||
}
|
||||
]
|
||||
}
|
||||
+4
-7
@@ -17,18 +17,15 @@ let loaded = 'Nothing loaded';
|
||||
let isQuitting = false;
|
||||
|
||||
const nodePath =
|
||||
env != 'prod'
|
||||
env !== 'prod'
|
||||
? path.join('file://', __dirname, 'src/app.js')
|
||||
: path.join('file://', __dirname, '../', 'extraResources', 'src/app.js');
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const { startServer, startOSCServer, startOSCClient } = await import(
|
||||
const { startServer, startOSCServer } = await import(
|
||||
nodePath
|
||||
);
|
||||
// Start OSC Client (Feedback)
|
||||
await startOSCClient();
|
||||
|
||||
// Start express server
|
||||
loaded = await startServer();
|
||||
|
||||
@@ -122,7 +119,7 @@ app.whenReady().then(() => {
|
||||
createWindow();
|
||||
|
||||
// register global shortcuts
|
||||
// (available regardless of wheter app is in focus)
|
||||
// (available regardless of whether app is in focus)
|
||||
// bring focus to window
|
||||
globalShortcut.register('Alt+1', () => {
|
||||
win.show();
|
||||
@@ -140,7 +137,7 @@ app.whenReady().then(() => {
|
||||
setTimeout(() => {
|
||||
// Load page served by node
|
||||
const reactApp =
|
||||
env == 'prod'
|
||||
env === 'prod'
|
||||
? 'http://localhost:4001/editor'
|
||||
: 'http://localhost:3000/editor';
|
||||
|
||||
|
||||
+35
-15
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "0.4.2",
|
||||
"version": "0.5.1",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
@@ -13,21 +13,19 @@
|
||||
"main": "main.js",
|
||||
"devDependencies": {
|
||||
"electron": "^13.6.1",
|
||||
"electron-builder": "^22.11.3",
|
||||
"eslint": "^7.26.0",
|
||||
"eslint-config-airbnb": "^18.2.1",
|
||||
"eslint-plugin-import": "^2.22.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.4.1",
|
||||
"eslint-plugin-react": "^7.23.2",
|
||||
"eslint-plugin-react-hooks": "^4.2.0",
|
||||
"eslint-plugin-simple-import-sort": "^7.0.0",
|
||||
"jest": "^27.0.4"
|
||||
"electron-builder": "^22.14.5",
|
||||
"eslint": "^8.5.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"jest": "^27.4.5",
|
||||
"prettier": "^2.5.1"
|
||||
},
|
||||
"scripts": {
|
||||
"nodestart": "NODE_ENV=development node src/app.js",
|
||||
"setdb": "cp data/db.json src/data/db.json",
|
||||
"setdb": "cp data/db.json src/data/db.json",
|
||||
"clean": "rm -rf ../client/build/ && rm -rf ../client/node_modules && rm -rf src/node_modules && rm -rf ./node_modules && rm -rf ./dist",
|
||||
"prep": "yarn clean && yarn prep",
|
||||
"cheat": "rm src/node_modules/ontime-utils && cp -R utils src/node_modules/ontime-utils",
|
||||
"prep": "yarn clean && yarn setdb",
|
||||
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
|
||||
"start": "NODE_ENV=development electron .",
|
||||
"pack": "electron-builder --dir",
|
||||
@@ -36,6 +34,13 @@
|
||||
"dist-mac": "electron-builder --publish=never --x64 --mac",
|
||||
"dist-all": "electron-builder -mw"
|
||||
},
|
||||
"jest": {
|
||||
"testEnvironment": "node",
|
||||
"testRunner": "jasmine2",
|
||||
"testPathIgnorePatterns": [
|
||||
"dist"
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"productName": "ontime",
|
||||
"appId": "no.lightdev.ontime",
|
||||
@@ -67,7 +72,9 @@
|
||||
},
|
||||
"files": [
|
||||
"**/*",
|
||||
"assets/"
|
||||
"assets/",
|
||||
"!**/{yarn.lock,yarn-error.log}",
|
||||
"!**/{test,tests,__test__,__tests__,mock,mocks,__mock__,__mocks__}"
|
||||
],
|
||||
"directories": {
|
||||
"buildResources": "./assets/"
|
||||
@@ -77,14 +84,27 @@
|
||||
"from": "../client/build",
|
||||
"to": "extraResources/client/build",
|
||||
"filter": [
|
||||
"**/*"
|
||||
"**/*",
|
||||
"!**/{yarn.lock,yarn-error.log}",
|
||||
"!**/{test,tests,__test__,__tests__,mock,mocks,__mock__,__mocks__}"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "src",
|
||||
"to": "extraResources/src",
|
||||
"filter": [
|
||||
"**/*"
|
||||
"**/*",
|
||||
"!**/{yarn.lock,yarn-error.log}",
|
||||
"!**/{test,tests,__test__,__tests__,mock,mocks,__mock__,__mocks__}"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "utils",
|
||||
"to": "extraResources/utils",
|
||||
"filter": [
|
||||
"**/*",
|
||||
"!**/{yarn.lock,yarn-error.log}",
|
||||
"!**/{test,tests,__test__,__tests__,mock,mocks,__mock__,__mocks__}"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
+28
-33
@@ -1,6 +1,7 @@
|
||||
// get environment vars
|
||||
import 'dotenv/config';
|
||||
import { sessionId, user } from './utils/analytics.js';
|
||||
|
||||
user.screenview('Node service', 'ontime').send();
|
||||
user.event('NODE', 'started', 'starting node service').send();
|
||||
|
||||
@@ -12,6 +13,7 @@ import { Low, JSONFile } from 'lowdb';
|
||||
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
@@ -22,12 +24,11 @@ const adapter = new JSONFile(file);
|
||||
export const db = new Low(adapter);
|
||||
|
||||
// dependencies
|
||||
import { Client } from 'node-osc';
|
||||
import express from 'express';
|
||||
import http from 'http';
|
||||
import cors from 'cors';
|
||||
import { dbModelv1 as dbModel } from './models/dataModel.js';
|
||||
import { parseJsonv1 as parseJson, validateFile } from './utils/parser.js';
|
||||
import { parseJson_v1 as parseJson, validateFile } from './utils/parser.js';
|
||||
import ua from 'universal-analytics';
|
||||
|
||||
// validate JSON before attempting read
|
||||
@@ -48,8 +49,8 @@ if (db.data == null || !isValid) {
|
||||
|
||||
// get data
|
||||
// there is also the case of the db being corrupt
|
||||
// try to parse the data
|
||||
export const data = await parseJson(db.data);
|
||||
// try to parse the data, make sure that all fields exist (enforce)
|
||||
export const data = await parseJson(db.data, true);
|
||||
db.data = data;
|
||||
await db.write();
|
||||
|
||||
@@ -57,6 +58,7 @@ await db.write();
|
||||
import { router as eventsRouter } from './routes/eventsRouter.js';
|
||||
import { router as eventRouter } from './routes/eventRouter.js';
|
||||
import { router as ontimeRouter } from './routes/ontimeRouter.js';
|
||||
import { router as playbackRouter } from './routes/playbackRouter.js';
|
||||
|
||||
// Global Objects
|
||||
import { EventTimer } from './classes/EventTimer.js';
|
||||
@@ -81,23 +83,24 @@ app.use('/uploads', express.static('uploads'));
|
||||
app.use('/events', eventsRouter);
|
||||
app.use('/event', eventRouter);
|
||||
app.use('/ontime', ontimeRouter);
|
||||
app.use('/playback', playbackRouter);
|
||||
|
||||
// serve react
|
||||
app.use(
|
||||
express.static(
|
||||
path.join(__dirname, env == 'prod' ? '../' : '../../', 'client/build')
|
||||
)
|
||||
path.join(__dirname, env === 'prod' ? '../' : '../../', 'client/build'),
|
||||
),
|
||||
);
|
||||
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(
|
||||
path.resolve(
|
||||
__dirname,
|
||||
env == 'prod' ? '../' : '../../',
|
||||
env === 'prod' ? '../' : '../../',
|
||||
'client',
|
||||
'build',
|
||||
'index.html'
|
||||
)
|
||||
'index.html',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -111,17 +114,17 @@ app.use((err, req, res, next) => {
|
||||
* ----------------
|
||||
*
|
||||
* Configuration of services comes from app general config
|
||||
* It can be overriden here by the settings in the db
|
||||
* It can also be overriden on call
|
||||
* It can be overridden here by the settings in the db
|
||||
* It can also be overridden on call
|
||||
*
|
||||
*/
|
||||
|
||||
const s = data.settings;
|
||||
const oscIP = s.oscOutIP || config.osc.ipOut;
|
||||
const oscOutPort = s.oscOutPort || config.osc.portOut;
|
||||
const oscInPort = s.oscInPort || config.osc.port;
|
||||
const osc = data.osc;
|
||||
const oscIP = osc?.targetIP || config.osc.targetIP;
|
||||
const oscOutPort = osc?.portOut || config.osc.portOut;
|
||||
const oscInPort = osc?.port || config.osc.port;
|
||||
|
||||
const serverPort = s.serverPort || config.server.port;
|
||||
const serverPort = data.settings.serverPort || config.server.port;
|
||||
|
||||
// Start OSC server
|
||||
import { initiateOSC, shutdownOSCServer } from './controllers/OscController.js';
|
||||
@@ -138,17 +141,6 @@ export const startOSCServer = async (overrideConfig = null) => {
|
||||
initiateOSC(oscSettings);
|
||||
};
|
||||
|
||||
// Start OSC Client
|
||||
let oscClient = null;
|
||||
|
||||
export const startOSCClient = async (overrideConfig = null) => {
|
||||
// Setup default port
|
||||
const port = overrideConfig?.port || oscOutPort;
|
||||
console.log('initialise OSC Client on port: ', port);
|
||||
|
||||
oscClient = new Client(oscIP, oscOutPort);
|
||||
};
|
||||
|
||||
// create HTTP server
|
||||
const server = http.createServer(app);
|
||||
|
||||
@@ -163,11 +155,17 @@ export const startServer = async (overrideConfig = null) => {
|
||||
const port = 4001;
|
||||
|
||||
// Start server
|
||||
const returnMessage = `HTTP Server is listening on port ${port}`;
|
||||
const returnMessage = `Ontime is listening on port ${port}`;
|
||||
server.listen(port, '0.0.0.0', () => console.log(returnMessage));
|
||||
|
||||
// OSC Config
|
||||
const oscConfig = {
|
||||
ip: oscIP,
|
||||
port: overrideConfig?.port || oscOutPort,
|
||||
};
|
||||
|
||||
// init timer
|
||||
global.timer = new EventTimer(server, oscClient, config);
|
||||
global.timer = new EventTimer(server, config.timer, oscConfig, data.http);
|
||||
global.timer.setupWithEventList(data.events);
|
||||
|
||||
return returnMessage;
|
||||
@@ -176,7 +174,7 @@ export const startServer = async (overrideConfig = null) => {
|
||||
export const shutdown = async () => {
|
||||
console.log('Node service shutdown');
|
||||
|
||||
user.event('NODE', 'shutdown', 'requesting node shutfown').send();
|
||||
user.event('NODE', 'shutdown', 'requesting node shutdown').send();
|
||||
|
||||
// shutdown express server
|
||||
server.close();
|
||||
@@ -184,9 +182,6 @@ export const shutdown = async () => {
|
||||
// shutdown OSC Server
|
||||
shutdownOSCServer();
|
||||
|
||||
// shutdown OSC Client
|
||||
oscClient.close();
|
||||
|
||||
// shutdown timer
|
||||
global.timer.shutdown();
|
||||
};
|
||||
|
||||
+668
-371
File diff suppressed because it is too large
Load Diff
+32
-30
@@ -4,25 +4,14 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import { stringFromMillis } from '../utils/time.js';
|
||||
import { stringFromMillis } from 'ontime-utils/time.js';
|
||||
|
||||
export class Timer {
|
||||
clock = null;
|
||||
duration = null;
|
||||
current = null;
|
||||
timeTag = null;
|
||||
secondaryTimer = null;
|
||||
_secondaryTarget = null;
|
||||
_finishAt = null;
|
||||
_finishedAt = null;
|
||||
_finishedFlag = false;
|
||||
_startedAt = null;
|
||||
_pausedAt = null;
|
||||
_pausedInterval = null;
|
||||
_pausedTotal = null;
|
||||
state = 'stop';
|
||||
|
||||
constructor() {}
|
||||
constructor() {
|
||||
this.clock = null;
|
||||
this._resetTimers(true);
|
||||
this.state = 'stop';
|
||||
}
|
||||
|
||||
// call setup separately
|
||||
setupWithSeconds(seconds, autoStart = false) {
|
||||
@@ -52,6 +41,7 @@ export class Timer {
|
||||
// get current time
|
||||
const now = this._getCurrentTime();
|
||||
this.clock = now;
|
||||
let checkFinish = false;
|
||||
|
||||
// check playstate
|
||||
switch (this.state) {
|
||||
@@ -63,6 +53,8 @@ export class Timer {
|
||||
this.current =
|
||||
this._startedAt + this.duration + this._pausedTotal - now;
|
||||
|
||||
// enable flag
|
||||
checkFinish = true;
|
||||
break;
|
||||
case 'pause':
|
||||
// update paused time
|
||||
@@ -77,20 +69,32 @@ export class Timer {
|
||||
this._pausedInterval -
|
||||
now;
|
||||
}
|
||||
|
||||
// enable flag
|
||||
checkFinish = true;
|
||||
break;
|
||||
case 'stop':
|
||||
// nothing here yet
|
||||
break;
|
||||
default:
|
||||
console.error('Timer: no playstate on update call', this.state);
|
||||
break;
|
||||
}
|
||||
|
||||
if (checkFinish) {
|
||||
// is event finished?
|
||||
const isTimeOver = this.current <= 0;
|
||||
const isUpdating = this.state !== 'pause';
|
||||
|
||||
if (isTimeOver && isUpdating && this._finishedAt == null) {
|
||||
if (this._finishedAt === null) this._finishedAt = now;
|
||||
this._finishedFlag = true;
|
||||
}
|
||||
}
|
||||
this.timeTag = stringFromMillis(this.current);
|
||||
}
|
||||
|
||||
// helpers
|
||||
static toSeconds(millis) {
|
||||
if (millis == null) return null;
|
||||
return Math.floor(Math.max(millis * 0.001), 0);
|
||||
return Math.ceil(millis * 0.001);
|
||||
}
|
||||
|
||||
// get current time in epoc
|
||||
@@ -122,6 +126,7 @@ export class Timer {
|
||||
_resetTimers(total = false) {
|
||||
if (total) this.duration = null;
|
||||
this.current = this.duration;
|
||||
this.timeTag = null;
|
||||
this.running = null;
|
||||
this.secondaryTimer = null;
|
||||
this._secondaryTarget = null;
|
||||
@@ -139,14 +144,11 @@ export class Timer {
|
||||
return this.duration - this.current;
|
||||
}
|
||||
|
||||
// get time object
|
||||
getTimes(update = true) {
|
||||
// update timer
|
||||
if (update) this.update();
|
||||
|
||||
// update timetag
|
||||
this.timeTag = stringFromMillis(this.current);
|
||||
|
||||
/**
|
||||
* Builds time object
|
||||
* @returns {{running: number, secondary: number, expectedFinish: number, durationSeconds: number, startedAt: null, clock: null}}
|
||||
*/
|
||||
getTimeObject() {
|
||||
return {
|
||||
clock: this.clock,
|
||||
running: Timer.toSeconds(this.current),
|
||||
@@ -169,7 +171,7 @@ export class Timer {
|
||||
// do we need to change
|
||||
if (this.state === 'start') return;
|
||||
else if (this._startedAt == null) {
|
||||
// it hasnt started yet
|
||||
// it hasn't started yet
|
||||
const now = this._getCurrentTime();
|
||||
// set start time as now
|
||||
this._startedAt = now;
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
import { getSelectionByRoll, sortArrayByProperty } from '../classUtils.js';
|
||||
import {
|
||||
DAY_TO_MS,
|
||||
getSelectionByRoll,
|
||||
replacePlaceholder,
|
||||
normaliseEndTime,
|
||||
sortArrayByProperty,
|
||||
updateRoll
|
||||
} from '../classUtils.js';
|
||||
|
||||
// test sortArrayByProperty()
|
||||
describe('sort simple arrays of objects', () => {
|
||||
it('sort array 1-5', () => {
|
||||
const arr1 = [
|
||||
{ timeStart: 1 },
|
||||
{ timeStart: 5 },
|
||||
{ timeStart: 3 },
|
||||
{ timeStart: 2 },
|
||||
{ timeStart: 4 },
|
||||
{timeStart: 1},
|
||||
{timeStart: 5},
|
||||
{timeStart: 3},
|
||||
{timeStart: 2},
|
||||
{timeStart: 4},
|
||||
];
|
||||
|
||||
const arr1Expected = [
|
||||
{ timeStart: 1 },
|
||||
{ timeStart: 2 },
|
||||
{ timeStart: 3 },
|
||||
{ timeStart: 4 },
|
||||
{ timeStart: 5 },
|
||||
{timeStart: 1},
|
||||
{timeStart: 2},
|
||||
{timeStart: 3},
|
||||
{timeStart: 4},
|
||||
{timeStart: 5},
|
||||
];
|
||||
|
||||
const sorted = sortArrayByProperty(arr1, 'timeStart');
|
||||
@@ -25,21 +32,21 @@ describe('sort simple arrays of objects', () => {
|
||||
|
||||
it('sort array 1-5 with null', () => {
|
||||
const arr1 = [
|
||||
{ timeStart: 1 },
|
||||
{ timeStart: 5 },
|
||||
{ timeStart: 3 },
|
||||
{ timeStart: 2 },
|
||||
{ timeStart: 4 },
|
||||
{ timeStart: null },
|
||||
{timeStart: 1},
|
||||
{timeStart: 5},
|
||||
{timeStart: 3},
|
||||
{timeStart: 2},
|
||||
{timeStart: 4},
|
||||
{timeStart: null},
|
||||
];
|
||||
|
||||
const arr1Expected = [
|
||||
{ timeStart: null },
|
||||
{ timeStart: 1 },
|
||||
{ timeStart: 2 },
|
||||
{ timeStart: 3 },
|
||||
{ timeStart: 4 },
|
||||
{ timeStart: 5 },
|
||||
{timeStart: null},
|
||||
{timeStart: 1},
|
||||
{timeStart: 2},
|
||||
{timeStart: 3},
|
||||
{timeStart: 4},
|
||||
{timeStart: 5},
|
||||
];
|
||||
|
||||
const sorted = sortArrayByProperty(arr1, 'timeStart');
|
||||
@@ -382,3 +389,243 @@ describe('test that roll behaviour with overlapping times', () => {
|
||||
expect(state).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
// test replacePlaceholder()
|
||||
describe('test that it replaces data correctly', () => {
|
||||
const values = {
|
||||
$timer: "timer",
|
||||
$title: "title",
|
||||
$presenter: "presenter",
|
||||
$subtitle: "subtitle",
|
||||
"$next-title": "next title",
|
||||
"$next-presenter": "next presenter",
|
||||
"$next-subtitle": "next subtitle"
|
||||
};
|
||||
|
||||
it('replaces timer', () => {
|
||||
const test = '___1232132 $timer';
|
||||
const expected = '___1232132 timer';
|
||||
const s = replacePlaceholder(test, values)
|
||||
expect(s).toBe(expected);
|
||||
});
|
||||
|
||||
it('replaces title', () => {
|
||||
const test = '___1232132 $title';
|
||||
const expected = '___1232132 title';
|
||||
const s = replacePlaceholder(test, values)
|
||||
expect(s).toBe(expected);
|
||||
});
|
||||
|
||||
it('replaces presenter', () => {
|
||||
const test = '___1232132 $presenter';
|
||||
const expected = '___1232132 presenter';
|
||||
const s = replacePlaceholder(test, values)
|
||||
expect(s).toBe(expected);
|
||||
});
|
||||
|
||||
it('replaces subtitle', () => {
|
||||
const test = '___1232132 $subtitle';
|
||||
const expected = '___1232132 subtitle';
|
||||
const s = replacePlaceholder(test, values)
|
||||
expect(s).toBe(expected);
|
||||
});
|
||||
|
||||
it('replaces next next title', () => {
|
||||
const test = '___1232132 $next-title';
|
||||
const expected = '___1232132 next title';
|
||||
const s = replacePlaceholder(test, values)
|
||||
expect(s).toBe(expected);
|
||||
});
|
||||
|
||||
it('replaces next presenter', () => {
|
||||
const test = '___1232132 $next-presenter';
|
||||
const expected = '___1232132 next presenter';
|
||||
const s = replacePlaceholder(test, values)
|
||||
expect(s).toBe(expected);
|
||||
});
|
||||
|
||||
it('replaces next subtitle', () => {
|
||||
const test = '___1232132 $next-subtitle';
|
||||
const expected = '___1232132 next subtitle';
|
||||
const s = replacePlaceholder(test, values)
|
||||
expect(s).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// test getSelectionByRoll() on issue #58
|
||||
describe('test that roll behaviour multi day event edge cases', () => {
|
||||
|
||||
it('if the start time is the day after end time, and start time is earlier than now', () => {
|
||||
const now = 66600000; // 19:30
|
||||
const eventlist = [
|
||||
{
|
||||
id: 1,
|
||||
timeStart: 66000000, // 19:20
|
||||
timeEnd: 54600000, // 16:10
|
||||
isPublic: false,
|
||||
}
|
||||
];
|
||||
const expected = {
|
||||
nowIndex: 0,
|
||||
nowId: 1,
|
||||
publicIndex: null,
|
||||
nextIndex: null,
|
||||
publicNextIndex: null,
|
||||
timers: {
|
||||
_startedAt: eventlist[0].timeStart,
|
||||
_finishAt: eventlist[0].timeEnd,
|
||||
current: eventlist[0].timeEnd + DAY_TO_MS - now,
|
||||
duration: DAY_TO_MS - eventlist[0].timeStart + eventlist[0].timeEnd,
|
||||
},
|
||||
timeToNext: null,
|
||||
};
|
||||
|
||||
const state = getSelectionByRoll(eventlist, now);
|
||||
expect(state).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('if the start time is the day after end time, and both are later than now', () => {
|
||||
const now = 66840000; // 19:34
|
||||
const eventlist = [
|
||||
{
|
||||
id: 1,
|
||||
timeStart: 67200000, // 19:40
|
||||
timeEnd: 66900000, // 19:35
|
||||
isPublic: false,
|
||||
}
|
||||
];
|
||||
const expected = {
|
||||
nowIndex: null,
|
||||
nowId: null,
|
||||
publicIndex: null,
|
||||
nextIndex: 0,
|
||||
publicNextIndex: null,
|
||||
timers: null,
|
||||
timeToNext: eventlist[0].timeStart - now,
|
||||
};
|
||||
|
||||
const state = getSelectionByRoll(eventlist, now);
|
||||
expect(state).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
// test normaliseEndTime() on issue #58
|
||||
test('test typical scenarios', () => {
|
||||
|
||||
const t1 = {
|
||||
start: 10,
|
||||
end: 20,
|
||||
}
|
||||
const t1_expected = 20;
|
||||
|
||||
expect(normaliseEndTime(t1.start, t1.end)).toBe(t1_expected);
|
||||
|
||||
const t2 = {
|
||||
start: 10 + DAY_TO_MS,
|
||||
end: 20,
|
||||
}
|
||||
const t2_expected = 20 + DAY_TO_MS;
|
||||
|
||||
expect(normaliseEndTime(t2.start, t2.end)).toBe(t2_expected);
|
||||
|
||||
const t3 = {
|
||||
start: 10,
|
||||
end: 10,
|
||||
}
|
||||
const t3_expected = 10;
|
||||
|
||||
expect(normaliseEndTime(t3.start, t3.end)).toBe(t3_expected);
|
||||
});
|
||||
|
||||
|
||||
// test updateRoll()
|
||||
describe('typical scenarios', () => {
|
||||
|
||||
it('it updates running events correctly', () => {
|
||||
const timers = {
|
||||
selectedEventId: 1,
|
||||
current: 10,
|
||||
_finishAt: 15,
|
||||
clock: 11,
|
||||
secondaryTimer: null,
|
||||
_secondaryTarget: null,
|
||||
};
|
||||
|
||||
const expected = {
|
||||
updatedTimer: timers._finishAt - timers.clock,
|
||||
updatedSecondaryTimer: null,
|
||||
doRollLoad: false,
|
||||
isFinished: false,
|
||||
};
|
||||
|
||||
expect(updateRoll(timers)).toStrictEqual(expected);
|
||||
|
||||
// test that it can jump time
|
||||
timers._finishAt = 1000;
|
||||
timers.clock = 600;
|
||||
expected.updatedTimer = timers._finishAt - timers.clock;
|
||||
|
||||
expect(updateRoll(timers)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('it updates secondary timer', () => {
|
||||
const timers = {
|
||||
selectedEventId: null,
|
||||
current: null,
|
||||
_finishAt: null,
|
||||
clock: 11,
|
||||
secondaryTimer: 1,
|
||||
_secondaryTarget: 15,
|
||||
};
|
||||
|
||||
const expected = {
|
||||
updatedTimer: null,
|
||||
updatedSecondaryTimer: timers._secondaryTarget - timers.clock,
|
||||
doRollLoad: false,
|
||||
isFinished: false,
|
||||
};
|
||||
|
||||
expect(updateRoll(timers)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('flags an event end', () => {
|
||||
const timers = {
|
||||
selectedEventId: 1,
|
||||
current: 10,
|
||||
_finishAt: 11,
|
||||
clock: 12,
|
||||
secondaryTimer: null,
|
||||
_secondaryTarget: null,
|
||||
};
|
||||
|
||||
const expected = {
|
||||
updatedTimer: timers._finishAt - timers.clock,
|
||||
updatedSecondaryTimer: null,
|
||||
doRollLoad: true,
|
||||
isFinished: true,
|
||||
};
|
||||
|
||||
expect(updateRoll(timers)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('secondary events do not trigger event ends', () => {
|
||||
const timers = {
|
||||
selectedEventId: null,
|
||||
current: null,
|
||||
_finishAt: null,
|
||||
clock: 16,
|
||||
secondaryTimer: 1,
|
||||
_secondaryTarget: 15,
|
||||
};
|
||||
|
||||
const expected = {
|
||||
updatedTimer: null,
|
||||
updatedSecondaryTimer: timers._secondaryTarget - timers.clock,
|
||||
doRollLoad: true,
|
||||
isFinished: false,
|
||||
};
|
||||
|
||||
expect(updateRoll(timers)).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { EventTimer } from '../EventTimer';
|
||||
import http from 'http';
|
||||
import express from 'express';
|
||||
|
||||
// Create server
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
|
||||
// necessary config
|
||||
const timerConfig = { refresh: 1000 };
|
||||
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test('object instantiates correctly', async () => {
|
||||
const t = new EventTimer(server, timerConfig);
|
||||
|
||||
// it contains everything from Timer
|
||||
expect(t.clock).toBeNull();
|
||||
expect(t.duration).toBeNull();
|
||||
expect(t.current).toBeNull();
|
||||
expect(t.timeTag).toBeNull();
|
||||
expect(t.secondaryTimer).toBeNull();
|
||||
expect(t._secondaryTarget).toBeNull();
|
||||
expect(t._finishAt).toBeNull();
|
||||
expect(t._finishedAt).toBeNull();
|
||||
expect(t._finishedFlag).toBeFalsy();
|
||||
expect(t._startedAt).toBeNull();
|
||||
expect(t._pausedAt).toBeNull();
|
||||
expect(t._pausedInterval).toBeNull();
|
||||
expect(t._pausedTotal).toBeNull();
|
||||
expect(t.state).toBe('stop');
|
||||
|
||||
// and its own properties
|
||||
expect(t.ontimeCycle).toBe('idle');
|
||||
expect(t.prevCycle).toBeNull();
|
||||
expect(t.io).not.toBeNull();
|
||||
expect(t.osc).toBeNull();
|
||||
expect(t.http).toBeNull();
|
||||
expect(t._numClients).toBe(0);
|
||||
expect(t._interval).not.toBeNull();
|
||||
expect(t.presenter).toStrictEqual({ text: '', visible: false });
|
||||
expect(t.public).toStrictEqual({ text: '', visible: false });
|
||||
expect(t.lower).toStrictEqual({ text: '', visible: false });
|
||||
expect(t.lower).toStrictEqual({ text: '', visible: false });
|
||||
|
||||
const expectTitlesPublic = {
|
||||
titleNow: null,
|
||||
subtitleNow: null,
|
||||
presenterNow: null,
|
||||
titleNext: null,
|
||||
subtitleNext: null,
|
||||
presenterNext: null,
|
||||
};
|
||||
|
||||
const expectTitles = {
|
||||
...expectTitlesPublic,
|
||||
noteNow: null,
|
||||
noteNext: null,
|
||||
};
|
||||
|
||||
expect(t.titlesPublic).toStrictEqual(expectTitlesPublic);
|
||||
expect(t.titles).toStrictEqual(expectTitles);
|
||||
|
||||
expect(t.selectedEventIndex).toBeNull();
|
||||
expect(t.selectedEventId).toBeNull();
|
||||
expect(t.nextEventId).toBeNull();
|
||||
expect(t.selectedPublicEventId).toBeNull();
|
||||
expect(t.nextPublicEventId).toBeNull();
|
||||
expect(t.numEvents).toBe(0);
|
||||
expect(t._eventlist).toBeNull();
|
||||
expect(t.onAir).toBeFalsy();
|
||||
|
||||
t.shutdown();
|
||||
});
|
||||
|
||||
describe('test triggers behaviour', () => {
|
||||
const t = new EventTimer(server, timerConfig);
|
||||
|
||||
test('ignores bad commands', async(done) => {
|
||||
const success = t.trigger('test');
|
||||
expect(success).toBeFalsy();
|
||||
done();
|
||||
});
|
||||
|
||||
test('does not allow triggering events with an empty list', async(done) => {
|
||||
expect(t.numEvents).toBe(0);
|
||||
|
||||
expect(t.trigger('start')).toBeFalsy();
|
||||
expect(t.trigger('pause')).toBeFalsy();
|
||||
expect(t.trigger('stop')).toBeFalsy();
|
||||
expect(t.trigger('roll')).toBeFalsy();
|
||||
expect(t.trigger('previous')).toBeFalsy();
|
||||
expect(t.trigger('next')).toBeFalsy();
|
||||
expect(t.trigger('reload')).toBeFalsy();
|
||||
|
||||
expect(t.onAir).toBeFalsy();
|
||||
expect(t.trigger('onAir')).toBeTruthy();
|
||||
expect(t.onAir).toBeTruthy();
|
||||
expect(t.trigger('offAir')).toBeTruthy();
|
||||
expect(t.onAir).toBeFalsy();
|
||||
done();
|
||||
});
|
||||
|
||||
test('...and is consistent by calling the class methods', async (done) => {
|
||||
expect(t.numEvents).toBe(0);
|
||||
expect(t.state).toBe('stop');
|
||||
|
||||
t.start();
|
||||
expect(t.state).toBe('stop');
|
||||
|
||||
t.pause();
|
||||
expect(t.state).toBe('stop');
|
||||
|
||||
t.stop();
|
||||
expect(t.state).toBe('stop');
|
||||
|
||||
t.roll();
|
||||
expect(t.state).toBe('stop');
|
||||
|
||||
t.previous();
|
||||
expect(t.state).toBe('stop');
|
||||
|
||||
t.next();
|
||||
expect(t.state).toBe('stop');
|
||||
|
||||
t.reload();
|
||||
expect(t.state).toBe('stop');
|
||||
done();
|
||||
});
|
||||
|
||||
t.shutdown();
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import {Timer} from "../Timer";
|
||||
|
||||
test('object instantiates correctly', () => {
|
||||
const t = new Timer();
|
||||
|
||||
expect(t.clock).toBeNull;
|
||||
expect(t.duration).toBeNull;
|
||||
expect(t.current).toBeNull;
|
||||
expect(t.timeTag).toBeNull;
|
||||
expect(t.secondaryTimer).toBeNull;
|
||||
expect(t._secondaryTarget).toBeNull;
|
||||
expect(t._finishAt).toBeNull;
|
||||
expect(t._finishedAt).toBeNull;
|
||||
expect(t._finishedFlag).toBeFalsy;
|
||||
expect(t._startedAt).toBeNull;
|
||||
expect(t._pausedAt).toBeNull;
|
||||
expect(t._pausedInterval).toBeNull;
|
||||
expect(t._pausedTotal).toBeNull;
|
||||
expect(t.state).toBe('stop');
|
||||
});
|
||||
|
||||
test('convert between mills and seconds correctly', () => {
|
||||
expect(Timer.toSeconds(10000)).toBe(10);
|
||||
expect(Timer.toSeconds(9016)).toBe(10);
|
||||
expect(Timer.toSeconds(8016)).toBe(9);
|
||||
expect(Timer.toSeconds(7010)).toBe(8);
|
||||
expect(Timer.toSeconds(6006)).toBe(7);
|
||||
expect(Timer.toSeconds(4999)).toBe(5);
|
||||
expect(Timer.toSeconds(2995)).toBe(3);
|
||||
expect(Timer.toSeconds(1991)).toBe(2);
|
||||
expect(Timer.toSeconds(992)).toBe(1);
|
||||
expect(Timer.toSeconds(127)).toBe(1);
|
||||
expect(Timer.toSeconds(0)).toBe(0);
|
||||
expect(Timer.toSeconds(-0)).toBe(-0);
|
||||
expect(Timer.toSeconds(-127)).toBe(-0);
|
||||
expect(Timer.toSeconds(-992)).toBe(-0);
|
||||
expect(Timer.toSeconds(-1991)).toBe(-1);
|
||||
expect(Timer.toSeconds(-2995)).toBe(-2);
|
||||
expect(Timer.toSeconds(-4999)).toBe(-4);
|
||||
expect(Timer.toSeconds(-6006)).toBe(-6);
|
||||
expect(Timer.toSeconds(-7010)).toBe(-7);
|
||||
expect(Timer.toSeconds(-8016)).toBe(-8);
|
||||
expect(Timer.toSeconds(-10000)).toBe(-10);
|
||||
});
|
||||
@@ -1,3 +1,18 @@
|
||||
/**
|
||||
* Utility variable: 24 hour in milliseconds .
|
||||
* @type {number}
|
||||
*/
|
||||
export const DAY_TO_MS = 86400000;
|
||||
|
||||
/**
|
||||
* @description handle events that span over midnight
|
||||
* @param {number} start - When does the event start
|
||||
* @param {number} end - When does the event end
|
||||
* @returns {number} normalised time
|
||||
*/
|
||||
export const normaliseEndTime = (start, end) =>
|
||||
end < start ? end + DAY_TO_MS : end;
|
||||
|
||||
/**
|
||||
* @description Sorts an array of objects by given property
|
||||
* @param {array} arr - array to be sorted
|
||||
@@ -11,6 +26,20 @@ export const sortArrayByProperty = (arr, property) => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Replaces placeholder variables in string with given data
|
||||
* @param {string} str - string to analyse
|
||||
* @param {object} values - map of variables: values to use
|
||||
* @returns {string} finished string
|
||||
*/
|
||||
|
||||
export const replacePlaceholder = (str, values) => {
|
||||
for (let [k, v] of Object.entries(values)) {
|
||||
str = str.replace(k, v);
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Used in roll mode, returns selection variables from array
|
||||
* @param {array} arr - event list
|
||||
@@ -41,8 +70,12 @@ export const getSelectionByRoll = (arr, now) => {
|
||||
let nowFound = false;
|
||||
|
||||
// exit early if we are past the events
|
||||
const lastEventEnd = orderedEvents[orderedEvents.length - 1].timeEnd;
|
||||
if (now > lastEventEnd) {
|
||||
const lastEvent = orderedEvents[orderedEvents.length - 1];
|
||||
const lastNormalEnd = normaliseEndTime(
|
||||
lastEvent.timeStart,
|
||||
lastEvent.timeEnd
|
||||
);
|
||||
if (now > lastNormalEnd) {
|
||||
return {
|
||||
nowIndex,
|
||||
nowId,
|
||||
@@ -57,8 +90,7 @@ export const getSelectionByRoll = (arr, now) => {
|
||||
// loop through events, look for where we should be
|
||||
for (const e of orderedEvents) {
|
||||
// When does the event end (handle midnight)
|
||||
const normalEnd =
|
||||
e.timeEnd < e.timeStart ? (e.timeEnd += this.DAYMS) : e.timeEnd;
|
||||
const normalEnd = normaliseEndTime(e.timeStart, e.timeEnd);
|
||||
|
||||
if (normalEnd <= now) {
|
||||
// event ran already
|
||||
@@ -83,7 +115,7 @@ export const getSelectionByRoll = (arr, now) => {
|
||||
// set timers
|
||||
timers = {
|
||||
_startedAt: e.timeStart,
|
||||
_finishAt: normalEnd,
|
||||
_finishAt: e.timeEnd,
|
||||
duration: normalEnd - e.timeStart,
|
||||
current: normalEnd - now,
|
||||
};
|
||||
@@ -98,7 +130,7 @@ export const getSelectionByRoll = (arr, now) => {
|
||||
// check how far the start is from now
|
||||
const wait = e.timeStart - now;
|
||||
|
||||
if (nextIndex === null || wait < timeToNext) {
|
||||
if (nextIndex == null || wait < timeToNext) {
|
||||
timeToNext = wait;
|
||||
nextIndex = arr.findIndex((a) => a.id === e.id);
|
||||
}
|
||||
@@ -118,3 +150,63 @@ export const getSelectionByRoll = (arr, now) => {
|
||||
timeToNext,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Implements update functions for roll mode
|
||||
* @param {object} currentTimers
|
||||
* @param {object} currentTimers.selectedEventId - Id of currently selected event
|
||||
* @param {object} currentTimers.current - Running timer
|
||||
* @param {object} currentTimers._finishAt - Expected finish time
|
||||
* @param {object} currentTimers.clock - time now
|
||||
* @param {object} currentTimers.secondaryTimer - secondary timer
|
||||
* @param {object} currentTimers._secondaryTarget - finish time of secondary timer
|
||||
* @returns {object} object with selection variables
|
||||
*/
|
||||
export const updateRoll = (currentTimers) => {
|
||||
const {
|
||||
selectedEventId,
|
||||
current,
|
||||
_finishAt,
|
||||
clock,
|
||||
secondaryTimer,
|
||||
_secondaryTarget,
|
||||
} = currentTimers;
|
||||
|
||||
// timers
|
||||
let updatedTimer = current;
|
||||
let updatedSecondaryTimer = secondaryTimer;
|
||||
// whether rollLoad should be called
|
||||
let doRollLoad = false;
|
||||
// whether runCycle should be called
|
||||
let isFinished = false;
|
||||
|
||||
if (selectedEventId && current >= 0) {
|
||||
// if we have something selected and a timer, we are running
|
||||
// this is true because roll never goes into negative times
|
||||
|
||||
// update timer
|
||||
updatedTimer = _finishAt - clock;
|
||||
if (updatedTimer < 0) {
|
||||
isFinished = true;
|
||||
}
|
||||
} else if (secondaryTimer >= 0) {
|
||||
// if secondaryTimer is running we are in waiting to roll
|
||||
|
||||
// update secondary
|
||||
updatedSecondaryTimer = _secondaryTarget - clock;
|
||||
}
|
||||
|
||||
// if nothing is running, we need to find out if
|
||||
// a) we just finished an event (finished was set to true)
|
||||
// b) we need to look for events
|
||||
// this could be caused by a secondary timer or event finished
|
||||
const secondaryRunning =
|
||||
updatedSecondaryTimer <= 0 && updatedSecondaryTimer != null;
|
||||
|
||||
if (isFinished || secondaryRunning) {
|
||||
// look for events
|
||||
doRollLoad = true;
|
||||
}
|
||||
|
||||
return { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/** Class contains logic towards outgoing HTTP communications. */
|
||||
import * as http from 'http';
|
||||
|
||||
export class HTTPIntegration {
|
||||
|
||||
constructor() {
|
||||
// nothing to do here
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @description Initializes oscClient
|
||||
* @param {object} httpConfig - Http configurations options
|
||||
*/
|
||||
init(httpConfig) {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Sends http get request from predefined messages
|
||||
* @param {string} path - complete http path
|
||||
*/
|
||||
async send(path) {
|
||||
if (path == null) {
|
||||
console.log('HTTP ERROR: Message undefined');
|
||||
return;
|
||||
}
|
||||
|
||||
const options = new URL(path);
|
||||
let str = '';
|
||||
|
||||
const req = http.request(options, res => {
|
||||
console.log(`statusCode: ${res.statusCode}`)
|
||||
|
||||
res.on('data', function (chunk) {
|
||||
str += chunk;
|
||||
});
|
||||
|
||||
res.on('end', function () {
|
||||
console.log(str);
|
||||
});
|
||||
})
|
||||
|
||||
req.on('error', error => {
|
||||
console.error(error)
|
||||
})
|
||||
|
||||
req.end()
|
||||
}
|
||||
|
||||
shutdown() { /* Nothing to shutdown */ }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user