diff --git a/apps/client/package.json b/apps/client/package.json index be9717b25..aadc5ed14 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -1,6 +1,6 @@ { "name": "ontime-ui", - "version": "2.0.0-beta5", + "version": "2.0.0-beta", "private": true, "dependencies": { "@chakra-ui/react": "^2.5.5", diff --git a/apps/client/src/AppRouter.tsx b/apps/client/src/AppRouter.tsx index 86194e63c..69f7869c3 100644 --- a/apps/client/src/AppRouter.tsx +++ b/apps/client/src/AppRouter.tsx @@ -26,7 +26,7 @@ const SPublic = withData(Public); const SLowerThird = withData(Lower); const SStudio = withData(StudioClock); -const FeatureWrapper = lazy(() => import('./features/FeatureWrapper')); +const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper')); const RundownPanel = lazy(() => import('./features/rundown/RundownExport')); const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport')); const MessageControl = lazy(() => import('./features/control/message/MessageControlExport')); @@ -83,33 +83,33 @@ export default function AppRouter() { + - + } /> + - + } /> + - + } /> + - + } /> {/*/!* Send to default if nothing found *!/*/} diff --git a/apps/client/src/common/components/input/time-input/TimeInput.tsx b/apps/client/src/common/components/input/time-input/TimeInput.tsx index bbdeb6e69..0be50bdc0 100644 --- a/apps/client/src/common/components/input/time-input/TimeInput.tsx +++ b/apps/client/src/common/components/input/time-input/TimeInput.tsx @@ -1,4 +1,4 @@ -import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react'; +import { FocusEvent, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Button, Input, InputGroup, InputLeftElement, Tooltip } from '@chakra-ui/react'; import { millisToString } from 'ontime-utils'; @@ -21,6 +21,20 @@ interface TimeInputProps { warning?: string; } +function ButtonInitial(name: TimeEntryField) { + if (name === 'timeStart') return 'S'; + if (name === 'timeEnd') return 'E'; + if (name === 'durationOverride') return 'D'; + return ''; +} + +function ButtonTooltip(name: TimeEntryField, warning?: string) { + if (name === 'timeStart') return `Start${warning ? `: ${warning}` : ''}`; + if (name === 'timeEnd') return `End${warning ? `: ${warning}` : ''}`; + if (name === 'durationOverride') return `Duration${warning ? `: ${warning}` : ''}`; + return ''; +} + export default function TimeInput(props: TimeInputProps) { const { name, submitHandler, time = 0, delay = 0, placeholder, validationHandler, previousEnd = 0, warning } = props; const { emitError } = useEmitLog(); @@ -142,30 +156,24 @@ export default function TimeInput(props: TimeInputProps) { useEffect(() => { if (time == null) return; resetValue(); - }, [emitError, resetValue, time]); - - const ButtonInitial = () => { - if (name === 'timeStart') return 'S'; - if (name === 'timeEnd') return 'E'; - if (name === 'durationOverride') return 'D'; - return ''; - }; - - const ButtonTooltip = () => { - if (name === 'timeStart') return `Start${warning ? `: ${warning}` : ''}`; - if (name === 'timeEnd') return `End${warning ? `: ${warning}` : ''}`; - if (name === 'durationOverride') return `Duration${warning ? `: ${warning}` : ''}`; - return ''; - }; + }, [resetValue, time]); const isDelayed = delay !== 0; const inputClasses = cx([style.timeInput, isDelayed ? style.delayed : null]); const buttonClasses = cx([style.inputButton, isDelayed ? style.delayed : null, warning ? style.warn : null]); + const TooltipLabel = useMemo(() => { + return ButtonTooltip(name, warning); + }, [name, warning]); + + const ButtonText = useMemo(() => { + return ButtonInitial(name); + }, [name]); + return ( - + diff --git a/apps/client/src/common/components/protect-route/PinPage.tsx b/apps/client/src/common/components/protect-route/PinPage.tsx new file mode 100644 index 000000000..dfbd461e7 --- /dev/null +++ b/apps/client/src/common/components/protect-route/PinPage.tsx @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useState } from 'react'; +import { HStack, IconButton, PinInput, PinInputField } from '@chakra-ui/react'; +import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark'; + +import style from './ProtectRoute.module.scss'; + +interface PinPageProps { + permission: 'editor' | 'operator'; + handleValidation: (pin: string) => boolean; +} + +export default function PinPage(props: PinPageProps) { + const { permission, handleValidation } = props; + const [pin, setPin] = useState(''); + const [failed, setFailed] = useState(false); + + const validate = useCallback(() => { + const isValid = handleValidation(pin); + if (!isValid) { + setFailed(true); + setPin(''); + } + }, [handleValidation, pin]); + + useEffect(() => { + const handleKeyPress = (event: KeyboardEvent) => { + if (event.repeat) return; + if (event.key === 'Enter') { + validate(); + } + }; + + document.addEventListener('keydown', handleKeyPress); + + return () => { + document.removeEventListener('keydown', handleKeyPress); + }; + }, [validate]); + + return ( +
+ {`Ontime ${permission || ''}`} + + { + setFailed(false); + setPin(value); + }} + > + + + + + + } onClick={validate} /> + +
+ ); +} diff --git a/apps/client/src/common/components/protect-route/ProtectRoute.jsx b/apps/client/src/common/components/protect-route/ProtectRoute.jsx deleted file mode 100644 index 0e58b123c..000000000 --- a/apps/client/src/common/components/protect-route/ProtectRoute.jsx +++ /dev/null @@ -1,91 +0,0 @@ -import { useCallback, useContext, useEffect, useState } from 'react'; -import { HStack, IconButton, PinInput, PinInputField } from '@chakra-ui/react'; -import { FiCheck } from '@react-icons/all-files/fi/FiCheck'; -import PropTypes from 'prop-types'; - -import { AppContext } from '../../context/AppContext'; - -import style from './ProtectRoute.module.scss'; - -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); - - const handleValidation = useCallback(() => { - const r = validate(pin); - if (!r) { - setFailed(true); - setPin(''); - } - }, [pin, validate]); - - // Set window title - useEffect(() => { - document.title = 'ontime'; - }, []); - - // Handle keyboard shortcuts - const handleKeyPress = useCallback( - (e) => { - // handle held key - if (e.repeat) return; - // Space bar - if (e.keyCode === 13) { - handleValidation(); - } - }, - [handleValidation] - ); - - useEffect(() => { - // attach the event listener - document.addEventListener('keydown', handleKeyPress); - - // remove the event listener - return () => { - document.removeEventListener('keydown', handleKeyPress); - }; - }, [handleKeyPress]); - - if (isLocal || auth) { - return children; - } - - return ( -
- ontime - - { - setFailed(false); - setPin(value); - }} - > - - - - - - } - onClick={() => handleValidation()} - /> - -
- ); -} - -ProtectRoute.propTypes = { - children: PropTypes.node.isRequired, -}; diff --git a/apps/client/src/common/components/protect-route/ProtectRoute.tsx b/apps/client/src/common/components/protect-route/ProtectRoute.tsx new file mode 100644 index 000000000..2cc826e3d --- /dev/null +++ b/apps/client/src/common/components/protect-route/ProtectRoute.tsx @@ -0,0 +1,38 @@ +import { PropsWithChildren, useCallback, useContext } from 'react'; + +import { AppContext } from '../../context/AppContext'; + +import PinPage from './PinPage'; + +interface ProtectRouteProps { + permission: 'editor' | 'operator'; +} + +export default function ProtectRoute({ permission, children }: PropsWithChildren) { + const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; + const { editorAuth, operatorAuth, validate } = useContext(AppContext); + + const handleValidation = useCallback( + (pin: string) => { + return validate(pin, permission); + }, + [permission, validate], + ); + + const hasRelevantAuth = () => { + if (permission === 'editor') { + return editorAuth; + } + if (permission === 'operator') { + return operatorAuth; + } + return false; + }; + + if (isLocal || hasRelevantAuth()) { + // eslint-disable-next-line react/jsx-no-useless-fragment -- trying to make typescript happy + return <>{children}; + } + + return ; +} diff --git a/apps/client/src/common/context/AppContext.jsx b/apps/client/src/common/context/AppContext.jsx deleted file mode 100644 index 1ffad8125..000000000 --- a/apps/client/src/common/context/AppContext.jsx +++ /dev/null @@ -1,54 +0,0 @@ -import { createContext, useCallback, useEffect, useState } from 'react'; - -import useSettings from '../hooks-query/useSettings'; - -export const AppContext = createContext({ - auth: false, - data: { - pinCode: null, - }, -}); - -export const AppContextProvider = ({ children }) => { - const [auth, setAuth] = useState(true); - const { data } = useSettings(); - - useEffect(() => { - if (data == null) return; - const previousEntry = sessionStorage.getItem('ontime-entry'); - if (previousEntry) { - if (previousEntry === data?.pinCode) { - setAuth(true); - } else { - sessionStorage.removeItem('ontime-entry'); - } - } else if (data?.pinCode == null || data?.pinCode === '') { - setAuth(true); - } else { - setAuth(false); - } - }, [data]); - - /** - * Validates a pincode - * @return boolean - whether the pin is valid - */ - const validate = useCallback( - (pin) => { - let correct; - if (data?.pinCode == null || data?.pinCode === '') { - correct = true; - } else { - correct = pin === data?.pinCode; - } - if (correct) { - sessionStorage.setItem('ontime-entry', pin); - } - setAuth(correct); - return correct; - }, - [data], - ); - - return {children}; -}; diff --git a/apps/client/src/common/context/AppContext.tsx b/apps/client/src/common/context/AppContext.tsx new file mode 100644 index 000000000..373a86dfb --- /dev/null +++ b/apps/client/src/common/context/AppContext.tsx @@ -0,0 +1,81 @@ +import { createContext, PropsWithChildren, useCallback, useEffect, useState } from 'react'; + +import useSettings from '../hooks-query/useSettings'; + +interface AppContextType { + editorAuth: boolean; + operatorAuth: boolean; + validate: (pin: string, permission: 'editor' | 'operator') => boolean; +} + +export const AppContext = createContext({ + editorAuth: false, + operatorAuth: false, + validate: () => false, +}); + +const storageKeys = { + editor: 'ontime-editor-entry', + operator: 'ontime-operator-entry', +}; + +export const AppContextProvider = ({ children }: PropsWithChildren) => { + const { status, data } = useSettings(); + const [editorAuth, setEditorAuth] = useState(true); + const [operatorAuth, setOperatorAuth] = useState(true); + + useEffect(() => { + if (status === 'loading') return; + if (!data) return; + const previousEditor = sessionStorage.getItem(storageKeys.editor); + + if (previousEditor && previousEditor === data.editorKey) { + setEditorAuth(true); + } else { + setEditorAuth(data.editorKey == null || data.editorKey === ''); + } + + const previousOperator = sessionStorage.getItem(storageKeys.operator); + if (previousOperator && previousOperator === data.operatorKey) { + setOperatorAuth(true); + } else { + setOperatorAuth(data.operatorKey == null || data.operatorKey === ''); + } + }, [data, status]); + + /** + * Validates a pincode + * @return boolean - whether the pin is valid + */ + const validate = useCallback( + (pin: string, permission: 'editor' | 'operator'): boolean => { + function isValid(pin: string, savedPin?: string | null): boolean { + return savedPin == null || savedPin === '' || pin === savedPin; + } + + if (!data) { + return false; + } + + if (permission === 'editor') { + const correct = isValid(pin, data.editorKey); + if (correct) { + sessionStorage.setItem(storageKeys.editor, pin); + } + setEditorAuth(correct); + return correct; + } else if (permission === 'operator') { + const correct = isValid(pin, data.operatorKey); + if (correct) { + sessionStorage.setItem(storageKeys.operator, pin); + } + setOperatorAuth(correct); + return correct; + } + return false; + }, + [data], + ); + + return {children}; +}; diff --git a/apps/client/src/common/queryClient.ts b/apps/client/src/common/queryClient.ts index a2f1d2675..368c6582b 100644 --- a/apps/client/src/common/queryClient.ts +++ b/apps/client/src/common/queryClient.ts @@ -1,3 +1,9 @@ import { QueryClient } from '@tanstack/react-query'; -export const ontimeQueryClient = new QueryClient(); +export const ontimeQueryClient = new QueryClient({ + defaultOptions: { + queries: { + cacheTime: 1000 * 60 * 10, // 10 min + }, + }, +}); diff --git a/apps/client/src/features/FeatureWrapper.tsx b/apps/client/src/features/EditorFeatureWrapper.tsx similarity index 54% rename from apps/client/src/features/FeatureWrapper.tsx rename to apps/client/src/features/EditorFeatureWrapper.tsx index 8e753f7a9..722f918a0 100644 --- a/apps/client/src/features/FeatureWrapper.tsx +++ b/apps/client/src/features/EditorFeatureWrapper.tsx @@ -1,16 +1,12 @@ -import { ReactNode } from 'react'; +import { PropsWithChildren } from 'react'; import ProtectRoute from '../common/components/protect-route/ProtectRoute'; import style from './FeatureWrapper.module.scss'; -interface FeatureWrapperProps { - children: ReactNode; -} - -export default function FeatureWrapper({ children }: FeatureWrapperProps) { +export default function EditorFeatureWrapper({ children }: PropsWithChildren) { return ( - +
{children}
); diff --git a/apps/client/src/features/editors/ProtectedEditor.jsx b/apps/client/src/features/editors/ProtectedEditor.tsx similarity index 84% rename from apps/client/src/features/editors/ProtectedEditor.jsx rename to apps/client/src/features/editors/ProtectedEditor.tsx index 4bc221a57..2763a55b3 100644 --- a/apps/client/src/features/editors/ProtectedEditor.jsx +++ b/apps/client/src/features/editors/ProtectedEditor.tsx @@ -4,7 +4,7 @@ import Editor from './Editor'; export default function ProtectedEditor() { return ( - + ); diff --git a/apps/client/src/features/menu/MenuBar.tsx b/apps/client/src/features/menu/MenuBar.tsx index 1dca8ae07..cefecc47e 100644 --- a/apps/client/src/features/menu/MenuBar.tsx +++ b/apps/client/src/features/menu/MenuBar.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from 'react'; +import { memo, useCallback, useEffect } from 'react'; import { VStack } from '@chakra-ui/react'; import { IoColorWand } from '@react-icons/all-files/io5/IoColorWand'; import { IoExtensionPuzzle } from '@react-icons/all-files/io5/IoExtensionPuzzle'; @@ -44,7 +44,7 @@ const buttonStyle = { }, }; -export default function MenuBar(props: MenuBarProps) { +const MenuBar = (props: MenuBarProps) => { const { isSettingsOpen, onSettingsOpen, @@ -175,4 +175,6 @@ export default function MenuBar(props: MenuBarProps) { /> ); -} +}; + +export default memo(MenuBar); diff --git a/apps/client/src/features/modals/integration-modal/OscIntegration.tsx b/apps/client/src/features/modals/integration-modal/OscIntegration.tsx index 6e671c81c..d63417c25 100644 --- a/apps/client/src/features/modals/integration-modal/OscIntegration.tsx +++ b/apps/client/src/features/modals/integration-modal/OscIntegration.tsx @@ -104,7 +104,7 @@ export default function OscIntegration() {
{subscriptionKeys.map((cycle, idx) => { return ( - <> +
{idx < subscriptionKeys.length - 1 &&
} - +
); })} - +
diff --git a/apps/client/src/features/rundown/event-block/EventBlock.tsx b/apps/client/src/features/rundown/event-block/EventBlock.tsx index a50fb1259..ab8675585 100644 --- a/apps/client/src/features/rundown/event-block/EventBlock.tsx +++ b/apps/client/src/features/rundown/event-block/EventBlock.tsx @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo'; @@ -136,14 +136,14 @@ export default function EventBlock(props: EventBlockProps) { hasCursor ? style.hasCursor : null, ]); + const handleFocusClick = (event: MouseEvent) => { + event.stopPropagation(); + moveCursorTo(eventId, true); + }; + return ( -
-
moveCursorTo(eventId, true)} - > +
+
diff --git a/apps/client/src/features/table/ProtectedTable.jsx b/apps/client/src/features/table/ProtectedTable.tsx similarity index 89% rename from apps/client/src/features/table/ProtectedTable.jsx rename to apps/client/src/features/table/ProtectedTable.tsx index 29f35863a..ca0de4beb 100644 --- a/apps/client/src/features/table/ProtectedTable.jsx +++ b/apps/client/src/features/table/ProtectedTable.tsx @@ -5,10 +5,10 @@ import TableWrapper from './TableWrapper'; export default function ProtectedTable() { return ( - + ); -} \ No newline at end of file +} diff --git a/apps/electron/package.json b/apps/electron/package.json index ed1e4bc22..782c7fc0e 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -1,6 +1,6 @@ { "name": "ontime", - "version": "2.0.0-beta5", + "version": "2.0.0-beta6", "author": "Carlos Valente", "description": "Time keeping for live events", "repository": "https://github.com/cpvalente/ontime", diff --git a/apps/server/package.json b/apps/server/package.json index a2d15c648..d28cd36bd 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -2,7 +2,7 @@ "name": "ontime-server", "type": "module", "main": "src/index.ts", - "version": "2.0.0-beta5", + "version": "2.0.0-beta6", "exports": "./src/index.js", "dependencies": { "body-parser": "^1.20.0", diff --git a/apps/server/src/controllers/ontimeController.validate.js b/apps/server/src/controllers/ontimeController.validate.js index 16ad59b35..b2aed1158 100644 --- a/apps/server/src/controllers/ontimeController.validate.js +++ b/apps/server/src/controllers/ontimeController.validate.js @@ -7,6 +7,11 @@ import { validateOscSubscription } from '../utils/parserFunctions.js'; export const viewValidator = [ check('overrideStyles').isBoolean().withMessage('overrideStyles value must be boolean'), check('endMessage').isString().trim().withMessage('endMessage value must be string'), + check('normalColor').isString().trim().withMessage('normalColor value must be string'), + check('warningColor').isString().trim().withMessage('warningColor value must be string'), + check('dangerColor').isString().trim().withMessage('dangerColor value must be string'), + check('warningThreshold').isNumeric().withMessage('warningThreshold value must be a number'), + check('dangerThreshold').isNumeric().withMessage('dangerThreshold value must a number'), (req, res, next) => { const errors = validationResult(req); if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); diff --git a/apps/server/src/utils/parser.ts b/apps/server/src/utils/parser.ts index 79e054309..296a583ec 100644 --- a/apps/server/src/utils/parser.ts +++ b/apps/server/src/utils/parser.ts @@ -11,7 +11,6 @@ import { deleteFile, makeString, validateDuration } from './parserUtils.js'; import { parseAliases, parseEventData, - parseHttp, parseOsc, parseRundown, parseSettings, @@ -282,7 +281,7 @@ export const parseJson = async (jsonData, enforce = false): Promise