Feat/timer/stylechange (#341)

* feat: multi color progress bar

* add color support to minimal timer

Co-Authored-By: Fabian Posenau <19673098+kellhogs@users.noreply.github.com>

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>
Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Fabian Posenau <19673098+kellhogs@users.noreply.github.com>
This commit is contained in:
Fabian Posenau
2023-05-03 20:16:35 +02:00
committed by GitHub
parent 38654f8981
commit 29a9167d61
18 changed files with 396 additions and 146 deletions
+1
View File
@@ -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",
@@ -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;
}
@@ -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 (
<Popover>
<PopoverTrigger>
<div className={style.swatch} style={{ backgroundColor: color }} />
</PopoverTrigger>
<PopoverContent style={{ width: 'auto' }}>
<HexAlphaColorPicker color={color} onChange={onChange} />
</PopoverContent>
</Popover>
);
}
@@ -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;
}
@@ -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 (
<div className={`progress-bar ${hidden ? 'progress-bar--hidden' : ''} ${className}`}>
<div className='progress-bar__bg-normal' style={{ backgroundColor: normalColor }} />
<div className='progress-bar__bg-warning' style={{ width: `${warningWidth}%`, backgroundColor: warningColor }} />
<div className='progress-bar__bg-danger' style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }} />
<div className='progress-bar__indicator' style={{ width: `${percentComplete}%` }} />
</div>
);
}
@@ -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) {
@@ -13,4 +13,5 @@ export type OverridableOptions = {
hideMessagesOverlay?: boolean;
hideEndMessage?: boolean;
language?: string;
showProgressBar?: boolean;
};
@@ -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: '',
};
@@ -52,6 +52,10 @@
}
}
.overflow {
overflow-y: visible !important;
}
/* Track */
::-webkit-scrollbar-track {
background: rgba($gray-50, 0.15);
@@ -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 (
<ModalBody className={style.modalBody}>
<p className={style.notes}>
Options related to the viewers
<br />
🔥 Changes take effect immediately 🔥
</p>
<form onSubmit={submitHandler}>
<div className={style.hSeparator}>Timer end message</div>
<div className={style.spacedEntry}>
<FormLabel htmlFor='endMessage'>
End Message
<span className={style.labelNote}>
<br />
Shown on presenter view when time is finished
</span>
</FormLabel>
<Input
{...inputProps}
maxLength={50}
name='endMessage'
placeholder='Empty message shows elapsed time'
value={formData.endMessage}
onChange={(event) => handleChange('endMessage', event.target.value)}
/>
</div>
<div className={style.hSeparator}>Style Options</div>
<div className={style.blockNotes}>
<span className={style.inlineFlex}>
<IoInformationCircleOutline color='#2b6cb0' fontSize='2em' />
CSS Style Overrides
</span>
This feature allows user defined CSS to customise viewers appearance. <br />
<a
href='#!'
onClick={() => openLink('https://cpvalente.gitbook.io/ontime/features/custom-styling')}
className={style.if}
>
For details on the styling and file location please refer to documentation
</a>
</div>
<div className={style.modalFields}>
<div className={style.modalInline}>
<FormControl>
<FormLabel htmlFor='overrideStyles'>
Override CSS Styles
<span className={style.labelNote}>
<br />
Enable / Disable override
</span>
</FormLabel>
<EnableBtn
active={formData.overrideStyles}
text={formData.overrideStyles ? 'Style Override Enabled' : 'Style Override Disabled'}
actionHandler={() => handleChange('overrideStyles', !formData.overrideStyles)}
/>
</FormControl>
</div>
<SubmitContainer revert={revert} submitting={submitting} changed={changed} status={status} />
</div>
</form>
</ModalBody>
);
}
@@ -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<HTMLInputElement>)} value - new object parameter value
*/
function handleThresholdChange(field: string, event: ChangeEvent<HTMLInputElement>) {
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 (
<form onSubmit={submitHandler} className={styles.sectionContainer} id='viewSettings'>
<ModalBody className={style.modalBody}>
<p className={style.notes}>
Options related to the viewers
<br />
<a href={stylingDocsUrl} target='_blank' rel='noreferrer'>
Read the docs
</a>
</p>
<div className={style.hSeparator}>Timer end message</div>
<div className={style.spacedEntry}>
<FormLabel htmlFor='endMessage'>
End Message
<span className={style.labelNote}>
<br />
Shown on presenter view when time is finished
</span>
</FormLabel>
<Input
{...inputProps}
maxLength={50}
name='endMessage'
placeholder='Empty message shows elapsed time'
value={formData.endMessage}
onChange={(event) => handleChange('endMessage', event.target.value)}
/>
</div>
<div className={style.hSeparator}>Style Options</div>
<div className={styles.splitSection}>
<div>
<span className={`${styles.sectionTitle} ${styles.main}`}>Override CSS Styles</span>
<span className={styles.sectionSubtitle}>Enable / Disable override</span>
</div>
<Switch onChange={() => handleChange('overrideStyles', !formData.overrideStyles)} variant='ontime-on-light' />
</div>
<hr className={styles.divider} />
<div className={style.hSeparator}>Timer Options</div>
<div className={styles.splitSection}>
<div>
<span className={`${styles.sectionTitle} ${styles.main}`}>Normal Color</span>
<span className={styles.sectionSubtitle}>Change timer Normal Color</span>
</div>
<PopoverPicker color={formData.normalColor} onChange={(event) => handleChange('normalColor', event)} />
</div>
<div className={styles.splitSection}>
<div>
<span className={`${styles.sectionTitle} ${styles.main}`}>Warning Color</span>
<span className={styles.sectionSubtitle}>Change timer Warning Color</span>
</div>
<PopoverPicker color={formData.warningColor} onChange={(event) => handleChange('warningColor', event)} />
</div>
<FormControl className={styles.splitSection}>
<label htmlFor='warningThreshold'>
<span className={styles.sectionTitle}>Warning Time</span>
<span className={styles.sectionSubtitle}>The time (in minutes) when the color changes</span>
</label>
<Input
{...inputProps}
id='warningThreshold'
variant='ontime-filled-on-light'
value={warningThresholdValue}
onChange={(event) => handleThresholdChange('warningThreshold', event)}
width='75px'
size='sm'
textAlign='right'
maxLength={3}
/>
</FormControl>
<div className={styles.splitSection}>
<div>
<span className={`${styles.sectionTitle} ${styles.main}`}>Danger Color</span>
<span className={styles.sectionSubtitle}>Change timer Danger Color</span>
</div>
<PopoverPicker color={formData.dangerColor} onChange={(event) => handleChange('dangerColor', event)} />
</div>
<FormControl className={styles.splitSection}>
<label htmlFor='dangerThreshold'>
<span className={styles.sectionTitle}>Danger Time</span>
<span className={styles.sectionSubtitle}>The time (in minutes) when the color changes</span>
</label>
<Input
{...inputProps}
id='dangerThreshold'
variant='ontime-filled-on-light'
value={dangerThresholdValue}
onChange={(event) => handleThresholdChange('dangerThreshold', event)}
width='75px'
size='sm'
textAlign='right'
maxLength={3}
/>
</FormControl>
<div className={style.modalFields}>
<SubmitContainer revert={revert} submitting={submitting} changed={changed} status={status} />
</div>
</ModalBody>
</form>
);
}
@@ -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) {
<div
className={timerClasses}
style={{
color: userOptions.textColour,
color: timerColor,
fontSize: `${timerFontSize}vw`,
fontFamily: userOptions.font,
top: userOptions.top,
@@ -3,8 +3,8 @@ import { AnimatePresence, motion } from 'framer-motion';
import { EventData, Message, Playback, TimerType, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import ProgressBar from '../../../common/components/progress-bar/ProgressBar';
import TitleCard from '../../../common/components/title-card/TitleCard';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
@@ -68,6 +68,14 @@ export default function Timer(props: TimerProps) {
const showEndMessage = (time.current ?? 1) < 0 && viewSettings.endMessage;
const showProgress = time.playback !== Playback.Stop;
const showFinished = time.finished && (time.timerType !== TimerType.Clock || showEndMessage);
const showWarning = (time.current ?? 1) < viewSettings.warningThreshold;
const showDanger = (time.current ?? 1) < viewSettings.dangerThreshold;
const timerColor =
showProgress && showDanger
? viewSettings.dangerColor
: showProgress && showWarning
? viewSettings.warningColor
: viewSettings.normalColor;
const showClock = time.timerType !== TimerType.Clock;
const baseClasses = `stage-timer ${isMirrored ? 'mirror' : ''}`;
@@ -101,6 +109,7 @@ export default function Timer(props: TimerProps) {
className={timerClasses}
style={{
fontSize: `${timerFontSize}vw`,
color: timerColor,
}}
>
{display}
@@ -108,10 +117,15 @@ export default function Timer(props: TimerProps) {
)}
</div>
<ProgressBar
<MultiPartProgressBar
className={isPlaying ? 'progress-container' : 'progress-container progress-container--paused'}
now={time.current || 0}
complete={time.duration || 0}
normalColor={viewSettings.normalColor}
warning={viewSettings.warningThreshold}
warningColor={viewSettings.warningColor}
danger={viewSettings.dangerThreshold}
dangerColor={viewSettings.dangerColor}
hidden={!showProgress}
/>
@@ -246,6 +246,11 @@ export const postViewSettings = async (req, res) => {
const newData = {
overrideStyles: req.body.overrideStyles,
endMessage: req.body?.endMessage || '',
normalColor: req.body.normalColor,
warningColor: req.body.warningColor,
warningThreshold: req.body.warningThreshold,
dangerColor: req.body.dangerColor,
dangerThreshold: req.body.dangerThreshold,
};
await DataProvider.setViewSettings(newData);
res.status(200).send(newData);
+5
View File
@@ -19,6 +19,11 @@ export const dbModel: DatabaseModel = {
},
viewSettings: {
overrideStyles: false,
normalColor: '#ffffffcc',
warningColor: '#FFAB33',
warningThreshold: 120000,
dangerColor: '#ED3333',
dangerThreshold: 60000,
endMessage: '',
},
aliases: [],
+5
View File
@@ -144,6 +144,11 @@ export const parseViewSettings = (data, enforce): ViewSettings => {
const viewSettings = {
overrideStyles: v.overrideStyles ?? dbModel.viewSettings.overrideStyles,
normalColor: v.normalColor ?? dbModel.viewSettings.normalColor,
warningColor: v.warningColor ?? dbModel.viewSettings.warningColor,
warningThreshold: v.warningThreshold ?? dbModel.viewSettings.warningThreshold,
dangerColor: v.dangerColor ?? dbModel.viewSettings.dangerColor,
dangerThreshold: v.dangerThreshold ?? dbModel.viewSettings.dangerThreshold,
endMessage: v.endMessage ?? dbModel.viewSettings.endMessage,
};
@@ -1,4 +1,9 @@
export type ViewSettings = {
overrideStyles: boolean;
endMessage: string;
normalColor: string;
warningColor: string;
warningThreshold: number;
dangerColor: string;
dangerThreshold: number;
}
+12
View File
@@ -75,6 +75,7 @@ importers:
prettier: ^2.8.3
prop-types: ^15.8.1
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
@@ -113,6 +114,7 @@ importers:
deepmerge: 4.3.0
framer-motion: 10.11.2_biqbaboplfbrettd7655fr4n2y
react: 18.2.0
react-colorful: 5.6.1_biqbaboplfbrettd7655fr4n2y
react-dom: 18.2.0_react@18.2.0
react-fast-compare: 3.2.0
react-hook-form: 7.43.5_react@18.2.0
@@ -7016,6 +7018,16 @@ packages:
react: 18.2.0
dev: false
/react-colorful/5.6.1_biqbaboplfbrettd7655fr4n2y:
resolution: {integrity: sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
dependencies:
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
dev: false
/react-dom/18.2.0_react@18.2.0:
resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==}
peerDependencies: