diff --git a/.husky/pre-commit b/.husky/pre-commit index 58993aaee..a5a29d9f7 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" -pnpm lint +pnpm lint-staged diff --git a/apps/client/package.json b/apps/client/package.json index 653c5ee96..c89afbdf3 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -41,6 +41,7 @@ "build:electron": "cross-env NODE_ENV=local vite build", "build:docker": "vite build", "lint": "eslint . --quiet", + "lint-staged": "eslint", "test": "vitest", "test:pipeline": "vitest run", "cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build" diff --git a/apps/client/src/common/components/multi-part-progress-bar/MultiPartProgressBar.scss b/apps/client/src/common/components/multi-part-progress-bar/MultiPartProgressBar.scss index 4b3ed43cd..b9378798c 100644 --- a/apps/client/src/common/components/multi-part-progress-bar/MultiPartProgressBar.scss +++ b/apps/client/src/common/components/multi-part-progress-bar/MultiPartProgressBar.scss @@ -10,6 +10,7 @@ $progress-bar-br: 3px; border-radius: $progress-bar-br; background-color: var(--timer-progress-bg-override, $viewer-card-bg-color); display: flex; + overflow: hidden; &--hidden { display: none; @@ -31,7 +32,6 @@ $progress-bar-br: 3px; position: absolute; height: inherit; right: 0; - border-radius: $progress-bar-br; width: 100%; } @@ -39,12 +39,10 @@ $progress-bar-br: 3px; position: absolute; height: inherit; right: 0; - border-radius: 0 $progress-bar-br $progress-bar-br 0; } .multiprogress-bar__bg-danger { position: absolute; height: inherit; right: 0; - border-radius: 0 $progress-bar-br $progress-bar-br 0; } \ No newline at end of file diff --git a/apps/client/src/common/components/multi-part-progress-bar/MultiPartProgressBar.tsx b/apps/client/src/common/components/multi-part-progress-bar/MultiPartProgressBar.tsx index d44725f52..151e3efad 100644 --- a/apps/client/src/common/components/multi-part-progress-bar/MultiPartProgressBar.tsx +++ b/apps/client/src/common/components/multi-part-progress-bar/MultiPartProgressBar.tsx @@ -3,7 +3,7 @@ import { clamp } from '../../utils/math'; import './MultiPartProgressBar.scss'; interface MultiPartProgressBar { - now: number; + now: number | null; complete: number; normalColor: string; warning: number; @@ -17,23 +17,27 @@ interface MultiPartProgressBar { export default function MultiPartProgressBar(props: MultiPartProgressBar) { const { now, complete, normalColor, warning, warningColor, danger, dangerColor, hidden, className = '' } = props; - const percentComplete = 100 - clamp(100 - (Math.max(now, 0) * 100) / complete, 0, 100); + const percentComplete = 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100); const dangerWidth = clamp((danger / complete) * 100, 0, 100); const warningWidth = clamp((warning / complete) * 100, 0, 100); return (
-
-
-
-
+ {now !== null && ( + <> +
+
+
+
+ + )}
); } diff --git a/apps/client/src/common/components/schedule/ScheduleItem.tsx b/apps/client/src/common/components/schedule/ScheduleItem.tsx index af265725c..dcfabc2e2 100644 --- a/apps/client/src/common/components/schedule/ScheduleItem.tsx +++ b/apps/client/src/common/components/schedule/ScheduleItem.tsx @@ -1,7 +1,12 @@ +import SuperscriptTime from '../../../features/viewers/common/superscript-time/SuperscriptTime'; import { formatTime } from '../../utils/time'; import './Schedule.scss'; +const formatOptions = { + format: 'hh:mm a', +}; + interface ScheduleItemProps { selected: 'past' | 'now' | 'future'; timeStart: number; @@ -16,8 +21,8 @@ interface ScheduleItemProps { export default function ScheduleItem(props: ScheduleItemProps) { const { selected, timeStart, timeEnd, title, presenter, backstageEvent, colour, skip } = props; - const start = formatTime(timeStart, { format: 'hh:mm' }); - const end = formatTime(timeEnd, { format: 'hh:mm' }); + const start = formatTime(timeStart, formatOptions); + const end = formatTime(timeEnd, formatOptions); const userColour = colour !== '' ? colour : ''; const selectStyle = `entry--${selected}`; @@ -25,7 +30,12 @@ export default function ScheduleItem(props: ScheduleItemProps) {
  • - {`${start} → ${end} ${backstageEvent ? '*' : ''}`} +
    + + {' → '} + + {backstageEvent ? '*' : ''} +
    {title}
    {presenter &&
    {presenter}
    } diff --git a/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx b/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx index cb0ba3d55..c26dc4c74 100644 --- a/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx +++ b/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx @@ -51,6 +51,12 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) { } }, [searchParams, onOpen]); + /** + * disabling this for now, this feature needs more testing + * - we seem to have a bug where this is conflicting with the aliases + * - I wonder if the logic below needs to be inside an effect, + * both localStorage and searchParams should trigger a component update when they change + useEffect(() => { const viewParamsObjFromLocalStorage = storedViewParams[pathname]; @@ -59,10 +65,12 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) { setSearchParams(defaultSearchParams); } - // linter is asking for `setSearchParams` in the useEffect deps - // rule is disabled since adding `setSearchParams` results in unnecessary re-renders + // linter is asking for `setSearchParams` & `storedViewParams` in the useEffect deps + // rule is disabled since adding `setSearchParams` & `storedViewParams` results in unnecessary re-renders // eslint-disable-next-line react-hooks/exhaustive-deps - }, [storedViewParams, pathname]); + }, [pathname]); + + */ const onEditDrawerClose = () => { onClose(); diff --git a/apps/client/src/common/components/view-params-editor/constants.ts b/apps/client/src/common/components/view-params-editor/constants.ts index 3021edb6e..bcb7c363e 100644 --- a/apps/client/src/common/components/view-params-editor/constants.ts +++ b/apps/client/src/common/components/view-params-editor/constants.ts @@ -70,7 +70,39 @@ export const CLOCK_OPTIONS: ParamField[] = [ }, ]; -export const TIMER_OPTIONS: ParamField[] = [TIME_FORMAT_OPTION]; +export const TIMER_OPTIONS: ParamField[] = [ + TIME_FORMAT_OPTION, + { + id: 'hideClock', + title: 'Hide Time Now', + description: 'Hides the Time Now field', + type: 'boolean', + }, + { + id: 'hideCards', + title: 'Hide Cards', + description: 'Hides the Now and Next cards', + type: 'boolean', + }, + { + id: 'hideProgress', + title: 'Hide progress bar', + description: 'Hides the progress bar', + type: 'boolean', + }, + { + id: 'hideMessage', + title: 'Hide Presenter Message', + description: 'Prevents the screen from displaying messages from the presenter', + type: 'boolean', + }, + { + id: 'hideExternal', + title: 'Hide External', + description: 'Prevents the screen from displaying the external field', + type: 'boolean', + }, +]; export const MINIMAL_TIMER_OPTIONS: ParamField[] = [ { diff --git a/apps/client/src/common/hooks/useLocalStorage.ts b/apps/client/src/common/hooks/useLocalStorage.ts index a6468e7af..3519821b9 100644 --- a/apps/client/src/common/hooks/useLocalStorage.ts +++ b/apps/client/src/common/hooks/useLocalStorage.ts @@ -1,53 +1,46 @@ -import { useEffect, useState } from 'react'; +import { useSyncExternalStore } from 'react'; -/** - * @description utility hook to handle state in local storage - * @param key - * @param initialValue - */ -export const useLocalStorage = (key: string, initialValue: T): [T, (value: T | ((val: T) => T)) => void] => { - const [storedValue, setStoredValue] = useState(() => { - try { - const item = window.localStorage.getItem(`ontime-${key}`); - return item ? JSON.parse(item) : initialValue; - } catch (error) { - return initialValue; - } - }); +const STORAGE_EVENT = 'ontime-storage'; - useEffect(() => { - const handleStorageChange = (event: StorageEvent) => { - if (event.storageArea === window.localStorage && event.key === key) { - try { - const newValue = event.newValue ? JSON.parse(event.newValue) : initialValue; - setStoredValue(newValue); - } catch (_) { - /* empty */ - } - } - }; +function getSnapshot(key: string): string | null { + try { + return window.localStorage.getItem(`ontime-${key}`); + } catch { + return null; + } +} - window.addEventListener('storage', handleStorageChange); +function getParsedJson(localStorageValue: string | null, initialValue: T): T { + try { + return localStorageValue ? JSON.parse(localStorageValue) : initialValue; + } catch { + return initialValue; + } +} - return () => { - window.removeEventListener('storage', handleStorageChange); - }; - }, [initialValue, key]); +export const useLocalStorage = (key: string, initialValue: T) => { + const localStorageValue = useSyncExternalStore(subscribe, () => getSnapshot(key)); + const parsedLocalStorageValue = getParsedJson(localStorageValue, initialValue); /** * @description Set value to local storage * @param value */ - const setValue = (value: T | ((val: T) => T)) => { - try { - // Allow value to be a function so we have same API as useState - const valueToStore = value instanceof Function ? value(storedValue) : value; + const setLocalStorageValue = (value: T | ((val: T) => T)) => { + // Allow value to be a function so we have same API as useState + const valueToStore = value instanceof Function ? value(parsedLocalStorageValue) : value; - setStoredValue(valueToStore); - window.localStorage.setItem(`ontime-${key}`, JSON.stringify(valueToStore)); - } catch (error) { - console.error(error); - } + localStorage.setItem(`ontime-${key}`, JSON.stringify(valueToStore)); + window.dispatchEvent(new StorageEvent(STORAGE_EVENT)); }; - return [storedValue, setValue]; + + return [parsedLocalStorageValue, setLocalStorageValue] as const; }; + +function subscribe(callback: () => void) { + window.addEventListener(STORAGE_EVENT, callback); + + return () => { + window.removeEventListener(STORAGE_EVENT, callback); + }; +} diff --git a/apps/client/src/common/hooks/useLongPress.tsx b/apps/client/src/common/hooks/useLongPress.tsx new file mode 100644 index 000000000..b191a509c --- /dev/null +++ b/apps/client/src/common/hooks/useLongPress.tsx @@ -0,0 +1,64 @@ +import { MouseEvent, SyntheticEvent, TouchEvent, useMemo, useRef } from 'react'; + +type LongPressOptions = { + threshold?: number; + onStart?: (e: SyntheticEvent) => void; + onFinish?: (e: SyntheticEvent) => void; + onCancel?: (e: SyntheticEvent) => void; +}; + +type LongPressFns = { + onMouseDown: (e: MouseEvent) => void; + onMouseUp: (e: MouseEvent) => void; + onMouseLeave: (e: MouseEvent) => void; + onTouchStart: (e: TouchEvent) => void; + onTouchEnd: (e: TouchEvent) => void; +}; + +export default function useLongPress(callback: () => void, options: LongPressOptions = {}): LongPressFns { + const { threshold = 400, onStart, onFinish, onCancel } = options; + const isLongPressActive = useRef(false); + const isPressed = useRef(false); + const timerId = useRef(); + + return useMemo(() => { + const start = (event: SyntheticEvent) => { + if (onStart) { + onStart(event); + } + + isPressed.current = true; + timerId.current = setTimeout(() => { + callback(); + isLongPressActive.current = true; + }, threshold); + }; + + const cancel = (event: SyntheticEvent) => { + if (isLongPressActive.current) { + if (onFinish) { + onFinish(event); + } + } else if (isPressed.current) { + if (onCancel) { + onCancel(event); + } + } + + isLongPressActive.current = false; + isPressed.current = false; + + if (timerId.current) { + clearTimeout(timerId.current); + } + }; + + return { + onMouseDown: start, + onMouseUp: cancel, + onMouseLeave: cancel, + onTouchStart: start, + onTouchEnd: cancel, + }; + }, [callback, threshold, onCancel, onFinish, onStart]); +} diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index f2d5fbb5f..4ce6edc2b 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -27,6 +27,7 @@ export const useMessageControl = () => { timerMessage: state.timerMessage, publicMessage: state.publicMessage, lowerMessage: state.lowerMessage, + externalMessage: state.externalMessage, onAir: state.onAir, }); @@ -40,6 +41,8 @@ export const setMessage = { publicVisible: (payload: boolean) => socketSendJson('set-public-message-visible', payload), lowerText: (payload: string) => socketSendJson('set-lower-message-text', payload), lowerVisible: (payload: boolean) => socketSendJson('set-lower-message-visible', payload), + externalText: (payload: string) => socketSendJson('set-external-message-text', payload), + externalVisible: (payload: boolean) => socketSendJson('set-external-message-visible', payload), onAir: (payload: boolean) => socketSendJson('set-onAir', payload), timerBlink: (payload: boolean) => socketSendJson('set-timer-blink', payload), timerBlackout: (payload: boolean) => socketSendJson('set-timer-blackout', payload), diff --git a/apps/client/src/common/stores/runtime.ts b/apps/client/src/common/stores/runtime.ts index 3f503308d..98a04c134 100644 --- a/apps/client/src/common/stores/runtime.ts +++ b/apps/client/src/common/stores/runtime.ts @@ -3,7 +3,7 @@ import { Playback, RuntimeStore } from 'ontime-types'; import { useStore } from 'zustand'; import { createStore } from 'zustand/vanilla'; -export const runtimeStorePlaceholder = { +export const runtimeStorePlaceholder: RuntimeStore = { timer: { clock: 0, current: null, @@ -33,6 +33,10 @@ export const runtimeStorePlaceholder = { text: '', visible: false, }, + externalMessage: { + text: '', + visible: false, + }, onAir: false, loaded: { numEvents: 0, diff --git a/apps/client/src/common/utils/__tests__/time.test.js b/apps/client/src/common/utils/__tests__/time.test.ts similarity index 73% rename from apps/client/src/common/utils/__tests__/time.test.js rename to apps/client/src/common/utils/__tests__/time.test.ts index 226d7c2a3..e96d432a7 100644 --- a/apps/client/src/common/utils/__tests__/time.test.js +++ b/apps/client/src/common/utils/__tests__/time.test.ts @@ -26,4 +26,14 @@ describe('formatTime()', () => { const time = formatTime(ms); expect(time).toStrictEqual('...'); }); + + it('shows 12h format without times', () => { + const ms = 13 * 60 * 60 * 1000; + const options = { + showSeconds: false, + format: 'hh:mm a', + }; + const time = formatTime(ms, options, () => '12'); + expect(time).toStrictEqual('01:00 PM'); + }); }); diff --git a/apps/client/src/features/control/message/InputRow.tsx b/apps/client/src/features/control/message/InputRow.tsx index 9683dec02..561c19740 100644 --- a/apps/client/src/features/control/message/InputRow.tsx +++ b/apps/client/src/features/control/message/InputRow.tsx @@ -1,4 +1,4 @@ -import { Input } from '@chakra-ui/react'; +import { IconButton, Input } from '@chakra-ui/react'; import { IoEye } from '@react-icons/all-files/io5/IoEye'; import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline'; @@ -13,13 +13,14 @@ interface InputRowProps { placeholder: string; text: string; visible?: boolean; + readonly?: boolean; actionHandler: (action: string, payload: object) => void; changeHandler: (newValue: string) => void; className?: string; } export default function InputRow(props: InputRowProps) { - const { label, placeholder, text, visible, actionHandler, changeHandler, className } = props; + const { label, placeholder, text, visible, actionHandler, changeHandler, className, readonly } = props; const handleInputChange = (newValue: string) => { changeHandler(newValue); @@ -33,19 +34,31 @@ export default function InputRow(props: InputRowProps) { handleInputChange(event.target.value)} placeholder={placeholder} /> - actionHandler('update', { field: 'isPublic', value: !visible })} - tooltip={visible ? 'Make invisible' : 'Make visible'} - aria-label={`Toggle ${label}`} - openDelay={tooltipDelayMid} - icon={visible ? : } - variant={visible ? 'ontime-filled' : 'ontime-subtle'} - size='sm' - /> + {readonly ? ( + : } + aria-label={`Toggle ${label}`} + variant={visible ? 'ontime-filled' : 'ontime-subtle'} + /> + ) : ( + actionHandler('update', { field: 'isPublic', value: !visible })} + tooltip={visible ? 'Make invisible' : 'Make visible'} + aria-label={`Toggle ${label}`} + openDelay={tooltipDelayMid} + icon={visible ? : } + variant={visible ? 'ontime-filled' : 'ontime-subtle'} + size='sm' + /> + )}
  • ); diff --git a/apps/client/src/features/control/message/MessageControl.module.scss b/apps/client/src/features/control/message/MessageControl.module.scss index ab966b116..5f3d0e46f 100644 --- a/apps/client/src/features/control/message/MessageControl.module.scss +++ b/apps/client/src/features/control/message/MessageControl.module.scss @@ -27,7 +27,3 @@ color: $action-text-color; } } - -.padTop { - margin-top: $section-spacing; -} diff --git a/apps/client/src/features/control/message/MessageControl.tsx b/apps/client/src/features/control/message/MessageControl.tsx index 73138367b..8feb66cc9 100644 --- a/apps/client/src/features/control/message/MessageControl.tsx +++ b/apps/client/src/features/control/message/MessageControl.tsx @@ -34,34 +34,48 @@ export default function MessageControl() { actionHandler={() => setMessage.lowerVisible(!data.lowerMessage.visible)} /> setMessage.presenterText(newValue)} actionHandler={() => setMessage.presenterVisible(!data.timerMessage.visible)} />
    - -
    -
    + undefined} + actionHandler={() => undefined} + /> +