Compare commits

..

23 Commits

Author SHA1 Message Date
Carlos Valente 79a24a134e Hotfix/6.0.2 (#94)
* hotfix/6.0.2 fix issue where events could not have an ID
* hotfix/6.0.2 version bump
* hotfix/6.0.2 testing
2022-01-14 22:11:33 +01:00
Carlos Valente acb5d8ae08 chore/update-deps update socket.io (#93) 2022-01-14 21:00:51 +01:00
cv 5ca8202368 version bump 2022-01-14 20:49:24 +01:00
Carlos Valente ba782d5d22 hotfix/92-osc timer is absolute (#92)
Fixes issues with approximating timers in the app views
2022-01-14 20:47:39 +01:00
Carlos Valente 48093b8651 Ux/54 help (#88)
* ux/54-help tooltips
* ux/54-help refetch after mutation
* ux/54-help broadcast event index
* ux/54-help prevent multiple keypresses when key held
* ux/54-help fat clock
* ux/54-help progress bar user defined
* ux/54-help onAir is button
* ux/54-help OSC: add missing presenter message
* ux/54-help OSC: enable / disable OSC
* ux/54-help handle timezone in excel date import
* ux/54-help tray left click to show app
* ux/54-help create queriable endpoint
* ux/54-help fix memoisation in lower thirds
* ux/54-help revise icons
* ux/54-help clarify text
* ux/54-help forgiving text parsing
* ux/54-help add smart keywords
* ux/54-help version bump
2022-01-12 22:41:12 +01:00
Carlos Valente c3f18feaae Fix/82 import (#83)
* fix/82-import fix import extension filter
* fix/82-import version bump
2022-01-05 22:15:21 +01:00
Carlos Valente 2fa397548a Feat/navigate (#81)
* feat/navigate upgrade react router
* feat/navigate migrate to react router 6
* feat/navigate style modal
* feat/navigate create endpoints for app settings
* feat/navigate protect editor with pin
* feat/navigate apply dynamic routing draft
* feat/navigate upgrade relevant packages
* feat/navigate restructure directory and add tests
* feat/navigate test aliases validation
* feat/navigate validate aliases before sending
* feat/navigate create data endpoint
* feat/navigate config: prettier
* feat/navigate invalidate empty strings
* feat/navigate create endpoints
* feat/navigate parse on import
* feat/navigate navigate to alias
* feat/navigate user help and sample data
* feat/navigate refact aliases modal
* feat/navigate refact settings style
* feat/navigate update sample db
* feat/navigate link is relative to hostname
* feat/navigate navigate to first match
* feat/navigate update readme and version bump
* feat/navigate config: create shared module
* feat/navigate config: cheat module install
* feat/navigate fix tests
* feat/navigate run tests in pull request
* Update ontime_cy.yml
2022-01-04 22:12:59 +01:00
xztraz dd43d5e20a added studio clock (#80) 2022-01-03 22:24:24 +01:00
Carlos Valente 9fc154955a Feat/62 logger (#79)
* feat/62-logger request log data in app
* feat/62-logger refact osc integration
* feat/62-logger feedback on osc
* feat/62-logger refact broadcast on triggers
* feat/62-logger log triggers
* feat/62-logger add link to studio
* feat/62-logger replace toasts with logger context
* feat/62-logger style improvements
* feat/62-logger refactor code duplications
* feat/62-logger cleanup and version bump
2021-12-25 19:39:40 +01:00
Carlos Valente 160ccabebc Config/eslint (#78)
* config/eslint: upgrade eslint config
* config/eslint: version bump
2021-12-22 22:22:21 +01:00
Carlos Valente 4974898050 version bump 2021-12-22 18:19:35 +01:00
Carlos Valente ac0d5832b6 event cucle (#76)
* install sass
* refact: integration settings
- OSC in its own HTTP endpoint
- OSC settings have own object in db
* refact: simplify event cycle
* refact: restructure external triggers http
* refact: restructure external triggers osc+socket
* refact: restructure data updates
* feat: osc integration class
* IO improvements
- timer uses osc integration
- create trigger handler to manage external triggers
* refact: refract state machine update
* Integration: simple HTTP Client
* Integration: http options in datamodel
* Integration: call http send on life cycle
* feat/62-logging: fix issue #71
2021-12-22 18:17:48 +01:00
Carlos Valente 2b2bafa8c6 add mock object (#75) 2021-12-19 16:36:11 +01:00
Carlos Valente fad34eeab2 add mock object (#74) 2021-12-19 14:30:14 +01:00
Carlos Valente c12bca05aa Chore/tests (#73)
* chore/tests: install testing libraries
* chore/tests: upgrade dependencies
* chore/tests: configure linters
* chore/tests: add basic component tests
* chore/tests: version bump
2021-12-19 14:22:02 +01:00
Carlos Valente 1e4206fcb2 Refract/updatecritical (#69) 2021-12-18 14:57:07 +01:00
Carlos Valente 2751ed3d33 fix/58-countdown
correct issues on countdown clock (#68)
2021-12-16 22:13:41 +01:00
Carlos Valente cd911aaaa2 Fix/58 roll issues (#67)
* fix/58-roll-issues: handle edge cases
2021-12-15 22:31:34 +01:00
cv 1321aa555e Revert "hotfix: add missing font"
This reverts commit 28c9cb0864.
2021-12-15 20:48:43 +01:00
cv 12e4eaef67 Merge branch 'master' of https://github.com/cpvalente/ontime 2021-12-15 20:47:07 +01:00
cv 28c9cb0864 hotfix: add missing font 2021-12-15 20:47:05 +01:00
Carlos Valente afdfae9073 Hotfix/4.0.3 (#66)
* hotfix 4.0.3: style tweaks
* hotfix 4.0.3: replace font
2021-12-15 20:43:21 +01:00
Carlos Valente 0736ce93c6 Hotfix/4.0.3 (#65)
* hotfix 4.0.3: style tweaks
* hotfix 4.0.3: replace font
2021-12-14 23:19:38 +01:00
177 changed files with 15799 additions and 10714 deletions
+34
View File
@@ -0,0 +1,34 @@
{
"parserOptions": {
"ecmaVersion": 2020
},
"env": {
"es6": true,
"jest": true
},
"extends": [
"eslint:recommended"
],
"rules": {
// disallow certain object properties
// https://eslint.org/docs/rules/no-restricted-properties
"no-restricted-properties": [
"error",
{
"object": "global",
"property": "isNaN",
"message": "Please use Number.isNaN instead"
},
{
"object": "self",
"property": "isNaN",
"message": "Please use Number.isNaN instead"
},
{
"object": "window",
"property": "isNaN",
"message": "Please use Number.isNaN instead"
}
]
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 248 KiB

+70
View File
@@ -0,0 +1,70 @@
# 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 && yarn make && yarn setdb
working-directory: ./server
- name: Electron - Run tests
run: yarn test
working-directory: ./server
- name: Cypress run
uses: cypress-io/github-action@v2
with:
working-directory: ./server
start: yarn cypress
+9 -4
View File
@@ -7,6 +7,7 @@ node_modules/
# testing
coverage/
*.mp4
# production
build/
@@ -23,13 +24,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/*
+5
View File
@@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
+37
View File
@@ -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>
+8
View File
@@ -0,0 +1,8 @@
{
"trailingComma": "es5",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true,
"printWidth": 100
}
+11 -2
View File
@@ -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
+13
View File
@@ -0,0 +1,13 @@
{
"extends": [
"react-app",
"react-app/jest"
],
"plugins": ["react", "testing-library", "jest"],
"rules": {
"jest/no-mocks-import": "warn",
"no-useless-concat": "warn",
"prefer-template": "warn"
}
}
+14 -16
View File
@@ -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,9 @@
]
},
"devDependencies": {
"@testing-library/react-hooks": "^7.0.2",
"prop-types": "^15.8.1",
"react-test-renderer": "^17.0.2",
"sass": "^1.44.0"
}
}
+68 -44
View File
@@ -1,18 +1,22 @@
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 TimerView = lazy(() =>
import('features/viewers/timer/Timer')
);
const PresenterSimple = lazy(() =>
import('features/viewers/presenter/PresenterSimple')
const MinimalTimerView = lazy(() =>
import('features/viewers/timer/MinimalTimer')
);
const StageManager = lazy(() =>
import('features/viewers/backstage/StageManager')
);
@@ -21,18 +25,10 @@ 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 STimer = withSocket(TimerView);
const SMinimalTimer = withSocket(MinimalTimerView);
const SStageManager = withSocket(StageManager);
const SPublic = withSocket(Public);
const SLowerThird = withSocket(Lower);
@@ -40,8 +36,14 @@ 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) => {
// handle held key
if (e.repeat) return;
// check if the alt key is pressed
if (e.altKey) {
if (e.key === 't' || e.key === 'T') {
@@ -65,33 +67,55 @@ 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={<STimer />} />
<Route path='/speaker' element={<STimer />} />
<Route path='/presenter' element={<STimer />} />
<Route path='/stage' element={<STimer />} />
<Route path='/timer' element={<STimer />} />
<Route path='/minimal' element={<SMinimalTimer />} />
<Route path='/minimalTimer' element={<SMinimalTimer />} />
<Route path='/simpleTimer' element={<SMinimalTimer />} />
<Route path='/sm' element={<SStageManager />} />
<Route path='/backstage' element={<SStageManager />} />
<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={<STimer />} />
</Routes>
</Suspense>
</ErrorBoundary>
</div>
);
}
-8
View File
@@ -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();
});
+9
View File
@@ -0,0 +1,9 @@
import {QueryClient} from "react-query";
export const queryClientMock = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
+3
View File
@@ -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}/`);
+8 -15
View File
@@ -1,5 +1,5 @@
import axios from 'axios';
import { eventsURL } from '../api/apiConstants';
import { eventsURL } from './apiConstants';
export const fetchAllEvents = async () => {
const res = await axios.get(eventsURL);
@@ -7,38 +7,31 @@ export const fetchAllEvents = async () => {
};
export const requestPost = async (data) => {
const res = await axios.post(eventsURL, data);
return res;
return await axios.post(eventsURL, data);
};
export const requestPut = async (data) => {
const res = await axios.put(eventsURL, data);
return res;
return await axios.put(eventsURL, data);
};
export const requestPatch = async (data) => {
const res = await axios.patch(eventsURL, data);
return res;
return await axios.patch(eventsURL, data);
};
export const requestReorder = async (data) => {
const action = 'reorder';
const res = await axios.patch(eventsURL + '/' + action, data);
return res;
return await axios.patch(eventsURL + '/' + action, data);
};
export const requestApplyDelay = async (eventId) => {
const action = 'applydelay';
const res = await axios.patch(eventsURL + '/' + action + '/' + eventId);
return res;
return await axios.patch(eventsURL + '/' + action + '/' + eventId);
};
export const requestDelete = async (eventId) => {
const res = await axios.delete(eventsURL + '/' + eventId);
return res;
return await axios.delete(eventsURL + '/' + eventId);
};
export const requestDeleteAll = async () => {
const res = await axios.delete(eventsURL + '/all');
return res;
return await axios.delete(eventsURL + '/all');
};
+114 -14
View File
@@ -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: false,
};
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 timer',
},
{
name: '$subtitle',
description: 'Current subtitle',
},
{
name: '$next-title',
description: 'Next title',
},
{
name: '$next-presenter',
description: 'Next timer',
},
{
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 });
};
+14
View File
@@ -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' }
];
+37
View File
@@ -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>
);
};
+96
View File
@@ -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>
)
}
+13
View File
@@ -0,0 +1,13 @@
/**
* @description Validates two time entries
* @param {number} timeStart
* @param {number} timeEnd
* @returns {{catch: string, value: boolean}}
*/
export const validateTimes = (timeStart, timeEnd) => {
let validate = { value: true, catch: '' };
if (timeStart > timeEnd) {
validate.catch = 'Start time later than end time';
}
return validate;
};
+1 -1
View File
@@ -2,7 +2,7 @@ const dummy = new Date();
export const sampleData = {
presenterMessage: {
text: 'Only the presenter sees this',
text: 'Only the timer sees this',
active: false,
},
publicMessage: {
-7
View File
@@ -1,7 +0,0 @@
export const userConfig = {
timerColorOnPause: '#555',
timerColorOnRunning: '#FFF',
timerColorOnMessage: '#CCC',
timerColorOnTimeOver: '#F00',
overTimeText: '',
}
-2
View File
@@ -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)
));
});
+29
View File
@@ -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;
};
+9
View File
@@ -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.
@@ -1,6 +1,7 @@
import { IconButton } from '@chakra-ui/button';
import { useState } from 'react';
import { FiMinus } from 'react-icons/fi';
import { IoRemove } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function DeleteIconBtn(props) {
const { actionHandler, ...rest } = props;
@@ -12,15 +13,17 @@ export default function DeleteIconBtn(props) {
};
return (
<IconButton
size={props.size || 'xs'}
icon={<FiMinus />}
colorScheme='red'
onClick={handleClick}
_focus={{ boxShadow: 'none' }}
disabled={loading}
isLoading={loading}
{...rest}
/>
<Tooltip label='Delete'>
<IconButton
size={props.size || 'xs'}
icon={<IoRemove />}
colorScheme='red'
onClick={handleClick}
_focus={{ boxShadow: 'none' }}
disabled={loading}
isLoading={loading}
{...rest}
/>
</Tooltip>
);
}
@@ -0,0 +1,18 @@
import { IoCloseSharp, IoCheckmarkSharp } from 'react-icons/io5';
import { Button } from '@chakra-ui/button';
export default function EnableBtn(props) {
const { active, text, actionHandler } = props;
return (
<Button
size={props.size || 'xs'}
leftIcon={active ? <IoCheckmarkSharp /> : <IoCloseSharp />}
colorScheme='blue'
variant={active ? 'solid' : 'outline'}
onClick={actionHandler}
_focus={{ boxShadow: 'none' }}
>
{text}
</Button>
);
}
@@ -1,18 +1,21 @@
import { IconButton } from '@chakra-ui/button';
import { FiSkipForward } from 'react-icons/fi';
import { IoPlaySkipForward } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function NextIconBtn(props) {
const { clickhandler, ...rest } = props;
return (
<IconButton
icon={<FiSkipForward />}
colorScheme='whiteAlpha'
backgroundColor='#ffffff11'
variant='outline'
onClick={clickhandler}
width={90}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
<Tooltip label='Next event' openDelay={500} shouldWrapChildren={props.disabled}>
<IconButton
icon={<IoPlaySkipForward size='22px' />}
colorScheme='whiteAlpha'
backgroundColor='#ffffff11'
variant='outline'
onClick={clickhandler}
width={90}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
</Tooltip>
);
}
@@ -0,0 +1,22 @@
import { IconButton } from '@chakra-ui/button';
import { IoMicSharp, IoMicOffOutline } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function OnAirIconBtn(props) {
const { actionHandler, active, ...rest } = props;
return (
<Tooltip label={active ? 'Go Off Air' : 'Go On Air'} openDelay={500}>
<IconButton
size={props.size || 'xs'}
icon={active ? <IoMicSharp size='24px' /> : <IoMicOffOutline size='24px' />}
colorScheme='blue'
variant={active ? 'solid' : 'outline'}
onClick={() =>
actionHandler('update', { field: 'isPublic', value: !active })
}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
</Tooltip>
);
}
@@ -1,17 +1,20 @@
import { IconButton } from '@chakra-ui/button';
import { FiPause } from 'react-icons/fi';
import { IoPause } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function PauseIconBtn(props) {
const { clickhandler, active, ...rest } = props;
return (
<IconButton
icon={<FiPause />}
colorScheme='orange'
variant={active ? 'solid' : 'outline'}
onClick={clickhandler}
width={120}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
<Tooltip label='Pause timer' openDelay={500} shouldWrapChildren={props.disabled}>
<IconButton
icon={<IoPause size='24px' />}
colorScheme='orange'
variant={active ? 'solid' : 'outline'}
onClick={clickhandler}
width={120}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
</Tooltip>
);
}
@@ -1,18 +1,21 @@
import { IconButton } from '@chakra-ui/button';
import { FiSkipBack } from 'react-icons/fi';
import { IoPlaySkipBack } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function PrevIconBtn(props) {
const { clickhandler, ...rest } = props;
return (
<IconButton
icon={<FiSkipBack />}
colorScheme='whiteAlpha'
backgroundColor='#ffffff11'
variant='outline'
onClick={clickhandler}
width={90}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
<Tooltip label='Previous event' openDelay={500} shouldWrapChildren={props.disabled}>
<IconButton
icon={<IoPlaySkipBack size='22px' />}
colorScheme='whiteAlpha'
backgroundColor='#ffffff11'
variant='outline'
onClick={clickhandler}
width={90}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
</Tooltip>
);
}
@@ -1,18 +1,21 @@
import { IconButton } from '@chakra-ui/button';
import { FiRefreshCcw } from 'react-icons/fi';
import { IoReload } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function ReloadIconButton(props) {
const { clickhandler, ...rest } = props;
return (
<IconButton
icon={<FiRefreshCcw />}
colorScheme='whiteAlpha'
backgroundColor='#ffffff05'
variant='outline'
onClick={clickhandler}
width={90}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
<Tooltip label='Reload event' openDelay={500} shouldWrapChildren={props.disabled}>
<IconButton
icon={<IoReload size='22px' />}
colorScheme='whiteAlpha'
backgroundColor='#ffffff05'
variant='outline'
onClick={clickhandler}
width={90}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
</Tooltip>
);
}
@@ -1,17 +1,20 @@
import { IconButton } from '@chakra-ui/button';
import { FiClock } from 'react-icons/fi';
import { IoTimeOutline } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function RollIconBtn(props) {
const { clickhandler, active, ...rest } = props;
return (
<IconButton
icon={<FiClock />}
colorScheme='blue'
variant={active ? 'solid' : 'outline'}
onClick={clickhandler}
width={120}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
<Tooltip label='Roll mode' openDelay={500} shouldWrapChildren={props.disabled}>
<IconButton
icon={<IoTimeOutline size='24px' />}
colorScheme='blue'
variant={active ? 'solid' : 'outline'}
onClick={clickhandler}
width={120}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
</Tooltip>
);
}
@@ -1,17 +1,20 @@
import { IconButton } from '@chakra-ui/button';
import { FiPlay } from 'react-icons/fi';
import { IoPlay } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function StartIconBtn(props) {
const { clickhandler, active, ...rest } = props;
return (
<IconButton
icon={<FiPlay />}
colorScheme='green'
variant={active ? 'solid' : 'outline'}
onClick={clickhandler}
width={120}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
<Tooltip label='Start timer' openDelay={500} shouldWrapChildren={props.disabled}>
<IconButton
icon={<IoPlay size='24px' />}
colorScheme='green'
variant={active ? 'solid' : 'outline'}
onClick={clickhandler}
width={120}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
</Tooltip>
);
}
@@ -1,18 +1,20 @@
import { IconButton } from '@chakra-ui/button';
import { FiXOctagon } from 'react-icons/fi';
import { IoStop } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function UnloadIconBtn(props) {
const { clickhandler, ...rest } = props;
return (
<IconButton
icon={<FiXOctagon />}
colorScheme='red'
backgroundColor='#ff000022'
variant='outline'
onClick={clickhandler}
width={90}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
<Tooltip label='Unload event' openDelay={500} shouldWrapChildren={props.disabled}>
<IconButton
icon={<IoStop size='22px' />}
colorScheme='red'
variant='outline'
onClick={clickhandler}
width={90}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
</Tooltip>
);
}
@@ -1,19 +1,22 @@
import { IconButton } from '@chakra-ui/button';
import { FiSun } from 'react-icons/fi';
import { IoSunny } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/tooltip';
export default function VisibleIconBtn(props) {
const { actionHandler, active, ...rest } = props;
return (
<IconButton
size={props.size || 'xs'}
icon={<FiSun />}
colorScheme='blue'
variant={active ? 'solid' : 'outline'}
onClick={() =>
actionHandler('update', { field: 'isPublic', value: !active })
}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
<Tooltip label={active ? 'Make invisible' : 'Make visible'} openDelay={500}>
<IconButton
size={props.size || 'xs'}
icon={<IoSunny size={'18px'}/>}
colorScheme='blue'
variant={active ? 'solid' : 'outline'}
onClick={() =>
actionHandler('update', { field: 'isPublic', value: !active })
}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
</Tooltip>
);
}
@@ -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,15 +1,16 @@
import { memo } from 'react';
import { formatDisplay } from 'common/utils/dateConfig';
import PropTypes from 'prop-types';
import styles from './Countdown.module.css';
const Countdown = ({ time, small, negative, hideZeroHours }) => {
const Countdown = ({ time, small, isNegative, hideZeroHours }) => {
// prepare display string
const display =
time != null && !isNaN(time)
? formatDisplay(time, hideZeroHours)
: '-- : -- : --';
const colour = negative ? '#ff7597' : '#fffffa';
const colour = isNegative ? '#ff7597' : '#fffffa';
return (
<div
@@ -22,3 +23,10 @@ const Countdown = ({ time, small, negative, hideZeroHours }) => {
};
export default memo(Countdown);
Countdown.propTypes = {
time: PropTypes.number.isRequired,
small: PropTypes.bool,
isNegative: PropTypes.bool,
hideZeroHour: PropTypes.bool,
};
@@ -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 };
@@ -16,16 +19,11 @@ class ErrorBoundary extends React.Component {
error: error,
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,24 +1,33 @@
import EditableTimer from 'common/input/EditableTimer';
import { showWarningToast } from 'common/helpers/toastManager';
import { useContext } from 'react';
import { LoggingContext } from '../../../app/context/LoggingContext';
import { validateTimes } from '../../../app/entryValidator';
import PropTypes from 'prop-types';
export default function EventTimes(props) {
const { actionHandler, delay, timeStart, timeEnd } = props;
const { actionHandler, delay, timeStart, timeEnd, previousEnd } = props;
const { emitWarning } = useContext(LoggingContext);
const handleValidate = (entry, v) => {
// we dont inforce validation here
if (v == null || timeStart == null || timeEnd == null) return true;
const handleValidate = (entry, val) => {
if (val == null || timeStart == null || timeEnd == null) return true;
if (timeStart === 0) return true;
let validate = { value: true, catch: '' };
if (entry === 'timeStart' && v > timeEnd)
validate.catch = 'Start time later than end time';
else if (entry === 'timeEnd' && v < timeStart)
validate.catch = 'End time earlier than start time';
let start = timeStart;
let end = timeEnd;
if (entry === 'timeStart') {
start = val;
} else if (entry === 'timeEnd') {
end = val;
} else {
return;
}
if (validate.catch !== '')
showWarningToast('Time Input Warning', validate.catch);
return validate.value;
const valid = validateTimes(start, end);
// give warning but not enforce validation
if (!valid.value) {
emitWarning(`Time Input Warning: ${valid.catch}`);
}
return valid.value;
};
return (
@@ -29,6 +38,7 @@ export default function EventTimes(props) {
actionHandler={actionHandler}
time={timeStart}
delay={delay}
previousEnd={previousEnd}
/>
<EditableTimer
name='timeEnd'
@@ -36,7 +46,16 @@ export default function EventTimes(props) {
actionHandler={actionHandler}
time={timeEnd}
delay={delay}
previousEnd={previousEnd}
/>
</>
);
}
EventTimes.propTypes = {
actionHandler: PropTypes.func.isRequired,
delay: PropTypes.number.isRequired,
timeStart: PropTypes.number.isRequired,
timeEnd: PropTypes.number.isRequired,
previousEnd: PropTypes.number.isRequired,
};
@@ -1,6 +1,9 @@
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';
import { validateTimes } from '../../../app/entryValidator';
import PropTypes from 'prop-types';
const label = {
fontSize: '0.75em',
@@ -8,8 +11,7 @@ const label = {
};
const TimesDelayed = (props) => {
const { handleValidate, actionHandler, delay, timeStart, timeEnd, duration } =
props;
const { handleValidate, actionHandler, delay, timeStart, timeEnd, duration, previousEnd } = props;
const scheduledStart = stringFromMillis(timeStart, false);
const scheduledEnd = stringFromMillis(timeEnd, false);
@@ -25,6 +27,7 @@ const TimesDelayed = (props) => {
actionHandler={actionHandler}
time={timeStart}
delay={delay}
previousEnd={previousEnd}
/>
<span style={label}>
End <span>{scheduledEnd}</span>
@@ -35,6 +38,7 @@ const TimesDelayed = (props) => {
actionHandler={actionHandler}
time={timeEnd}
delay={delay}
previousEnd={previousEnd}
/>
<span style={label}>Duration</span>
<EditableTimer
@@ -43,13 +47,24 @@ const TimesDelayed = (props) => {
actionHandler={actionHandler}
time={duration}
delay={0}
previousEnd={previousEnd}
/>
</>
);
};
TimesDelayed.propTypes = {
handleValidate: PropTypes.func.isRequired,
actionHandler: PropTypes.func.isRequired,
delay: PropTypes.number.isRequired,
timeStart: PropTypes.number.isRequired,
timeEnd: PropTypes.number.isRequired,
duration: PropTypes.number.isRequired,
previousEnd: PropTypes.number.isRequired,
};
const Times = (props) => {
const { handleValidate, actionHandler, timeStart, timeEnd, duration } = props;
const { handleValidate, actionHandler, timeStart, timeEnd, duration, previousEnd } = props;
return (
<>
@@ -60,6 +75,7 @@ const Times = (props) => {
actionHandler={actionHandler}
time={timeStart}
delay={0}
previousEnd={previousEnd}
/>
<span style={label}>End</span>
<EditableTimer
@@ -68,6 +84,7 @@ const Times = (props) => {
actionHandler={actionHandler}
time={timeEnd}
delay={0}
previousEnd={previousEnd}
/>
<span style={label}>Duration</span>
<EditableTimer
@@ -76,31 +93,48 @@ const Times = (props) => {
actionHandler={actionHandler}
time={duration}
delay={0}
previousEnd={previousEnd}
/>
</>
);
};
export default function EventTimesVertical(props) {
const { delay, timeStart, timeEnd, duration } = props;
const handleValidate = (entry, v) => {
// we dont enforce validation here
Times.propTypes = {
handleValidate: PropTypes.func.isRequired,
actionHandler: PropTypes.func.isRequired,
timeStart: PropTypes.number.isRequired,
timeEnd: PropTypes.number.isRequired,
duration: PropTypes.number.isRequired,
previousEnd: PropTypes.number.isRequired,
};
if (v == null || timeStart == null || timeEnd == null) return true;
export default function EventTimesVertical(props) {
const { delay, timeStart, timeEnd, duration, previousEnd } = props;
const { emitWarning } = useContext(LoggingContext);
const handleValidate = (entry, val) => {
if (val == null || timeStart == null || timeEnd == null) return true;
if (timeStart === 0) return true;
let validate = { value: true, catch: '' };
if (entry === 'timeStart' && v > timeEnd)
validate.catch = 'Start time later than end time';
else if (entry === 'timeEnd' && v < timeStart)
validate.catch = 'End time earlier than start time';
let start = timeStart;
let end = timeEnd;
if (entry === 'timeStart') {
start = val;
} else if (entry === 'timeEnd') {
end = val;
} else {
return;
}
if (validate.catch !== '')
showWarningToast('Time Input Warning', validate.catch);
return validate.value;
const valid = validateTimes(start, end);
// give warning but not enforce validation
if (!valid.value) {
emitWarning(`Time Input Warning: ${valid.catch}`);
}
return valid.value;
};
return (delay != null) & (delay > 0) ? (
return delay != null && delay > 0 ? (
<TimesDelayed
handleValidate={handleValidate}
actionHandler={props.actionHandler}
@@ -108,6 +142,7 @@ export default function EventTimesVertical(props) {
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
previousEnd={previousEnd}
/>
) : (
<Times
@@ -116,6 +151,15 @@ export default function EventTimesVertical(props) {
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
previousEnd={previousEnd}
/>
);
}
EventTimesVertical.propTypes = {
delay: PropTypes.number.isRequired,
timeStart: PropTypes.number.isRequired,
timeEnd: PropTypes.number.isRequired,
duration: PropTypes.number.isRequired,
previousEnd: PropTypes.number.isRequired,
};
@@ -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) {
+17 -14
View File
@@ -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';
@@ -16,6 +16,8 @@ export default function NavLogo(props) {
// Handle keyboard shortcuts
const handleKeyPress = useCallback((e) => {
// handle held key
if (e.repeat) return;
// Space bar
if (e.keyCode === 32) {
setShowNav((s) => !s);
@@ -54,50 +56,51 @@ export default function NavLogo(props) {
className={showNav ? style.nav : style.navHidden}
>
<Link
to='/presenter'
to='/timer'
className={style.navItem}
tabIndex={1}
onKeyDownCapture={() => <Redirect push to='/presenter' />}
>
Presenter
Timer
</Link>
<Link
to='/minimal'
className={style.navItem}
tabIndex={2}
>
Minimal Timer
</Link>
<Link
to='/sm'
className={style.navItem}
tabIndex={2}
onKeyDownCapture={() => <Redirect push to='/sm' />}
tabIndex={3}
>
Backstage
</Link>
<Link
to='/public'
className={style.navItem}
tabIndex={3}
onKeyDownCapture={() => <Redirect push to='/public' />}
tabIndex={4}
>
Public
</Link>
<Link
to='/lower'
className={style.navItem}
tabIndex={4}
onKeyDownCapture={() => <Redirect push to='/lower' />}
tabIndex={5}
>
Lower Thirds
</Link>
<Link
to='/pip'
className={style.navItem}
tabIndex={4}
onKeyDownCapture={() => <Redirect push to='/pip' />}
tabIndex={6}
>
PIP
</Link>
<Link
to='/studio'
className={style.navItem}
tabIndex={5}
onKeyDownCapture={() => <Redirect push to='/studio' />}
tabIndex={7}
>
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,10 +0,0 @@
import styles from './SmallTimer.module.css';
export default function SmallTimer({ label, time }) {
return (
<div className={styles.SmallTimer}>
<div className={styles.label}>{label}</div>
<div className={styles.timer}>{time}</div>
</div>
);
}
@@ -1,23 +0,0 @@
.smallTimer {
display: flex;
flex-direction: column;
}
.label,
.timer {
font-size: 1vw;
text-align: center;
}
.label {
font-size: 1.5vw;
color: #888;
margin-bottom: 0;
}
.timer {
font-family: 'Open Sans', sans-serif;
font-size: 4vw;
letter-spacing: 0.125em;
}
@@ -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,
});
};
-22
View File
@@ -1,22 +0,0 @@
import { FormErrorMessage } from '@chakra-ui/form-control';
import { FormLabel } from '@chakra-ui/form-control';
import { FormControl } from '@chakra-ui/form-control';
import { Input } from '@chakra-ui/input';
import { Field } from 'formik';
export default function ChakraInput(props) {
const { label, name, ...rest } = props;
return (
<Field name={name}>
{({ field, form }) => {
return (
<FormControl isInvalid={form.errors[name] && form.touched[name]}>
<FormLabel htmlFor={name}>{label}</FormLabel>
<Input id={name} {...rest} {...field} />
<FormErrorMessage>{form.errors[name]}</FormErrorMessage>
</FormControl>
);
}}
</Field>
);
}
+36 -15
View File
@@ -1,15 +1,14 @@
import { Editable, EditableInput, EditablePreview } from '@chakra-ui/editable';
import { useEffect, useState } from 'react';
import {
isTimeString,
stringFromMillis,
timeStringToMillis,
} from '../utils/dateConfig';
import { showErrorToast } from '../helpers/toastManager';
import { useContext, useEffect, useState } from 'react';
import { forgivingStringToMillis } from '../utils/dateConfig';
import { stringFromMillis } from 'ontime-utils/time';
import style from './EditableTimer.module.css';
import { LoggingContext } from '../../app/context/LoggingContext';
import PropTypes from 'prop-types';
export default function EditableTimer(props) {
const { name, actionHandler, time, delay, validate } = props;
const { name, actionHandler, time, delay, validate, previousEnd } = props;
const { emitError } = useContext(LoggingContext);
const [value, setValue] = useState('');
// prepare time fields
@@ -18,9 +17,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);
@@ -32,13 +31,26 @@ export default function EditableTimer(props) {
// Check if there is anything there
if (value === '') return false;
// check if its valid time string
if (!isTimeString(value)) return false;
let newValMillis;
// convert entered value to milliseconds
const newValMillis = timeStringToMillis(value);
// check for known aliases
if (value === 'p' || value === 'prev' || value === 'previous') {
// string to pass should be the time of the end before
if (previousEnd != null) {
newValMillis = previousEnd;
} else {
newValMillis = 0;
}
} else if (value.startsWith('+')) {
// string to pass should add to the end before
const val = value.substring(1);
newValMillis = previousEnd + forgivingStringToMillis(val);
} else {
// convert entered value to milliseconds
newValMillis = forgivingStringToMillis(value);
}
// Time now and time submitedVal
// Time now and time submittedVal
const originalMillis = time + delay;
// check if time is different from before
@@ -66,3 +78,12 @@ export default function EditableTimer(props) {
</Editable>
);
}
EditableTimer.propTypes = {
name: PropTypes.string.isRequired,
actionHandler: PropTypes.func.isRequired,
time: PropTypes.number.isRequired,
delay: PropTypes.number.isRequired,
validate: PropTypes.func.isRequired,
previousEnd: PropTypes.number.isRequired,
};
@@ -1,9 +1,12 @@
import {
formatDisplay,
isTimeString,
millisToMinutes,
millisToSeconds,
forgivingStringToMillis,
timeStringToMillis,
} from '../dateConfig';
import { stringFromMillis } from 'ontime-utils/time';
describe('test string from formatDisplay function', () => {
it('test with null values', () => {
@@ -47,6 +50,13 @@ describe('test string from formatDisplay function', () => {
});
});
describe('test formatDisplay handles partial secs', () => {
it('test with 1795829', () => {
const t = { val: 1795829, result: '00:29:55' };
expect(stringFromMillis(t.val)).toBe(t.result);
});
});
describe('test string from formatDisplay function with hidezero', () => {
it('test with null values', () => {
const t = { val: null, result: '00:00' };
@@ -92,22 +102,22 @@ describe('test string from formatDisplay function with hidezero', () => {
describe('test millisToSeconds function', () => {
it('test with null values', () => {
const t = { val: null, result: 0 };
expect(millisToSeconds(t.val, false)).toBe(t.result);
expect(millisToSeconds(t.val)).toBe(t.result);
});
it('test with valid millis', () => {
const t = { val: 3600000, result: 3600 };
expect(millisToSeconds(t.val, false)).toBe(t.result);
expect(millisToSeconds(t.val)).toBe(t.result);
});
it('test with negative millis', () => {
const t = { val: -3600000, result: -3600 };
expect(millisToSeconds(t.val, false)).toBe(t.result);
expect(millisToSeconds(t.val)).toBe(t.result);
});
it('test with 0', () => {
const t = { val: 0, result: 0 };
expect(millisToSeconds(t.val, false)).toBe(t.result);
expect(millisToSeconds(t.val)).toBe(t.result);
});
it('test with -0', () => {
@@ -244,3 +254,59 @@ describe('test timeStringToMillis function', () => {
expect(timeStringToMillis(t.val)).toBe(t.result);
});
});
describe('test isTimeString() function', () => {
test('it validates time strings', () => {
const ts = ['2', '2:10', '2:10:22'];
for (const s of ts) {
expect(isTimeString(s)).toBe(true);
}
});
test('it fails overloaded times', () => {
const ts = ['70', '89:10', '26:10:22'];
for (const s of ts) {
expect(isTimeString(s)).toBe(false);
}
});
});
describe('test isTimeString() function handle different separators', () => {
const ts = ['2:10', '2,10', '2.10'];
for (const s of ts) {
test(`it handles ${s}`, () => {
expect(isTimeString(s)).toBe(true);
});
}
});
describe('test timeHelper() function handles separators', () => {
const ts = ['1:2:3:10', '2,10', '2.10'];
for (const s of ts) {
test(`it handles ${s}`, () => {
expect(typeof forgivingStringToMillis(s)).toBe('number');
});
}
});
describe('test timeHelper() parses strings correctly', () => {
const ts = [
{ value: '', expect: 0 },
{ value: '0', expect: 0 },
{ value: '-0', expect: 0 },
{ value: '1', expect: 60 * 1000 },
{ value: '-1', expect: 60 * 1000 },
{ value: '1.2', expect: 60 * 1000 + 2 * 1000 },
{ value: '1.70', expect: 60 * 1000 + 70 * 1000 },
{ value: '1.1.1', expect: 60 * 60 * 1000 + 60 * 1000 + 1000 },
{ value: '12.1.1', expect: 12 * 60 * 60 * 1000 + 60 * 1000 + 1000 },
{ value: '12.55.1', expect: 12 * 60 * 60 * 1000 + 55 * 60 * 1000 + 1000 },
{ value: '12.55.40', expect: 12 * 60 * 60 * 1000 + 55 * 60 * 1000 + 40 * 1000 },
];
for (const s of ts) {
test(`it handles ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
+40 -45
View File
@@ -4,37 +4,6 @@ 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)
@@ -43,8 +12,7 @@ export const stringFromMillis = (
* @param {boolean} [hideZero] - whether to show hours in case its 00
* @returns {string} String representing absolute time 00:12:02
*/
export function formatDisplay(seconds, hideZero=false) {
export function formatDisplay(seconds, hideZero = false) {
// add an extra 0 if necessary
const format = (val) => `0${Math.floor(val)}`.slice(-2);
@@ -61,8 +29,6 @@ export function formatDisplay(seconds, hideZero=false) {
* @param {number} millis - time in seconds
* @returns {number} Amount in seconds
*/
// millis to seconds
export const millisToSeconds = (millis) => {
return millis < 0 ? Math.ceil(millis / mts) : Math.floor(millis / mts);
};
@@ -72,26 +38,21 @@ export const millisToSeconds = (millis) => {
* @param {number} millis - time in seconds
* @returns {number} Amount in seconds
*/
// millis to minutes
export const millisToMinutes = (millis) => {
return millis < 0 ? Math.ceil(millis / mtm) : Math.floor(millis / mtm);
};
/**
* @description Converts timestring to milliseconds
* @description Converts timestring to milliseconds
* @param {string} string - time string "23:00:12"
* @returns {number} Amount in milliseconds
*/
// timeStringToMillis
export const timeStringToMillis = (string) => {
if (typeof string !== 'string') return 0;
const time = string.split(':');
if (time.length === 1) return Math.abs(time[0] * mts);
if (time.length === 2) return Math.abs(time[0]) * mtm + time[1] * mts;
if (time.length === 3)
return Math.abs(time[0]) * mth + time[1] * mtm + time[2] * mts;
if (time.length === 3) return Math.abs(time[0]) * mth + time[1] * mtm + time[2] * mts;
else return 0;
};
@@ -100,8 +61,6 @@ export const timeStringToMillis = (string) => {
* @param {string} string - time string "23:00:12"
* @returns {boolean} string represents time
*/
// isTimeString
export const isTimeString = (string) => {
// ^ # Start of string
// (?: # Try to match...
@@ -113,6 +72,42 @@ export const isTimeString = (string) => {
// ([0-5]?\d) # SS (required)
// $ # End of string
const regex = /^(?:(?:([01]?\d|2[0-3]):)?([0-5]?\d):)?([0-5]?\d)$/;
const regex = /^(?:(?:([01]?\d|2[0-3])[:,.])?([0-5]?\d)[:,.])?([0-5]?\d)$/;
return regex.test(string);
};
const parse = (valueAsString) => {
const parsed = parseInt(valueAsString, 10);
if (isNaN(parsed)) {
return 0;
}
return Math.abs(parsed);
};
/**
* @description Parses a time string to millis
* @param string - time string
* @returns {number} - time string in millis
*/
export const forgivingStringToMillis = (string) => {
let millis = 0;
// split string at known separators : , .
const separatorRegex = /[\s,:.]+/;
const [first, second, third] = string.split(separatorRegex);
if (first != null && second != null && third != null) {
// if string has three sections, treat as [hours] [minutes] [seconds]
millis = parse(first) * mth;
millis += parse(second) * mtm;
millis += parse(third) * mts;
} else if (third == null) {
// if string has two sections, treat as [minutes] [seconds]
millis = parse(first) * mtm;
millis += parse(second) * mts;
} else if (second == null) {
// if string has one section, treat as [minutes]
millis = parse(first) * mtm;
}
return millis;
};
+2 -2
View File
@@ -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 [];
+13
View File
@@ -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);
}
}
+21 -21
View File
@@ -1,8 +1,8 @@
import {Editable, EditableInput, EditablePreview} from '@chakra-ui/editable';
import {Switch} from "@chakra-ui/react";
import {useEffect, useState} from 'react';
import {useSocket} from 'app/context/socketContext';
import { Editable, EditableInput, EditablePreview } from '@chakra-ui/editable';
import { useEffect, useState } from 'react';
import { useSocket } from 'app/context/socketContext';
import VisibleIconBtn from 'common/components/buttons/VisibleIconBtn';
import OnAirIconBtn from '../../common/components/buttons/OnAirIconBtn';
import style from './MessageControl.module.scss';
const inputProps = {
@@ -10,7 +10,7 @@ const inputProps = {
};
const InputRow = (props) => {
const {label, placeholder, text, visible} = props;
const { label, placeholder, text, visible } = props;
return (
<>
@@ -23,8 +23,8 @@ const InputRow = (props) => {
className={style.inline}
color={text === '' ? '#666' : 'inherit'}
>
<EditablePreview className={style.padleft}/>
<EditableInput className={style.padleft}/>
<EditablePreview className={style.padleft} />
<EditableInput className={style.padleft} />
</Editable>
<VisibleIconBtn
active={visible || undefined}
@@ -55,19 +55,19 @@ export default function MessageControl() {
useEffect(() => {
if (socket == null) return;
// Handle presenter messages
socket.on('messages-presenter', (data) => {
setPres({...data});
// Handle timer messages
socket.on('messages-timer', (data) => {
setPres({ ...data });
});
// Handle public messages
socket.on('messages-public', (data) => {
setPubl({...data});
setPubl({ ...data });
});
// Handle lower third messages
socket.on('messages-lower', (data) => {
setLower({...data});
setLower({ ...data });
});
// Handle lower third messages
@@ -83,7 +83,7 @@ export default function MessageControl() {
// Clear listeners
return () => {
socket.off('messages-public');
socket.off('messages-presenter');
socket.off('messages-timer');
socket.off('messages-lower');
socket.off('onAir');
};
@@ -92,10 +92,10 @@ export default function MessageControl() {
const messageControl = async (action, payload) => {
switch (action) {
case 'pres-text':
socket.emit('set-presenter-text', payload);
socket.emit('set-timer-text', payload);
break;
case 'toggle-pres-visible':
socket.emit('set-presenter-visible', !pres.visible);
socket.emit('set-timer-visible', !pres.visible);
break;
case 'publ-text':
socket.emit('set-public-text', payload);
@@ -146,13 +146,13 @@ export default function MessageControl() {
/>
</div>
<div className={style.onAirToggle}>
<Switch
colorScheme='green'
<OnAirIconBtn
className={style.btn}
active={onAir}
size='md'
isChecked={onAir}
onChange={() => messageControl('toggle-onAir')}>
On Air?
</Switch>
actionHandler={() => messageControl('toggle-onAir')}
/>
<span className={style.onAirLabel}>On Air</span>
<span className={style.oscLabel}>
{`/ontime/offAir << OSC >> /ontime/onAir`}
</span>
@@ -1,12 +1,12 @@
@use '../../styles/main' as *;
@use '../../styles/mixins' as *;
.messageContainer,
.onAirToggle {
background-color: rgba(0, 0, 0, 0.05);
border: 1px solid rgba(0, 0, 0, 0.05);
border-radius: 4px;
@include main-container;
display: flex;
gap: 0.5em;
padding: 0.5em;
}
.messageContainer {
@@ -17,17 +17,20 @@
grid-template-columns: 1fr auto;
gap: 1em;
}
.label {
padding: 0;
margin: 0;
font-size: 0.9em;
color: #ccc;
}
.inline {
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.padleft {
padding-left: 0.5em;
}
@@ -35,20 +38,25 @@
.onAirToggle {
margin-top: 1em;
display: flex;
gap: 1em;
align-items: center;
line-height: 3em;
display: grid;
grid-template-areas:
'btn label'
'btn osc';
grid-template-columns: 2.5em 1fr;
grid-template-rows: 1.2em 0.8em;
.btn {
grid-area: btn;
}
.onAirLabel {
grid-area: label;
font-size: 1.2em;
}
.oscLabel {
color: #4bffabcc;
font-size: 0.8em;
float: right;
padding-right: 1em;
-webkit-user-select: text;
user-select: text;
@include osc-label;
grid-area: osc;
}
}
+34 -12
View File
@@ -1,4 +1,5 @@
import { memo } from 'react';
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';
@@ -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,
};
@@ -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>
+70 -36
View File
@@ -1,34 +1,37 @@
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.isNegative === nextProps.timer.isNegative &&
prevProps.timer.expectedFinish === nextProps.timer.expectedFinish &&
prevProps.timer.startedAt === nextProps.timer.startedAt &&
prevProps.playback === nextProps.playback &&
prevProps.timer.secondary === nextProps.timer.secondary
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,18 +39,18 @@ 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}
className={timer.isNegative ? style.indNegativeActive : style.indNegative}
/>
<div className={style.indDelay}/>
<div className={style.indDelay} />
</div>
<div className={style.timer}>
<Countdown
time={isWaiting ? timer.secondary : timer.running}
isNegative={timer.isNegative}
small
negative={isNegative}
/>
</div>
{isWaiting ? (
@@ -68,34 +71,58 @@ const PlaybackTimer = (props) => {
</>
)}
<div className={style.btn}>
<Button
{...incrementProps}
disabled={isRolling}
onClick={() => handleIncrement(-1)}
<Tooltip
label={'Remove 1 minute'}
delay={500}
shouldWrapChildren={disableButtons}
>
-1
</Button>
<Button
{...incrementProps}
disabled={isRolling}
onClick={() => handleIncrement(1)}
<Button
{...incrementProps}
disabled={disableButtons}
onClick={() => handleIncrement(-1)}
>
-1
</Button>
</Tooltip>
<Tooltip
label={'Add 1 minute'}
delay={500}
shouldWrapChildren={disableButtons}
>
+1
</Button>
<Button
{...incrementProps}
disabled={isRolling}
onClick={() => handleIncrement(-5)}
<Button
{...incrementProps}
disabled={disableButtons}
onClick={() => handleIncrement(1)}
>
+1
</Button>
</Tooltip>
<Tooltip
label={'Remove 5 minutes'}
delay={500}
shouldWrapChildren={disableButtons}
>
-5
</Button>
<Button
{...incrementProps}
disabled={isRolling}
onClick={() => handleIncrement(5)}
<Button
{...incrementProps}
disabled={disableButtons}
onClick={() => handleIncrement(-5)}
>
-5
</Button>
</Tooltip>
<Tooltip
label={'Add 5 minutes'}
delay={500}
shouldWrapChildren={disableButtons}
>
+5
</Button>
<Button
{...incrementProps}
disabled={disableButtons}
onClick={() => handleIncrement(5)}
>
+5
</Button>
</Tooltip>
</div>
</div>
</>
@@ -103,3 +130,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,8 +1,9 @@
import { Draggable } from 'react-beautiful-dnd';
import { FiMoreVertical } from 'react-icons/fi';
import DeleteIconBtn from 'common/components/buttons/DeleteIconBtn';
import ActionButtons from './ActionButtons';
import ActionButtons from '../list/ActionButtons';
import style from './BlockBlock.module.css';
import PropTypes from 'prop-types';
export default function BlockBlock(props) {
const { index, data, actionHandler } = props;
@@ -27,3 +28,10 @@ export default function BlockBlock(props) {
</Draggable>
);
}
BlockBlock.propTypes = {
index: PropTypes.number.isRequired,
data: PropTypes.object.isRequired,
actionHandler: PropTypes.func.isRequired,
};
@@ -1,11 +1,12 @@
import { Draggable } from 'react-beautiful-dnd';
import { FiMoreVertical } from 'react-icons/fi';
import { millisToMinutes } from 'common/utils/dateConfig';
import ActionButtons from './ActionButtons';
import ActionButtons from '../list/ActionButtons';
import DeleteIconBtn from 'common/components/buttons/DeleteIconBtn';
import ApplyIconBtn from 'common/components/buttons/ApplyIconBtn';
import DelayInput from 'common/input/DelayInput';
import style from './DelayBlock.module.css';
import PropTypes from 'prop-types';
export default function DelayBlock(props) {
const { eventsHandler, data, index, actionHandler } = props;
@@ -14,25 +15,15 @@ export default function DelayBlock(props) {
eventsHandler('applyDelay', { id: data.id, duration: data.duration });
};
let delayValue =
data.duration != null ? millisToMinutes(data.duration) : undefined;
let delayValue = data.duration != null ? millisToMinutes(data.duration) : undefined;
return (
<Draggable key={data.id} draggableId={data.id} index={index}>
{(provided) => (
<div
className={style.delay}
{...provided.draggableProps}
ref={provided.innerRef}
>
<div className={style.delay} {...provided.draggableProps} ref={provided.innerRef}>
<span className={style.drag} {...provided.dragHandleProps}>
<FiMoreVertical />
</span>
<DelayInput
className={style.input}
value={delayValue}
actionHandler={actionHandler}
/>
<DelayInput className={style.input} value={delayValue} actionHandler={actionHandler} />
<div className={style.actionOverlay}>
<ApplyIconBtn clickhandler={applyDelayHandler} />
<DeleteIconBtn actionHandler={actionHandler} />
@@ -43,3 +34,10 @@ export default function DelayBlock(props) {
</Draggable>
);
}
DelayBlock.propTypes = {
eventsHandler: PropTypes.func.isRequired,
data: PropTypes.object.isRequired,
index: PropTypes.number.isRequired,
actionHandler: PropTypes.func.isRequired,
};
+8 -5
View File
@@ -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>
);
}
@@ -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 {
@@ -5,19 +5,19 @@ import { Draggable } from 'react-beautiful-dnd';
import EventTimes from 'common/components/eventTimes/EventTimes';
import EventTimesVertical from 'common/components/eventTimes/EventTimesVertical';
import EditableText from 'common/input/EditableText';
import ActionButtons from './ActionButtons';
import ActionButtons from '../list/ActionButtons';
import PublicIconBtn from 'common/components/buttons/PublicIconBtn';
import DeleteIconBtn from 'common/components/buttons/DeleteIconBtn';
import { millisToMinutes } from 'common/utils/dateConfig';
import style from './EventBlock.module.css';
import { SelectCollapse, HandleCollapse } from 'app/context/collapseAtom';
import { HandleCollapse, SelectCollapse } from 'app/context/collapseAtom';
import { useAtom } from 'jotai';
import PropTypes from 'prop-types';
const ExpandedBlock = (props) => {
const { provided, data, eventIndex, next, delay, delayValue, actionHandler } =
props;
const { provided, data, eventIndex, next, delay, delayValue, previousEnd, actionHandler } = props;
const oscid = data.id.length > 4 ? '...' : data.id;
const oscid = data?.id || '...';
// if end is before, assume is the day after
const duration =
@@ -28,14 +28,12 @@ const ExpandedBlock = (props) => {
return (
<>
<span className={style.drag} {...provided.dragHandleProps}>
<FiMoreVertical />
<FiMoreVertical />
</span>
<div className={style.indicators}>
<span className={next ? style.next : style.nextDisabled}>Next</span>
{delayValue != null && (
<span className={style.delayValue}>+ {delayValue}</span>
)}
{delayValue != null && <span className={style.delayValue}>+ {delayValue}</span>}
</div>
<div className={style.timeExpanded}>
<EventTimesVertical
@@ -44,6 +42,7 @@ const ExpandedBlock = (props) => {
timeEnd={data.timeEnd}
duration={duration}
delay={delay}
previousEnd={previousEnd}
className={style.time}
/>
</div>
@@ -53,25 +52,19 @@ const ExpandedBlock = (props) => {
label='Title'
defaultValue={data.title}
placeholder='Add Title'
submitHandler={(v) =>
actionHandler('update', { field: 'title', value: v })
}
submitHandler={(v) => actionHandler('update', { field: 'title', value: v })}
/>
<EditableText
label='Presenter'
defaultValue={data.presenter}
placeholder='Add Presenter name'
submitHandler={(v) =>
actionHandler('update', { field: 'presenter', value: v })
}
submitHandler={(v) => actionHandler('update', { field: 'presenter', value: v })}
/>
<EditableText
label='Subtitle'
defaultValue={data.subtitle}
placeholder='Add Subtitle'
submitHandler={(v) =>
actionHandler('update', { field: 'subtitle', value: v })
}
submitHandler={(v) => actionHandler('update', { field: 'subtitle', value: v })}
/>
<EditableText
label='Note'
@@ -79,9 +72,7 @@ const ExpandedBlock = (props) => {
placeholder='Add Note'
style={{ color: '#d69e2e' }}
maxchar={160}
submitHandler={(v) =>
actionHandler('update', { field: 'note', value: v })
}
submitHandler={(v) => actionHandler('update', { field: 'note', value: v })}
/>
<span className={style.oscLabel}>
{`/ontime/goto ${eventIndex + 1} << OSC >> /ontime/gotoid ${oscid}`}
@@ -89,38 +80,43 @@ const ExpandedBlock = (props) => {
</div>
<div className={style.actionOverlay}>
<PublicIconBtn actionHandler={actionHandler} active={data.isPublic} />
<ActionButtons
showAdd
showDelay
showBlock
actionHandler={actionHandler}
/>
<ActionButtons showAdd showDelay showBlock actionHandler={actionHandler} />
<DeleteIconBtn actionHandler={actionHandler} />
</div>
</>
);
};
ExpandedBlock.propTypes = {
provided: PropTypes.any.isRequired,
data: PropTypes.object.isRequired,
eventIndex: PropTypes.number.isRequired,
next: PropTypes.bool.isRequired,
delay: PropTypes.number,
delayValue: PropTypes.number,
previousEnd: PropTypes.number.isRequired,
actionHandler: PropTypes.func.isRequired,
};
const CollapsedBlock = (props) => {
const { provided, data, next, delay, delayValue, actionHandler } = props;
const { provided, data, next, delay, delayValue, previousEnd, actionHandler } = props;
return (
<>
<span className={style.drag} {...provided.dragHandleProps}>
<FiMoreVertical />
<FiMoreVertical />
</span>
<div className={style.indicators}>
<span className={next ? style.next : style.nextDisabled}>Next</span>
{delayValue != null && (
<span className={style.delayValue}>+ {delayValue}</span>
)}
{delayValue != null && <span className={style.delayValue}>+ {delayValue}</span>}
</div>
<EventTimes
actionHandler={actionHandler}
timeStart={data.timeStart}
timeEnd={data.timeEnd}
delay={delay}
previousEnd={previousEnd}
className={style.time}
/>
<div className={style.titleContainer}>
@@ -128,33 +124,32 @@ const CollapsedBlock = (props) => {
label='Title'
defaultValue={data.title}
placeholder='Add Title'
submitHandler={(v) =>
actionHandler('update', { field: 'title', value: v })
}
submitHandler={(v) => actionHandler('update', { field: 'title', value: v })}
/>
</div>
<div className={style.actionOverlay}>
<PublicIconBtn actionHandler={actionHandler} active={data.isPublic} />
<ActionButtons
showAdd
showDelay
showBlock
actionHandler={actionHandler}
/>
<ActionButtons showAdd showDelay showBlock actionHandler={actionHandler} />
</div>
</>
);
};
CollapsedBlock.propTypes = {
provided: PropTypes.any.isRequired,
data: PropTypes.object.isRequired,
next: PropTypes.bool.isRequired,
delay: PropTypes.any,
delayValue: PropTypes.any,
previousEnd: PropTypes.number.isRequired,
actionHandler: PropTypes.func.isRequired,
};
export default function EventBlock(props) {
const { data, selected, delay, index, eventIndex, actionHandler } = props;
const [collapsed] = useAtom(
useMemo(() => SelectCollapse(data.id), [data.id])
);
const { data, selected, delay, index, eventIndex, previousEnd, actionHandler } = props;
const [collapsed] = useAtom(useMemo(() => SelectCollapse(data.id), [data.id]));
const [, setCollapsed] = useAtom(HandleCollapse);
// TODO: should this go inside useEffect()
// Would I then need to add this to state?
const isSelected = selected ? style.active : '';
const isCollapsed = collapsed ? style.collapsed : style.expanded;
const classSelect = `${style.event} ${isCollapsed} ${isSelected}`;
@@ -169,11 +164,7 @@ export default function EventBlock(props) {
return (
<Draggable key={data.id} draggableId={data.id} index={index}>
{(provided) => (
<div
className={classSelect}
{...provided.draggableProps}
ref={provided.innerRef}
>
<div className={classSelect} {...provided.draggableProps} ref={provided.innerRef}>
<Icon
className={collapsed ? style.moreCollapsed : style.moreExpanded}
as={FiChevronUp}
@@ -186,6 +177,7 @@ export default function EventBlock(props) {
next={props.next}
delay={delay}
delayValue={delayValue}
previousEnd={previousEnd}
actionHandler={actionHandler}
/>
) : (
@@ -196,6 +188,7 @@ export default function EventBlock(props) {
next={props.next}
delay={delay}
delayValue={delayValue}
previousEnd={previousEnd}
actionHandler={actionHandler}
/>
)}
@@ -204,3 +197,13 @@ export default function EventBlock(props) {
</Draggable>
);
}
EventBlock.propTypes = {
data: PropTypes.object.isRequired,
selected: PropTypes.bool.isRequired,
delay: PropTypes.number,
index: PropTypes.number.isRequired,
eventIndex: PropTypes.number.isRequired,
previousEnd: PropTypes.number.isRequired,
actionHandler: PropTypes.func.isRequired,
};
@@ -1,6 +1,7 @@
import { Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/menu';
import { IconButton } from '@chakra-ui/button';
import { FiPlus, FiMinusCircle, FiClock } from 'react-icons/fi';
import { FiClock, FiMinusCircle, FiPlus } from 'react-icons/fi';
import { Tooltip } from '@chakra-ui/tooltip';
export default function ActionButtons(props) {
const { showAdd, showDelay, showBlock, actionHandler } = props;
@@ -12,16 +13,18 @@ export default function ActionButtons(props) {
return (
<Menu isLazy lazyBehavior='unmount'>
<MenuButton
as={IconButton}
aria-label='Options'
size='xs'
icon={<FiPlus />}
_expanded={{ bg: 'orange.300', color: 'white' }}
_focus={{ boxShadow: 'none' }}
backgroundColor={'orange.200'}
color={'orange.500'}
/>
<Tooltip label='Add ...' delay={500}>
<MenuButton
as={IconButton}
aria-label='Options'
size='xs'
icon={<FiPlus />}
_expanded={{ bg: 'orange.300', color: 'white' }}
_focus={{ boxShadow: 'none' }}
backgroundColor={'orange.200'}
color={'orange.500'}
/>
</Tooltip>
<MenuList style={menuStyle}>
<MenuItem
icon={<FiPlus />}
@@ -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';
@@ -20,8 +20,10 @@ export default function EventList(props) {
// Handle keyboard shortcuts
const handleKeyPress = useCallback(
(e) => {
// handle held key
if (e.repeat) return;
// Check if the alt key is pressed
if (e.altKey) {
if (e.altKey && (!e.ctrlKey || !e.shiftKey)) {
// Arrow down
if (e.keyCode === 40) {
if (cursor == null) setCursor(0);
@@ -145,6 +147,8 @@ export default function EventList(props) {
let cumulativeDelay = 0;
let eventIndex = -1;
let previousEnd = 0;
let thisEnd = 0;
return (
<div className={style.eventContainer}>
@@ -167,6 +171,8 @@ export default function EventList(props) {
cumulativeDelay = 0;
} else if (e.type === 'event') {
eventIndex++;
previousEnd = thisEnd;
thisEnd = e.timeEnd;
}
return (
@@ -184,6 +190,7 @@ export default function EventList(props) {
next={nextId === e.id}
eventsHandler={eventsHandler}
delay={cumulativeDelay}
previousEnd={previousEnd}
/>
</div>
);
@@ -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 DelayBlock from '../DelayBlock/DelayBlock';
import BlockBlock from '../BlockBlock/BlockBlock';
import EventBlock from '../EventBlock/EventBlock';
import { memo, useContext } from 'react';
import { LoggingContext } from '../../../app/context/LoggingContext';
const areEqual = (prevProps, nextProps) => {
return (
@@ -10,7 +10,8 @@ const areEqual = (prevProps, nextProps) => {
prevProps.selected === nextProps.selected &&
prevProps.next === nextProps.next &&
prevProps.index === nextProps.index &&
prevProps.delay === nextProps.delay
prevProps.delay === nextProps.delay &&
prevProps.previousEnd === nextProps.previousEnd
);
};
@@ -24,8 +25,10 @@ const EventListItem = (props) => {
next,
eventsHandler,
delay,
previousEnd,
...rest
} = props;
const { emitError } = useContext(LoggingContext);
// Create / delete new events
const actionHandler = (action, payload) => {
@@ -59,7 +62,7 @@ const EventListItem = (props) => {
// request update in parent
eventsHandler('patch', newData);
} else {
showErrorToast('Field Error: ' + field);
emitError(`Unknown field: ${field}`);
}
break;
default:
@@ -78,6 +81,7 @@ const EventListItem = (props) => {
next={next}
actionHandler={actionHandler}
delay={delay}
previousEnd={previousEnd}
/>
);
case 'block':
@@ -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;
}
},
@@ -7,7 +7,7 @@
border-radius: 4px;
padding: 8px;
overflow-y: scroll;
height: 73vh;
height: 100%;
}
.list {
+5 -7
View File
@@ -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 />
</>
);
}
@@ -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;
+108 -25
View File
@@ -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);
}
}
+5 -21
View File
@@ -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 -1
View File
@@ -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();
});
+14 -11
View File
@@ -1,7 +1,8 @@
import { Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/menu';
import { IconButton } from '@chakra-ui/button';
import { FiTrash2, FiPlus, FiClock, FiMinusCircle } from 'react-icons/fi';
import { FiClock, FiMinusCircle, FiPlus, FiTrash2 } from 'react-icons/fi';
import { Divider } from '@chakra-ui/layout';
import { Tooltip } from '@chakra-ui/tooltip';
export default function MenuActionButtons(props) {
const { actionHandler } = props;
@@ -12,16 +13,18 @@ export default function MenuActionButtons(props) {
return (
<Menu isLazy lazyBehavior='unmount'>
<MenuButton
as={IconButton}
aria-label='Create Menu'
size={props.size || 'xs'}
icon={<FiPlus />}
_expanded={{ bg: 'orange.300', color: 'white' }}
_focus={{ boxShadow: 'none' }}
backgroundColor={'orange.200'}
color={'orange.500'}
/>
<Tooltip label='Add / Delete ...'>
<MenuButton
as={IconButton}
aria-label='Create Menu'
size={props.size || 'xs'}
icon={<FiPlus />}
_expanded={{ bg: 'orange.300', color: 'white' }}
_focus={{ boxShadow: 'none' }}
backgroundColor={'orange.200'}
color={'orange.500'}
/>
</Tooltip>
<MenuList style={menuStyle}>
<MenuItem icon={<FiPlus />} onClick={() => actionHandler('event')}>
Add Event first
+36 -23
View File
@@ -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);
});
@@ -5,7 +5,7 @@ import { FiDownload } from 'react-icons/fi';
export default function DownloadIconBtn(props) {
const { clickhandler, ...rest } = props;
return (
<Tooltip label='Download File'>
<Tooltip label='Export event list'>
<IconButton
size={props.size || 'xs'}
icon={<FiDownload />}
@@ -1,19 +0,0 @@
import { IconButton } from '@chakra-ui/button';
import { Tooltip } from '@chakra-ui/tooltip';
import { FiHome } from 'react-icons/fi';
export default function InfoIconBtn(props) {
const { clickhandler, ...rest } = props;
return (
<Tooltip label='Event Main'>
<IconButton
size={props.size || 'xs'}
icon={<FiHome />}
colorScheme='white'
onClick={clickhandler}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
</Tooltip>
);
}
@@ -5,7 +5,7 @@ import { FiUpload } from 'react-icons/fi';
export default function UploadIconBtn(props) {
const { clickhandler, ...rest } = props;
return (
<Tooltip label='Upload File'>
<Tooltip label='Import event list'>
<IconButton
size={props.size || 'xs'}
icon={<FiUpload />}
+299 -130
View File
@@ -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 { IoInformationCircleOutline, IoRemove, IoSunny } from 'react-icons/io5';
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}>
<IoInformationCircleOutline 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={<IoSunny />}
colorScheme='blue'
variant={alias.enabled ? null : 'outline'}
onClick={() => setEnabled(alias.id, !alias.enabled)}
/>
</Tooltip>
<Tooltip label='Delete alias' openDelay={500}>
<IconButton
size='xs'
icon={<IoRemove />}
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>
);
}
+134 -149
View File
@@ -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 on save 🔥
</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>
);
}

Some files were not shown because too many files have changed in this diff Show More