diff --git a/apps/client/package.json b/apps/client/package.json index 0e1eb9cec..be9717b25 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -21,6 +21,7 @@ "deepmerge": "^4.3.0", "framer-motion": "^10.10.0", "react": "^18.2.0", + "react-colorful": "^5.6.1", "react-dom": "^18.2.0", "react-fast-compare": "^3.2.0", "react-hook-form": "^7.43.5", diff --git a/apps/client/src/common/components/input/popover-picker/PopoverPicker.module.scss b/apps/client/src/common/components/input/popover-picker/PopoverPicker.module.scss new file mode 100644 index 000000000..67ea73722 --- /dev/null +++ b/apps/client/src/common/components/input/popover-picker/PopoverPicker.module.scss @@ -0,0 +1,18 @@ +@use "../../../../theme/_ontimeColours" as *; +@use "../../../../theme/_v2Styles" as *; + +.swatch { + width: 32px; + height: 32px; + border-radius: 16px; + border: 1px solid $gray-300; + cursor: pointer; + + transition-property: box-shadow; + transition-duration: $transition-time-action; +} + +.swatch:hover { + box-shadow: 0 0 0 1px $blue-300; +} + diff --git a/apps/client/src/common/components/input/popover-picker/PopoverPicker.tsx b/apps/client/src/common/components/input/popover-picker/PopoverPicker.tsx new file mode 100644 index 000000000..54bda14c6 --- /dev/null +++ b/apps/client/src/common/components/input/popover-picker/PopoverPicker.tsx @@ -0,0 +1,24 @@ +import { HexAlphaColorPicker } from 'react-colorful'; +import { Popover, PopoverContent, PopoverTrigger } from '@chakra-ui/react'; + +import style from './PopoverPicker.module.scss'; + +interface PopoverPickerProps { + color: string; + onChange: (color: string) => void; +} + +export default function PopoverPicker(props: PopoverPickerProps) { + const { color, onChange } = props; + + return ( + + +
+ + + + + + ); +} 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 new file mode 100644 index 000000000..98c97f2bc --- /dev/null +++ b/apps/client/src/common/components/multi-part-progress-bar/MultiPartProgressBar.scss @@ -0,0 +1,50 @@ +@use '../../../theme/viewerDefs' as *; + +$progress-bar-size: 12px; +$progress-bar-br: 3px; + +.progress-bar { + position: relative; + width: 100%; + height: $progress-bar-size; + border-radius: $progress-bar-br; + background-color: var(--timer-progress-bg-override, $viewer-card-bg-color); + display: flex; + + &--hidden { + display: none; + transition: display 0.5s; + } +} + +.progress-bar__indicator { + position: absolute; + height: inherit; + background-color: black; + opacity: 0.8; + transition: 1s linear; + transition-property: width; + right: 0; +} + +.progress-bar__bg-normal { + position: absolute; + height: inherit; + right: 0; + border-radius: $progress-bar-br; + width: 100%; +} + +.progress-bar__bg-warning { + position: absolute; + height: inherit; + right: 0; + border-radius: 0 $progress-bar-br $progress-bar-br 0; +} + +.progress-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 new file mode 100644 index 000000000..9ccd03380 --- /dev/null +++ b/apps/client/src/common/components/multi-part-progress-bar/MultiPartProgressBar.tsx @@ -0,0 +1,33 @@ +import { clamp } from '../../utils/math'; + +import './MultiPartProgressBar.scss'; + +interface MultiPartProgressBar { + now: number; + complete: number; + normalColor: string; + warning: number; + warningColor: string; + danger: number; + dangerColor: string; + hidden?: boolean; + className?: string; +} + +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 dangerWidth = clamp((danger / complete) * 100, 0, 100); + const warningWidth = clamp((warning / complete) * 100, 0, 100); + + return ( +
+
+
+
+
+
+ ); +} diff --git a/apps/client/src/common/components/title-card/TitleCard.tsx b/apps/client/src/common/components/title-card/TitleCard.tsx index fa785827f..f6b9a43f0 100644 --- a/apps/client/src/common/components/title-card/TitleCard.tsx +++ b/apps/client/src/common/components/title-card/TitleCard.tsx @@ -4,9 +4,9 @@ import './TitleCard.scss'; interface TitleCardProps { label: 'now' | 'next'; - title: string; - subtitle: string; - presenter: string; + title: string | null; + subtitle: string | null; + presenter: string | null; } export default function TitleCard(props: TitleCardProps) { diff --git a/apps/client/src/common/models/View.types.ts b/apps/client/src/common/models/View.types.ts index 6ecbeba46..8085a93d0 100644 --- a/apps/client/src/common/models/View.types.ts +++ b/apps/client/src/common/models/View.types.ts @@ -13,4 +13,5 @@ export type OverridableOptions = { hideMessagesOverlay?: boolean; hideEndMessage?: boolean; language?: string; + showProgressBar?: boolean; }; diff --git a/apps/client/src/common/models/ViewSettings.type.ts b/apps/client/src/common/models/ViewSettings.type.ts index 58b31d5f7..d2d72b519 100644 --- a/apps/client/src/common/models/ViewSettings.type.ts +++ b/apps/client/src/common/models/ViewSettings.type.ts @@ -2,5 +2,10 @@ import { ViewSettings } from 'ontime-types'; export const viewsSettingsPlaceholder: ViewSettings = { overrideStyles: false, + normalColor: '#ffffffcc', + warningColor: '#FFAB33', + warningThreshold: 120000, + dangerColor: '#ED3333', + dangerThreshold: 60000, endMessage: '', }; diff --git a/apps/client/src/features/modals/Modals.module.scss b/apps/client/src/features/modals/Modals.module.scss index 2ca04cee4..9ed25fee7 100644 --- a/apps/client/src/features/modals/Modals.module.scss +++ b/apps/client/src/features/modals/Modals.module.scss @@ -52,6 +52,10 @@ } } + .overflow { + overflow-y: visible !important; + } + /* Track */ ::-webkit-scrollbar-track { background: rgba($gray-50, 0.15); diff --git a/apps/client/src/features/modals/ViewsSettingsModal.jsx b/apps/client/src/features/modals/ViewsSettingsModal.jsx deleted file mode 100644 index a2eebae6d..000000000 --- a/apps/client/src/features/modals/ViewsSettingsModal.jsx +++ /dev/null @@ -1,140 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { FormControl, FormLabel, Input, ModalBody } from '@chakra-ui/react'; -import { IoInformationCircleOutline } from '@react-icons/all-files/io5/IoInformationCircleOutline'; - -import { postView } from '../../common/api/ontimeApi'; -import EnableBtn from '../../common/components/buttons/EnableBtn'; -import useViewSettings from '../../common/hooks-query/useViewSettings'; -import { viewsSettingsPlaceholder } from '../../common/models/ViewSettings.type'; -import { useEmitLog } from '../../common/stores/logger'; -import { openLink } from '../../common/utils/linkUtils'; -import { inputProps } from '../../features/modals/modalHelper'; - -import SubmitContainer from './SubmitContainer'; - -import style from './Modals.module.scss'; - -export default function ViewsSettingsModal() { - const { data, status, refetch } = useViewSettings(); - - const { emitError } = useEmitLog(); - const [formData, setFormData] = useState(viewsSettingsPlaceholder); - const [changed, setChanged] = useState(false); - const [submitting, setSubmitting] = useState(false); - - /** - * Set formdata from server state - */ - useEffect(() => { - if (data == null) return; - if (changed) return; - setFormData({ ...data }); - }, [changed, data]); - - /** - * Validate and submit data - */ - const submitHandler = useCallback( - async (event) => { - event.preventDefault(); - setSubmitting(true); - try { - await postView(formData); - } catch (error) { - emitError(`Error view settings: ${error}`); - } finally { - await refetch(); - setChanged(false); - } - setSubmitting(false); - }, - [emitError, formData, refetch], - ); - - /** - * Reverts local state equals to server state - */ - const revert = useCallback(async () => { - setChanged(false); - await refetch(); - }, [refetch]); - - /** - * Handles change of input field in local state - * @param {string} field - object parameter to update - * @param {(string | number | boolean)} value - new object parameter value - */ - const handleChange = useCallback( - (field, value) => { - const temp = { ...formData }; - temp[field] = value; - setFormData(temp); - setChanged(true); - }, - [formData], - ); - - return ( - -

- Options related to the viewers -
- 🔥 Changes take effect immediately 🔥 -

-
-
Timer end message
-
- - End Message - -
- Shown on presenter view when time is finished -
-
- handleChange('endMessage', event.target.value)} - /> -
-
Style Options
- -
-
- - - Override CSS Styles - -
- Enable / Disable override -
-
- handleChange('overrideStyles', !formData.overrideStyles)} - /> -
-
- -
-
-
- ); -} diff --git a/apps/client/src/features/modals/ViewsSettingsModal.tsx b/apps/client/src/features/modals/ViewsSettingsModal.tsx new file mode 100644 index 000000000..c4b4ad622 --- /dev/null +++ b/apps/client/src/features/modals/ViewsSettingsModal.tsx @@ -0,0 +1,197 @@ +import { ChangeEvent, FormEvent, useCallback, useEffect, useState } from 'react'; +import { FormControl, FormLabel, Input, ModalBody, Switch } from '@chakra-ui/react'; +import { ViewSettings } from 'ontime-types'; + +import { postView } from '../../common/api/ontimeApi'; +import PopoverPicker from '../../common/components/input/popover-picker/PopoverPicker'; +import useViewSettings from '../../common/hooks-query/useViewSettings'; +import { viewsSettingsPlaceholder } from '../../common/models/ViewSettings.type'; +import { useEmitLog } from '../../common/stores/logger'; +import { forgivingStringToMillis, millisToMinutes } from '../../common/utils/dateConfig'; + +import { inputProps } from './modalHelper'; +import SubmitContainer from './SubmitContainer'; + +import styles from './Modal.module.scss'; +import style from './Modals.module.scss'; + +const stylingDocsUrl = 'https://cpvalente.gitbook.io/ontime/features/custom-styling'; + +export default function ViewsSettingsModal() { + const { data, status, refetch } = useViewSettings(); + + const { emitError } = useEmitLog(); + const [formData, setFormData] = useState(viewsSettingsPlaceholder); + const [changed, setChanged] = useState(false); + const [submitting, setSubmitting] = useState(false); + + /** + * Set formdata from server state + */ + useEffect(() => { + if (data == null) return; + if (changed) return; + setFormData({ ...data }); + }, [changed, data]); + + /** + * Validate and submit data + */ + const submitHandler = useCallback( + async (event: FormEvent) => { + event.preventDefault(); + setSubmitting(true); + try { + await postView(formData); + } catch (error) { + emitError(`Error view settings: ${error}`); + } finally { + await refetch(); + setChanged(false); + } + setSubmitting(false); + }, + [emitError, formData, refetch], + ); + + /** + * Reverts local state equals to server state + */ + const revert = useCallback(async () => { + setChanged(false); + await refetch(); + }, [refetch]); + + /** + * Handles change of input field in local state + * @param {string} field - object parameter to update + * @param {(string | number | boolean)} value - new object parameter value + */ + const handleChange = useCallback( + (field: string, value: string | number | boolean) => { + const temp = { ...formData }; + //@ts-expect-error - TODO: dont know what to type here + temp[field] = value; + setFormData(temp); + setChanged(true); + }, + [formData], + ); + + /** + * Handles change of number input field in local state + * @param {string} field - object parameter to update + * @param {(ChangeEvent)} value - new object parameter value + */ + function handleThresholdChange(field: string, event: ChangeEvent) { + const temp: ViewSettings = { ...formData }; + const converted: number = forgivingStringToMillis(event.target.value); + //@ts-expect-error - TODO: dont know what to type here + temp[field] = converted; + setFormData(temp); + setChanged(true); + } + + const dangerThresholdValue = millisToMinutes(formData.dangerThreshold); + const warningThresholdValue = millisToMinutes(formData.warningThreshold); + + return ( +
+ +

+ Options related to the viewers +
+ + Read the docs + +

+
Timer end message
+
+ + End Message + +
+ Shown on presenter view when time is finished +
+
+ handleChange('endMessage', event.target.value)} + /> +
+
Style Options
+
+
+ Override CSS Styles + Enable / Disable override +
+ handleChange('overrideStyles', !formData.overrideStyles)} variant='ontime-on-light' /> +
+
+
Timer Options
+
+
+ Normal Color + Change timer Normal Color +
+ handleChange('normalColor', event)} /> +
+
+
+ Warning Color + Change timer Warning Color +
+ handleChange('warningColor', event)} /> +
+ + + handleThresholdChange('warningThreshold', event)} + width='75px' + size='sm' + textAlign='right' + maxLength={3} + /> + +
+
+ Danger Color + Change timer Danger Color +
+ handleChange('dangerColor', event)} /> +
+ + + handleThresholdChange('dangerThreshold', event)} + width='75px' + size='sm' + textAlign='right' + maxLength={3} + /> + +
+ +
+
+
+ ); +} diff --git a/apps/client/src/features/viewers/minimal-timer/MinimalTimer.tsx b/apps/client/src/features/viewers/minimal-timer/MinimalTimer.tsx index 4ca3ea6c9..54beb3e1a 100644 --- a/apps/client/src/features/viewers/minimal-timer/MinimalTimer.tsx +++ b/apps/client/src/features/viewers/minimal-timer/MinimalTimer.tsx @@ -133,6 +133,17 @@ export default function MinimalTimer(props: MinimalTimerProps) { const showFinished = time.finished && !userOptions?.hideOvertime && (time.timerType !== TimerType.Clock || showEndMessage); + const showProgress = time.playback !== Playback.Stop; + const showWarning = (time.current ?? 1) < viewSettings.warningThreshold; + const showDanger = (time.current ?? 1) < viewSettings.dangerThreshold; + const timerColor = userOptions.textColour + ? userOptions.textColour + : showProgress && showDanger + ? viewSettings.dangerColor + : showProgress && showWarning + ? viewSettings.warningColor + : viewSettings.normalColor; + const stageTimer = getTimerByType(time); let display = formatTimerDisplay(stageTimer); if (isNegative) { @@ -167,7 +178,7 @@ export default function MinimalTimer(props: MinimalTimerProps) {
{display} @@ -108,10 +117,15 @@ export default function Timer(props: TimerProps) { )}
-