Compare commits

..

32 Commits

Author SHA1 Message Date
Carlos Valente 82d0508672 hotfix/0.8.1 prevent issue with falsy value (#107) 2022-04-12 19:46:01 +02:00
Carlos Valente dd1b9bd38c version bump
version bump from fixes in #106
2022-04-10 10:07:13 +02:00
Carlos Valente b01145ca4b Fix/102 (#106)
* fix/102 improve osx feedback on navigation
* fix/102 refactor enable / disable OSC in
2022-04-10 10:05:35 +02:00
Carlos Valente 67ddcd8c68 Feat/table (#105)
* feat/table add react-table
* feat/table add protected table route
* feat/table upgrade dependencies
* feat/table support parsing of new properties

Co-authored-by: DeepSource Bot <bot@deepsource.io>
2022-04-09 22:05:56 +02:00
DeepSource Bot 8841a02754 Add .deepsource.toml 2022-02-06 14:08:33 +00:00
Carlos Valente 1a0d7810c4 Create codeql-analysis.yml 2022-01-29 10:01:10 +01:00
Carlos Valente f2dd24b74a Feat/delays (#96)
* feat/delays allow negative delays
* feat/delays recover expand all
* feat/delays refact: icons import
* feat/delays refact: upgrade chakra
* feat/delays version bump
2022-01-23 14:32:15 +01:00
Carlos Valente 6a6f68b2f9 Feat/block improvements (#95)
* feat/89-block-improvements style: replace reload icon
* feat/block-feat/block-improvements add cursor buttons
* feat/block-improvements hover to create
* feat/block-improvements change settings in modal
* feat/block-improvements version bump
2022-01-22 23:23:31 +01:00
Carlos Valente 906a6631a7 Update README.md 2022-01-14 22:26:23 +01:00
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
241 changed files with 20546 additions and 13371 deletions
+18
View File
@@ -0,0 +1,18 @@
version = 1
test_patterns = [
"__mocks__/**",
"__tests__/**",
"cypress/**",
"*.test.*",
"*.mock.*",
"*.spec.*"
]
[[analyzers]]
name = "javascript"
enabled = true
[analyzers.meta]
plugins = ["react"]
environment = ["nodejs","browser","jest"]
+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 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ master ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ master ]
schedule:
- cron: '33 02 * * 5'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://git.io/codeql-language-support
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
+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/
+50
View File
@@ -0,0 +1,50 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="CssUnknownProperty" enabled="true" level="WARNING" enabled_by_default="true">
<option name="myCustomPropertiesEnabled" value="true" />
<option name="myIgnoreVendorSpecificProperties" value="false" />
<option name="myCustomPropertiesList">
<value>
<list size="3">
<item index="0" class="java.lang.String" itemvalue="scrollbar-color" />
<item index="1" class="java.lang.String" itemvalue="scrollbar-width" />
<item index="2" class="java.lang.String" itemvalue="app-region" />
</list>
</value>
</option>
</inspection_tool>
<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
}
+22 -11
View File
@@ -2,7 +2,7 @@
Download the latest releases here
- [Windows](https://gitreleases.dev/gh/cpvalente/ontime/latest/ontime-win64.exe)
- [Windows](https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe)
# Ontime
Ontime is an application for managing event rundowns and running stage timers.
@@ -21,15 +21,24 @@ 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
```
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
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
For the presentation views...
-------------------------------------------------------------
IP.ADDRESS:4001 > Web server default to presenter timer view
IP.ADDRESS:4001/presenter > Presenter / Stage timer view
IP.ADDRESS:4001/sm > Stage Manager / Backstage view
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
IP.ADDRESS:4001/cuesheet > Cue Sheet
...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/)
@@ -40,18 +49,22 @@ More documentation available [here](https://cpvalente.gitbook.io/ontime/)
- Backstage Info
- Public Info
- Picture in Picture
- Studio Clock
- [x] Configurable realtime Lower Thirds
- [x] Cuesheets with additional custom fields
- [x] Send live messages to different screen types
- [x] Ability to differentiate between backstage and public data
- [x] Manage delays workflow
- [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.
- [x] You do not need an order list to use the timer. Create an empty event and the OSC API works just the same
- [x] If you want just the info screens, no need to use the timer!
- [x] Don't have or care for a schedule?
@@ -77,8 +90,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
+82
View File
@@ -0,0 +1,82 @@
{
"extends": [
"react-app",
"react-app/jest",
"plugin:react/recommended"
],
"plugins": [
"react",
"testing-library",
"jest"
],
"rules": {
"jest/no-mocks-import": "warn",
"no-useless-concat": "warn",
"prefer-template": "warn",
"react/jsx-no-bind": [
"error",
{
"ignoreRefs": true,
"allowArrowFunctions": true,
"allowFunctions": false,
"allowBind": false,
"ignoreDOMComponents": true
}
],
"react/jsx-boolean-value": [
"error",
"never",
{
"always": []
}
],
"react/jsx-handler-names": [
"off",
{
"eventHandlerPrefix": "handle",
"eventHandlerPropPrefix": "on"
}
],
"react/no-danger": "warn",
"react/no-deprecated": [
"error"
],
"react/jsx-no-undef": "error",
"react/jsx-closing-tag-location": "warn",
"react/jsx-no-useless-fragment": "warn",
"react/no-unescaped-entities": "error",
"react/jsx-tag-spacing": [
"error",
{
"closingSlash": "never",
"beforeSelfClosing": "always",
"afterOpening": "never",
"beforeClosing": "never"
}
],
"react/jsx-space-before-closing": [
"off",
"always"
],
"react/jsx-curly-brace-presence": [
"error",
{
"props": "never",
"children": "never"
}
],
"react/destructuring-assignment": [
"error",
"always"
],
"react/no-unsafe": "off",
"react/prop-types": [
"error",
{
"ignore": [],
"customValidators": [],
"skipUndeclared": true
}
]
}
}
+27 -24
View File
@@ -3,42 +3,38 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@chakra-ui/react": "^1.7.1",
"@emotion/react": "^11.5.0",
"@emotion/styled": "^11.3.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",
"framer-motion": "^4.1.6",
"jotai": "^0.16.5",
"@chakra-ui/react": "^1.8.6",
"@dnd-kit/core": "^5.0.3",
"@dnd-kit/sortable": "^6.0.1",
"@dnd-kit/utilities": "^3.1.0",
"@emotion/react": "^11.7.1",
"@emotion/styled": "^11.6.0",
"@react-icons/all-files": "^4.1.0",
"autosize": "^5.0.1",
"axios": "^0.25.0",
"color": "^4.2.1",
"framer-motion": "4.1.17",
"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.12",
"react-router-dom": "^6.2.1",
"react-table": "^7.7.0",
"socket.io-client": "^4.4.1",
"typeface-open-sans": "^1.1.13",
"use-fit-text": "^2.4.0",
"web-vitals": "^1.0.1"
"web-vitals": "^2.1.4"
},
"scripts": {
"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 +48,13 @@
]
},
"devDependencies": {
"sass": "^1.44.0"
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^12.1.2",
"@testing-library/react-hooks": "^7.0.2",
"@testing-library/user-event": "^13.5.0",
"prop-types": "^15.8.1",
"react-scripts": "^5.0.0",
"react-test-renderer": "^17.0.2",
"sass": "^1.49.0"
}
}
+85 -56
View File
@@ -1,53 +1,62 @@
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 React, { lazy, Suspense, useCallback, useEffect } from 'react';
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';
import { TableSettingsProvider } from './app/context/TableSettingsContext';
const Editor = lazy(() => import('features/editors/Editor'));
const PresenterView = lazy(() =>
import('features/viewers/presenter/PresenterView')
);
const PresenterSimple = lazy(() =>
import('features/viewers/presenter/PresenterSimple')
);
const StageManager = lazy(() =>
import('features/viewers/backstage/StageManager')
);
const Public = lazy(() => import('features/viewers/foh/Public'));
const Lower = lazy(() =>
import('features/viewers/production/lower/LowerWrapper')
);
const Pip = lazy(() => import('features/viewers/production/Pip'));
const Table = lazy(() => import('features/table/TableWrapper'));
const TimerView = lazy(() => import('features/viewers/timer/Timer'));
const MinimalTimerView = lazy(() => import('features/viewers/timer/MinimalTimer'));
const StageManager = lazy(() => import('features/viewers/backstage/StageManager'));
const Public = lazy(() => import('features/viewers/foh/Public'));
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);
const SPip = withSocket(Pip);
const SStudio = withSocket(StudioClock);
const ProtectedEditor = () => (
<ProtectRoute>
<Editor />
</ProtectRoute>
);
const ProtectedTable = () => (
<ProtectRoute>
<TableSettingsProvider>
<Table />
</TableSettingsProvider>
</ProtectRoute>
);
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') {
// if we are in electron
if (window.process?.type === undefined) return;
if (window.process.type === 'renderer') {
if (window.process?.type === 'renderer') {
// ask to see debug
window.ipcRenderer.send('set-window', 'show-dev');
}
@@ -65,33 +74,53 @@ 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={<ProtectedEditor />} />
<Route path='/cuesheet' element={<ProtectedTable />} />
<Route path='/cuelist' element={<ProtectedTable />} />
<Route path='/table' element={<ProtectedTable />} />
{/* Send to default if nothing found */}
<Route path='*' element={<STimer />} />
</Routes>
</Suspense>
</ErrorBoundary>
</div>
);
}
+3 -2
View File
@@ -8,7 +8,7 @@
body,
html,
.App {
margin: 0px auto;
margin: 0 auto;
overflow: hidden;
overflow: clip;
height: 100vh;
@@ -23,7 +23,8 @@ option {
/* width */
::-webkit-scrollbar {
width: 0.5em;
width: 0.5rem;
height: 0.5rem;
}
/* Track */
-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,
},
},
});
+6 -2
View File
@@ -1,7 +1,11 @@
export const NODE_PORT = 4001;
export const EVENT_TABLE = 'event';
export const ALIASES = 'aliases';
export const USERFIELDS = 'userFields';
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}/`);
@@ -10,5 +14,5 @@ const calculateServer = () => {
export const serverURL = calculateServer();
export const eventURL = serverURL + EVENT_TABLE;
export const eventsURL = serverURL + EVENTS_TABLE;
export const playbackURL = serverURL + 'playback';
export const ontimeURL = serverURL + 'ontime';
export const playbackURL = `${serverURL}playback`;
export const ontimeURL = `${serverURL}ontime`;
+8 -16
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,30 @@ export const fetchAllEvents = async () => {
};
export const requestPost = async (data) => {
const res = await axios.post(eventsURL, data);
return res;
await axios.post(eventsURL, data);
};
export const requestPut = async (data) => {
const res = await axios.put(eventsURL, data);
return res;
await axios.put(eventsURL, data);
};
export const requestPatch = async (data) => {
const res = await axios.patch(eventsURL, data);
return res;
await axios.patch(eventsURL, data);
};
export const requestReorder = async (data) => {
const action = 'reorder';
const res = await axios.patch(eventsURL + '/' + action, data);
return res;
await axios.patch(`${eventsURL}/reorder`, 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;
await axios.delete(`${eventsURL}/${eventId}`);
};
export const requestDeleteAll = async () => {
const res = await axios.delete(eventsURL + '/all');
return res;
await axios.delete(`${eventsURL}/all`);
};
+137 -14
View File
@@ -3,26 +3,151 @@ 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 userFieldsPlaceholder = {
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
};
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) => {
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;
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) => {
await axios.post(`${ontimeURL}/aliases`, data);
};
export const getUserFields = async () => {
const res = await axios.get(`${ontimeURL}/userfields`);
return res.data;
};
export const postUserFields = async (data) => {
await axios.post(`${ontimeURL}/userfields`, data);
};
export const getOSC = async () => {
const res = await axios.get(`${ontimeURL}/osc`);
return res.data;
};
export const postOSC = async (data) => {
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 +174,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 });
};
+16
View File
@@ -0,0 +1,16 @@
// Exported viewer link location
const speakerLocation = 'speaker';
const smLocation = 'sm';
const publicLocation = 'public';
const pipLocation = 'pip';
const studioLocation = 'studio';
const cuesheetLocation = 'cuesheet';
export const viewerLocations = [
{ link: speakerLocation, label: 'Speaker Screen' },
{ link: smLocation, label: 'Backstage Screen' },
{ link: publicLocation, label: 'Public Screen' },
{ link: pipLocation, label: 'Picture in Picture' },
{ link: studioLocation, label: 'Studio Clock' },
{ link: cuesheetLocation, label: 'Cuesheet' }
];
+36
View File
@@ -0,0 +1,36 @@
import React, { 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 = ({ children }) => {
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 }}>{children}</AppContext.Provider>;
};
+71
View File
@@ -0,0 +1,71 @@
import React, { createContext, useCallback } from 'react';
import { useLocalStorage } from '../hooks/useLocalStorage';
export const CollapseContext = createContext({
collapsed: {},
setCollapsed: () => undefined,
clearCollapsed: () => undefined,
isCollapsed: () => undefined,
});
export const CollapseProvider = ({ children }) => {
const [collapsed, saveCollapsed] = useLocalStorage('collapsed', {});
/**
* @description Sets collapsed state for a single id
* @param {string} - id
* @param {boolean} - collapsed / not collapsed
*/
const setCollapsed = useCallback(
(id, isCollapsed) => {
if (isCollapsed) {
saveCollapsed((prev) => ({ ...prev, [id]: true }));
} else {
saveCollapsed((prev) => {
const newObject = { ...prev };
delete newObject[id];
return { ...newObject };
});
}
},
[saveCollapsed]
);
/**
* @description Clears collapsed state in local-storage
*/
const collapseMultiple = useCallback(
(events) => {
const newOptions = {};
for (const event of events) {
newOptions[event.id] = true;
}
saveCollapsed(newOptions);
},
[saveCollapsed]
);
/**
* @description Clears collapsed state in local-storage
*/
const expandAll = useCallback(() => {
saveCollapsed({});
}, [saveCollapsed]);
/**
* @description Clears collapsed state in local-storage
* @return {boolean} - collapsed / not collapsed
*/
const isCollapsed = useCallback(
(id) => {
return id in collapsed;
},
[collapsed]
);
return (
<CollapseContext.Provider value={{ setCollapsed, collapseMultiple, expandAll, isCollapsed }}>
{children}
</CollapseContext.Provider>
);
};
+63
View File
@@ -0,0 +1,63 @@
import React, { createContext, useCallback, useMemo, useState } from 'react';
import { useLocalStorage } from '../hooks/useLocalStorage';
export const CursorContext = createContext({
cursor: 0,
isCursorLocked: false,
setCursor: () => undefined,
moveCursorUp: () => undefined,
moveCursorDown: () => undefined,
});
export const CursorProvider = ({ children }) => {
const [cursor, setCursor] = useState(0);
const [_cursorLocked, _setCursorLocked] = useLocalStorage('isCursorLocked', 'locked');
const isCursorLocked = useMemo(() => _cursorLocked === 'locked', [_cursorLocked]);
const cursorLockedOff = useCallback(() => _setCursorLocked('unlocked'), [_setCursorLocked]);
const cursorLockedOn = useCallback(() => _setCursorLocked('locked'), [_setCursorLocked]);
const moveCursorUp = useCallback(() => {
setCursor((prev) => Math.max(prev - 1, 0));
}, []);
const moveCursorDown = useCallback(() => {
setCursor((prev) => prev + 1);
}, []);
/**
* @param {boolean | undefined} newValue
*/
const toggleCursorLocked = useCallback(
(newValue = undefined) => {
if (newValue === undefined) {
if (isCursorLocked) {
cursorLockedOff();
} else {
cursorLockedOn();
}
} else if (!newValue) {
cursorLockedOff();
} else if (newValue) {
cursorLockedOn();
}
},
[cursorLockedOff, cursorLockedOn, isCursorLocked]
);
return (
<CursorContext.Provider
value={{
cursor,
isCursorLocked,
toggleCursorLocked,
setCursor,
moveCursorUp,
moveCursorDown,
}}
>
{children}
</CursorContext.Provider>
);
};
@@ -0,0 +1,32 @@
import React, { createContext, useState } from 'react';
export const LocalEventSettingsContext = createContext({
showQuickEntry: false,
starTimeIsLastEnd: true,
defaultPublic: true,
setShowQuickEntry: () => undefined,
setStarTimeIsLastEnd: () => undefined,
setDefaultPublic: () => undefined,
});
export const LocalEventSettingsProvider = ({ children }) => {
const [showQuickEntry, setShowQuickEntry] = useState(false);
const [starTimeIsLastEnd, setStarTimeIsLastEnd] = useState(true);
const [defaultPublic, setDefaultPublic] = useState(false);
return (
<LocalEventSettingsContext.Provider
value={{
showQuickEntry,
setShowQuickEntry,
starTimeIsLastEnd,
setStarTimeIsLastEnd,
defaultPublic,
setDefaultPublic,
}}
>
{children}
</LocalEventSettingsContext.Provider>
);
};
+108
View File
@@ -0,0 +1,108 @@
import React, { createContext, useCallback, useEffect, useState } from 'react';
import { useSocket } from './socketContext';
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 = ({ children }) => {
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 }}>
{children}
</LoggingContext.Provider>
);
};
@@ -0,0 +1,78 @@
import React, { createContext, useCallback, useState } from 'react';
import { useLocalStorage } from '../hooks/useLocalStorage';
export const TableSettingsContext = createContext({
theme: '',
showSettings: false,
followSelected: false,
toggleSettings: () => undefined,
toggleTheme: () => undefined,
toggleFollow: () => undefined,
});
export const TableSettingsProvider = ({ children }) => {
const [theme, setTheme] = useLocalStorage('table-color-theme', 'dark');
const [followSelected, setFollowSelected] = useLocalStorage('table-follow-selected', false);
const [showSettings, setShowSettings] = useState(false);
/**
* @description Toggles the current value of dark mode
* @param {string} val - 'light' or 'dark'
*/
const toggleTheme = useCallback(
(val) => {
if (val === undefined) {
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
} else {
setTheme(val);
}
},
[setTheme]
);
/**
* @description Toggles visibility state for settings
* @param {boolean} val - whether the settings window is visible
*/
const toggleSettings = useCallback(
(val) => {
if (val === undefined) {
setShowSettings((prev) => !prev);
} else {
setShowSettings(val);
}
},
[setShowSettings]
);
/**
* @description Toggles follow option
* @param {boolean} val - whether the window follows selected event
*/
const toggleFollow = useCallback(
(val) => {
if (val === undefined) {
setFollowSelected((prev) => !prev);
} else {
setFollowSelected(val);
}
},
[setFollowSelected]
);
return (
<TableSettingsContext.Provider
value={{
theme,
showSettings,
followSelected,
toggleSettings,
toggleTheme,
toggleFollow,
}}
>
{children}
</TableSettingsContext.Provider>
);
};
-60
View File
@@ -1,60 +0,0 @@
const { atom } = require('jotai');
const PATH = 'option-collapse';
const initialValue = {};
// collapse options object, initialised from local storage
// REFRACT: When do we read from local storage?
// on every render?
export const collapseAtom = atom(
(get) => {
const storedOptions = localStorage.getItem(PATH);
if (storedOptions == null) return initialValue;
return JSON.parse(storedOptions);
},
(get, set, newValues) => {
set(collapseAtom, newValues);
localStorage.setItem(PATH, JSON.stringify(newValues));
}
);
// get a single option, if it exists
export const SelectCollapse = (id) => {
return atom((get) => get(collapseAtom)[id]);
};
// change a single item in object
export const HandleCollapse = atom(null, (get, set, payload) => {
const updatedVal = {
...get(collapseAtom),
...payload,
};
set(collapseAtom, updatedVal);
localStorage.setItem(PATH, JSON.stringify(updatedVal));
});
// change collapsed in several items
export const BatchOperation = atom(null, (get, set, payload) => {
let prevOptions = get(collapseAtom);
let newOptions = {};
for (const item of payload.items) {
newOptions[item.id] = payload.isCollapsed;
}
if (payload.clear) {
// clear object
prevOptions = {};
// clear localstorage
localStorage.removeItem(PATH);
}
const options = { ...prevOptions, ...newOptions };
set(collapseAtom, options);
localStorage.setItem(PATH, JSON.stringify(options));
});
-34
View File
@@ -1,34 +0,0 @@
const { atom } = require('jotai');
const PATH = 'option-gen';
const initialValue = {
cursor: 'locked',
};
export const settingsAtom = atom(
(get) => {
const storedOptions = localStorage.getItem(PATH);
if (storedOptions == null) return initialValue;
return JSON.parse(storedOptions);
},
(get, set, newValues) => {
set(settingsAtom, newValues);
localStorage.setItem(PATH, JSON.stringify(newValues));
}
);
// get a single option, if it exists
export const SelectSetting = (setting) => {
return atom((get) => get(settingsAtom)[setting]);
};
// change a single item in object
export const HandleOptions = atom(null, (get, set, payload) => {
const updatedVal = {
...get(settingsAtom),
...payload,
};
set(settingsAtom, updatedVal);
localStorage.setItem(PATH, JSON.stringify(updatedVal));
});
+4 -7
View File
@@ -1,4 +1,4 @@
import { createContext, useContext, useEffect, useState } from 'react';
import React, { createContext, useContext, useEffect, useState } from 'react';
import io from 'socket.io-client';
import { serverURL } from 'app/api/apiConstants';
@@ -8,7 +8,7 @@ export const useSocket = () => {
return useContext(SocketContext);
};
function SocketProvider(props) {
function SocketProvider({ children }) {
const [socket, setSocket] = useState();
useEffect(() => {
@@ -17,10 +17,7 @@ function SocketProvider(props) {
return () => s.disconnect();
}, []);
return (
<SocketContext.Provider value={socket}>
{props.children}
</SocketContext.Provider>
);
return <SocketContext.Provider value={socket}>{children}</SocketContext.Provider>;
}
export default SocketProvider;
+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;
};
+28
View File
@@ -0,0 +1,28 @@
import { useState } from 'react';
// Roughly from useHooks - useLocalStorage
export const useLocalStorage = (key, initialValue) => {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(`ontime-${key}`);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
return initialValue;
}
});
const setValue = (value) => {
try {
// Allow value to be a function so we have same API as useState
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(`ontime-${key}`, JSON.stringify(valueToStore));
} catch (error) {
console.log(error);
}
};
return [storedValue, setValue];
}
+32
View File
@@ -0,0 +1,32 @@
import { useMutation, useQueryClient } from 'react-query';
import { EVENTS_TABLE } from '../api/apiConstants';
export default function useMutateEvents(mutation){
const queryClient = useQueryClient();
return useMutation(mutation, {
onMutate: async (newEvent) => {
// cancel ongoing queries
queryClient.cancelQueries(EVENTS_TABLE, { exact: true });
// Snapshot the previous value
const previousEvent = queryClient.getQueryData([EVENTS_TABLE, newEvent.id]);
// optimistically update object
queryClient.setQueryData([EVENTS_TABLE, newEvent.id], newEvent);
// Return a context with the previous and new event
return { previousEvent, newEvent };
},
// Mutation fails, rollback undoes optimist update
onError: (error, newEvent, context) => {
queryClient.setQueryData([EVENTS_TABLE, context.newEvent.id], context.previousEvent);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: (newEvent) => {
queryClient.invalidateQueries([EVENTS_TABLE, newEvent.id]);
},
});
}
-38
View File
@@ -1,38 +0,0 @@
import { useEffect } from 'react';
//
// useTimeout React Hook
//
// React hook for delaying calls with time
export const useTimeout = (
callback, // function to call. No args passed.
timeout = 0, // delay, ms (default: immediately put into JS Event Queue)
{
// manage re-render behavior.
// by default, a re-render in your component will re-define the callback,
// which will cause this timeout to cancel itself.
// to avoid cancelling on re-renders (but still cancel on unmounts),
// set `persistRenders: true,`.
persistRenders = false,
} = {},
// These dependencies are injected for testing purposes.
// (pure functions - where all dependencies are arguments - is often easier to test)
_setTimeout = setTimeout,
_clearTimeout = clearTimeout,
_useEffect = useEffect
) => {
let timeoutId;
const cancel = () => timeoutId && _clearTimeout(timeoutId);
_useEffect(
() => {
timeoutId = _setTimeout(callback, timeout);
return cancel;
},
persistRenders
? [_setTimeout, _clearTimeout]
: [callback, timeout, _setTimeout, _clearTimeout]
);
return cancel;
};
+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,5 +1,5 @@
import { IconButton } from '@chakra-ui/button';
import { FiPlus } from 'react-icons/fi';
import { FiPlus } from '@react-icons/all-files/fi/FiPlus';
export default function AddIconBtn(props) {
const { clickhandler, ...rest } = props;
@@ -1,14 +1,15 @@
import React from 'react';
import { IconButton } from '@chakra-ui/button';
import { Tooltip } from '@chakra-ui/tooltip';
import { FiChevronsDown } from 'react-icons/fi';
import { FiCheck } from '@react-icons/all-files/fi/FiCheck';
export default function ApplyIconBtn(props) {
const { clickhandler, ...rest } = props;
const { clickhandler, size, ...rest } = props;
return (
<Tooltip label='Apply delays'>
<IconButton
size={props.size || 'xs'}
icon={<FiChevronsDown />}
size={size || 'xs'}
icon={<FiCheck />}
colorScheme='orange'
onClick={clickhandler}
_focus={{ boxShadow: 'none' }}
@@ -1,5 +1,5 @@
import { IconButton } from '@chakra-ui/button';
import { FiMinusCircle } from 'react-icons/fi';
import { FiMinusCircle } from '@react-icons/all-files/fi/FiMinusCircle';
export default function BlockIconBtn(props) {
const { clickhandler, ...rest } = props;
@@ -1,13 +1,14 @@
import React from 'react';
import { IconButton } from '@chakra-ui/button';
import { Tooltip } from '@chakra-ui/tooltip';
import { FiChevronsUp } from 'react-icons/fi';
import { FiChevronsUp } from '@react-icons/all-files/fi/FiChevronsUp';
export default function CollapseBtn(props) {
const { clickhandler } = props;
const { clickhandler, size } = props;
return (
<Tooltip label='Collapse all'>
<IconButton
size={props.size || 'xs'}
size={size || 'xs'}
icon={<FiChevronsUp />}
colorScheme='white'
variant='outline'
@@ -1,5 +1,5 @@
import { Button } from '@chakra-ui/button';
import { FiTarget } from 'react-icons/fi';
import { FiTarget } from '@react-icons/all-files/fi/FiTarget';
export default function CurrentBtn(props) {
const { clickhandler, active } = props;
@@ -0,0 +1,23 @@
import React from 'react';
import { IconButton } from '@chakra-ui/button';
import { Tooltip } from '@chakra-ui/tooltip';
import { IoCaretDown } from '@react-icons/all-files/io5/IoCaretDown';
export default function CursorDownBtn(props) {
const { clickhandler, active, ref, size } = props;
return (
<Tooltip label='Move cursor down Alt + ↓'>
<IconButton
ref={ref}
size={size || 'xs'}
icon={<IoCaretDown />}
color={active ? 'pink.100' : 'pink.300'}
borderColor={active ? undefined : 'pink.300'}
backgroundColor={active ? 'pink.400' : undefined}
variant={active ? 'solid' : 'outline'}
onClick={clickhandler}
_focus={{ boxShadow: 'none' }}
/>
</Tooltip>
);
}
@@ -1,14 +1,15 @@
import React from 'react';
import { IconButton } from '@chakra-ui/button';
import { Tooltip } from '@chakra-ui/tooltip';
import { FiTarget } from 'react-icons/fi';
import { FiTarget } from '@react-icons/all-files/fi/FiTarget';
export default function LockIconBtn(props) {
const { clickhandler, active, ref } = props;
export default function CursorLockedBtn(props) {
const { clickhandler, active, ref, size } = props;
return (
<Tooltip label='Lock cursor to current'>
<IconButton
<IconButton
ref={ref}
size={props.size || 'xs'}
size={size || 'xs'}
icon={<FiTarget />}
color={active ? 'pink.100' : 'pink.300'}
borderColor={active ? undefined : 'pink.300'}
@@ -0,0 +1,23 @@
import React from 'react';
import { IconButton } from '@chakra-ui/button';
import { Tooltip } from '@chakra-ui/tooltip';
import { IoCaretUp } from '@react-icons/all-files/io5/IoCaretUp';
export default function CursorUpBtn(props) {
const { clickhandler, active, ref, size } = props;
return (
<Tooltip label='Move cursor up Alt + ↑'>
<IconButton
ref={ref}
size={size || 'xs'}
icon={<IoCaretUp />}
color={active ? 'pink.100' : 'pink.300'}
borderColor={active ? undefined : 'pink.300'}
backgroundColor={active ? 'pink.400' : undefined}
variant={active ? 'solid' : 'outline'}
onClick={clickhandler}
_focus={{ boxShadow: 'none' }}
/>
</Tooltip>
);
}
@@ -1,5 +1,5 @@
import { IconButton } from '@chakra-ui/button';
import { FiClock } from 'react-icons/fi';
import { FiClock } from '@react-icons/all-files/fi/FiClock';
export default function DelayIconBtn(props) {
const { clickhandler, ...rest } = props;
@@ -1,9 +1,11 @@
import React from 'react';
import { IconButton } from '@chakra-ui/button';
import { useState } from 'react';
import { FiMinus } from 'react-icons/fi';
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
import { Tooltip } from '@chakra-ui/tooltip';
export default function DeleteIconBtn(props) {
const { actionHandler, ...rest } = props;
const { actionHandler, size, ...rest } = props;
const [loading, setLoading] = useState(false);
const handleClick = () => {
@@ -12,15 +14,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={size || 'xs'}
icon={<IoRemove />}
colorScheme='red'
onClick={handleClick}
_focus={{ boxShadow: 'none' }}
disabled={loading}
isLoading={loading}
{...rest}
/>
</Tooltip>
);
}
@@ -0,0 +1,20 @@
import React from 'react';
import { IoCloseSharp } from '@react-icons/all-files/io5/IoCloseSharp';
import { IoCheckmarkSharp } from '@react-icons/all-files/io5/IoCheckmarkSharp';
import { Button } from '@chakra-ui/button';
export default function EnableBtn(props) {
const { active, text, actionHandler, size } = props;
return (
<Button
size={size || 'xs'}
leftIcon={active ? <IoCheckmarkSharp /> : <IoCloseSharp />}
colorScheme='blue'
variant={active ? 'solid' : 'outline'}
onClick={actionHandler}
_focus={{ boxShadow: 'none' }}
>
{text}
</Button>
);
}
@@ -1,13 +1,14 @@
import React from 'react';
import { IconButton } from '@chakra-ui/button';
import { Tooltip } from '@chakra-ui/tooltip';
import { FiChevronsDown } from 'react-icons/fi';
import { FiChevronsDown } from '@react-icons/all-files/fi/FiChevronsDown';
export default function ExpandBtn(props) {
const { clickhandler } = props;
const { clickhandler, size } = props;
return (
<Tooltip label='Expand all'>
<IconButton
size={props.size || 'xs'}
size={size || 'xs'}
icon={<FiChevronsDown />}
colorScheme='white'
variant='outline'
@@ -1,18 +1,22 @@
import React from 'react';
import { IconButton } from '@chakra-ui/button';
import { FiSkipForward } from 'react-icons/fi';
import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward';
import { Tooltip } from '@chakra-ui/tooltip';
export default function NextIconBtn(props) {
const { clickhandler, ...rest } = props;
const { clickhandler, disabled, ...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={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 React from 'react';
import { IconButton } from '@chakra-ui/button';
import { IoMicSharp } from '@react-icons/all-files/io5/IoMicSharp';
import { IoMicOffOutline } from '@react-icons/all-files/io5/IoMicOffOutline';
import { Tooltip } from '@chakra-ui/tooltip';
export default function OnAirIconBtn(props) {
const { actionHandler, active, size, ...rest } = props;
return (
<Tooltip label={active ? 'Go Off Air' : 'Go On Air'} openDelay={500}>
<IconButton
size={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,21 @@
import React from 'react';
import { IconButton } from '@chakra-ui/button';
import { FiPause } from 'react-icons/fi';
import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { Tooltip } from '@chakra-ui/tooltip';
export default function PauseIconBtn(props) {
const { clickhandler, active, ...rest } = props;
const { clickhandler, active, disabled, ...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={disabled}>
<IconButton
icon={<IoPause size='24px' />}
colorScheme='orange'
variant={active ? 'solid' : 'outline'}
onClick={clickhandler}
width={120}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
</Tooltip>
);
}
@@ -1,18 +1,22 @@
import React from 'react';
import { IconButton } from '@chakra-ui/button';
import { FiSkipBack } from 'react-icons/fi';
import { IoPlaySkipBack } from '@react-icons/all-files/io5/IoPlaySkipBack';
import { Tooltip } from '@chakra-ui/tooltip';
export default function PrevIconBtn(props) {
const { clickhandler, ...rest } = props;
const { clickhandler, disabled, ...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={disabled}>
<IconButton
icon={<IoPlaySkipBack size='22px' />}
colorScheme='whiteAlpha'
backgroundColor='#ffffff11'
variant='outline'
onClick={clickhandler}
width={90}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
</Tooltip>
);
}
@@ -1,19 +1,18 @@
import React from 'react';
import { IconButton } from '@chakra-ui/button';
import { Tooltip } from '@chakra-ui/tooltip';
import { FiUsers } from 'react-icons/fi';
import { FiUsers } from '@react-icons/all-files/fi/FiUsers';
export default function PublicIconBtn(props) {
const { actionHandler, active, ...rest } = props;
const { actionHandler, active, size, ...rest } = props;
return (
<Tooltip label={active ? 'Make event private' : 'Make event public'}>
<IconButton
size={props.size || 'xs'}
size={size || 'xs'}
icon={<FiUsers />}
colorScheme='blue'
variant={active ? 'solid' : 'outline'}
onClick={() =>
actionHandler('update', { field: 'isPublic', value: !active })
}
onClick={() => actionHandler('update', { field: 'isPublic', value: !active })}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
@@ -1,18 +1,22 @@
import React from 'react';
import { IconButton } from '@chakra-ui/button';
import { FiRefreshCcw } from 'react-icons/fi';
import { IoArrowUndo } from '@react-icons/all-files/io5/IoArrowUndo';
import { Tooltip } from '@chakra-ui/tooltip';
export default function ReloadIconButton(props) {
const { clickhandler, ...rest } = props;
const { clickhandler, disabled, ...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={disabled}>
<IconButton
icon={<IoArrowUndo size='22px' />}
colorScheme='whiteAlpha'
backgroundColor='#ffffff05'
variant='outline'
onClick={clickhandler}
width={90}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
</Tooltip>
);
}
@@ -1,17 +1,21 @@
import React from 'react';
import { IconButton } from '@chakra-ui/button';
import { FiClock } from 'react-icons/fi';
import { IoTimeOutline } from '@react-icons/all-files/io5/IoTimeOutline';
import { Tooltip } from '@chakra-ui/tooltip';
export default function RollIconBtn(props) {
const { clickhandler, active, ...rest } = props;
const { clickhandler, active, disabled, ...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={disabled}>
<IconButton
icon={<IoTimeOutline size='24px' />}
colorScheme='blue'
variant={active ? 'solid' : 'outline'}
onClick={clickhandler}
width={120}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
</Tooltip>
);
}
@@ -1,17 +1,21 @@
import React from 'react';
import { IconButton } from '@chakra-ui/button';
import { FiPlay } from 'react-icons/fi';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { Tooltip } from '@chakra-ui/tooltip';
export default function StartIconBtn(props) {
const { clickhandler, active, ...rest } = props;
const { clickhandler, active, disabled, ...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={disabled}>
<IconButton
icon={<IoPlay size='24px' />}
colorScheme='green'
variant={active ? 'solid' : 'outline'}
onClick={clickhandler}
width={120}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
</Tooltip>
);
}
@@ -1,5 +1,5 @@
import { IconButton } from '@chakra-ui/button';
import { FiTrash2 } from 'react-icons/fi';
import { FiTrash2 } from '@react-icons/all-files/fi/FiTrash2';
export default function TrashIconBtn(props) {
const { clickhandler, ...rest } = props;
@@ -1,18 +1,21 @@
import React from 'react';
import { IconButton } from '@chakra-ui/button';
import { FiXOctagon } from 'react-icons/fi';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { Tooltip } from '@chakra-ui/tooltip';
export default function UnloadIconBtn(props) {
const { clickhandler, ...rest } = props;
const { clickhandler, disabled, ...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={disabled}>
<IconButton
icon={<IoStop size='22px' />}
colorScheme='red'
variant='outline'
onClick={clickhandler}
width={90}
_focus={{ boxShadow: 'none' }}
{...rest}
/>
</Tooltip>
);
}
@@ -1,19 +1,23 @@
import React from 'react';
import { IconButton } from '@chakra-ui/button';
import { FiSun } from 'react-icons/fi';
import { IoSunny } from '@react-icons/all-files/io5/IoSunny';
import { Tooltip } from '@chakra-ui/tooltip';
export default function VisibleIconBtn(props) {
const { actionHandler, active, ...rest } = props;
const { actionHandler, active, size, ...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={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,25 @@
import React from 'react';
import PropTypes from "prop-types";
import style from "../../../features/info/Info.module.scss";
import {Icon} from "@chakra-ui/react";
import {FiChevronUp} from "@react-icons/all-files/fi/FiChevronUp";
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 React, { 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,
small: PropTypes.bool,
isNegative: PropTypes.bool,
hideZeroHours: PropTypes.bool,
};
@@ -1,6 +1,10 @@
/* eslint-disable react/destructuring-assignment */
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 +20,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,42 @@
import React, { useContext } from 'react';
import EditableTimer from 'common/input/EditableTimer';
import { showWarningToast } from 'common/helpers/toastManager';
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;
/**
* This code is duplicated from EventTimesVertical
* @description Validates a time input against its pair
* @param {string} entry - field to validate: timeStart, timeEnd, durationOverride
* @param {number} val - field value
* @return {boolean}
*/
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 if (entry === 'durationOverride'){
return true;
} else {
return false
}
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 +47,7 @@ export default function EventTimes(props) {
actionHandler={actionHandler}
time={timeStart}
delay={delay}
previousEnd={previousEnd}
/>
<EditableTimer
name='timeEnd'
@@ -36,7 +55,16 @@ export default function EventTimes(props) {
actionHandler={actionHandler}
time={timeEnd}
delay={delay}
previousEnd={previousEnd}
/>
</>
);
}
EventTimes.propTypes = {
actionHandler: PropTypes.func.isRequired,
delay: PropTypes.number,
timeStart: PropTypes.number,
timeEnd: PropTypes.number,
previousEnd: PropTypes.number,
};
@@ -1,6 +1,9 @@
import React, { useContext } from 'react';
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 { 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,
timeStart: PropTypes.number,
timeEnd: PropTypes.number,
duration: PropTypes.number,
previousEnd: PropTypes.number,
};
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,46 +93,83 @@ 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,
timeEnd: PropTypes.number,
duration: PropTypes.number,
previousEnd: PropTypes.number,
};
if (v == null || timeStart == null || timeEnd == null) return true;
export default function EventTimesVertical(props) {
const { delay, timeStart, timeEnd, duration, previousEnd, actionHandler } = props;
const { emitWarning } = useContext(LoggingContext);
/**
* This code is duplicated from EventTimes
* @description Validates a time input against its pair
* @param {string} entry - field to validate: timeStart, timeEnd, durationOverride
* @param {number} val - field value
* @return {boolean}
*/
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 if (entry === 'durationOverride') {
return true;
} else {
return false;
}
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}
actionHandler={actionHandler}
delay={delay}
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
previousEnd={previousEnd}
/>
) : (
<Times
handleValidate={handleValidate}
actionHandler={props.actionHandler}
actionHandler={actionHandler}
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
previousEnd={previousEnd}
/>
);
}
EventTimesVertical.propTypes = {
delay: PropTypes.number,
timeStart: PropTypes.number,
timeEnd: PropTypes.number,
duration: PropTypes.number,
previousEnd: PropTypes.number,
actionHandler: PropTypes.func.isRequired,
};
@@ -1,4 +1,5 @@
import { clamp } from 'app/utils';
import React from 'react';
import { clamp } from 'app/utils/math';
import styles from './MyProgressBar.module.css';
export default function MyProgressBar(props) {
+18 -15
View File
@@ -1,8 +1,8 @@
import React, { useState, useEffect, useCallback } from 'react';
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';
import navlogo from 'assets/images/logos/LOGO-72.png';
import style from './NavLogo.module.scss';
@@ -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 React, { useContext, useEffect, useState } from 'react';
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/all-files/fi/FiCheck';
import { AppContext } from '../../../app/context/AppContext';
export default function ProtectRoute({ children }) {
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>
) : (
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,12 +1,13 @@
import React, { useEffect, useState } from 'react';
import TodayItem from './TodayItem';
import style from './Paginator.module.css';
import { useEffect, useState } from 'react';
import { useInterval } from 'app/hooks/useInterval';
import PropTypes from 'prop-types';
export default function Paginator(props) {
const { events, selectedId } = props;
const LIMIT_PER_PAGE = props.limit || 8;
const SCROLL_TIME = props.time * 1000 || 10000;
const { events, selectedId, limit = 7, time = 10, isBackstage } = props;
const LIMIT_PER_PAGE = limit;
const SCROLL_TIME = time * 1000 || 10000;
const [numEvents, setNumEvents] = useState(0);
const [page, setPage] = useState([]);
const [pages, setPages] = useState(0);
@@ -46,10 +47,7 @@ export default function Paginator(props) {
<div className={style.nav}>
{pages > 1 &&
[...Array(pages)].map((p, i) => (
<div
key={i}
className={i === selPage ? style.navItemSelected : style.navItem}
/>
<div key={i} className={i === selPage ? style.navItemSelected : style.navItem} />
))}
</div>
<div className={style.entries}>
@@ -63,6 +61,7 @@ export default function Paginator(props) {
timeStart={e.timeStart}
timeEnd={e.timeEnd}
title={e.title}
colour={isBackstage ? e.colour : ''}
backstageEvent={!e.isPublic}
/>
);
@@ -71,3 +70,11 @@ export default function Paginator(props) {
</>
);
}
Paginator.propTypes = {
events: PropTypes.array,
selectedId: PropTypes.string,
limit: PropTypes.number,
time: PropTypes.number,
isBackstage: PropTypes.bool,
};
@@ -1,3 +1,4 @@
import React from 'react';
import style from './TitleCard.module.css';
export default function TitleCard(props) {
@@ -1,3 +1,4 @@
import React from 'react';
import style from './TitleSide.module.css';
export default function TitleSide(props) {
@@ -1,25 +1,38 @@
import { stringFromMillis } from 'common/utils/dateConfig';
import React from 'react';
import { stringFromMillis } from 'ontime-utils/time';
import style from './Paginator.module.css';
import PropTypes from 'prop-types';
export default function TodayItem(props) {
const { selected, timeStart, timeEnd, title, backstageEvent } = props;
const { selected, timeStart, timeEnd, title, backstageEvent, colour } = props;
// Format timers
const start = stringFromMillis(timeStart, false) || '';
const end = stringFromMillis(timeEnd, false) || '';
// user colours
const userColour = colour !== '' ? colour : 'transparent';
// select styling
let selectStyle = style.entryPast;
if (selected === 1) selectStyle = style.entryNow;
else if (selected === 2) selectStyle = style.entryFuture;
return (
<div className={selectStyle}>
<div
className={`${style.entryTimes} ${
backstageEvent ? style.backstage : undefined
}`}
>{`${start} · ${end}`}</div>
<div className={selectStyle} style={{ borderLeft: `4px solid ${userColour}` }}>
<div className={`${style.entryTimes} ${backstageEvent ? style.backstage : undefined}`}>
{`${start} · ${end}`}
</div>
<div className={style.entryTitle}>{title}</div>
{backstageEvent && <div className={style.backstageInd}></div>}
{backstageEvent && <div className={style.backstageInd} />}
</div>
);
}
TodayItem.propTypes = {
selected: PropTypes.bool,
timeStart: PropTypes.number,
timeEnd: PropTypes.number,
title: PropTypes.string,
backstageEvent: PropTypes.bool,
colour: PropTypes.string,
};
@@ -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,
});
};
+27
View File
@@ -0,0 +1,27 @@
import React, { useEffect, useRef } from 'react';
import { Textarea } from '@chakra-ui/react';
import autosize from 'autosize/dist/autosize';
export const AutoTextArea = (props) => {
const ref = useRef();
useEffect(() => {
const node = ref.current;
autosize(ref.current);
return () => {
autosize.destroy(node);
};
}, []);
return (
<Textarea
overflow='hidden'
w='100%'
resize='none'
ref={ref}
transition='height none'
{...props}
/>
);
};
-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>
);
}
+25 -21
View File
@@ -1,6 +1,8 @@
import React, { useEffect, useState } from 'react';
import { Editable, EditableInput, EditablePreview } from '@chakra-ui/react';
import { useEffect, useState } from 'react';
import { clamp } from '../../app/utils/math';
import style from './TimeInput.module.css';
import PropTypes from 'prop-types';
const inputProps = {
width: 20,
@@ -8,46 +10,48 @@ const inputProps = {
backgroundColor: 'rgba(0,0,0,0.05)',
color: '#fff',
border: '1px solid #ecc94b55',
borderRadius: '4px',
borderRadius: '8px',
placeholder: '-',
textAlign: 'center',
};
export default function DelayInput(props) {
const [value, setValue] = useState(props.value);
const { actionHandler, value } = props;
const [_value, setValue] = useState(value);
useEffect(() => {
if (props.value == null) return;
setValue(props.value);
}, [props.value]);
const handleChange = (val) => {
if (val <= 666) setValue(val);
};
if (value == null) return;
setValue(value);
}, [value]);
const handleSubmit = (val) => {
if (val === props.value) return;
if (val === value) return;
if (val === '') setValue(0);
if (val >= 0 && val <= 60) {
// convert to ms and updates
props.actionHandler('update', { field: 'duration', value: val * 60000 });
} else {
props.actionHandler('update', { field: 'duration', value: 3600000 });
}
// convert to ms and updates
const msVal = clamp(val, -60, 60) * 60000;
actionHandler('update', { field: 'duration', value: msVal });
};
const labelText = `minutes ${value >= 0 ? 'delayed' : 'ahead'}`;
return (
<div className={style.timeInput}>
<Editable
{...inputProps}
value={value}
onChange={(v) => handleChange(v)}
value={_value}
onChange={(v) => setValue(v)}
onSubmit={(v) => handleSubmit(v)}
>
<EditablePreview />
<EditableInput type='number' min='0' max='60' />
<EditableInput type='number' min='-60' max='60' />
</Editable>
<span className={style.label}>{'minutes'}</span>
<span className={style.label}>{labelText}</span>
</div>
);
}
DelayInput.propTypes = {
actionHandler: PropTypes.func,
value: PropTypes.number,
};
+23 -12
View File
@@ -1,21 +1,27 @@
import React, { useEffect, useState } from 'react';
import { Editable, EditableInput, EditablePreview } from '@chakra-ui/editable';
import { useEffect, useState } from 'react';
import style from './EditableText.module.css';
import style from './EditableText.module.scss';
import PropTypes from 'prop-types';
export default function EditableText(props) {
const { label, defaultValue, placeholder, submitHandler, ...rest } = props;
const { label, defaultValue, placeholder, submitHandler, maxchar = 40, ...rest } = props;
const [text, setText] = useState(defaultValue || '');
const maxchar = props.maxchar || 40;
useEffect(() => {
if (defaultValue == null) setText('');
else setText(defaultValue);
}, [defaultValue]);
const handleSubmit = (submitedVal) => {
const handleSubmit = (submittedVal) => {
// No need to update if it hasnt changed
if (submitedVal === defaultValue) return;
submitHandler(submitedVal);
if (submittedVal === defaultValue) return;
// submit a cleaned up version of the string
const cleanVal = submittedVal.trim();
submitHandler(cleanVal);
if (cleanVal !== submittedVal) {
setText(cleanVal);
}
};
const handleChange = (val) => {
@@ -33,12 +39,17 @@ export default function EditableText(props) {
className={style.inline}
{...rest}
>
<EditablePreview
color={text === '' ? '#666' : 'inherit'}
maxWidth='75%'
/>
<EditableInput overflowX='hidden' maxWidth='75%' />
<EditablePreview className={text === '' ? style.preview : ''} />
<EditableInput />
</Editable>
</div>
);
}
EditableText.propTypes = {
label: PropTypes.string,
defaultValue: PropTypes.string,
placeholder: PropTypes.string,
submitHandler: PropTypes.func.isRequired,
maxchar: PropTypes.number,
};
@@ -1,19 +0,0 @@
.title {
padding-left: 1em;
font-size: 0.75em;
color: #aaa;
display: inline-block;
width: 6em;
}
.block {
display: 'block';
overflow: hidden;
}
.inline {
display: inline;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@@ -0,0 +1,28 @@
@use '../../styles/main' as *;
.block {
overflow: hidden;
display: flex;
align-items: center;
width: 100%;
.title {
padding-left: 1em;
font-size: 0.75em;
color: $label-gray;
display: inline-block;
min-width: 6em;
}
.preview {
color: #666;
}
.inline {
display: inline;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
}
+49 -23
View File
@@ -1,44 +1,58 @@
import React, { useContext, useEffect, useState } from 'react';
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 { 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
const validateValue = (value) => {
const success = handleSubmit(value);
if (success) {
const ms = forgivingStringToMillis(value);
setValue(stringFromMillis(ms + delay));
} else {
setValue(stringFromMillis(time + delay));
}
};
useEffect(() => {
if (time == null) return;
try {
setValue(stringFromMillis(time + delay));
} catch (error) {
showErrorToast('Error parsing date', error.text);
emitError(`Unable to parse date: ${error.text}`);
}
}, [time, delay]);
const validateValue = (value) => {
const success = handleSubmit(value);
if (success) setValue(value);
else setValue(stringFromMillis(time + delay, true));
};
}, [time, delay, emitError]);
const handleSubmit = (value) => {
// Check if there is anything there
if (value === '') return false;
// check if its valid time string
if (!isTimeString(value)) return false;
let newValMillis = 0;
// 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 if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) {
// 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
@@ -53,16 +67,28 @@ export default function EditableTimer(props) {
return true;
};
const isDelayed = delay != null && delay !== 0;
return (
<Editable
data-testid='editable-timer'
onChange={(v) => setValue(v)}
onSubmit={(v) => validateValue(v)}
onCancel={() => setValue(stringFromMillis(time + delay, true))}
value={value}
className={delay > 0 ? style.delayedEditable : style.editable}
className={isDelayed ? style.delayedEditable : style.editable}
>
<EditablePreview />
<EditableInput type='text' placeholder='--:--:--' />
<EditableInput type='text' placeholder='--:--:--' data-testid='editable-timer-input' />
</Editable>
);
}
EditableTimer.propTypes = {
name: PropTypes.string.isRequired,
actionHandler: PropTypes.func.isRequired,
time: PropTypes.number,
delay: PropTypes.number,
validate: PropTypes.func.isRequired,
previousEnd: PropTypes.number,
};
@@ -2,13 +2,12 @@
.delayedEditable {
background-color: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 4px;
width: 6.5em;
letter-spacing: 1px;
height: fit-content;
text-align: center;
border-radius: 4px;
border-radius: 8px;
}
.delayedEditable {
@@ -0,0 +1,32 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import EditableTimer from '../EditableTimer';
const renderEditableTimer = (
name = 'test',
actionHandler = () => undefined,
validate = () => undefined
) => render(<EditableTimer name={name} actionHandler={actionHandler} validate={validate} />);
describe('test EditableTimer component', () => {
const testName = "test";
const actionHandler = jest.fn();
const validate = jest.fn();
renderEditableTimer(testName, actionHandler, validate);
const editableTimer = screen.getByTestId('editable-timer');
const editableInput = screen.getByTestId('editable-timer-input');
it('renders correctly', () => {
expect(editableTimer).toBeInTheDocument();
expect(editableInput).toBeInTheDocument();
userEvent.type(editableInput, 'p');
expect(editableInput).toHaveValue('p');
userEvent.type(editableInput, '{enter}');
// no previous is given, defaults to 0
expect(validate).toHaveBeenCalledWith(testName, 0);
});
});
+1
View File
@@ -1,3 +1,4 @@
import React from 'react';
import { ReactComponent as Emptyimage } from 'assets/images/empty.svg';
import style from './Empty.module.css';
@@ -1,9 +1,12 @@
import {
forgivingStringToMillis,
formatDisplay,
isTimeString,
millisToMinutes,
millisToSeconds,
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,113 @@ 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 forgivingStringToMillis()', () => {
describe('function handles separators', () => {
const testData = [
{ value: '1:2:3:10', expect: 3723000 },
{ value: '2,10', expect: 130000 },
{ value: '2.10', expect: 130000 },
{ value: '2 10', expect: 130000 },
];
for (const s of testData) {
test(`it handles ${s.value}`, () => {
expect(typeof forgivingStringToMillis(s.value)).toBe('number');
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
describe('function handles time with no separators', () => {
const testData = [
{ value: '000000', expect: 0 },
{ value: '000001', expect: 1000 },
{ value: '000100', expect: 1000*60 },
{ value: '010000', expect: 1000*60*60 },
{ value: '230000', expect: 1000*60*60*23 },
{ value: '121212', expect: 12*1000+12*60*1000+12*1000*60*60 },
];
for (const s of testData) {
test(`it handles ${s.value}`, () => {
expect(typeof forgivingStringToMillis(s.value)).toBe('number');
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
describe('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);
});
}
});
describe('handles overflows', () => {
const ts = [
// minutes overflow
{ value: '120', expect: 1000*60*120 },
{ value: '2.0.0', expect: 1000*60*120 },
{ value: '99', expect: 1000*60*99 },
{ value: '1.39.0', expect: 1000*60*99 },
// seconds overflow
{ value: '0.120', expect: 120*1000 },
{ value: '0.0.120', expect: 120*1000 },
{ value: '0.2.0', expect: 120*1000 },
{ value: '0.99', expect: 99*1000 },
{ value: '0.0.99', expect: 99*1000 },
{ value: '0.1.39', expect: 99*1000 },
// hours overflow
{ value: '25.0.0', expect: 1000*60*60*25 },
// hours overflow
{ value: '50.0.0', expect: 1000*60*60*50 },
];
for (const s of ts) {
test(`it handles ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
});
@@ -1,111 +1,122 @@
import {formatEventList, getEventsWithDelay, trimEventlist} from "../eventsManager";
import { formatEventList, getEventsWithDelay, trimEventlist } from '../eventsManager';
test('getEventsWithDelay function', () => {
const testData = [
{
"title": "Welcome to Ontime",
"timeStart": 28800000,
"timeEnd": 30600000,
"type": "event",
"id": "5946"
title: 'Welcome to Ontime',
timeStart: 28800000,
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
"duration": 60000,
"type": "delay",
"id": "24240"
duration: 60000,
type: 'delay',
id: '24240',
},
{
"title": "Unless recalled by the OSC address",
"timeStart": 34920000,
"timeEnd": 35520000,
"type": "event",
"id": "8ee5"
title: 'Unless recalled by the OSC address',
timeStart: 34920000,
timeEnd: 35520000,
colour: '',
type: 'event',
id: '8ee5',
},
{
"title": "Use simpler times to create a timer",
"timeStart": 120000,
"timeEnd": 720000,
"type": "event",
"id": "8222"
title: 'Use simpler times to create a timer',
timeStart: 120000,
timeEnd: 720000,
colour: '',
type: 'event',
id: '8222',
},
{
"duration": 900000,
"type": "delay",
"revision": 0,
"id": "a386"
duration: 900000,
type: 'delay',
revision: 0,
id: 'a386',
},
{
"title": "Add delay blocks to affect all events",
"timeStart": 37320000,
"timeEnd": 38520000,
"type": "event",
"id": "6dce"
title: 'Add delay blocks to affect all events',
timeStart: 37320000,
timeEnd: 38520000,
colour: '',
type: 'event',
id: '6dce',
},
{
"title": "Add and remove events with [+] and [-]",
"timeStart": 38520000,
"timeEnd": 45120000,
"type": "event",
"id": "2651"
title: 'Add and remove events with [+] and [-]',
timeStart: 38520000,
timeEnd: 45120000,
colour: '',
type: 'event',
id: '2651',
},
{
"type": "block",
"id": "e6a1"
type: 'block',
id: 'e6a1',
},
{
"title": "And control whether they are public",
"timeStart": 46800000,
"timeEnd": 57600000,
"type": "event",
"id": "1358"
}
title: 'And control whether they are public',
timeStart: 46800000,
timeEnd: 57600000,
colour: '',
type: 'event',
id: '1358',
},
];
const expected = [
{
"title": "Welcome to Ontime",
"timeStart": 28800000,
"timeEnd": 30600000,
"type": "event",
"id": "5946"
title: 'Welcome to Ontime',
timeStart: 28800000,
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
"title": "Unless recalled by the OSC address",
"timeStart": 34920000+60000,
"timeEnd": 35520000+60000,
"type": "event",
"id": "8ee5"
title: 'Unless recalled by the OSC address',
timeStart: 34920000 + 60000,
timeEnd: 35520000 + 60000,
colour: '',
type: 'event',
id: '8ee5',
},
{
"title": "Use simpler times to create a timer",
"timeStart": 120000+60000,
"timeEnd": 720000+60000,
"type": "event",
"id": "8222"
title: 'Use simpler times to create a timer',
timeStart: 120000 + 60000,
timeEnd: 720000 + 60000,
colour: '',
type: 'event',
id: '8222',
},
{
"title": "Add delay blocks to affect all events",
"timeStart": 37320000+60000+900000,
"timeEnd": 38520000+60000+900000,
"type": "event",
"id": "6dce"
title: 'Add delay blocks to affect all events',
timeStart: 37320000 + 60000 + 900000,
timeEnd: 38520000 + 60000 + 900000,
colour: '',
type: 'event',
id: '6dce',
},
{
"title": "Add and remove events with [+] and [-]",
"timeStart": 38520000+60000+900000,
"timeEnd": 45120000+60000+900000,
"type": "event",
"id": "2651"
title: 'Add and remove events with [+] and [-]',
timeStart: 38520000 + 60000 + 900000,
timeEnd: 45120000 + 60000 + 900000,
colour: '',
type: 'event',
id: '2651',
},
{
"title": "And control whether they are public",
"timeStart": 46800000,
"timeEnd": 57600000,
"type": "event",
"id": "1358"
}
]
title: 'And control whether they are public',
timeStart: 46800000,
timeEnd: 57600000,
colour: '',
type: 'event',
id: '1358',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
@@ -135,6 +146,7 @@ describe('getEventsWithDelay edge cases', () => {
{
"title": "Welcome to Ontime",
"timeEnd": 30600000,
"colour": "",
"type": "event",
"id": "5946"
},
@@ -147,6 +159,7 @@ describe('getEventsWithDelay edge cases', () => {
"title": "Unless recalled by the OSC address",
"timeStart": 34920000,
"timeEnd": 35520000,
"colour": "",
"type": "event",
"id": "8ee5"
}
@@ -155,13 +168,15 @@ describe('getEventsWithDelay edge cases', () => {
{
"title": "Welcome to Ontime",
"timeEnd": 30600000,
"colour": "",
"type": "event",
"id": "5946"
},
{
"title": "Unless recalled by the OSC address",
"timeStart": 34920000+60000,
"timeEnd": 35520000+60000,
"timeStart": 34920000 + 60000,
"timeEnd": 35520000 + 60000,
"colour": "",
"type": "event",
"id": "8ee5"
}
@@ -176,6 +191,7 @@ describe('getEventsWithDelay edge cases', () => {
"title": "Welcome to Ontime",
"timeStart": 28800000,
"timeEnd": 30600000,
"colour": "",
"type": "event",
"id": "5946"
},
@@ -187,6 +203,7 @@ describe('getEventsWithDelay edge cases', () => {
"title": "Unless recalled by the OSC address",
"timeStart": 34920000,
"timeEnd": 35520000,
"colour": "",
"type": "event",
"id": "8ee5"
}
@@ -196,6 +213,7 @@ describe('getEventsWithDelay edge cases', () => {
"title": "Welcome to Ontime",
"timeStart": 28800000,
"timeEnd": 30600000,
"colour": "",
"type": "event",
"id": "5946"
},
@@ -203,6 +221,7 @@ describe('getEventsWithDelay edge cases', () => {
"title": "Unless recalled by the OSC address",
"timeStart": 34920000,
"timeEnd": 35520000,
"colour": "",
"type": "event",
"id": "8ee5"
}
@@ -216,32 +235,32 @@ describe('test trimEventlist function', () => {
const limit = 8;
const testData = [
{id: '1'},
{id: '2'},
{id: '3'},
{id: '4'},
{id: '5'},
{id: '6'},
{id: '7'},
{id: '8'},
{id: '9'},
{id: '10'},
{id: '11'},
{id: '12'},
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
{ id: '9' },
{ id: '10' },
{ id: '11' },
{ id: '12' },
];
test('when we use the first item', () => {
const selectedId = '1';
const expected = [
{id: '1'},
{id: '2'},
{id: '3'},
{id: '4'},
{id: '5'},
{id: '6'},
{id: '7'},
{id: '8'},
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
];
const l = trimEventlist(testData, selectedId, limit);
@@ -252,14 +271,14 @@ describe('test trimEventlist function', () => {
test('when we use the third item', () => {
const selectedId = '3';
const expected = [
{id: '1'},
{id: '2'},
{id: '3'},
{id: '4'},
{id: '5'},
{id: '6'},
{id: '7'},
{id: '8'}
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' }
];
const l = trimEventlist(testData, selectedId, limit);
@@ -270,14 +289,14 @@ describe('test trimEventlist function', () => {
test('when we use the fourth item', () => {
const selectedId = '4';
const expected = [
{id: '2'},
{id: '3'},
{id: '4'},
{id: '5'},
{id: '6'},
{id: '7'},
{id: '8'},
{id: '9'}
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
{ id: '9' }
];
const l = trimEventlist(testData, selectedId, limit);
@@ -288,14 +307,14 @@ describe('test trimEventlist function', () => {
test('if selected is not found', () => {
const selectedId = '15';
const expected = [
{id: '1'},
{id: '2'},
{id: '3'},
{id: '4'},
{id: '5'},
{id: '6'},
{id: '7'},
{id: '8'},
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
];
const l = trimEventlist(testData, selectedId, limit);
@@ -314,25 +333,27 @@ describe('test formatEvents function', () => {
"timeStart": 28800000,
"timeEnd": 30600000,
"isPublic": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "5946"
},
{
"title": "Unless recalled by the OSC address",
"subtitle": "",
"presenter": "",
"note": "In green, below",
"timeStart": 34800000,
"timeEnd": 35400000,
"isPublic": false,
"type": "event",
"revision": 0,
"id": "8ee5"
}
},
{
"title": "Unless recalled by the OSC address",
"subtitle": "",
"presenter": "",
"note": "In green, below",
"timeStart": 34800000,
"timeEnd": 35400000,
"isPublic": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "8ee5"
}
];
test ('it parses correctly', () => {
test('it parses correctly', () => {
const selectedId = 'otherEvent';
const nextId = 'notHere';
const expected = [
@@ -342,6 +363,7 @@ describe('test formatEvents function', () => {
title: 'Welcome to Ontime',
isNow: false,
isNext: false,
colour: ""
},
{
id: '8ee5',
@@ -349,6 +371,7 @@ describe('test formatEvents function', () => {
title: 'Unless recalled by the OSC address',
isNow: false,
isNext: false,
colour: "",
},
]
@@ -357,7 +380,7 @@ describe('test formatEvents function', () => {
expect(parsed).toStrictEqual(expected);
});
test ('it handles selected correctly', () => {
test('it handles selected correctly', () => {
const selectedId = '5946';
const nextId = '8ee5';
const expected = [
@@ -367,6 +390,7 @@ describe('test formatEvents function', () => {
title: 'Welcome to Ontime',
isNow: true,
isNext: false,
colour: "",
},
{
id: '8ee5',
@@ -374,15 +398,16 @@ describe('test formatEvents function', () => {
title: 'Unless recalled by the OSC address',
isNow: false,
isNext: true,
colour: "",
},
]
const parsed = formatEventList(testEvent, selectedId, nextId,true);
const parsed = formatEventList(testEvent, selectedId, nextId, true);
expect(parsed).toStrictEqual(expected);
});
test ('it handles next correctly', () => {
test('it handles next correctly', () => {
const selectedId = '8ee5';
const nextId = 'notHere';
@@ -393,6 +418,7 @@ describe('test formatEvents function', () => {
title: 'Welcome to Ontime',
isNow: false,
isNext: false,
colour: "",
},
{
id: '8ee5',
@@ -400,6 +426,7 @@ describe('test formatEvents function', () => {
title: 'Unless recalled by the OSC address',
isNow: true,
isNext: false,
colour: "",
},
]
+51 -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,53 @@ 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} value - time string
* @returns {number} - time string in millis
*/
export const forgivingStringToMillis = (value) => {
let millis = 0;
// split string at known separators : , .
const separatorRegex = /[\s,:.]+/;
const [first, second, third] = value.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 (first != null && second != null && third == null) {
// if string has two sections, treat as [minutes] [seconds]
millis = parse(first) * mtm;
millis += parse(second) * mts;
} else if (first != null && second == null && third == null) {
// if string has one section,
// could be a complete string like 121010 - 12:10:10
if (first.length === 6) {
const hours = first.substring(0, 2);
const minutes = first.substring(2, 4);
const seconds = first.substring(4);
millis = parse(hours) * mth;
millis += parse(minutes) * mtm;
millis += parse(seconds) * mts;
} else {
// otherwise lets treat as [minutes]
millis = parse(first) * mtm;
}
}
return millis;
};
+8 -7
View File
@@ -1,12 +1,11 @@
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 [];
const unfilteredEvents = [...events];
@@ -43,8 +42,11 @@ export const trimEventlist = (events, selectedId, limit) => {
if (limit != null) {
while (trimmedEvents.length > limit) {
const idx = trimmedEvents.findIndex((e) => e.id === selectedId);
if (idx <= BEFORE) { trimmedEvents.pop(); }
else { trimmedEvents.shift(); }
if (idx <= BEFORE) {
trimmedEvents.pop();
} else {
trimmedEvents.shift();
}
}
}
return trimmedEvents;
@@ -75,10 +77,9 @@ export const formatEventList = (events, selectedId, nextId, showEnd = false) =>
title: g.title,
isNow: g.id === selectedId,
isNext: g.id === nextId,
colour: g.colour,
});
}
return formattedEvents;
};
+30
View File
@@ -0,0 +1,30 @@
/**
* Returns hostname
* @type {string}
*/
export const host = window?.location?.host;
/**
* Open an 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 function openLink(url) {
if (window.process?.type === 'renderer') {
window.ipcRenderer.send('send-to-link', url);
} else {
window.open(url);
}
}
/**
* Handles opening external links
* @param event
* @param location
*/
export function handleLinks(event, location) {
// we handle the link manually
event.preventDefault();
openLink(`http://${host}/${location}`);
}
+23 -23
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 React, { useEffect, useState } from 'react';
import { Editable, EditableInput, EditablePreview } from '@chakra-ui/editable';
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,25 +10,25 @@ const inputProps = {
};
const InputRow = (props) => {
const {label, placeholder, text, visible} = props;
const { label, placeholder, text, visible, actionHandler, changeHandler } = props;
return (
<>
<span className={style.label}>{label}</span>
<div className={style.inputItems}>
<Editable
onChange={(event) => props.changeHandler(event)}
onChange={(event) => changeHandler(event)}
value={text}
placeholder={placeholder}
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}
actionHandler={props.actionHandler}
actionHandler={actionHandler}
{...inputProps}
/>
</div>
@@ -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;
}
}
+37 -15
View File
@@ -1,4 +1,5 @@
import { memo } from 'react';
import React, { 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,74 +11,95 @@ 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, playbackControl } = props;
return (
<>
<Playback
playback={playback}
selectedId={selectedId}
playbackControl={props.playbackControl}
noEvents={noEvents}
playbackControl={playbackControl}
/>
<Transport
playback={playback}
selectedId={selectedId}
playbackControl={props.playbackControl}
noEvents={noEvents}
playbackControl={playbackControl}
/>
</>
);
};
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,
};

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