mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-15 04:13:47 +00:00
V2 monorepo (#285)
* refactor(project structure): UI * refactor(project structure): extract utilities * refactor(project structure): remove unused * refactor(project structure): electron * refactor(project structure): server refactor: migrate to vitest refactor: monorepo config * refactor: extract application menu * refactor: exit process * refactor: extract tray menu * chore: electron build * Added Seconds in studio clock #282 --------- Co-authored-by: Fabian Posenau <fabian@fphome.de> --------- Co-authored-by: Fabian Posenau <fabian.p99@gmx.de> Co-authored-by: Fabian Posenau <fabian@fphome.de>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
@use '../theme/v2Styles' as *;
|
||||
|
||||
.wrapper {
|
||||
background: $bg-container-l1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: max(16px, 2vh);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ReactNode } 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) {
|
||||
return (
|
||||
<ProtectRoute>
|
||||
<div className={style.wrapper}>{children}</div>
|
||||
</ProtectRoute>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
|
||||
.inputItems {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: $element-spacing;
|
||||
margin-top: $element-inner-spacing;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: $inner-section-text-size;
|
||||
color: $label-gray;
|
||||
|
||||
&.active {
|
||||
color: $action-text-color;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Input } from '@chakra-ui/react';
|
||||
import { IoEye } from '@react-icons/all-files/io5/IoEye';
|
||||
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
|
||||
|
||||
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
|
||||
import style from './InputRow.module.scss';
|
||||
|
||||
interface InputRowProps {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
text: string;
|
||||
visible?: boolean;
|
||||
actionHandler: (action: string, payload: object) => void;
|
||||
changeHandler: (newValue: string) => void;
|
||||
}
|
||||
|
||||
export default function InputRow(props: InputRowProps) {
|
||||
const { label, placeholder, text, visible, actionHandler, changeHandler } = props;
|
||||
|
||||
const handleInputChange = (newValue: string) => {
|
||||
changeHandler(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.inputRow}>
|
||||
<label className={`${style.label} ${visible ? style.active : ''}`}>{label}</label>
|
||||
<div className={style.inputItems}>
|
||||
<Input
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
value={text}
|
||||
onChange={(event) => handleInputChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<TooltipActionBtn
|
||||
clickHandler={() => actionHandler('update', { field: 'isPublic', value: !visible })}
|
||||
tooltip={visible ? 'Make invisible' : 'Make visible'}
|
||||
aria-label={`Toggle ${label}`}
|
||||
openDelay={tooltipDelayMid}
|
||||
icon={visible? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
|
||||
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
|
||||
size='sm'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
|
||||
.messageContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $section-spacing;
|
||||
}
|
||||
|
||||
.onAirSection {
|
||||
margin-top: $section-spacing;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $element-spacing;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: $inner-section-text-size;
|
||||
color: $label-gray;
|
||||
|
||||
&.active {
|
||||
color: $action-text-color;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { IoMicOffOutline } from '@react-icons/all-files/io5/IoMicOffOutline';
|
||||
import { IoMicSharp } from '@react-icons/all-files/io5/IoMicSharp';
|
||||
|
||||
import { setMessage, useMessageControl } from '../../../common/hooks/useSocket';
|
||||
|
||||
import InputRow from './InputRow';
|
||||
|
||||
import style from './MessageControl.module.scss';
|
||||
|
||||
export default function MessageControl() {
|
||||
const { data } = useMessageControl();
|
||||
|
||||
return (
|
||||
<div className={style.messageContainer}>
|
||||
<InputRow
|
||||
label='Timer screen message'
|
||||
placeholder='Shown in stage timer'
|
||||
text={data?.presenter.text || ''}
|
||||
visible={data?.presenter.visible || false}
|
||||
changeHandler={(newValue) => setMessage.presenterText(newValue)}
|
||||
actionHandler={() => setMessage.presenterVisible(!data?.presenter.visible)}
|
||||
/>
|
||||
<InputRow
|
||||
label='Public / Backstage screen message'
|
||||
placeholder='Shown in public and backstage screens'
|
||||
text={data?.public.text || ''}
|
||||
visible={data?.public.visible || false}
|
||||
changeHandler={(newValue) => setMessage.publicText(newValue)}
|
||||
actionHandler={() => setMessage.publicVisible(!data?.public.visible)}
|
||||
/>
|
||||
<InputRow
|
||||
label='Lower third message'
|
||||
placeholder='Shown in lower third'
|
||||
text={data?.lower.text || ''}
|
||||
visible={data?.lower.visible || false}
|
||||
changeHandler={(newValue) => setMessage.lowerText(newValue)}
|
||||
actionHandler={() => setMessage.lowerVisible(!data?.lower.visible)}
|
||||
/>
|
||||
<div className={style.onAirSection}>
|
||||
<label className={style.label}>Toggle On Air state</label>
|
||||
<Button
|
||||
variant={data?.onAir ? 'ontime-filled' : 'ontime-subtle'}
|
||||
leftIcon={data?.onAir ? <IoMicSharp size='24px' /> : <IoMicOffOutline size='24px' />}
|
||||
onClick={() => setMessage.onAir(!data?.onAir)}
|
||||
>
|
||||
{data?.onAir ? 'Ontime is On Air' : 'Ontime is Off Air'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
|
||||
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
|
||||
import { handleLinks } from '../../../common/utils/linkUtils';
|
||||
|
||||
import MessageControl from './MessageControl';
|
||||
|
||||
import style from '../../editors/Editor.module.scss';
|
||||
|
||||
export default function MessageControlExport() {
|
||||
return (
|
||||
<Box className={style.messages} data-testid="panel-messages-control">
|
||||
<IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'messagecontrol')} />
|
||||
<div className={style.content}>
|
||||
<ErrorBoundary>
|
||||
<MessageControl />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Playback } from '../../../common/models/OntimeTypes';
|
||||
|
||||
import PlaybackDisplay from './PlaybackDisplay';
|
||||
import Transport from './Transport';
|
||||
|
||||
interface PlaybackButtonsProps {
|
||||
playback: Playback;
|
||||
selectedId: string | null;
|
||||
noEvents: boolean;
|
||||
}
|
||||
|
||||
export default function PlaybackButtons(props: PlaybackButtonsProps) {
|
||||
const { playback, selectedId, noEvents } = props;
|
||||
return (
|
||||
<>
|
||||
<PlaybackDisplay
|
||||
playback={playback}
|
||||
selectedId={selectedId}
|
||||
noEvents={noEvents}
|
||||
/>
|
||||
<Transport
|
||||
playback={playback}
|
||||
selectedId={selectedId}
|
||||
noEvents={noEvents}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
@use '../../../theme/ontimeColours' as *;
|
||||
@use '../../../theme/mixins' as *;
|
||||
|
||||
.mainContainer {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
margin: 0 auto;
|
||||
gap: $element-inner-spacing;
|
||||
}
|
||||
|
||||
.timeContainer {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'ind clk clk btn'
|
||||
'... sta fin btn';
|
||||
grid-template-rows: 1fr auto;
|
||||
grid-template-columns: 1.5em 1fr 1fr 5em;
|
||||
gap: $element-inner-spacing;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.timer {
|
||||
grid-area: clk;
|
||||
white-space: nowrap;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.indicators {
|
||||
grid-area: ind;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
.indRoll,
|
||||
.indDelay,
|
||||
.indNegative {
|
||||
background-color: $gray-1300;
|
||||
}
|
||||
|
||||
.indRoll,
|
||||
.indRollActive,
|
||||
.indDelay {
|
||||
margin: 0 auto;
|
||||
border-radius: 6px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.indRollActive {
|
||||
background-color: $ontime-roll;
|
||||
}
|
||||
|
||||
.indNegative,
|
||||
.indNegativeActive {
|
||||
margin: 0 auto;
|
||||
width: 90%;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.indNegativeActive {
|
||||
background-color: $playback-negative;
|
||||
}
|
||||
|
||||
.indDelayActive {
|
||||
background-color: $ontime-delay;
|
||||
}
|
||||
|
||||
.btn {
|
||||
grid-area: btn;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
width: 100%;
|
||||
gap: $element-inner-spacing;
|
||||
}
|
||||
|
||||
.minus {
|
||||
grid-area: min;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.start,
|
||||
.finish,
|
||||
.roll {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.start {
|
||||
grid-area: sta;
|
||||
}
|
||||
|
||||
.finish {
|
||||
grid-area: fin;
|
||||
}
|
||||
|
||||
.roll {
|
||||
grid-area: 2 / 2 / 2 / 4 ;
|
||||
}
|
||||
|
||||
.time {
|
||||
color: $section-white;
|
||||
font-size: $text-body-size;
|
||||
}
|
||||
|
||||
.tag {
|
||||
color: $label-gray;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.rolltag {
|
||||
color: $ontime-roll;
|
||||
font-size: $text-body-size;
|
||||
}
|
||||
|
||||
.playbackContainer {
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
padding-top: 0.5em;
|
||||
gap: $element-spacing;
|
||||
}
|
||||
|
||||
.invertX {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { usePlaybackControl } from '../../../common/hooks/useSocket';
|
||||
import { Playback } from '../../../common/models/OntimeTypes';
|
||||
|
||||
import PlaybackButtons from './PlaybackButtons';
|
||||
import PlaybackTimer from './PlaybackTimer';
|
||||
|
||||
import style from './PlaybackControl.module.scss';
|
||||
|
||||
export default function PlaybackControl() {
|
||||
const { data } = usePlaybackControl();
|
||||
|
||||
return (
|
||||
<div className={style.mainContainer}>
|
||||
<PlaybackTimer
|
||||
playback={data.playback as Playback}
|
||||
selectedId={data.selectedEventId}
|
||||
/>
|
||||
<PlaybackButtons
|
||||
playback={data.playback}
|
||||
selectedId={data.selectedEventId}
|
||||
noEvents={data.numEvents < 1}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { IoPause } from '@react-icons/all-files/io5/IoPause';
|
||||
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
|
||||
import { IoTimeOutline } from '@react-icons/all-files/io5/IoTimeOutline';
|
||||
|
||||
import { setPlayback } from '../../../common/hooks/useSocket';
|
||||
import { Playback } from '../../../common/models/OntimeTypes';
|
||||
|
||||
import TapButton from './TapButton';
|
||||
|
||||
import style from './PlaybackControl.module.scss';
|
||||
|
||||
interface PlaybackProps {
|
||||
playback: Playback;
|
||||
selectedId: string | null;
|
||||
noEvents: boolean;
|
||||
}
|
||||
|
||||
export default function PlaybackDisplay(props: PlaybackProps) {
|
||||
const { playback, selectedId, noEvents } = props;
|
||||
const isRolling = playback === 'roll';
|
||||
const isPlaying = playback === 'play';
|
||||
const isPaused = playback === 'pause';
|
||||
const isArmed = playback === 'armed';
|
||||
|
||||
return (
|
||||
<div className={style.playbackContainer}>
|
||||
<TapButton
|
||||
onClick={() => setPlayback.start()}
|
||||
disabled={!selectedId || isRolling}
|
||||
theme='play'
|
||||
active={isPlaying}
|
||||
>
|
||||
<IoPlay />
|
||||
</TapButton>
|
||||
|
||||
<TapButton
|
||||
onClick={() => setPlayback.pause()}
|
||||
disabled={!selectedId || isRolling || isArmed}
|
||||
theme='pause'
|
||||
active={isPaused}
|
||||
>
|
||||
<IoPause />
|
||||
</TapButton>
|
||||
|
||||
<TapButton
|
||||
onClick={() => setPlayback.roll()}
|
||||
disabled={noEvents}
|
||||
theme='roll'
|
||||
active={isRolling}
|
||||
>
|
||||
<IoTimeOutline />
|
||||
</TapButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
|
||||
import TimerDisplay from '../../../common/components/timer-display/TimerDisplay';
|
||||
import { setPlayback, useTimer } from '../../../common/hooks/useSocket';
|
||||
import { Playback } from '../../../common/models/OntimeTypes';
|
||||
import { stringFromMillis } from '../../../common/utils/time';
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
|
||||
import TapButton from './TapButton';
|
||||
|
||||
import style from './PlaybackControl.module.scss';
|
||||
|
||||
interface PlaybackTimerProps {
|
||||
playback: Playback;
|
||||
selectedId: string | null;
|
||||
}
|
||||
|
||||
export default function PlaybackTimer(props: PlaybackTimerProps) {
|
||||
const { playback, selectedId } = props;
|
||||
const { data: timerData } = useTimer();
|
||||
|
||||
// TODO: checkout typescript in utilities
|
||||
const started = stringFromMillis(timerData?.startedAt, true);
|
||||
const finish = stringFromMillis(timerData.expectedFinish, true);
|
||||
const isRolling = playback === 'roll';
|
||||
const isWaiting = timerData.secondaryTimer !== null && timerData.secondaryTimer > 0 && timerData.current === null;
|
||||
const disableButtons = selectedId === null || isRolling;
|
||||
const isOvertime = timerData.current !== null && timerData.current < 0;
|
||||
|
||||
return (
|
||||
<div className={style.timeContainer}>
|
||||
<div className={style.indicators}>
|
||||
<Tooltip label='Roll mode active'>
|
||||
<div className={isRolling ? style.indRollActive : style.indRoll} />
|
||||
</Tooltip>
|
||||
<div className={isOvertime ? style.indNegativeActive : style.indNegative} />
|
||||
<div className={style.indDelay} />
|
||||
</div>
|
||||
<div className={style.timer}>
|
||||
<TimerDisplay time={isWaiting ? timerData.secondaryTimer : timerData.current} small />
|
||||
</div>
|
||||
{isWaiting ? (
|
||||
<div className={style.roll}>
|
||||
<span className={style.rolltag}>Roll: Countdown to start</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={style.start}>
|
||||
<span className={style.tag}>Started at </span>
|
||||
<span className={style.time}>{started}</span>
|
||||
</div>
|
||||
<div className={style.finish}>
|
||||
<span className={style.tag}>Finish at </span>
|
||||
<span className={style.time}>{finish}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className={style.btn}>
|
||||
<Tooltip label='Remove 1 minute' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
|
||||
<TapButton onClick={() => setPlayback.delay(-1)} disabled={disableButtons} square>
|
||||
-1
|
||||
</TapButton>
|
||||
</Tooltip>
|
||||
<Tooltip label='Add 1 minute' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
|
||||
<TapButton onClick={() => setPlayback.delay(1)} disabled={disableButtons} square>
|
||||
+1
|
||||
</TapButton>
|
||||
</Tooltip>
|
||||
<Tooltip label='Remove 5 minutes' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
|
||||
<TapButton onClick={() => setPlayback.delay(-5)} disabled={disableButtons} square>
|
||||
-5
|
||||
</TapButton>
|
||||
</Tooltip>
|
||||
<Tooltip label='Add 5 minutes' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
|
||||
<TapButton onClick={() => setPlayback.delay(+5)} disabled={disableButtons} square>
|
||||
+5
|
||||
</TapButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
@use '../../../theme/ontimeColours' as *;
|
||||
|
||||
$button-bg-gray: $gray-1050;
|
||||
$button-color-white: $gray-50;
|
||||
|
||||
@mixin tap-factory($theme-color) {
|
||||
font-family: $ontime-font-family;
|
||||
font-size: 22px;
|
||||
border-radius: $component-border-radius-md;
|
||||
width: 100%;
|
||||
aspect-ratio: 3/1;
|
||||
transition-property: color, background-color;
|
||||
transition-duration: $transition-time-feedback;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
background-color: $button-bg-gray;
|
||||
color: $theme-color;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: $opacity-disabled;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: $button-color-white;
|
||||
background-color: $theme-color;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
color: $button-bg-gray;
|
||||
background-color: $button-color-white;
|
||||
transition-property: color, background-color;
|
||||
transition-duration: $transition-time-action;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: $theme-color;
|
||||
color: $button-color-white;
|
||||
}
|
||||
}
|
||||
|
||||
.tapButton.neutral {
|
||||
@include tap-factory($gray-50);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: $gray-50;
|
||||
background-color: $gray-1000;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
color: $button-bg-gray;
|
||||
background-color: $button-color-white;
|
||||
transition-property: color, background-color;
|
||||
transition-duration: $transition-time-action;
|
||||
}
|
||||
}
|
||||
|
||||
.tapButton.play {
|
||||
@include tap-factory($playback-start);
|
||||
}
|
||||
|
||||
.tapButton.roll {
|
||||
@include tap-factory($ontime-roll);
|
||||
}
|
||||
|
||||
.tapButton.pause {
|
||||
@include tap-factory($ontime-paused);
|
||||
}
|
||||
|
||||
.tapButton.ontime {
|
||||
@include tap-factory($ontime-color);
|
||||
}
|
||||
|
||||
.tapButton.stop {
|
||||
@include tap-factory($ontime-stop);
|
||||
}
|
||||
|
||||
.tapButton.square {
|
||||
aspect-ratio: 1;
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ForwardedRef, forwardRef, PropsWithChildren } from 'react';
|
||||
|
||||
import { Playback } from '../../../common/models/OntimeTypes';
|
||||
|
||||
import style from './TapButton.module.scss';
|
||||
|
||||
interface TapButtonProps {
|
||||
disabled?: boolean;
|
||||
square?: boolean;
|
||||
onClick: () => void;
|
||||
theme?: Playback | 'neutral';
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
const TapButton = forwardRef((props: PropsWithChildren<TapButtonProps>, ref: ForwardedRef<HTMLButtonElement> ) => {
|
||||
const { children, disabled, onClick, theme = 'neutral', square, active } = props;
|
||||
return (
|
||||
<button
|
||||
className={`${style.tapButton} ${style[theme]} ${square ? style.square : ''} ${active ? style.active : ''}`}
|
||||
disabled={disabled}
|
||||
type='button'
|
||||
onClick={onClick}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
TapButton.displayName = "TabButton";
|
||||
export default TapButton;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
|
||||
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
|
||||
import { handleLinks } from '../../../common/utils/linkUtils';
|
||||
|
||||
import PlaybackControl from './PlaybackControl';
|
||||
|
||||
import style from '../../editors/Editor.module.scss';
|
||||
|
||||
export default function TimerControlExport() {
|
||||
return (
|
||||
<Box className={style.playback} data-testid='panel-timer-control'>
|
||||
<IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'timercontrol')} />
|
||||
<div className={style.content}>
|
||||
<ErrorBoundary>
|
||||
<PlaybackControl />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { IoPlaySkipBack } from '@react-icons/all-files/io5/IoPlaySkipBack';
|
||||
import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward';
|
||||
import { IoReload } from '@react-icons/all-files/io5/IoReload';
|
||||
import { IoStop } from '@react-icons/all-files/io5/IoStop';
|
||||
|
||||
import { setPlayback } from '../../../common/hooks/useSocket';
|
||||
import { Playback } from '../../../common/models/OntimeTypes';
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
|
||||
import TapButton from './TapButton';
|
||||
|
||||
import style from './PlaybackControl.module.scss';
|
||||
|
||||
interface TransportProps {
|
||||
playback: Playback;
|
||||
selectedId: string | null;
|
||||
noEvents: boolean;
|
||||
}
|
||||
|
||||
export default function Transport(props: TransportProps) {
|
||||
const { playback, selectedId, noEvents } = props;
|
||||
const isRolling = playback === 'roll';
|
||||
|
||||
return (
|
||||
<div className={style.playbackContainer}>
|
||||
<Tooltip label='Previous event' openDelay={tooltipDelayMid}>
|
||||
<TapButton
|
||||
onClick={() => setPlayback.previous()}
|
||||
disabled={isRolling || noEvents}
|
||||
>
|
||||
<IoPlaySkipBack />
|
||||
</TapButton>
|
||||
</Tooltip>
|
||||
<Tooltip label='Next event' openDelay={tooltipDelayMid}>
|
||||
<TapButton
|
||||
onClick={() => setPlayback.next()}
|
||||
disabled={isRolling || noEvents}
|
||||
>
|
||||
<IoPlaySkipForward />
|
||||
</TapButton>
|
||||
</Tooltip>
|
||||
<Tooltip label='Reload event' openDelay={tooltipDelayMid}>
|
||||
<TapButton
|
||||
onClick={() => setPlayback.reload()}
|
||||
disabled={!selectedId || isRolling}
|
||||
>
|
||||
<IoReload className={style.invertX} />
|
||||
</TapButton>
|
||||
</Tooltip>
|
||||
<Tooltip label='Unload Event' openDelay={tooltipDelayMid}>
|
||||
<TapButton
|
||||
onClick={() => setPlayback.stop()}
|
||||
disabled={!selectedId && !isRolling}
|
||||
theme='stop'
|
||||
>
|
||||
<IoStop />
|
||||
</TapButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
@use '../../theme/ontimeColours' as *;
|
||||
@use '../../theme/v2Styles' as *;
|
||||
|
||||
$menu-width: 48px;
|
||||
$rundown-width: 46em;
|
||||
$playback-width: 450px;
|
||||
|
||||
@mixin absolute-top-right($distance) {
|
||||
position: absolute;
|
||||
top: $distance;
|
||||
right: $distance;
|
||||
cursor: pointer;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
.corner {
|
||||
display: none;
|
||||
@include absolute-top-right(8px);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.mainContainer {
|
||||
background: $ui-black;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: auto;
|
||||
color: $ui-white;
|
||||
padding: 16px 8px;
|
||||
font-family: $ontime-font-family;
|
||||
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-columns: $menu-width $rundown-width $playback-width auto;
|
||||
grid-template-areas:
|
||||
'sett even play info'
|
||||
'sett even mess info';
|
||||
gap: 8px;
|
||||
|
||||
.editor,
|
||||
.playback,
|
||||
.messages,
|
||||
.info,
|
||||
.settings {
|
||||
position: relative;
|
||||
|
||||
.corner {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 2/3 window, hide info */
|
||||
@media (max-width: 1450px) and (min-height: 700px) {
|
||||
.mainContainer {
|
||||
height: 100%;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-columns: $menu-width $rundown-width auto;
|
||||
|
||||
.info {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 1/2 window, event list only */
|
||||
@media (max-width: 1100px) {
|
||||
.mainContainer {
|
||||
height: 100%;
|
||||
grid-template-rows: 100%;
|
||||
grid-template-columns: $menu-width $rundown-width;
|
||||
grid-template-areas:
|
||||
'sett even';
|
||||
|
||||
.info,
|
||||
.messages,
|
||||
.playback {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 1/3 window, show control only */
|
||||
@media (max-width: 850px) and (min-height: 500px) {
|
||||
.mainContainer {
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-columns: 100%;
|
||||
grid-template-areas:
|
||||
'play'
|
||||
'mess';
|
||||
|
||||
.playback,
|
||||
.messages {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.editor,
|
||||
.info,
|
||||
.settings {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 1/3 corner window, playback only */
|
||||
@media (max-width: 850px) and (max-height: 500px) {
|
||||
.mainContainer {
|
||||
grid-template-rows: 100%;
|
||||
grid-template-columns: 100%;
|
||||
grid-template-areas: 'play';
|
||||
|
||||
.playback {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.editor,
|
||||
.messages,
|
||||
.info,
|
||||
.settings {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mainContainer {
|
||||
.settings,
|
||||
.editor,
|
||||
.messages,
|
||||
.playback,
|
||||
.info {
|
||||
border-radius: 8px;
|
||||
height: 100%;
|
||||
background-color: $bg-container-l2;
|
||||
padding: 0.8em 1.5em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.eventEditor {
|
||||
border-radius: 8px 8px 0 0;
|
||||
background-color: $bg-container-l2;
|
||||
box-shadow: rgba(0, 0, 0, 0.35) 0 3px 6px 6px;
|
||||
border-top: 1px solid $white-10;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100vw;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
color: white;
|
||||
transition: bottom $transition-time-feedback;
|
||||
|
||||
&.noEvent {
|
||||
bottom: -500px;
|
||||
transition: bottom 0.7s;
|
||||
}
|
||||
|
||||
.eventEditorLayout {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: #202020;
|
||||
padding: 8px;
|
||||
border-left: 1px solid $white-10;
|
||||
}
|
||||
}
|
||||
|
||||
.editor {
|
||||
grid-area: even;
|
||||
height: calc(100% - 24px);
|
||||
|
||||
.content {
|
||||
height: calc(100% - 24px);
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
grid-area: info;
|
||||
min-width: 17em;
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100% - 24px);
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.messages {
|
||||
grid-area: mess;
|
||||
}
|
||||
|
||||
.playback {
|
||||
grid-area: play;
|
||||
min-height: 250px;
|
||||
max-height: 380px;
|
||||
min-width: 450px;
|
||||
}
|
||||
|
||||
.mainContainer > .settings {
|
||||
grid-area: sett;
|
||||
background-color: transparent;
|
||||
margin: 0;
|
||||
padding: 0 8px 0 0;
|
||||
width: fit-content;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding-top: 24px;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { lazy, useEffect } from 'react';
|
||||
import { Box, useDisclosure } from '@chakra-ui/react';
|
||||
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
|
||||
import UploadModal from '../../common/components/upload-modal/UploadModal';
|
||||
import ModalManager from '../../features/modals/ModalManager';
|
||||
|
||||
import MenuBar from '../menu/MenuBar';
|
||||
|
||||
import styles from './Editor.module.scss';
|
||||
|
||||
const Rundown = lazy(() => import('../../features/rundown/RundownExport'));
|
||||
const TimerControl = lazy(() => import('../../features/control/playback/TimerControlExport'));
|
||||
const MessageControl = lazy(() => import('../../features/control/message/MessageControlExport'));
|
||||
const Info = lazy(() => import('../../features/info/InfoExport'));
|
||||
const EventEditor = lazy(() => import('../../features/event-editor/EventEditorExport'));
|
||||
|
||||
export default function Editor() {
|
||||
const {
|
||||
isOpen: isSettingsOpen,
|
||||
onOpen: onSettingsOpen,
|
||||
onClose: onSettingsClose,
|
||||
} = useDisclosure();
|
||||
|
||||
const {
|
||||
isOpen: isUploadModalOpen,
|
||||
onOpen: onUploadModalOpen,
|
||||
onClose: onUploadModalClose,
|
||||
} = useDisclosure();
|
||||
|
||||
// Set window title
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Editor';
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UploadModal onClose={onUploadModalClose} isOpen={isUploadModalOpen} />
|
||||
<ErrorBoundary>
|
||||
<ModalManager isOpen={isSettingsOpen} onClose={onSettingsClose} />
|
||||
</ErrorBoundary>
|
||||
<div className={styles.mainContainer} data-testid="event-editor">
|
||||
<Box id='settings' className={styles.settings}>
|
||||
<ErrorBoundary>
|
||||
<MenuBar
|
||||
onSettingsOpen={onSettingsOpen}
|
||||
isSettingsOpen={isSettingsOpen}
|
||||
onSettingsClose={onSettingsClose}
|
||||
isUploadOpen={isUploadModalOpen}
|
||||
onUploadOpen={onUploadModalOpen}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</Box>
|
||||
<Rundown />
|
||||
<MessageControl />
|
||||
<TimerControl />
|
||||
<Info />
|
||||
</div>
|
||||
<EventEditor />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
|
||||
|
||||
import Editor from './Editor';
|
||||
|
||||
export default function ProtectedEditor() {
|
||||
return (
|
||||
<ProtectRoute>
|
||||
<Editor />
|
||||
</ProtectRoute>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
@use '../../theme/v2Styles' as *;
|
||||
|
||||
.eventEditor {
|
||||
padding: 16px 32px 32px 32px;
|
||||
width: 100%;
|
||||
gap: max(16px, 2vh);
|
||||
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'eventInfo eventActions'
|
||||
'timeOptions titles';
|
||||
grid-template-columns: auto 1fr;
|
||||
|
||||
.timers,
|
||||
.timeSettings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.eventInfo {
|
||||
grid-area: eventInfo;
|
||||
}
|
||||
|
||||
.eventActions {
|
||||
grid-area: eventActions;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.timeOptions {
|
||||
grid-area: timeOptions;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.titles {
|
||||
grid-area: titles;
|
||||
display: grid;
|
||||
grid-template-areas: 'left right';
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
.left,
|
||||
.right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.left {
|
||||
grid-area: left;
|
||||
padding: 0 16px;
|
||||
border-left: 1px solid $border-color-ondark;
|
||||
}
|
||||
|
||||
.right {
|
||||
padding-left: 16px;
|
||||
grid-area: right;
|
||||
border-left: 1px solid $border-color-ondark;
|
||||
}
|
||||
}
|
||||
|
||||
.inputLabel {
|
||||
font-size: 13px;
|
||||
display: block;
|
||||
color: $label-gray;
|
||||
|
||||
.delayLabel {
|
||||
color: $ontime-delay-text;
|
||||
}
|
||||
|
||||
&.publicToggle {
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.spacer {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.padTop {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.fullHeight {
|
||||
height: 100%
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { Button, Select, Switch } from '@chakra-ui/react';
|
||||
import { IoBan } from '@react-icons/all-files/io5/IoBan';
|
||||
import { useAtom } from 'jotai';
|
||||
|
||||
import { editorEventId } from '../../common/atoms/LocalEventSettings';
|
||||
import CopyTag from '../../common/components/copy-tag/CopyTag';
|
||||
import ColourInput from '../../common/components/input/colour-input/ColourInput';
|
||||
import TextInput from '../../common/components/input/text-input/TextInput';
|
||||
import TimeInput from '../../common/components/input/time-input/TimeInput';
|
||||
import { LoggingContext } from '../../common/context/LoggingContext';
|
||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||
import useRundown from '../../common/hooks-query/useRundown';
|
||||
import { OntimeEvent } from '../../common/models/EventTypes';
|
||||
import { millisToMinutes } from '../../common/utils/dateConfig';
|
||||
import getDelayTo from '../../common/utils/getDelayTo';
|
||||
import { stringFromMillis } from '../../common/utils/time';
|
||||
import { calculateDuration, TimeEntryField, validateEntry } from '../../common/utils/timesManager';
|
||||
|
||||
import style from './EventEditor.module.scss';
|
||||
|
||||
export type EventEditorSubmitActions = keyof OntimeEvent | 'durationOverride';
|
||||
|
||||
// Todo: add previous end to TimeInput fields
|
||||
export default function EventEditor() {
|
||||
const [openId] = useAtom(editorEventId);
|
||||
const { data } = useRundown();
|
||||
const { emitWarning, emitError } = useContext(LoggingContext);
|
||||
const { updateEvent } = useEventAction();
|
||||
const [event, setEvent] = useState<OntimeEvent | null>(null);
|
||||
const [delay, setDelay] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || !openId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventIndex = data.findIndex((event) => event.id === openId);
|
||||
if (eventIndex > -1) {
|
||||
const event = data[eventIndex];
|
||||
if (event.type === 'event') {
|
||||
setDelay(getDelayTo(data, eventIndex));
|
||||
setEvent(data[eventIndex] as OntimeEvent);
|
||||
}
|
||||
}
|
||||
}, [data, event, openId]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(field: EventEditorSubmitActions, value: string | number) => {
|
||||
if (event === null) {
|
||||
return;
|
||||
}
|
||||
const newEventData: Partial<OntimeEvent> = { id: event.id };
|
||||
switch (field) {
|
||||
case 'durationOverride': {
|
||||
// duration defines timeEnd
|
||||
newEventData.duration = value as number;
|
||||
newEventData.timeEnd = event.timeStart + (value as number);
|
||||
break;
|
||||
}
|
||||
case 'timeStart': {
|
||||
newEventData.duration = calculateDuration(value as number, event.timeEnd);
|
||||
newEventData.timeStart = value as number;
|
||||
break;
|
||||
}
|
||||
case 'timeEnd': {
|
||||
newEventData.duration = calculateDuration(event.timeStart, value as number);
|
||||
newEventData.timeEnd = value as number;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
if (field in event) {
|
||||
// create object with new field
|
||||
newEventData[field] = value;
|
||||
break;
|
||||
} else {
|
||||
emitError(`Unknown field: ${field}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
updateEvent(newEventData);
|
||||
},
|
||||
[emitError, event, updateEvent],
|
||||
);
|
||||
|
||||
const timerValidationHandler = useCallback((entry: TimeEntryField, val: number) => {
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
const valid = validateEntry(entry, val, event.timeStart, event.timeEnd);
|
||||
if (!valid.value) {
|
||||
emitWarning(`Time Input Warning: ${valid.catch}`);
|
||||
}
|
||||
return valid.value;
|
||||
},
|
||||
[event, emitWarning],
|
||||
);
|
||||
|
||||
const togglePublic = useCallback((currentValue: boolean) => {
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
updateEvent({ id: event.id, isPublic: !currentValue });
|
||||
},
|
||||
[event, updateEvent],
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return <span>Loading...</span>;
|
||||
}
|
||||
|
||||
const delayed = delay !== 0;
|
||||
const addedTime = delayed
|
||||
? `${delay >= 0 ? '+' : '-'} ${millisToMinutes(Math.abs(delay))} minutes`
|
||||
: null;
|
||||
const newStart = delayed ? `New start ${stringFromMillis(event.timeStart + delay)}` : null;
|
||||
const newEnd = delayed ? `New end ${stringFromMillis(event.timeEnd + delay)}` : null;
|
||||
|
||||
return (
|
||||
<div className={style.eventEditor}>
|
||||
<div className={style.eventInfo}>{`Event ID ${event.id}`}</div>
|
||||
<div className={style.eventActions}>
|
||||
<CopyTag label='OSC trigger'>{`/ontime/gotoid/${event.id}`}</CopyTag>
|
||||
</div>
|
||||
<div className={style.timeOptions}>
|
||||
<div className={style.timers}>
|
||||
<label className={style.inputLabel}>
|
||||
Start time {delayed && <span className={style.delayLabel}>{addedTime}</span>}
|
||||
{delayed && <div className={style.delayLabel}>{newStart}</div>}
|
||||
</label>
|
||||
<TimeInput
|
||||
name='timeStart'
|
||||
submitHandler={handleSubmit}
|
||||
validationHandler={timerValidationHandler}
|
||||
time={event.timeStart}
|
||||
delay={delay}
|
||||
placeholder='Start'
|
||||
/>
|
||||
<label className={style.inputLabel}>
|
||||
End time {delayed && <span className={style.delayLabel}>{addedTime}</span>}
|
||||
{delayed && <div className={style.delayLabel}>{newEnd}</div>}
|
||||
</label>
|
||||
<TimeInput
|
||||
name='timeEnd'
|
||||
submitHandler={handleSubmit}
|
||||
validationHandler={timerValidationHandler}
|
||||
time={event.timeEnd}
|
||||
delay={delay}
|
||||
placeholder='End'
|
||||
/>
|
||||
<label className={style.inputLabel}>Duration</label>
|
||||
<TimeInput
|
||||
name='durationOverride'
|
||||
submitHandler={handleSubmit}
|
||||
validationHandler={timerValidationHandler}
|
||||
time={event.duration}
|
||||
placeholder='Duration'
|
||||
/>
|
||||
</div>
|
||||
<div className={style.timeSettings}>
|
||||
<label className={style.inputLabel}>Timer type</label>
|
||||
<Select size='sm' variant='ontime'>
|
||||
<option value='option1'>Start to end</option>
|
||||
<option value='option2'>Duration</option>
|
||||
<option value='option3'>Follow previous</option>
|
||||
<option value='option3'>Start only</option>
|
||||
</Select>
|
||||
<label className={style.inputLabel}>Countdown style</label>
|
||||
<Select size='sm' variant='ontime'>
|
||||
<option value='option1'>Count down</option>
|
||||
<option value='option2'>Count up</option>
|
||||
<option value='option3'>Clock</option>
|
||||
</Select>
|
||||
<span className={style.spacer} />
|
||||
<label className={`${style.inputLabel} ${style.publicToggle}`}>
|
||||
<Switch
|
||||
isChecked={event.isPublic}
|
||||
onChange={() => togglePublic(event.isPublic)}
|
||||
variant='ontime'
|
||||
/>
|
||||
Event is public
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.titles}>
|
||||
<div className={style.left}>
|
||||
<div className={style.column}>
|
||||
<label className={style.inputLabel}>Title</label>
|
||||
<TextInput field='title' initialText={event.title} submitHandler={handleSubmit} />
|
||||
</div>
|
||||
<div className={style.column}>
|
||||
<label className={style.inputLabel}>Presenter</label>
|
||||
<TextInput
|
||||
field='presenter'
|
||||
initialText={event.presenter}
|
||||
submitHandler={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.column}>
|
||||
<label className={style.inputLabel}>Subtitle</label>
|
||||
<TextInput field='subtitle' initialText={event.subtitle} submitHandler={handleSubmit} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.right}>
|
||||
<div className={style.column}>
|
||||
<label className={style.inputLabel}>Colour</label>
|
||||
<div className={style.inline}>
|
||||
<ColourInput
|
||||
name="colour"
|
||||
value={event?.colour}
|
||||
handleChange={handleSubmit}
|
||||
/>
|
||||
<Button
|
||||
leftIcon={<IoBan />}
|
||||
onClick={() => handleSubmit('colour', '')}
|
||||
variant='ontime-subtle'
|
||||
size='sm'
|
||||
>
|
||||
Clear colour
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${style.column} ${style.fullHeight}`}>
|
||||
<label className={style.inputLabel}>Note</label>
|
||||
<TextInput
|
||||
field='note'
|
||||
initialText={event.note}
|
||||
submitHandler={handleSubmit}
|
||||
isTextArea
|
||||
isFullHeight
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Box, IconButton } from '@chakra-ui/react';
|
||||
import { FiX } from '@react-icons/all-files/fi/FiX';
|
||||
import { editorEventId } from '../../common/atoms/LocalEventSettings';
|
||||
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
|
||||
import { useAtom } from 'jotai';
|
||||
|
||||
import EventEditor from './EventEditor';
|
||||
|
||||
import style from '../editors/Editor.module.scss';
|
||||
|
||||
/* Styling for action buttons */
|
||||
const closeBtnStyle = {
|
||||
size: 'md',
|
||||
variant: 'ghost',
|
||||
colorScheme: 'white',
|
||||
_hover: { bg: '#ebedf0', color: '#333' },
|
||||
};
|
||||
|
||||
export default function InfoExport() {
|
||||
const [openId, setOpenId] = useAtom(editorEventId);
|
||||
|
||||
return (
|
||||
<Box className={`${style.eventEditor} ${!openId ? style.noEvent : ''}`}>
|
||||
<ErrorBoundary>
|
||||
<div className={style.eventEditorLayout}>
|
||||
<EventEditor />
|
||||
<div className={style.header}>
|
||||
<IconButton
|
||||
aria-label='Close Menu'
|
||||
icon={<FiX />}
|
||||
onClick={() => setOpenId(null)}
|
||||
{...closeBtnStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import CollapseBar from '../../common/components/collapse-bar/CollapseBar';
|
||||
|
||||
import style from './Info.module.scss';
|
||||
|
||||
type TitleShape = {
|
||||
title: string;
|
||||
presenter: string;
|
||||
subtitle: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
interface CollapsableInfoProps {
|
||||
title: string;
|
||||
data: TitleShape;
|
||||
}
|
||||
|
||||
export default function CollapsableInfo(props: CollapsableInfoProps) {
|
||||
const { title, data } = props;
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={style.container}>
|
||||
<CollapseBar
|
||||
title={title}
|
||||
isCollapsed={collapsed}
|
||||
onClick={() => setCollapsed((prev) => !prev)}
|
||||
/>
|
||||
{!collapsed && (
|
||||
<div className={style.labels}>
|
||||
<div>
|
||||
<span className={style.label}>Title:</span>
|
||||
<span className={style.content}>{data.title}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={style.label}>Presenter:</span>
|
||||
<span className={style.content}>{data.presenter}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={style.label}>Subtitle:</span>
|
||||
<span className={style.content}>{data.subtitle}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={style.label}>Note:</span>
|
||||
<span className={style.content}>{data.note}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useInfoPanel } from '../../common/hooks/useSocket';
|
||||
|
||||
import InfoTitle from './CollapsableInfo';
|
||||
import InfoLogger from './InfoLogger';
|
||||
import InfoNif from './InfoNif';
|
||||
|
||||
import style from './Info.module.scss';
|
||||
|
||||
export default function Info() {
|
||||
const { data } = useInfoPanel();
|
||||
|
||||
const titlesNow = {
|
||||
title: data.titles.titleNow,
|
||||
subtitle: data.titles.subtitleNow,
|
||||
presenter: data.titles.presenterNow,
|
||||
note: data.titles.noteNow,
|
||||
};
|
||||
|
||||
const titlesNext = {
|
||||
title: data.titles.titleNext,
|
||||
subtitle: data.titles.subtitleNext,
|
||||
presenter: data.titles.presenterNext,
|
||||
note: data.titles.noteNext,
|
||||
};
|
||||
|
||||
const selected = !data.numEvents
|
||||
? 'No events'
|
||||
: `Event ${data.selectedEventIndex != null ? data.selectedEventIndex + 1 : '-'} / ${
|
||||
data.numEvents ? data.numEvents : '-'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={style.panelHeader}>
|
||||
<span>Ontime running on port 4001</span>
|
||||
<span>{selected}</span>
|
||||
</div>
|
||||
<InfoNif />
|
||||
<InfoTitle title='Playing Now' data={titlesNow} />
|
||||
<InfoTitle title='Playing Next' data={titlesNext} />
|
||||
<InfoLogger />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
@use '../../theme/mixins' as *;
|
||||
@use '../../theme/v2Styles' as *;
|
||||
|
||||
.panelHeader {
|
||||
font-size: $inner-section-text-size;
|
||||
font-family: $ontime-font-family;
|
||||
color: $label-gray;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin-top: $main-spacing;
|
||||
}
|
||||
|
||||
.labels {
|
||||
font-size: $inner-section-text-size;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $element-inner-spacing;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: $section-white;
|
||||
}
|
||||
|
||||
.content {
|
||||
color: $secondary-text-gray;
|
||||
margin-left: $element-inner-spacing;
|
||||
font-size: $text-body-size;
|
||||
}
|
||||
|
||||
.interfaceList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $section-spacing;
|
||||
row-gap: $element-inner-spacing;
|
||||
|
||||
.interface {
|
||||
@include action-link;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.linkIcon {
|
||||
margin-left: $element-inner-spacing;
|
||||
display: inline-block;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
|
||||
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
|
||||
import { handleLinks } from '../../common/utils/linkUtils';
|
||||
|
||||
import Info from './Info';
|
||||
|
||||
import style from '../editors/Editor.module.scss';
|
||||
|
||||
export default function InfoExport() {
|
||||
return (
|
||||
<Box className={style.info} data-testid="panel-info">
|
||||
<IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'info')} />
|
||||
<div className={style.content}>
|
||||
<ErrorBoundary>
|
||||
<Info />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
@use '../../theme/v2Styles' as *;
|
||||
@use '../../theme/mixins' as *;
|
||||
|
||||
$info-gray: $secondary-text-gray;
|
||||
$info-hover: $section-white;
|
||||
|
||||
.infoLoggerContainer {
|
||||
max-height: 80%;
|
||||
margin-top: 32px;
|
||||
|
||||
&.expanded {
|
||||
min-height: 50%;
|
||||
height: 100%
|
||||
}
|
||||
}
|
||||
|
||||
.log {
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
font-size: 0.8em;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.logEntry {
|
||||
display: flex;
|
||||
margin-bottom: 2px;
|
||||
&.INFO {
|
||||
color: $info-gray;
|
||||
}
|
||||
|
||||
&.WARN {
|
||||
color: $warning-orange;
|
||||
}
|
||||
|
||||
&.ERROR {
|
||||
color: $error-red;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: $info-hover;
|
||||
}
|
||||
|
||||
.time {
|
||||
width: 4.5em
|
||||
}
|
||||
|
||||
.origin {
|
||||
width: 6em;
|
||||
}
|
||||
|
||||
.msg {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.buttonBar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
|
||||
import CollapseBar from '../../common/components/collapse-bar/CollapseBar';
|
||||
import { Log, LoggingContext } from '../../common/context/LoggingContext';
|
||||
|
||||
import style from './InfoLogger.module.scss';
|
||||
|
||||
enum LOG_FILTER {
|
||||
USER = 'USER',
|
||||
CLIENT = 'CLIENT',
|
||||
SERVER = 'SERVER',
|
||||
RX = 'RX',
|
||||
TX = 'TX',
|
||||
PLAYBACK = 'PLAYBACK',
|
||||
}
|
||||
|
||||
export default function InfoLogger() {
|
||||
const { logData, clearLog } = useContext(LoggingContext);
|
||||
const [data, setData] = useState<Log[]>([]);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [showClient, setShowClient] = useState(true);
|
||||
const [showServer, setShowServer] = useState(true);
|
||||
const [showRx, setShowRx] = useState(true);
|
||||
const [showTx, setShowTx] = useState(true);
|
||||
const [showPlayback, setShowPlayback] = useState(true);
|
||||
const [showUser, setShowUser] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!logData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matchers: LOG_FILTER[] = [];
|
||||
if (showUser) {
|
||||
matchers.push(LOG_FILTER.USER);
|
||||
}
|
||||
if (showClient) {
|
||||
matchers.push(LOG_FILTER.CLIENT);
|
||||
}
|
||||
if (showServer) {
|
||||
matchers.push(LOG_FILTER.SERVER);
|
||||
}
|
||||
if (showRx) {
|
||||
matchers.push(LOG_FILTER.RX);
|
||||
}
|
||||
if (showTx) {
|
||||
matchers.push(LOG_FILTER.TX);
|
||||
}
|
||||
if (showPlayback) {
|
||||
matchers.push(LOG_FILTER.PLAYBACK);
|
||||
}
|
||||
|
||||
const filteredData = logData.filter((entry) => matchers.some((match) => entry.origin === match));
|
||||
setData(filteredData);
|
||||
}, [logData, showUser, showClient, showServer, showPlayback, showRx, showTx]);
|
||||
|
||||
const disableOthers = useCallback((toEnable: LOG_FILTER) => {
|
||||
toEnable === LOG_FILTER.USER ? setShowUser(true) : setShowUser(false);
|
||||
toEnable === LOG_FILTER.CLIENT ? setShowClient(true) : setShowClient(false);
|
||||
toEnable === LOG_FILTER.SERVER ? setShowServer(true) : setShowServer(false);
|
||||
toEnable === LOG_FILTER.RX ? setShowRx(true) : setShowRx(false);
|
||||
toEnable === LOG_FILTER.TX ? setShowTx(true) : setShowTx(false);
|
||||
toEnable === LOG_FILTER.PLAYBACK ? setShowPlayback(true) : setShowPlayback(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`${style.infoLoggerContainer} ${collapsed? '' : style.expanded}`}>
|
||||
<CollapseBar title='Log' isCollapsed={collapsed} onClick={() => setCollapsed((prev) => !prev)} />
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div className={style.buttonBar}>
|
||||
<Button
|
||||
variant={showUser ? 'ontime-filled' : 'ontime-subtle'}
|
||||
size='xs'
|
||||
onClick={() => setShowUser((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LOG_FILTER.USER)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
USER
|
||||
</Button>
|
||||
<Button
|
||||
variant={showClient ? 'ontime-filled' : 'ontime-subtle'}
|
||||
size='xs'
|
||||
onClick={() => setShowClient((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LOG_FILTER.CLIENT)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
CLIENT
|
||||
</Button>
|
||||
<Button
|
||||
variant={showServer ? 'ontime-filled' : 'ontime-subtle'}
|
||||
size='xs'
|
||||
onClick={() => setShowServer((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LOG_FILTER.SERVER)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
SERVER
|
||||
</Button>
|
||||
<Button
|
||||
variant={showPlayback ? 'ontime-filled' : 'ontime-subtle'}
|
||||
size='xs'
|
||||
onClick={() => setShowPlayback((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LOG_FILTER.PLAYBACK)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
PLAYBACK
|
||||
</Button>
|
||||
<Button
|
||||
variant={showRx ? 'ontime-filled' : 'ontime-subtle'}
|
||||
size='xs'
|
||||
onClick={() => setShowRx((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LOG_FILTER.RX)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
RX
|
||||
</Button>
|
||||
<Button
|
||||
variant={showTx ? 'ontime-filled' : 'ontime-subtle'}
|
||||
size='xs'
|
||||
onClick={() => setShowTx((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LOG_FILTER.TX)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
TX
|
||||
</Button>
|
||||
<Button
|
||||
variant='ontime-outlined'
|
||||
size='xs'
|
||||
onClick={clearLog}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<ul className={style.log}>
|
||||
{data.map((logEntry) => (
|
||||
<li key={logEntry.id} className={`${style.logEntry} ${style[logEntry.level]} `}>
|
||||
<span className={style.time}>{logEntry.time}</span>
|
||||
<span className={style.origin}>{logEntry.origin}</span>
|
||||
<span className={style.msg}>{logEntry.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState } from 'react';
|
||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
|
||||
import CollapseBar from '../../common/components/collapse-bar/CollapseBar';
|
||||
import useInfo from '../../common/hooks-query/useInfo';
|
||||
import { openLink } from '../../common/utils/linkUtils';
|
||||
|
||||
import style from './Info.module.scss';
|
||||
|
||||
export default function InfoNif() {
|
||||
const { data } = useInfo();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const handleClick = (address: string) => {
|
||||
const baseURL = 'http://__IP__:4001';
|
||||
openLink(baseURL.replace('__IP__', address));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.container}>
|
||||
<CollapseBar
|
||||
title='Network Info'
|
||||
isCollapsed={collapsed}
|
||||
onClick={() => setCollapsed((prev) => !prev)}
|
||||
/>
|
||||
{!collapsed && (
|
||||
<div className={style.interfaceList}>
|
||||
{data?.networkInterfaces.map((nif) => (
|
||||
<span
|
||||
key={nif.address}
|
||||
onClick={() => handleClick(nif.address)}
|
||||
className={style.interface}
|
||||
>
|
||||
{`${nif.name} - ${nif.address}`}
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
@use '../../theme/ontimeColours' as *;
|
||||
|
||||
.gap {
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.open {
|
||||
background: $blue-700;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { VStack } from '@chakra-ui/react';
|
||||
import { FiHelpCircle } from '@react-icons/all-files/fi/FiHelpCircle';
|
||||
import { FiMinimize } from '@react-icons/all-files/fi/FiMinimize';
|
||||
import { FiSave } from '@react-icons/all-files/fi/FiSave';
|
||||
import { FiUpload } from '@react-icons/all-files/fi/FiUpload';
|
||||
import { IoScan } from '@react-icons/all-files/io5/IoScan';
|
||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||
import { downloadRundown } from '../../common/api/ontimeApi';
|
||||
|
||||
import QuitIconBtn from '../../common/components/buttons/QuitIconBtn';
|
||||
import TooltipActionBtn from '../../common/components/buttons/TooltipActionBtn';
|
||||
import useElectronEvent from '../../common/hooks/useElectronEvent';
|
||||
|
||||
import style from './MenuBar.module.scss';
|
||||
|
||||
interface MenuBarProps {
|
||||
isSettingsOpen: boolean;
|
||||
onSettingsOpen: () => void;
|
||||
onSettingsClose: () => void;
|
||||
isUploadOpen: boolean;
|
||||
onUploadOpen: () => void;
|
||||
}
|
||||
|
||||
type Actions = 'min' | 'max' | 'shutdown' | 'help';
|
||||
|
||||
const buttonStyle = {
|
||||
fontSize: '1.5em',
|
||||
size: 'lg',
|
||||
colorScheme: 'white',
|
||||
_hover: {
|
||||
background: 'rgba(255, 255, 255, 0.10)' // $white-10
|
||||
},
|
||||
_active: {
|
||||
background: 'rgba(255, 255, 255, 0.13)' // $white-13
|
||||
}
|
||||
};
|
||||
|
||||
export default function MenuBar(props: MenuBarProps) {
|
||||
const { isSettingsOpen, onSettingsOpen, onSettingsClose, isUploadOpen, onUploadOpen } = props;
|
||||
const { isElectron, sendToElectron } = useElectronEvent();
|
||||
|
||||
const actionHandler = useCallback((action: Actions) => {
|
||||
// Stop crashes when testing locally
|
||||
if (!isElectron) {
|
||||
if (action === 'help') {
|
||||
window.open('https://cpvalente.gitbook.io/ontime/');
|
||||
}
|
||||
} else {
|
||||
switch (action) {
|
||||
case 'min':
|
||||
sendToElectron('set-window', 'to-tray');
|
||||
break;
|
||||
case 'max':
|
||||
sendToElectron('set-window', 'to-max');
|
||||
break;
|
||||
case 'shutdown':
|
||||
sendToElectron('shutdown', 'now');
|
||||
break;
|
||||
case 'help':
|
||||
sendToElectron('send-to-link', 'help');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [sendToElectron, isElectron]);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyPress = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
// handle held key
|
||||
if (event.repeat) return;
|
||||
|
||||
// check if the ctrl key is pressed
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
// ctrl + , (settings)
|
||||
if (event.key === ',') {
|
||||
// open if not open
|
||||
isSettingsOpen ? onSettingsClose() : onSettingsOpen();
|
||||
}
|
||||
}
|
||||
},
|
||||
[isSettingsOpen, onSettingsClose, onSettingsOpen],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isElectron) {
|
||||
document.addEventListener('keydown', handleKeyPress);
|
||||
}
|
||||
return () => {
|
||||
if (isElectron) {
|
||||
document.removeEventListener('keydown', handleKeyPress);
|
||||
}
|
||||
};
|
||||
}, [handleKeyPress, isElectron]);
|
||||
|
||||
return (
|
||||
<VStack>
|
||||
<QuitIconBtn clickHandler={() => actionHandler('shutdown')} />
|
||||
<TooltipActionBtn
|
||||
{...buttonStyle}
|
||||
icon={<IoScan />}
|
||||
clickHandler={() => actionHandler('max')}
|
||||
tooltip='Show full window'
|
||||
aria-label='Show full window'
|
||||
/>
|
||||
<TooltipActionBtn
|
||||
{...buttonStyle}
|
||||
icon={<FiMinimize />}
|
||||
clickHandler={() => actionHandler('min')}
|
||||
tooltip='Minimise to tray'
|
||||
aria-label='Minimise to tray'
|
||||
/>
|
||||
<div className={style.gap} />
|
||||
<TooltipActionBtn
|
||||
{...buttonStyle}
|
||||
icon={<FiHelpCircle />}
|
||||
clickHandler={() => actionHandler('help')}
|
||||
tooltip='Help'
|
||||
aria-label='Help'
|
||||
/>
|
||||
<TooltipActionBtn
|
||||
{...buttonStyle}
|
||||
icon={<IoSettingsOutline />}
|
||||
className={isSettingsOpen ? style.open : ''}
|
||||
clickHandler={onSettingsOpen}
|
||||
tooltip='Settings'
|
||||
aria-label='Settings'
|
||||
/>
|
||||
<div className={style.gap} />
|
||||
<TooltipActionBtn
|
||||
{...buttonStyle}
|
||||
icon={<FiUpload />}
|
||||
className={isUploadOpen ? style.open : ''}
|
||||
clickHandler={onUploadOpen}
|
||||
tooltip='Upload showfile'
|
||||
aria-label='Upload showfile'
|
||||
/>
|
||||
<TooltipActionBtn
|
||||
{...buttonStyle}
|
||||
icon={<FiSave />}
|
||||
clickHandler={downloadRundown}
|
||||
tooltip='Export showfile'
|
||||
aria-label='Export showfile'
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
@use '../../theme/v2Styles' as *;
|
||||
@use '../../theme/ontimeColours' as *;
|
||||
|
||||
.headerButtons {
|
||||
align-content: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.labelledSwitch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $element-spacing;
|
||||
color: $gray-100;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { memo, useCallback, useContext } from 'react';
|
||||
import { Button, HStack, Menu, MenuButton, MenuDivider, MenuItem, MenuList, Switch } from '@chakra-ui/react';
|
||||
import { FiMinusCircle } from '@react-icons/all-files/fi/FiMinusCircle';
|
||||
import { FiTrash2 } from '@react-icons/all-files/fi/FiTrash2';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { IoTimerOutline } from '@react-icons/all-files/io5/IoTimerOutline';
|
||||
|
||||
import { CursorContext } from '../../common/context/CursorContext';
|
||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||
import { SupportedEvent } from '../../common/models/EventTypes';
|
||||
|
||||
import style from './RundownMenu.module.scss';
|
||||
|
||||
const RundownMenu = () => {
|
||||
const { isCursorLocked, toggleCursorLocked } = useContext(CursorContext);
|
||||
const { addEvent, deleteAllEvents } = useEventAction();
|
||||
|
||||
// TODO: re-write this with stable functions
|
||||
type ActionTypes = SupportedEvent | 'delete-all';
|
||||
const eventAction = useCallback(
|
||||
(action: ActionTypes) => {
|
||||
switch (action) {
|
||||
case SupportedEvent.Event:
|
||||
addEvent({ type: action });
|
||||
break;
|
||||
case SupportedEvent.Delay:
|
||||
addEvent({ type: action });
|
||||
break;
|
||||
case SupportedEvent.Block:
|
||||
addEvent({ type: action });
|
||||
break;
|
||||
case 'delete-all':
|
||||
deleteAllEvents();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[addEvent, deleteAllEvents],
|
||||
);
|
||||
|
||||
return (
|
||||
<HStack className={style.headerButtons}>
|
||||
<label className={style.labelledSwitch}>
|
||||
<Switch
|
||||
defaultChecked={isCursorLocked}
|
||||
onChange={(event) => toggleCursorLocked(event.target.checked)}
|
||||
variant='ontime'
|
||||
/>
|
||||
Lock cursor to current
|
||||
</label>
|
||||
<Menu isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
leftIcon={<IoAdd />}
|
||||
size='sm'
|
||||
variant='ontime-subtle'
|
||||
>
|
||||
Event...
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
<MenuItem icon={<IoAdd />} onClick={() => eventAction(SupportedEvent.Event)}>
|
||||
Add event at start
|
||||
</MenuItem>
|
||||
<MenuItem icon={<IoTimerOutline />} onClick={() => eventAction(SupportedEvent.Delay)}>
|
||||
Add delay at start
|
||||
</MenuItem>
|
||||
<MenuItem icon={<FiMinusCircle />} onClick={() => eventAction(SupportedEvent.Block)}>
|
||||
Add block at start
|
||||
</MenuItem>
|
||||
<MenuDivider />
|
||||
<MenuItem icon={<FiTrash2 />} onClick={() => eventAction('delete-all')} color='#D20300'>
|
||||
Delete all events
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(RundownMenu);
|
||||
@@ -0,0 +1,308 @@
|
||||
/* eslint-disable jsx-a11y/anchor-has-content */
|
||||
import { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { Button, IconButton, Input, ModalBody, Tooltip } from '@chakra-ui/react';
|
||||
import { IoInformationCircleOutline } from '@react-icons/all-files/io5/IoInformationCircleOutline';
|
||||
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
|
||||
import { IoSunny } from '@react-icons/all-files/io5/IoSunny';
|
||||
|
||||
import { viewerLocations } from '../../appConstants';
|
||||
import { postAliases } from '../../common/api/ontimeApi';
|
||||
import { LoggingContext } from '../../common/context/LoggingContext';
|
||||
import useAliases from '../../common/hooks-query/useAliases';
|
||||
import { validateAlias } from '../../common/utils/aliases';
|
||||
import { handleLinks, host } from '../../common/utils/linkUtils';
|
||||
import { tooltipDelayFast } from '../../ontimeConfig';
|
||||
|
||||
import SubmitContainer from './SubmitContainer';
|
||||
|
||||
import style from './Modals.module.scss';
|
||||
|
||||
export default function AliasesModal() {
|
||||
const { data, status, refetch } = useAliases();
|
||||
const { emitError } = useContext(LoggingContext);
|
||||
const [changed, setChanged] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [aliases, setAliases] = useState([]);
|
||||
|
||||
/**
|
||||
* Set formdata from server state
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (data == null) return;
|
||||
if (changed) return;
|
||||
setAliases([...data]);
|
||||
}, [changed, data]);
|
||||
|
||||
/**
|
||||
* Validate and submit data
|
||||
*/
|
||||
const submitHandler = useCallback(
|
||||
async (event) => {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
|
||||
const validatedAliases = [...aliases];
|
||||
let errors = false;
|
||||
for (const alias of validatedAliases) {
|
||||
// validate url
|
||||
const isURLValid = validateAlias(alias.pathAndParams);
|
||||
if (!isURLValid.status) {
|
||||
alias.urlError = isURLValid.message;
|
||||
errors = true;
|
||||
} else {
|
||||
alias.urlError = undefined;
|
||||
}
|
||||
// validate alias
|
||||
const isAliasValid = validateAlias(alias.alias);
|
||||
if (!isAliasValid.status) {
|
||||
alias.aliasError = isAliasValid.message;
|
||||
errors = true;
|
||||
} else {
|
||||
alias.aliasError = undefined;
|
||||
}
|
||||
}
|
||||
setAliases(validatedAliases);
|
||||
|
||||
if (!errors) {
|
||||
try {
|
||||
await postAliases(aliases);
|
||||
} catch (error) {
|
||||
emitError(`Error saving settings: ${error}`);
|
||||
} finally {
|
||||
await refetch();
|
||||
setChanged(false);
|
||||
}
|
||||
}
|
||||
setSubmitting(false);
|
||||
},
|
||||
[aliases, emitError, refetch],
|
||||
);
|
||||
|
||||
/**
|
||||
* Creates a new alias in state with a temporary id
|
||||
*/
|
||||
const addNew = useCallback(() => {
|
||||
if (aliases.length > 20) {
|
||||
emitError('Maximum amount of aliases reacted (20)');
|
||||
return;
|
||||
}
|
||||
|
||||
const emptyAlias = {
|
||||
id: Math.floor(Math.random() * 1000),
|
||||
enabled: false,
|
||||
alias: '',
|
||||
pathAndParams: '',
|
||||
};
|
||||
setAliases((prevState) => [...prevState, emptyAlias]);
|
||||
setChanged(true);
|
||||
}, [aliases.length, emitError]);
|
||||
|
||||
/**
|
||||
* Deletes an alias by a given id
|
||||
* @param {string} id - id of alias to delete
|
||||
*/
|
||||
const deleteAlias = useCallback((id) => {
|
||||
setAliases((prevState) => [...prevState.filter((a) => a.id !== id)]);
|
||||
setChanged(true);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Sets enabled flag to true / false
|
||||
* @param {string} id - object id
|
||||
* @param {boolean} isEnabled - whether to enable / disable flag
|
||||
*/
|
||||
const setEnabled = useCallback((id, isEnabled) => {
|
||||
const aliasesState = [...aliases];
|
||||
for (const a of aliasesState) {
|
||||
if (a.id === id) {
|
||||
if (isEnabled) {
|
||||
if (a.alias === '' || a.pathAndParams === '') {
|
||||
emitError('Alias incomplete');
|
||||
break;
|
||||
}
|
||||
|
||||
const isRepeated = aliases.some((r) => a.alias === r.alias && r.enabled);
|
||||
if (isRepeated) {
|
||||
emitError('There is already an alias with this name');
|
||||
break;
|
||||
}
|
||||
}
|
||||
a.enabled = isEnabled;
|
||||
break;
|
||||
}
|
||||
}
|
||||
setChanged(true);
|
||||
setAliases(aliasesState);
|
||||
}, [aliases, emitError]);
|
||||
|
||||
/**
|
||||
* Reverts local state equals to server state
|
||||
*/
|
||||
const revert = async () => {
|
||||
setChanged(false);
|
||||
await refetch();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles change of input field in local state
|
||||
* @param {number} index - index of item in array
|
||||
* @param {string} field - object parameter to update
|
||||
* @param {string} value - new object parameter value
|
||||
*/
|
||||
const handleChange = useCallback(
|
||||
(index, field, value) => {
|
||||
const temp = [...aliases];
|
||||
temp[index][field] = value;
|
||||
setAliases(temp);
|
||||
setChanged(true);
|
||||
},
|
||||
[aliases],
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalBody className={style.modalBody}>
|
||||
<p className={style.notes}>
|
||||
Configure easy to use URL Aliases
|
||||
<br />
|
||||
🔥 Changes take effect on save 🔥
|
||||
</p>
|
||||
<form onSubmit={submitHandler}>
|
||||
<div className={style.modalFields}>
|
||||
<div className={style.hSeparator}>Default URLs</div>
|
||||
<div className={style.blockNotes}>
|
||||
{viewerLocations.map((l) => (
|
||||
<a
|
||||
href={l.link}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className={style.flexNote}
|
||||
key={l.link}
|
||||
onClick={(e) => handleLinks(e, l.link)}
|
||||
>
|
||||
{`${l.label} - http://${host}/${l.link}`}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<div className={style.hSeparator}>Custom Aliases</div>
|
||||
<div className={style.blockNotes}>
|
||||
<span className={style.inlineFlex}>
|
||||
<IoInformationCircleOutline color='#2b6cb0' fontSize='2em' />
|
||||
URL aliases are useful in two main scenarios
|
||||
</span>
|
||||
<span className={style.labelNote}>Complicated URLs</span>
|
||||
<br />
|
||||
eg. a lower third url with some custom parameters
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className={style.labelNote} style={{ width: '30%' }}>
|
||||
Alias
|
||||
</td>
|
||||
<td className={style.labelNote}>Page URL</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>mylower</td>
|
||||
<td>lower?bg=ff2&text=f00&size=0.6&transition=5</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<br />
|
||||
<span className={style.labelNote}>URLs to be changed dynamically</span>
|
||||
<br />
|
||||
eg. an unattended screen that you would need to change route from the app
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className={style.labelNote} style={{ width: '30%' }}>
|
||||
Alias
|
||||
</td>
|
||||
<td className={style.labelNote}>Page URL</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>thirdfloor</td>
|
||||
<td>public</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className={style.inlineAliasPlaceholder}>
|
||||
<span className={style.labelNote}>Alias</span>
|
||||
<span className={style.labelNote}>Page URL</span>
|
||||
</div>
|
||||
{aliases.map((alias, index) => (
|
||||
<div key={alias.id}>
|
||||
<div className={style.inlineAlias}>
|
||||
<Input
|
||||
size='sm'
|
||||
variant='flushed'
|
||||
name='Alias'
|
||||
placeholder='URL Alias'
|
||||
autoComplete='off'
|
||||
value={alias.alias}
|
||||
isInvalid={alias.aliasError}
|
||||
onChange={(event) => handleChange(index, 'alias', event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
size='sm'
|
||||
fontSize='0.75em'
|
||||
variant='flushed'
|
||||
name='URL'
|
||||
placeholder='URL (portion after ontime Port)'
|
||||
autoComplete='off'
|
||||
value={alias.pathAndParams}
|
||||
isInvalid={alias.urlError}
|
||||
onChange={(event) => handleChange(index, 'pathAndParams', event.target.value)}
|
||||
/>
|
||||
<Tooltip label={`Test /${alias.pathAndParams}`} openDelay={tooltipDelayFast}>
|
||||
<a
|
||||
href='#!'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
onClick={(e) => handleLinks(e, alias.pathAndParams)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label='Enable alias' openDelay={tooltipDelayFast}>
|
||||
<IconButton
|
||||
aria-label='Enable alias'
|
||||
size='xs'
|
||||
icon={<IoSunny />}
|
||||
colorScheme='blue'
|
||||
variant={alias.enabled ? null : 'outline'}
|
||||
onClick={() => setEnabled(alias.id, !alias.enabled)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label='Delete alias' openDelay={tooltipDelayFast}>
|
||||
<IconButton
|
||||
aria-label='Delete alias'
|
||||
size='xs'
|
||||
icon={<IoRemove />}
|
||||
colorScheme='red'
|
||||
onClick={() => deleteAlias(alias.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{alias.aliasError ? (
|
||||
<div className={style.error}>{`Alias error: ${alias.aliasError}`}</div>
|
||||
) : null}
|
||||
{alias.urlError ? (
|
||||
<div className={style.error}>{`URL error: ${alias.urlError}`}</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className={style.inlineAliasPlaceholder}>
|
||||
<Button size='xs' colorScheme='blue' variant='outline' onClick={() => addNew()}>
|
||||
Add new
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<SubmitContainer
|
||||
revert={revert}
|
||||
submitting={submitting}
|
||||
changed={changed}
|
||||
status={status}
|
||||
/>
|
||||
</form>
|
||||
</ModalBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import isEqual from 'react-fast-compare';
|
||||
import {
|
||||
Checkbox,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
ModalBody,
|
||||
PinInput,
|
||||
PinInputField,
|
||||
Select,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiEye } from '@react-icons/all-files/fi/FiEye';
|
||||
import { FiX } from '@react-icons/all-files/fi/FiX';
|
||||
import { useAtom } from 'jotai';
|
||||
|
||||
import { version } from '../../../package.json';
|
||||
import { postSettings } from '../../common/api/ontimeApi';
|
||||
import { eventSettingsAtom } from '../../common/atoms/LocalEventSettings';
|
||||
import TooltipActionBtn from '../../common/components/buttons/TooltipActionBtn';
|
||||
import { LoggingContext } from '../../common/context/LoggingContext';
|
||||
import useSettings from '../../common/hooks-query/useSettings';
|
||||
import { ontimePlaceholderSettings } from '../../common/models/OntimeSettings.type';
|
||||
|
||||
import { inputProps } from './modalHelper';
|
||||
import SubmitContainer from './SubmitContainer';
|
||||
|
||||
import style from './Modals.module.scss';
|
||||
|
||||
export default function AppSettingsModal() {
|
||||
const { data, status, refetch } = useSettings();
|
||||
const { emitError, emitWarning } = useContext(LoggingContext);
|
||||
const [formData, setFormData] = useState(ontimePlaceholderSettings);
|
||||
const [changed, setChanged] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [hidePin, setHidePin] = useState(true);
|
||||
|
||||
const [eventSettings, saveEventSettings] = useAtom(eventSettingsAtom);
|
||||
const [formSettings, setFormSettings] = useState(eventSettings);
|
||||
|
||||
/**
|
||||
* Set formdata from server state
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (data == null) return;
|
||||
if (changed) return;
|
||||
setFormData({
|
||||
pinCode: data.pinCode,
|
||||
timeFormat: data.timeFormat,
|
||||
});
|
||||
}, [changed, data]);
|
||||
|
||||
/**
|
||||
* Validate and submit data
|
||||
*/
|
||||
const submitHandler = async (event) => {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
const validation = { isValid: false, message: '' };
|
||||
|
||||
const hasChanged = !isEqual(formSettings,eventSettings);
|
||||
if (hasChanged) {
|
||||
saveEventSettings(formSettings);
|
||||
validation.isValid = true;
|
||||
}
|
||||
|
||||
// we might not have changed this
|
||||
if (formData.pinCode !== data.pinCode) {
|
||||
// Validate fields
|
||||
if (formData.pinCode === '' || formData.pinCode == null) {
|
||||
validation.isValid = true;
|
||||
validation.message += 'App pin code removed';
|
||||
} else {
|
||||
validation.isValid = true;
|
||||
validation.message += 'App pin code added';
|
||||
}
|
||||
}
|
||||
|
||||
if (formData.timeFormat !== data.timeFormat) {
|
||||
if (formData.timeFormat === '12' || formData.timeFormat === '24') {
|
||||
validation.isValid = true;
|
||||
} else {
|
||||
validation.isValue = false;
|
||||
}
|
||||
}
|
||||
|
||||
let resetChange = hasChanged;
|
||||
// set fields with error
|
||||
if (!validation.isValid) {
|
||||
emitError(`Invalid Input: ${validation.message}`);
|
||||
} else {
|
||||
try {
|
||||
await postSettings(formData);
|
||||
} catch (error) {
|
||||
emitError(`Error saving settings: ${error}`)
|
||||
} finally {
|
||||
await refetch();
|
||||
resetChange = true;
|
||||
}
|
||||
validation?.message && emitWarning(validation.message);
|
||||
}
|
||||
if (resetChange) {
|
||||
setChanged(false);
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Reverts local state equals to server state
|
||||
*/
|
||||
const revert = async () => {
|
||||
setChanged(false);
|
||||
// set from context
|
||||
setFormSettings(eventSettings);
|
||||
await refetch();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles change of input field in local state
|
||||
* @param {string} field - object parameter to update
|
||||
* @param {string} value - new object parameter value
|
||||
*/
|
||||
const handleChange = (field, value) => {
|
||||
const temp = { ...formData };
|
||||
temp[field] = value;
|
||||
setFormData(temp);
|
||||
setChanged(true);
|
||||
};
|
||||
|
||||
const disableModal = status !== 'success';
|
||||
|
||||
return (
|
||||
<ModalBody className={style.modalBody}>
|
||||
<p className={style.notes}>
|
||||
Options related to the application
|
||||
<br />
|
||||
🔥 Changes take effect on save 🔥
|
||||
</p>
|
||||
<p className={style.notes}>{`Running ontime version ${version}`}</p>
|
||||
<form onSubmit={submitHandler}>
|
||||
<div className={style.modalFields}>
|
||||
<div className={style.hSeparator}>General App Settings</div>
|
||||
<div className={style.modalInline}>
|
||||
<FormControl id='serverPort'>
|
||||
<FormLabel htmlFor='serverPort'>
|
||||
Viewer Port
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
Ontime is available at port
|
||||
</span>
|
||||
</FormLabel>
|
||||
<Input
|
||||
{...inputProps}
|
||||
name='title'
|
||||
value={4001}
|
||||
disabled
|
||||
style={{ width: '6em', textAlign: 'center' }}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl id='editorPin'>
|
||||
<FormLabel htmlFor='editorPin'>
|
||||
Editor Pincode
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
Protect the editor with a Pincode
|
||||
</span>
|
||||
</FormLabel>
|
||||
<div className={style.pin}>
|
||||
<PinInput
|
||||
{...inputProps}
|
||||
type='alphanumeric'
|
||||
name='pinCode'
|
||||
defaultValue=''
|
||||
value={formData.pinCode}
|
||||
mask={hidePin}
|
||||
isDisabled={disableModal}
|
||||
onChange={(value) => handleChange('pinCode', value)}
|
||||
>
|
||||
<PinInputField />
|
||||
<PinInputField />
|
||||
<PinInputField />
|
||||
<PinInputField />
|
||||
</PinInput>
|
||||
<IconButton
|
||||
size='sm'
|
||||
colorScheme='blue'
|
||||
variant='ghost'
|
||||
icon={<FiEye />}
|
||||
aria-label='Editor pin code'
|
||||
onMouseDown={() => setHidePin(false)}
|
||||
onMouseUp={() => setHidePin(true)}
|
||||
isDisabled={disableModal}
|
||||
/>
|
||||
<TooltipActionBtn
|
||||
tooltip='Clear pincode'
|
||||
size='sm'
|
||||
colorScheme='red'
|
||||
variant='ghost'
|
||||
icon={<FiX />}
|
||||
clickHandler={() => handleChange('pinCode', '')}
|
||||
isDisabled={disableModal}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
</div>
|
||||
<div className={style.modalColumn}>
|
||||
<FormControl id='timeFormat'>
|
||||
<FormLabel htmlFor='timeFormat'>
|
||||
Time format
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
12 / 24 hour format (viewers only for now)
|
||||
</span>
|
||||
</FormLabel>
|
||||
<Select
|
||||
size='sm'
|
||||
name='timeFormat'
|
||||
value={formData.timeFormat}
|
||||
isDisabled={disableModal}
|
||||
onChange={(event) => handleChange('timeFormat', event.target.value)}
|
||||
>
|
||||
<option value='12'>12 hours eg. 11:00:10 PM</option>
|
||||
<option value='24'>24 hours eg. 23:00:10</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
<div className={style.hSeparator}>Create Event Default Settings</div>
|
||||
<div className={style.modalColumn}>
|
||||
<Checkbox
|
||||
isChecked={formSettings.showQuickEntry}
|
||||
onChange={(e) => {
|
||||
setFormSettings((prev) => ({ ...prev, showQuickEntry: e.target.checked }));
|
||||
setChanged(true);
|
||||
}}
|
||||
>
|
||||
Show quick entry on cursor
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
isChecked={formSettings.startTimeIsLastEnd}
|
||||
onChange={(e) => {
|
||||
setFormSettings((prev) => ({ ...prev, startTimeIsLastEnd: e.target.checked }));
|
||||
setChanged(true);
|
||||
}}
|
||||
>
|
||||
Start time is last end
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
isChecked={formSettings.defaultPublic}
|
||||
onChange={(e) => {
|
||||
setFormSettings((prev) => ({ ...prev, defaultPublic: e.target.checked }));
|
||||
setChanged(true);
|
||||
}}
|
||||
>
|
||||
Event default public
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<SubmitContainer
|
||||
revert={revert}
|
||||
submitting={submitting}
|
||||
changed={changed}
|
||||
status={status}
|
||||
/>
|
||||
</form>
|
||||
</ModalBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { FormLabel, Input, ModalBody, Textarea } from '@chakra-ui/react';
|
||||
|
||||
import { postEvent } from '../../common/api/eventApi';
|
||||
import { LoggingContext } from '../../common/context/LoggingContext';
|
||||
import useEvent from '../../common/hooks-query/useEvent';
|
||||
import { eventDataPlaceholder } from '../../common/models/EventData.type';
|
||||
|
||||
import { inputProps } from './modalHelper';
|
||||
import SubmitContainer from './SubmitContainer';
|
||||
|
||||
import style from './Modals.module.scss';
|
||||
|
||||
export default function SettingsModal() {
|
||||
const { data, status, refetch } = useEvent();
|
||||
const { emitError } = useContext(LoggingContext);
|
||||
const [formData, setFormData] = useState(eventDataPlaceholder);
|
||||
const [changed, setChanged] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
/**
|
||||
* Set formdata from server state
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (data == null) return;
|
||||
if (changed) return;
|
||||
|
||||
setFormData({
|
||||
title: data.title,
|
||||
url: data.url,
|
||||
publicInfo: data.publicInfo,
|
||||
backstageInfo: data.backstageInfo,
|
||||
endMessage: data.endMessage,
|
||||
});
|
||||
}, [changed, data]);
|
||||
|
||||
/**
|
||||
* Validate and submit data
|
||||
*/
|
||||
const submitHandler = useCallback(
|
||||
async (event) => {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
await postEvent(formData);
|
||||
} catch (error) {
|
||||
emitError(`Error saving event 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} 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 running event
|
||||
<br />
|
||||
Affects rendered views
|
||||
</p>
|
||||
<form onSubmit={submitHandler}>
|
||||
<div className={style.modalFields}>
|
||||
<div className={style.hSeparator}>Event Data</div>
|
||||
<div className={style.spacedEntry}>
|
||||
<FormLabel htmlFor='title'>Event Title</FormLabel>
|
||||
<Input
|
||||
{...inputProps}
|
||||
maxLength={35}
|
||||
name='title'
|
||||
placeholder='Event Title'
|
||||
value={formData.title}
|
||||
onChange={(event) => handleChange('title', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.hSeparator}>Additional Screen Info</div>
|
||||
<div className={style.spacedEntry}>
|
||||
<FormLabel htmlFor='url'>
|
||||
Event URL
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
Shown as a QR code in some views
|
||||
</span>
|
||||
</FormLabel>
|
||||
<Input
|
||||
{...inputProps}
|
||||
name='url'
|
||||
placeholder='www.onsite.no'
|
||||
value={formData.url}
|
||||
onChange={(event) => handleChange('url', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.spacedEntry}>
|
||||
<FormLabel htmlFor='pubInfo'>
|
||||
Public Info
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
Information to be shown on public screens
|
||||
</span>
|
||||
</FormLabel>
|
||||
<Textarea
|
||||
{...inputProps}
|
||||
name='pubInfo'
|
||||
placeholder='Information to be shown on public screens'
|
||||
value={formData.publicInfo}
|
||||
onChange={(event) => handleChange('publicInfo', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.spacedEntry}>
|
||||
<FormLabel htmlFor='backstageInfo'>
|
||||
Backstage Info
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
Information to be shown on backstage screens
|
||||
</span>
|
||||
</FormLabel>
|
||||
<Textarea
|
||||
{...inputProps}
|
||||
name='backstageInfo'
|
||||
placeholder='Information to be shown on backstage screens'
|
||||
resize={false}
|
||||
value={formData.backstageInfo}
|
||||
onChange={(event) => handleChange('backstageInfo', event.target.value)}
|
||||
/>
|
||||
</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={30}
|
||||
name='endMessage'
|
||||
placeholder='Empty message shows elapsed time'
|
||||
value={formData.endMessage}
|
||||
onChange={(event) => handleChange('endMessage', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SubmitContainer
|
||||
revert={revert}
|
||||
submitting={submitting}
|
||||
changed={changed}
|
||||
status={status}
|
||||
/>
|
||||
</form>
|
||||
</ModalBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { FormControl, FormLabel, Input, ModalBody, Switch } from '@chakra-ui/react';
|
||||
import { FiInfo } from '@react-icons/all-files/fi/FiInfo';
|
||||
|
||||
import { LoggingContext } from '../../common/context/LoggingContext';
|
||||
import useInfo from '../../common/hooks-query/useInfo';
|
||||
import { httpPlaceholder } from '../../common/models/Http.type';
|
||||
import { ontimeVars } from '../../common/models/OntimeVars';
|
||||
|
||||
import { inputProps } from './modalHelper';
|
||||
import SubmitContainer from './SubmitContainer';
|
||||
|
||||
import style from './Modals.module.scss';
|
||||
|
||||
export default function IntegrationSettingsModal() {
|
||||
const { data, status, refetch } = useInfo();
|
||||
const { emitError } = useContext(LoggingContext);
|
||||
const [formData, setFormData] = useState(httpPlaceholder);
|
||||
const [changed, setChanged] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
/**
|
||||
* Set formdata from server state
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (data == null) return;
|
||||
if (changed) return;
|
||||
|
||||
setFormData({
|
||||
onLoad: data?.onLoad,
|
||||
onStart: data?.onStart,
|
||||
onUpdate: data?.onUpdate,
|
||||
onPause: data?.onPause,
|
||||
onStop: data?.onStop,
|
||||
});
|
||||
}, [changed, data]);
|
||||
|
||||
/**
|
||||
* Validate and submit data
|
||||
*/
|
||||
const submitHandler = useCallback(
|
||||
async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const f = formData;
|
||||
const e = { status: false, message: '' };
|
||||
|
||||
// set fields with error
|
||||
if (e.status) {
|
||||
emitError(`Invalid Input: ${e.message}`);
|
||||
} else {
|
||||
// call API endpoint here with value of f
|
||||
setChanged(false);
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
[emitError, formData],
|
||||
);
|
||||
|
||||
/**
|
||||
* Reverts local state equals to server state
|
||||
*/
|
||||
const revert = useCallback(async () => {
|
||||
setChanged(false);
|
||||
await refetch();
|
||||
}, [refetch]);
|
||||
|
||||
// Todo: make change handler
|
||||
// Todo: toggle between GET / POST
|
||||
// Todo: add test button
|
||||
// Todo: enabled should be button
|
||||
// Todo: add friendly placeholder to input
|
||||
|
||||
return (
|
||||
<ModalBody className={style.modalBody}>
|
||||
<p className={style.notes}>
|
||||
Integrate with third party over an HTTP API
|
||||
<br />
|
||||
🔥 Changes take effect after app restart 🔥
|
||||
</p>
|
||||
<form onSubmit={submitHandler}>
|
||||
<div className={style.modalFields}>
|
||||
<div className={style.hSeparator}>Ontime event cycle</div>
|
||||
<div className={style.blockNotes}>
|
||||
<span className={style.inlineFlex}>
|
||||
<FiInfo color='#2b6cb0' fontSize='2em' />
|
||||
Add HTTP messages that ontime will send during the event cycle
|
||||
</span>
|
||||
<span className={style.labelNote}>
|
||||
You can use variables in the HTTP request URL to send data from ontime
|
||||
</span>
|
||||
<span className={style.emNote}>
|
||||
http://127.0.0.1:8088/API/?setHeadline=
|
||||
<span className={style.labelNoteInline}>$title</span>
|
||||
&setSub=<span className={style.labelNoteInline}>$presenter</span>
|
||||
</span>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className={style.labelNote} style={{ width: '30%' }}>
|
||||
Variable
|
||||
</td>
|
||||
<td className={style.labelNote}>Value</td>
|
||||
</tr>
|
||||
{ontimeVars.map((v) => (
|
||||
<tr key={v.name}>
|
||||
<td className={style.labelNote}>{v.name}</td>
|
||||
<td>{v.description}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className={style.hSeparator}>Send HTTP</div>
|
||||
<FormLabel style={{ paddingLeft: '0.5em' }}>
|
||||
On Load
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
When a new event loads
|
||||
</span>
|
||||
</FormLabel>
|
||||
<div className={style.modalInline}>
|
||||
<Input
|
||||
{...inputProps}
|
||||
name='onLoadURL'
|
||||
value={formData?.onLoad?.url}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
onLoad: {
|
||||
...formData.onLoad,
|
||||
url: event.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
colorScheme='green'
|
||||
id='onLoadEnable'
|
||||
value={formData?.onLoad?.enabled}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
onLoad: {
|
||||
...formData.onLoad,
|
||||
enabled: event.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<FormLabel style={{ paddingLeft: '0.5em' }}>
|
||||
On Start
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
When an timer starts / resumes{' '}
|
||||
</span>
|
||||
</FormLabel>
|
||||
<div className={style.modalInline}>
|
||||
<Input
|
||||
{...inputProps}
|
||||
name='onStartURL'
|
||||
value={formData?.onStart?.url}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
onStart: {
|
||||
...formData.onStart,
|
||||
url: event.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
colorScheme='green'
|
||||
id='onStartEnable'
|
||||
value={formData?.onStart?.enabled}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
onStart: {
|
||||
...formData.onStart,
|
||||
enabled: event.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<FormLabel style={{ paddingLeft: '0.5em' }}>
|
||||
On Update
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
At every clock tick
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl id='onUpdate' className={style.modalInline}>
|
||||
<Input
|
||||
{...inputProps}
|
||||
name='onUpdateURL'
|
||||
value={formData?.onUpdate?.url}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
onUpdate: {
|
||||
...formData.onUpdate,
|
||||
url: event.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
colorScheme='green'
|
||||
id='onUpdateEnable'
|
||||
value={formData?.onUpdate?.enabled}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
onUpdate: {
|
||||
...formData.onUpdate,
|
||||
enabled: event.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel style={{ paddingLeft: '0.5em' }}>
|
||||
On Pause
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
When a timer pauses
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl id='onPause' className={style.modalInline}>
|
||||
<Input
|
||||
{...inputProps}
|
||||
name='onPauseURL'
|
||||
value={formData?.onPause?.url}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
onPause: {
|
||||
...formData.onPause,
|
||||
url: event.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
colorScheme='green'
|
||||
id='onPauseEnable'
|
||||
value={formData?.onPause?.enabled}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
onPause: {
|
||||
...formData.onPause,
|
||||
enabled: event.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel style={{ paddingLeft: '0.5em' }}>
|
||||
On Stop
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
When an event is unloaded
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl id='onStop' className={style.modalInline}>
|
||||
<Input
|
||||
{...inputProps}
|
||||
name='onStopURL'
|
||||
value={formData?.onStop?.url}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
onStop: {
|
||||
...formData.onStop,
|
||||
url: event.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
colorScheme='green'
|
||||
id='onStopEnable'
|
||||
value={formData?.onStop?.enabled}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
onStop: {
|
||||
...formData.onStop,
|
||||
enabled: event.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel style={{ paddingLeft: '0.5em' }}>
|
||||
On Finish
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
When an event is finished
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl id='onFinish' className={style.modalInline}>
|
||||
<Input
|
||||
{...inputProps}
|
||||
name='onFinishURL'
|
||||
value={formData?.onFinish?.url}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
onStop: {
|
||||
...formData.onFinish,
|
||||
url: event.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
colorScheme='green'
|
||||
id='onFinishEnable'
|
||||
value={formData?.onFinish?.enabled}
|
||||
onChange={(event) => {
|
||||
setChanged(true);
|
||||
setFormData({
|
||||
...formData,
|
||||
onStop: {
|
||||
...formData.onStop,
|
||||
enabled: event.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<SubmitContainer
|
||||
revert={revert}
|
||||
submitting={submitting}
|
||||
changed={changed}
|
||||
status={status}
|
||||
/>
|
||||
</form>
|
||||
</ModalBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
Modal,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Tabs,
|
||||
} from '@chakra-ui/react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import AliasesModal from './AliasesModal';
|
||||
import AppSettingsModal from './AppSettingsModal';
|
||||
import EventSettingsModal from './EventSettingsModal';
|
||||
import IntegrationSettingsModal from './IntegrationSettingsModal';
|
||||
import OscSettingsModal from './OscSettingsModal';
|
||||
import TableOptionsModal from './TableOptionsModal';
|
||||
import ViewsSettingsModal from './ViewsSettingsModal';
|
||||
|
||||
export default function ModalManager(props) {
|
||||
const { isOpen, onClose } = props;
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
closeOnOverlayClick={false}
|
||||
motionPreset='slideInBottom'
|
||||
size='xl'
|
||||
scrollBehavior='inside'
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Ontime Settings</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
|
||||
<Tabs size='sm' isLazy>
|
||||
<TabList>
|
||||
<Tab style={{ fontSize: '0.9em' }}>App Settings</Tab>
|
||||
<Tab style={{ fontSize: '0.9em' }}>Viewers</Tab>
|
||||
<Tab style={{ fontSize: '0.9em' }}>Event Data</Tab>
|
||||
<Tab style={{ fontSize: '0.9em' }}>URL Aliases</Tab>
|
||||
<Tab style={{ fontSize: '0.9em' }}>Cuesheet</Tab>
|
||||
<Tab style={{ fontSize: '0.9em' }}>OSC</Tab>
|
||||
{/*<Tab style={{ fontSize: '0.9em' }}>Integration</Tab>*/}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<AppSettingsModal />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<ViewsSettingsModal />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<EventSettingsModal />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<AliasesModal />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<TableOptionsModal />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<OscSettingsModal />
|
||||
</TabPanel>
|
||||
{/*<TabPanel>*/}
|
||||
{/* <IntegrationSettingsModal />*/}
|
||||
{/*</TabPanel>*/}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
ModalManager.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
@use '../../theme/main' as *;
|
||||
|
||||
//////////////////////////////////// main
|
||||
|
||||
.modalBody {
|
||||
font-weight: 400;
|
||||
|
||||
.notes {
|
||||
font-weight: 400;
|
||||
color: $light-bg;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 4em;
|
||||
}
|
||||
|
||||
.modalFields {
|
||||
max-height: 45vh;
|
||||
overflow-y: auto;
|
||||
scrollbar-color: rgba($light-bg, 0.35) rgba($light-bg, 0.15);
|
||||
padding-right: 6px;
|
||||
|
||||
label {
|
||||
//font-weight: 400;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.inlineAlias,
|
||||
.inlineAliasPlaceholder {
|
||||
display: grid;
|
||||
grid-template-columns: 20% 1fr 1em 1.5em 1.5em;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 0.5em 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: 0.8em;
|
||||
color: $error-red;
|
||||
}
|
||||
|
||||
.inlineAliasPlaceholder {
|
||||
grid-template-columns: 20% 1fr 4em;
|
||||
|
||||
.placeholder {
|
||||
background: $light-text;
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Track */
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba($light-bg, 0.15);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Handle */
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba($light-bg, 0.35);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Handle on hover */
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba($light-bg, 0.45);
|
||||
}
|
||||
|
||||
.modalInline {
|
||||
display: flex;
|
||||
gap: 2em;
|
||||
align-items: center;
|
||||
padding: 0 0.5em 0.5em 0.5em;
|
||||
}
|
||||
|
||||
.modalColumn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 0 0.5em 0.5em 0.5em;
|
||||
}
|
||||
|
||||
.spacedEntry {
|
||||
padding: 0 0.5em 0.5em 0.5em;
|
||||
}
|
||||
|
||||
.pin {
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
border-radius: 50%;
|
||||
|
||||
input {
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.submitContainer {
|
||||
margin-top: auto;
|
||||
padding-top: 2em;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.modalBody > * {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
//////////////////////////////////// notes
|
||||
|
||||
ul.featureList {
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
svg {
|
||||
color: $ontime-accent-text;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
&.notes {
|
||||
text-align: center;
|
||||
border-color: $light-bg-transparent;
|
||||
border-width: 0 2px;
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
&.notes {
|
||||
font-size: 0.9em;
|
||||
padding-left: 0.4em;
|
||||
}
|
||||
}
|
||||
|
||||
.blockNotes {
|
||||
background-color: $bg-gray;
|
||||
margin: 1em 0;
|
||||
padding: 0.5em;
|
||||
font-size: 0.8em;
|
||||
border-radius: 2px;
|
||||
|
||||
table {
|
||||
background-color: #fff;
|
||||
border-left: 4px solid lighten($ontime-pink, 5%);
|
||||
width: 100%;
|
||||
margin: 0.5em 0;
|
||||
border-radius: 2px;
|
||||
|
||||
:first-child {
|
||||
padding-left: 1em;
|
||||
}
|
||||
|
||||
td {
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
|
||||
.noteItem {
|
||||
user-select: text;
|
||||
font-weight: 600;
|
||||
padding-right: 2em;
|
||||
}
|
||||
|
||||
.flexNote {
|
||||
user-select: text;
|
||||
padding-bottom: 0.3em;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.emNote {
|
||||
user-select: text;
|
||||
display: block;
|
||||
background-color: #fffc;
|
||||
}
|
||||
}
|
||||
|
||||
.labelNote {
|
||||
color: $light-bg;
|
||||
padding-right: 1em;
|
||||
}
|
||||
|
||||
.labelNoteInline {
|
||||
color: $light-bg;
|
||||
}
|
||||
|
||||
.inlineFlex {
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
align-items: center;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { FormControl, FormLabel, Input, ModalBody } from '@chakra-ui/react';
|
||||
import { IoInformationCircleOutline } from '@react-icons/all-files/io5/IoInformationCircleOutline';
|
||||
|
||||
import { postOSC } from '../../common/api/ontimeApi';
|
||||
import EnableBtn from '../../common/components/buttons/EnableBtn';
|
||||
import { LoggingContext } from '../../common/context/LoggingContext';
|
||||
import useOscSettings from '../../common/hooks-query/useOscSettings';
|
||||
import { oscPlaceholderSettings } from '../../common/models/OscSettings.type';
|
||||
|
||||
import { inputProps, portInputProps } from './modalHelper';
|
||||
import SubmitContainer from './SubmitContainer';
|
||||
|
||||
import style from './Modals.module.scss';
|
||||
|
||||
// currently defined endpoints
|
||||
// temporary
|
||||
const oscCycleEndpoints = [
|
||||
{
|
||||
title: 'On Event Start',
|
||||
message: '/ontime/eventNumber',
|
||||
value: '8 | int',
|
||||
},
|
||||
{
|
||||
title: 'On Update',
|
||||
message: '/ontime/time',
|
||||
value: '10:12:12 | string',
|
||||
},
|
||||
{
|
||||
title: 'On Update',
|
||||
message: '/ontime/overtime',
|
||||
value: '0-1 | int',
|
||||
},
|
||||
{
|
||||
title: 'On Update',
|
||||
message: '/ontime/title',
|
||||
value: 'Title of running event | string',
|
||||
},
|
||||
{
|
||||
title: 'On Finish',
|
||||
message: '/ontime/finished',
|
||||
value: '-',
|
||||
},
|
||||
];
|
||||
const oscTriggerEndpoints = [
|
||||
{
|
||||
title: 'On Start',
|
||||
message: '/ontime/play',
|
||||
value: '-',
|
||||
},
|
||||
{
|
||||
title: 'On Pause',
|
||||
message: '/ontime/pause',
|
||||
value: '-',
|
||||
},
|
||||
{
|
||||
title: 'On Previous',
|
||||
message: '/ontime/prev',
|
||||
value: '-',
|
||||
},
|
||||
{
|
||||
title: 'On Next',
|
||||
message: '/ontime/next',
|
||||
value: '-',
|
||||
},
|
||||
{
|
||||
title: 'On Reload',
|
||||
message: '/ontime/reload',
|
||||
value: '-',
|
||||
},
|
||||
{
|
||||
title: 'On Stop',
|
||||
message: '/ontime/stop',
|
||||
value: '-',
|
||||
},
|
||||
];
|
||||
|
||||
export default function OscSettingsModal() {
|
||||
const { data, status, refetch } = useOscSettings();
|
||||
const { emitError } = useContext(LoggingContext);
|
||||
const [formData, setFormData] = useState(oscPlaceholderSettings);
|
||||
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);
|
||||
|
||||
const f = formData;
|
||||
const e = { status: false, message: '' };
|
||||
|
||||
// Validate fields
|
||||
if (f.port < 1024 || f.port > 65535) {
|
||||
// Port in incorrect range
|
||||
e.status = true;
|
||||
e.message += 'OSC IN Port in incorrect range (1024 - 65535)';
|
||||
} else if (f.portOut < 1024 || f.portOut > 65535) {
|
||||
// Port in incorrect range
|
||||
e.status = true;
|
||||
e.message += 'OSC OUT Port in incorrect range (1024 - 65535)';
|
||||
} else if (f.port === f.portOut) {
|
||||
// Cant use the same port
|
||||
e.status = true;
|
||||
e.message += 'OSC IN and OUT Ports cant be the same';
|
||||
}
|
||||
|
||||
// set fields with error
|
||||
if (e.status) {
|
||||
emitError(`Invalid Input: ${e.message}`);
|
||||
} else {
|
||||
try {
|
||||
await postOSC(formData);
|
||||
} catch (error){
|
||||
emitError(`Error setting OSC: ${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 Open Sound Control
|
||||
<br />
|
||||
🔥 Changes take effect after app restart 🔥
|
||||
</p>
|
||||
<form onSubmit={submitHandler}>
|
||||
<div className={style.modalFields}>
|
||||
<div className={style.hSeparator}>OSC Input (Control ontime over OSC)</div>
|
||||
<div className={style.modalInline}>
|
||||
<FormControl id='oscInEnabled'>
|
||||
<FormLabel htmlFor='oscInEnabled'>
|
||||
OSC Enable
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
Enable / Disable control
|
||||
</span>
|
||||
</FormLabel>
|
||||
<EnableBtn
|
||||
active={formData.enabled}
|
||||
text={formData.enabled ? 'OSC IN Enabled' : 'OSC IN Disabled'}
|
||||
actionHandler={() => handleChange('enabled', !formData.enabled)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl id='portIn'>
|
||||
<FormLabel htmlFor='portIn'>
|
||||
OSC In Port
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
Port - Default 8888
|
||||
</span>
|
||||
</FormLabel>
|
||||
<Input
|
||||
{...portInputProps}
|
||||
name='port'
|
||||
placeholder='8888'
|
||||
value={formData.port}
|
||||
onChange={(event) => handleChange('port', parseInt(event.target.value, 10))}
|
||||
style={{ width: '6em' }}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<div className={style.hSeparator}>OSC Output (feedback)</div>
|
||||
<div className={style.modalInline}>
|
||||
<FormControl id='targetIP'>
|
||||
<FormLabel htmlFor='targetIP'>
|
||||
OSC Out Target IP
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
Default 127.0.0.1
|
||||
</span>
|
||||
</FormLabel>
|
||||
<Input
|
||||
{...inputProps}
|
||||
size='sm'
|
||||
name='targetIP'
|
||||
placeholder='127.0.0.1'
|
||||
autoComplete='off'
|
||||
value={formData.targetIP}
|
||||
onChange={(event) => handleChange('targetIP', event.target.value)}
|
||||
isDisabled={submitting}
|
||||
style={{ width: '12em', textAlign: 'right' }}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl id='portOut'>
|
||||
<FormLabel htmlFor='portOut'>
|
||||
OSC Out Port
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
Default 9999
|
||||
</span>
|
||||
</FormLabel>
|
||||
<Input
|
||||
{...portInputProps}
|
||||
name='portOut'
|
||||
placeholder='9999'
|
||||
value={formData.portOut}
|
||||
onChange={(event) => handleChange('portOut', parseInt(event.target.value, 10))}
|
||||
style={{ width: '6em', textAlign: 'left' }}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<div className={style.blockNotes}>
|
||||
<span className={style.inlineFlex}>
|
||||
<IoInformationCircleOutline color='#2b6cb0' fontSize='2em' />
|
||||
OSC Feedback messages
|
||||
</span>
|
||||
<span>
|
||||
In future OSC feedback will be user defined. <br />
|
||||
For now this is the list of OSC messages sent from ontime
|
||||
</span>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className={style.labelNote} style={{ width: '30%' }}>
|
||||
Cycle
|
||||
</td>
|
||||
<td className={style.labelNote}>Message</td>
|
||||
<td className={style.labelNote}>Value (example | type)</td>
|
||||
</tr>
|
||||
{oscCycleEndpoints.map((e) => (
|
||||
<tr key={e.message}>
|
||||
<td>{e.title}</td>
|
||||
<td>{e.message}</td>
|
||||
<td>{e.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td className={style.labelNote} style={{ width: '30%' }}>
|
||||
Trigger
|
||||
</td>
|
||||
<td className={style.labelNote}>Message</td>
|
||||
<td className={style.labelNote}>Value (example | type)</td>
|
||||
</tr>
|
||||
{oscTriggerEndpoints.map((e) => (
|
||||
<tr key={e.message}>
|
||||
<td>{e.title}</td>
|
||||
<td>{e.message}</td>
|
||||
<td>{e.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<SubmitContainer
|
||||
revert={revert}
|
||||
submitting={submitting}
|
||||
changed={changed}
|
||||
status={status}
|
||||
/>
|
||||
</form>
|
||||
</ModalBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import style from './Modals.module.scss';
|
||||
|
||||
export default function SubmitContainer(props) {
|
||||
const { submitting, changed, revert, status } = props;
|
||||
|
||||
return (
|
||||
<div className={style.submitContainer}>
|
||||
<Button
|
||||
isDisabled={submitting || !changed}
|
||||
variant='ghosted'
|
||||
onClick={revert}
|
||||
>
|
||||
Revert
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme='blue'
|
||||
type='submit'
|
||||
isLoading={submitting}
|
||||
disabled={!changed || status !== 'success'}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SubmitContainer.propTypes = {
|
||||
submitting: PropTypes.bool,
|
||||
changed: PropTypes.bool,
|
||||
status: PropTypes.string,
|
||||
revert: PropTypes.func.isRequired,
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { Input, ModalBody } from '@chakra-ui/react';
|
||||
import { IoInformationCircleOutline } from '@react-icons/all-files/io5/IoInformationCircleOutline';
|
||||
|
||||
import { postUserFields } from '../../common/api/ontimeApi';
|
||||
import { LoggingContext } from '../../common/context/LoggingContext';
|
||||
import useUserFields from '../../common/hooks-query/useUserFields';
|
||||
import { userFieldsPlaceholder } from '../../common/models/UserFields.type';
|
||||
import { handleLinks, host } from '../../common/utils/linkUtils';
|
||||
|
||||
import SubmitContainer from './SubmitContainer';
|
||||
|
||||
import style from './Modals.module.scss';
|
||||
|
||||
export default function TableOptionsModal() {
|
||||
const { data, status, refetch } = useUserFields();
|
||||
const { emitError } = useContext(LoggingContext);
|
||||
const [changed, setChanged] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [userFields, setUserFields] = useState(userFieldsPlaceholder);
|
||||
|
||||
/**
|
||||
* Set formdata from server state
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (data == null) return;
|
||||
if (changed) return;
|
||||
// Todo: we need some validation on API replies
|
||||
setUserFields(data);
|
||||
}, [changed, data]);
|
||||
|
||||
/**
|
||||
* Validate and submit data
|
||||
*/
|
||||
const submitHandler = useCallback(async (event) => {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
|
||||
// validation step makes clean string
|
||||
const validatedFields = { ...userFields };
|
||||
const errors = false;
|
||||
for (const field in validatedFields) {
|
||||
validatedFields[field] = validatedFields[field].trim();
|
||||
}
|
||||
|
||||
if (!errors) {
|
||||
try {
|
||||
await postUserFields(validatedFields);
|
||||
} catch (error) {
|
||||
emitError(`Error saving table options: ${error}`)
|
||||
}
|
||||
await refetch();
|
||||
setChanged(false);
|
||||
}
|
||||
|
||||
setSubmitting(false);
|
||||
},[emitError, refetch, userFields]);
|
||||
|
||||
/**
|
||||
* 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} value - new object parameter value
|
||||
*/
|
||||
const handleChange = useCallback((field, value) => {
|
||||
if (value.length < 30) {
|
||||
const temp = { ...userFields };
|
||||
temp[field] = value;
|
||||
setUserFields(temp);
|
||||
setChanged(true);
|
||||
}
|
||||
},[userFields]);
|
||||
|
||||
return (
|
||||
<ModalBody className={style.modalBody}>
|
||||
<p className={style.notes}>
|
||||
Options related to cuesheets
|
||||
<br />
|
||||
🔥 Changes take effect on save 🔥
|
||||
</p>
|
||||
<form onSubmit={submitHandler}>
|
||||
<div className={style.modalFields}>
|
||||
<div className={style.hSeparator}>User Fields</div>
|
||||
<div className={style.blockNotes}>
|
||||
<span className={style.inlineFlex}>
|
||||
<IoInformationCircleOutline color='#2b6cb0' fontSize='2em' />
|
||||
User Fields
|
||||
</span>
|
||||
<span>
|
||||
Userfields facilitate adding custom fields to an event (eg: light, sound, camera).{' '}
|
||||
<br />
|
||||
These are available for excel imports and shown in the{' '}
|
||||
<a
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
href={`http://${host}cuesheet`}
|
||||
onClick={(e) => handleLinks(e, 'cuesheet')}
|
||||
>
|
||||
cuesheet
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.inlineAliasPlaceholder} style={{ padding: '0.5em 0' }}>
|
||||
<span className={style.labelNote}>User Field</span>
|
||||
<span className={style.labelNote}>Display Name</span>
|
||||
</div>
|
||||
{Object.keys(userFields).map((field) => (
|
||||
<div className={style.inlineAlias} key={field}>
|
||||
<span>{field}</span>
|
||||
<Input
|
||||
size='sm'
|
||||
variant='flushed'
|
||||
name='Alias'
|
||||
placeholder={field}
|
||||
autoComplete='off'
|
||||
value={userFields[field]}
|
||||
onChange={(event) => handleChange(field, event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<SubmitContainer
|
||||
revert={revert}
|
||||
submitting={submitting}
|
||||
changed={changed}
|
||||
status={status}
|
||||
/>
|
||||
</form>
|
||||
</ModalBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { FormControl, FormLabel, ModalBody } from '@chakra-ui/react';
|
||||
import { IoCheckmarkSharp } from '@react-icons/all-files/io5/IoCheckmarkSharp';
|
||||
import { IoInformationCircleOutline } from '@react-icons/all-files/io5/IoInformationCircleOutline';
|
||||
|
||||
import { postView } from '../../common/api/ontimeApi';
|
||||
import EnableBtn from '../../common/components/buttons/EnableBtn';
|
||||
import { LoggingContext } from '../../common/context/LoggingContext';
|
||||
import useViewSettings from '../../common/hooks-query/useViewSettings';
|
||||
import { viewsSettingsPlaceholder } from '../../common/models/ViewSettings.type';
|
||||
import { openLink } from '../../common/utils/linkUtils';
|
||||
|
||||
import SubmitContainer from './SubmitContainer';
|
||||
|
||||
import style from './Modals.module.scss';
|
||||
|
||||
export default function ViewsSettingsModal() {
|
||||
const { data, status, refetch } = useViewSettings();
|
||||
|
||||
const { emitError } = useContext(LoggingContext);
|
||||
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}>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 override the application stylesheets as a way to
|
||||
customise viewers appearance.
|
||||
<br />
|
||||
Currently the feature affects the following views<br />
|
||||
<ul className={style.featureList}>
|
||||
<li><IoCheckmarkSharp /> Stage timer</li>
|
||||
<li><IoCheckmarkSharp /> Clock</li>
|
||||
<li><IoCheckmarkSharp /> Minimal timer</li>
|
||||
<li><IoCheckmarkSharp /> Backstage screen</li>
|
||||
<li><IoCheckmarkSharp /> Public screen</li>
|
||||
<li><IoCheckmarkSharp /> Picture in Picture</li>
|
||||
<li><IoCheckmarkSharp /> Countdown</li>
|
||||
</ul>
|
||||
Read more about it in the documentation{' '}
|
||||
<a
|
||||
href='#!'
|
||||
onClick={() => openLink('https://cpvalente.gitbook.io/ontime/features/custom-styling')}
|
||||
className={style.if}
|
||||
>
|
||||
over at Gitbook
|
||||
</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,12 @@
|
||||
export const inputProps = {
|
||||
size: 'sm',
|
||||
autoComplete: 'off',
|
||||
variant: 'outline',
|
||||
};
|
||||
|
||||
export const portInputProps = {
|
||||
...inputProps,
|
||||
type: 'number',
|
||||
min: '1024',
|
||||
max: '65535',
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
.eventContainer {
|
||||
margin-top: 1em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 4px 8px 0;
|
||||
overflow-y: scroll;
|
||||
-ms-overflow-style: -ms-autohiding-scrollbar;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.empty {
|
||||
opacity: 0.3;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.alignCenter {
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
.spaceTop {
|
||||
margin-top: 24px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { createRef, Fragment, useCallback, useContext, useEffect } from 'react';
|
||||
import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defaultPublicAtom, showQuickEntryAtom, startTimeIsLastEndAtom } from '../../common/atoms/LocalEventSettings';
|
||||
import Empty from '../../common/components/state/Empty';
|
||||
import { CursorContext } from '../../common/context/CursorContext';
|
||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||
import { useRundownEditor } from '../../common/hooks/useSocket';
|
||||
import { OntimeRundown, SupportedEvent } from '../../common/models/EventTypes';
|
||||
import { cloneEvent } from '../../common/utils/eventsManager';
|
||||
|
||||
import QuickAddBlock from './quick-add-block/QuickAddBlock';
|
||||
import RundownEntry from './RundownEntry';
|
||||
|
||||
import style from './Rundown.module.scss';
|
||||
|
||||
interface RundownProps {
|
||||
entries: OntimeRundown;
|
||||
}
|
||||
|
||||
export default function Rundown(props: RundownProps) {
|
||||
const { entries } = props;
|
||||
const { data } = useRundownEditor();
|
||||
const { cursor, moveCursorUp, moveCursorDown, moveCursorTo, isCursorLocked } =
|
||||
useContext(CursorContext);
|
||||
const startTimeIsLastEnd = useAtomValue(startTimeIsLastEndAtom);
|
||||
const defaultPublic = useAtomValue(defaultPublicAtom);
|
||||
const { addEvent, reorderEvent } = useEventAction();
|
||||
const cursorRef = createRef<HTMLDivElement>();
|
||||
const showQuickEntry = useAtomValue(showQuickEntryAtom);
|
||||
|
||||
const insertAtCursor = useCallback(
|
||||
(type: SupportedEvent | 'clone', cursor: number) => {
|
||||
if (cursor === -1) {
|
||||
if (type === 'clone') {
|
||||
return;
|
||||
}
|
||||
addEvent({ type });
|
||||
} else {
|
||||
const previousEvent = entries?.[cursor];
|
||||
const nextEvent = entries?.[cursor + 1];
|
||||
|
||||
// prevent adding two non-event blocks consecutively
|
||||
const isPreviousDifferent = previousEvent?.type !== type;
|
||||
const isNextDifferent = nextEvent?.type !== type;
|
||||
if (type === 'clone' && previousEvent?.type === SupportedEvent.Event) {
|
||||
const newEvent = cloneEvent(previousEvent);
|
||||
newEvent.after = previousEvent.id;
|
||||
addEvent(newEvent);
|
||||
} else if (type === SupportedEvent.Event) {
|
||||
const newEvent = {
|
||||
type: SupportedEvent.Event,
|
||||
};
|
||||
const options = {
|
||||
defaultPublic: defaultPublic,
|
||||
startTimeIsLastEnd: startTimeIsLastEnd,
|
||||
lastEventId: previousEvent.id,
|
||||
after: previousEvent.id,
|
||||
};
|
||||
addEvent(newEvent, options);
|
||||
} else if (isPreviousDifferent && isNextDifferent && type !== 'clone') {
|
||||
addEvent({ type }, { after: previousEvent.id });
|
||||
}
|
||||
}
|
||||
},
|
||||
[addEvent, defaultPublic, entries, startTimeIsLastEnd],
|
||||
);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyPress = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
// handle held key
|
||||
if (event.repeat) return;
|
||||
// Check if the alt key is pressed
|
||||
if (event.altKey && (!event.ctrlKey || !event.shiftKey)) {
|
||||
switch (event.code) {
|
||||
case 'ArrowDown': {
|
||||
if (cursor < entries.length - 1) moveCursorDown();
|
||||
break;
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
if (cursor > 0) moveCursorUp();
|
||||
break;
|
||||
}
|
||||
case 'KeyE': {
|
||||
event.preventDefault();
|
||||
if (cursor === -1) return;
|
||||
insertAtCursor(SupportedEvent.Event, cursor);
|
||||
break;
|
||||
}
|
||||
case 'KeyD': {
|
||||
event.preventDefault();
|
||||
if (cursor < 0) return;
|
||||
insertAtCursor(SupportedEvent.Delay, cursor);
|
||||
break;
|
||||
}
|
||||
case 'KeyB': {
|
||||
event.preventDefault();
|
||||
if (cursor < 0) return;
|
||||
insertAtCursor(SupportedEvent.Block, cursor);
|
||||
break;
|
||||
}
|
||||
case 'KeyC': {
|
||||
event.preventDefault();
|
||||
if (cursor < 0) return;
|
||||
insertAtCursor('clone', cursor);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[cursor, entries.length, insertAtCursor, moveCursorDown, moveCursorUp],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// attach the event listener
|
||||
document.addEventListener('keydown', handleKeyPress);
|
||||
|
||||
if (cursor > entries.length - 1) moveCursorTo(entries.length - 1);
|
||||
if (entries.length > 0 && cursor === -1) moveCursorTo(0);
|
||||
|
||||
// remove the event listener
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyPress);
|
||||
};
|
||||
}, [handleKeyPress, cursor, entries, moveCursorTo]);
|
||||
|
||||
// when cursor moves, view should follow
|
||||
useEffect(() => {
|
||||
if (cursorRef.current == null) return;
|
||||
cursorRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'start',
|
||||
});
|
||||
}, [cursorRef]);
|
||||
|
||||
// if selected event
|
||||
// or cursor settings changed
|
||||
useEffect(() => {
|
||||
// and if we are locked
|
||||
if (!isCursorLocked || !data?.selectedEventId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// move cursor
|
||||
let gotoIndex = -1;
|
||||
let found = false;
|
||||
for (const e of entries) {
|
||||
gotoIndex++;
|
||||
if (e.id === data.selectedEventId) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) {
|
||||
// move cursor
|
||||
moveCursorTo(gotoIndex);
|
||||
}
|
||||
}, [data?.selectedEventId, entries, isCursorLocked, moveCursorTo]);
|
||||
|
||||
const handleOnDragEnd = useCallback(
|
||||
(result: DropResult) => {
|
||||
// drop outside of area
|
||||
if (!result?.destination) return;
|
||||
|
||||
// no change
|
||||
if (result.destination.index === result.source.index) return;
|
||||
|
||||
// Call API
|
||||
reorderEvent(result.draggableId, result.source.index, result.destination.index);
|
||||
},
|
||||
[reorderEvent],
|
||||
);
|
||||
|
||||
if (!entries.length) {
|
||||
return (
|
||||
<div className={style.alignCenter}>
|
||||
<Empty text='No data yet' style={{ marginTop: '7vh' }} />
|
||||
<Button
|
||||
onClick={() => insertAtCursor(SupportedEvent.Event, -1)}
|
||||
variant='ontime-filled'
|
||||
className={style.spaceTop}
|
||||
leftIcon={<IoAdd />}
|
||||
>
|
||||
Create Event
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
let cumulativeDelay = 0;
|
||||
let eventIndex = -1;
|
||||
let previousEnd = 0;
|
||||
let thisEnd = 0;
|
||||
let previousEventId: string | undefined;
|
||||
|
||||
return (
|
||||
<div className={style.eventContainer}>
|
||||
<DragDropContext onDragEnd={handleOnDragEnd}>
|
||||
<Droppable droppableId='eventlist'>
|
||||
{(provided) => (
|
||||
<div className={style.list} {...provided.droppableProps} ref={provided.innerRef}>
|
||||
{entries.map((entry, index) => {
|
||||
if (index === 0) {
|
||||
cumulativeDelay = 0;
|
||||
eventIndex = -1;
|
||||
}
|
||||
if (entry.type === 'delay' && entry.duration != null) {
|
||||
cumulativeDelay += entry.duration;
|
||||
} else if (entry.type === 'block') {
|
||||
cumulativeDelay = 0;
|
||||
} else if (entry.type === 'event') {
|
||||
eventIndex++;
|
||||
previousEnd = thisEnd;
|
||||
thisEnd = entry.timeEnd;
|
||||
previousEventId = entry.id;
|
||||
}
|
||||
const isLast = index === entries.length - 1;
|
||||
const isSelected = data?.selectedEventId === entry.id;
|
||||
const isNext = data?.nextEventId === entry.id;
|
||||
|
||||
return (
|
||||
<Fragment key={entry.id}>
|
||||
<div ref={cursor === index ? cursorRef : undefined}>
|
||||
<RundownEntry
|
||||
type={entry.type}
|
||||
index={index}
|
||||
eventIndex={eventIndex}
|
||||
data={entry}
|
||||
selected={isSelected}
|
||||
hasCursor={cursor === index}
|
||||
next={isNext}
|
||||
delay={cumulativeDelay}
|
||||
previousEnd={previousEnd}
|
||||
previousEventId={previousEventId}
|
||||
playback={isSelected ? data.playback || undefined : undefined}
|
||||
/>
|
||||
</div>
|
||||
{((showQuickEntry && index === cursor) || isLast) && (
|
||||
<QuickAddBlock
|
||||
showKbd={index === cursor}
|
||||
eventId={entry.id}
|
||||
previousEventId={previousEventId}
|
||||
disableAddDelay={entry.type === 'delay'}
|
||||
disableAddBlock={entry.type === 'block'}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Rundown.propTypes = {
|
||||
entries: PropTypes.array,
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
|
||||
import { defaultPublicAtom, editorEventId, startTimeIsLastEndAtom } from '../../common/atoms/LocalEventSettings';
|
||||
import { CursorContext } from '../../common/context/CursorContext';
|
||||
import { LoggingContext } from '../../common/context/LoggingContext';
|
||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||
import { OntimeEvent, OntimeRundownEntry, SupportedEvent } from '../../common/models/EventTypes';
|
||||
import { Playback } from '../../common/models/OntimeTypes';
|
||||
import { cloneEvent } from '../../common/utils/eventsManager';
|
||||
import { calculateDuration } from '../../common/utils/timesManager';
|
||||
|
||||
import BlockBlock from './block-block/BlockBlock';
|
||||
import DelayBlock from './delay-block/DelayBlock';
|
||||
import EventBlock from './event-block/EventBlock';
|
||||
|
||||
export type EventItemActions =
|
||||
'set-cursor'
|
||||
| 'event'
|
||||
| 'delay'
|
||||
| 'block'
|
||||
| 'delete'
|
||||
| 'clone'
|
||||
| 'update'
|
||||
|
||||
interface RundownEntryProps {
|
||||
type: SupportedEvent;
|
||||
index: number;
|
||||
eventIndex: number;
|
||||
data: OntimeRundownEntry;
|
||||
selected: boolean;
|
||||
hasCursor: boolean;
|
||||
next: boolean;
|
||||
delay: number;
|
||||
previousEnd: number;
|
||||
previousEventId?: string;
|
||||
playback?: Playback; // we only care about this if this event is playing
|
||||
}
|
||||
|
||||
export default function RundownEntry(props: RundownEntryProps) {
|
||||
const {
|
||||
index,
|
||||
eventIndex,
|
||||
data,
|
||||
selected,
|
||||
hasCursor,
|
||||
next,
|
||||
delay,
|
||||
previousEnd,
|
||||
previousEventId,
|
||||
playback,
|
||||
} = props;
|
||||
const { emitError } = useContext(LoggingContext);
|
||||
const startTimeIsLastEnd = useAtomValue(startTimeIsLastEndAtom);
|
||||
const defaultPublic = useAtomValue(defaultPublicAtom);
|
||||
const { addEvent, updateEvent, deleteEvent } = useEventAction();
|
||||
const { moveCursorTo } = useContext(CursorContext);
|
||||
const [openId, setOpenId] = useAtom(editorEventId);
|
||||
|
||||
// Create / delete new events
|
||||
type FieldValue = {
|
||||
field: keyof Omit<OntimeEvent, 'duration'> | 'durationOverride';
|
||||
value: unknown;
|
||||
}
|
||||
const actionHandler = useCallback(
|
||||
(action: EventItemActions, payload?: number | FieldValue) => {
|
||||
switch (action) {
|
||||
case 'set-cursor': {
|
||||
moveCursorTo(payload as number);
|
||||
break;
|
||||
}
|
||||
case 'event': {
|
||||
const newEvent = { type: SupportedEvent.Event };
|
||||
const options = {
|
||||
startTimeIsLastEnd,
|
||||
defaultPublic,
|
||||
lastEventId: previousEventId,
|
||||
after: data.id,
|
||||
};
|
||||
addEvent(newEvent, options);
|
||||
break;
|
||||
}
|
||||
case 'delay': {
|
||||
addEvent({ type: SupportedEvent.Delay }, { after: data.id });
|
||||
break;
|
||||
}
|
||||
case 'block': {
|
||||
addEvent({ type: SupportedEvent.Block }, { after: data.id });
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
if (openId === data.id) {
|
||||
setOpenId(null);
|
||||
}
|
||||
deleteEvent(data.id);
|
||||
break;
|
||||
}
|
||||
case 'clone': {
|
||||
const newEvent = cloneEvent(data as OntimeEvent, data.id);
|
||||
addEvent(newEvent);
|
||||
break;
|
||||
}
|
||||
case 'update': {
|
||||
// Handles and filters update requests
|
||||
const { field, value } = payload as FieldValue;
|
||||
const newData: Partial<OntimeEvent> = { id: data.id };
|
||||
|
||||
if (field === 'durationOverride' && data.type === SupportedEvent.Event) {
|
||||
// duration defines timeEnd
|
||||
newData.duration = value as number;
|
||||
newData.timeEnd = data.timeStart + (value as number);
|
||||
updateEvent(newData);
|
||||
} else if (field === 'timeStart' && data.type === SupportedEvent.Event) {
|
||||
newData.duration = calculateDuration(value as number, data.timeEnd);
|
||||
newData.timeStart = value as number;
|
||||
updateEvent(newData);
|
||||
} else if (field === 'timeEnd' && data.type === SupportedEvent.Event) {
|
||||
newData.duration = calculateDuration(data.timeStart, value as number);
|
||||
newData.timeEnd = value as number;
|
||||
updateEvent(newData);
|
||||
} else if (field in data) {
|
||||
// @ts-expect-error not sure how to type this
|
||||
newData[field] = value;
|
||||
updateEvent(newData);
|
||||
} else {
|
||||
emitError(`Unknown field: ${field}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
emitError(`Unknown action called: ${action}`);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[
|
||||
addEvent,
|
||||
data,
|
||||
defaultPublic,
|
||||
deleteEvent,
|
||||
emitError,
|
||||
moveCursorTo,
|
||||
openId,
|
||||
previousEventId,
|
||||
setOpenId,
|
||||
startTimeIsLastEnd,
|
||||
updateEvent,
|
||||
],
|
||||
);
|
||||
|
||||
if (data.type === SupportedEvent.Event) {
|
||||
return (
|
||||
<EventBlock
|
||||
timeStart={data.timeStart}
|
||||
timeEnd={data.timeEnd}
|
||||
duration={data.duration}
|
||||
index={index}
|
||||
eventIndex={eventIndex + 1}
|
||||
eventId={data.id}
|
||||
isPublic={data.isPublic}
|
||||
title={data.title}
|
||||
note={data.note}
|
||||
delay={delay}
|
||||
previousEnd={previousEnd}
|
||||
colour={data.colour}
|
||||
next={next}
|
||||
skip={data.skip}
|
||||
selected={selected}
|
||||
hasCursor={hasCursor}
|
||||
playback={playback}
|
||||
actionHandler={actionHandler}
|
||||
/>
|
||||
);
|
||||
} else if (data.type === SupportedEvent.Block) {
|
||||
return <BlockBlock
|
||||
index={index}
|
||||
data={data}
|
||||
hasCursor={hasCursor}
|
||||
actionHandler={actionHandler}
|
||||
/>;
|
||||
} else if (data.type === SupportedEvent.Delay) {
|
||||
return <DelayBlock
|
||||
index={index}
|
||||
data={data}
|
||||
hasCursor={hasCursor}
|
||||
actionHandler={actionHandler}
|
||||
/>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
|
||||
import { CursorProvider } from '../../common/context/CursorContext';
|
||||
import { handleLinks } from '../../common/utils/linkUtils';
|
||||
|
||||
import RundownWrapper from './RundownWrapper';
|
||||
|
||||
import style from '../editors/Editor.module.scss';
|
||||
|
||||
export default function RundownExport() {
|
||||
return (
|
||||
<CursorProvider>
|
||||
<Box className={style.editor} data-testid='panel-rundown'>
|
||||
<IoArrowUp
|
||||
className={style.corner}
|
||||
onClick={(event) => handleLinks(event, 'rundown')}
|
||||
/>
|
||||
<ErrorBoundary>
|
||||
<RundownWrapper />
|
||||
</ErrorBoundary>
|
||||
</Box>
|
||||
</CursorProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import Empty from '../../common/components/state/Empty';
|
||||
import useRundown from '../../common/hooks-query/useRundown';
|
||||
import RundownMenu from '../../features/menu/RundownMenu';
|
||||
|
||||
import Rundown from './Rundown';
|
||||
|
||||
import styles from '../editors/Editor.module.scss';
|
||||
|
||||
export default function RundownWrapper() {
|
||||
const { data, status } = useRundown();
|
||||
|
||||
return (
|
||||
<>
|
||||
<RundownMenu />
|
||||
<div className={styles.content}>
|
||||
{status === 'success' && data ? (
|
||||
<Rundown entries={data} />
|
||||
) : (
|
||||
<Empty text='Connecting to server' />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
@use '../../theme/ontimeColours' as *;
|
||||
@use '../../theme/v2Styles' as *;
|
||||
|
||||
$block-gap: 4px;
|
||||
$block-element-spacing: 4px;
|
||||
$block-binder-width: 32px;
|
||||
$block-clearance: 8px;
|
||||
$block-border-radius: 8px;
|
||||
$block-text-color: $gray-50;
|
||||
$block-bg: $gray-1200;
|
||||
$block-box-shadow: rgba(0, 0, 0, 0.5) 0 0 3px 2px;
|
||||
$secondary-block-height: 40px;
|
||||
$block-cursor-color: $blue-400;
|
||||
|
||||
@mixin block-styling() {
|
||||
box-sizing: content-box;
|
||||
background-color: $block-bg;
|
||||
border: 1px solid $white-10;
|
||||
font-family: $ontime-font-family;
|
||||
border-radius: $block-border-radius;
|
||||
margin: 4px 2px;
|
||||
}
|
||||
|
||||
@mixin block-spacing() {
|
||||
padding: 4px 8px 4px 2px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
@mixin drag-style() {
|
||||
font-size: 20px;
|
||||
justify-self: center;
|
||||
opacity: 0.3;
|
||||
cursor: grab;
|
||||
transition: opacity 0.3s;
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
@use '../blockMixins' as *;
|
||||
|
||||
.block {
|
||||
@include block-spacing;
|
||||
@include block-styling;
|
||||
box-sizing: content-box;
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr auto;
|
||||
align-items: center;
|
||||
height: $secondary-block-height;
|
||||
|
||||
&.hasCursor {
|
||||
outline: 1px solid $block-cursor-color;
|
||||
}
|
||||
}
|
||||
|
||||
.drag {
|
||||
@include drag-style;
|
||||
}
|
||||
|
||||
.actionOverlay {
|
||||
justify-self: flex-end;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Draggable } from 'react-beautiful-dnd';
|
||||
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
|
||||
|
||||
import { OntimeBlock, OntimeEvent } from '../../../common/models/EventTypes';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import BlockActionMenu from '../event-block/composite/BlockActionMenu';
|
||||
import { EventItemActions } from '../RundownEntry';
|
||||
|
||||
import style from './BlockBlock.module.scss';
|
||||
|
||||
interface BlockBlockProps {
|
||||
index: number;
|
||||
data: OntimeBlock;
|
||||
hasCursor: boolean;
|
||||
actionHandler: (action: EventItemActions, payload?: number | { field: keyof OntimeEvent, value: unknown }) => void;
|
||||
}
|
||||
|
||||
export default function BlockBlock(props: BlockBlockProps) {
|
||||
const { index, data, hasCursor, actionHandler } = props;
|
||||
const onFocusRef = useRef<null | HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasCursor) {
|
||||
onFocusRef?.current?.focus();
|
||||
}
|
||||
}, [hasCursor])
|
||||
|
||||
const blockClasses = cx([
|
||||
style.block,
|
||||
hasCursor ? style.hasCursor : null,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Draggable key={data.id} draggableId={data.id} index={index}>
|
||||
{(provided) => (
|
||||
<div className={blockClasses} {...provided.draggableProps} ref={provided.innerRef}>
|
||||
<span className={style.drag} {...provided.dragHandleProps} ref={onFocusRef}>
|
||||
<IoReorderTwo />
|
||||
</span>
|
||||
<BlockActionMenu
|
||||
className={style.actionOverlay}
|
||||
showAdd
|
||||
showDelay
|
||||
enableDelete
|
||||
actionHandler={actionHandler}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
@use '../blockMixins' as *;
|
||||
|
||||
.delay {
|
||||
@include block-spacing;
|
||||
@include block-styling;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr auto;
|
||||
grid-template-areas: 'drag inpt btns';
|
||||
align-items: center;
|
||||
height: $secondary-block-height;
|
||||
gap: 8px;
|
||||
|
||||
&.hasCursor {
|
||||
outline: 1px solid $block-cursor-color;
|
||||
}
|
||||
}
|
||||
|
||||
.drag {
|
||||
@include drag-style;
|
||||
grid-area: drag;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { Draggable } from 'react-beautiful-dnd';
|
||||
import { Button, HStack } from '@chakra-ui/react';
|
||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
||||
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
|
||||
import DelayInput from '../../../common/components/input/delay-input/DelayInput';
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import { millisToMinutes } from '../../../common/utils/dateConfig';
|
||||
|
||||
import { OntimeDelay, OntimeEvent } from '../../../common/models/EventTypes';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import BlockActionMenu from '../event-block/composite/BlockActionMenu';
|
||||
import { EventItemActions } from '../RundownEntry';
|
||||
|
||||
import style from './DelayBlock.module.scss';
|
||||
|
||||
interface DelayBlockProps {
|
||||
data: OntimeDelay,
|
||||
index: number;
|
||||
hasCursor: boolean;
|
||||
actionHandler: (action: EventItemActions, payload?: number | { field: keyof OntimeEvent, value: unknown }) => void;
|
||||
}
|
||||
|
||||
export default function DelayBlock(props: DelayBlockProps) {
|
||||
const { data, index, hasCursor, actionHandler } = props;
|
||||
const { applyDelay, updateEvent } = useEventAction();
|
||||
const onFocusRef = useRef<null | HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasCursor) {
|
||||
onFocusRef?.current?.focus();
|
||||
}
|
||||
}, [hasCursor])
|
||||
|
||||
const applyDelayHandler = useCallback(() => {
|
||||
applyDelay(data.id);
|
||||
}, [data.id, applyDelay]);
|
||||
|
||||
const delaySubmitHandler = useCallback(
|
||||
(value: number) => {
|
||||
const newEvent = {
|
||||
id: data.id,
|
||||
duration: value * 60000,
|
||||
};
|
||||
|
||||
updateEvent(newEvent);
|
||||
},
|
||||
[data.id, updateEvent],
|
||||
);
|
||||
|
||||
const blockClasses = cx([
|
||||
style.delay,
|
||||
hasCursor ? style.hasCursor : null,
|
||||
]);
|
||||
|
||||
const delayValue = data.duration != null ? millisToMinutes(data.duration) : undefined;
|
||||
|
||||
return (
|
||||
<Draggable key={data.id} draggableId={data.id} index={index}>
|
||||
{(provided) => (
|
||||
<div className={blockClasses} {...provided.draggableProps} ref={provided.innerRef}>
|
||||
<span className={style.drag} {...provided.dragHandleProps} ref={onFocusRef}>
|
||||
<IoReorderTwo />
|
||||
</span>
|
||||
<DelayInput
|
||||
value={delayValue}
|
||||
submitHandler={delaySubmitHandler}
|
||||
/>
|
||||
<HStack spacing='8px' className={style.actionOverlay}>
|
||||
<Button
|
||||
onClick={applyDelayHandler}
|
||||
size='sm'
|
||||
leftIcon={<IoCheckmark />}
|
||||
variant='ontime-subtle-white'
|
||||
>
|
||||
Apply delay
|
||||
</Button>
|
||||
<BlockActionMenu showAdd enableDelete actionHandler={actionHandler} />
|
||||
</HStack>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
@use '../../../theme/ontimeColours' as *;
|
||||
@use '../blockMixins' as *;
|
||||
|
||||
.eventBlock {
|
||||
@include block-styling;
|
||||
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"binder ... ... ..."
|
||||
"binder pb-actions times actions"
|
||||
"binder pb-actions title title"
|
||||
"binder pb-actions estatus estatus"
|
||||
"binder ... ... ...";
|
||||
|
||||
grid-template-columns: $block-binder-width auto 1fr auto;
|
||||
grid-template-rows: 4px 36px 36px 36px 4px;
|
||||
align-items: center;
|
||||
padding-right: $block-clearance;
|
||||
gap: 2px;
|
||||
|
||||
&.selected {
|
||||
background-color: $gray-1350;
|
||||
}
|
||||
|
||||
&.hasCursor {
|
||||
outline: 1px solid $block-cursor-color;
|
||||
}
|
||||
|
||||
&.skip {
|
||||
border: 1px solid $white-3;
|
||||
box-shadow: none;
|
||||
|
||||
.delayNote,
|
||||
.eventTitle,
|
||||
.eventNote,
|
||||
.binder,
|
||||
.eventTimers,
|
||||
.eventStatus {
|
||||
opacity: $opacity-disabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.binder {
|
||||
grid-area: binder;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
border-radius: $block-border-radius 0 0 $block-border-radius;
|
||||
|
||||
background-color: $gray-1050; // to override inline
|
||||
color: $section-white;
|
||||
font-size: 17px;
|
||||
|
||||
.drag {
|
||||
@include drag-style;
|
||||
position: absolute;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.playbackActions {
|
||||
grid-area: pb-actions;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.eventTimers {
|
||||
grid-area: times;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $block-clearance;
|
||||
height: 100%;
|
||||
|
||||
.delayNote {
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
color: $ontime-delay-text;
|
||||
}
|
||||
}
|
||||
|
||||
.eventTitle {
|
||||
grid-area: title;
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&.noTitle {
|
||||
.preview {
|
||||
opacity: $opacity-disabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.eventActions {
|
||||
grid-area: actions;
|
||||
display: flex;
|
||||
gap: $block-clearance;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
|
||||
.eventOptions {
|
||||
margin: $element-spacing 16px $element-spacing 0;
|
||||
}
|
||||
|
||||
.progressBg {
|
||||
grid-area: progb;
|
||||
border-radius: 2px;
|
||||
background-color: $gray-1100;
|
||||
opacity: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.progressBg.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.flip {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.statusElements {
|
||||
grid-area: estatus;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"notes status"
|
||||
"progb progb";
|
||||
gap: 2px;
|
||||
grid-template-rows: auto 4px;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.eventNote {
|
||||
grid-area: notes;
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: $block-text-color;
|
||||
line-height: 13px;
|
||||
}
|
||||
|
||||
.eventStatus {
|
||||
grid-area: status;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
|
||||
.statusIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: $gray-1000;
|
||||
}
|
||||
|
||||
.statusIcon.active {
|
||||
color: $active-indicator;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Draggable } from 'react-beautiful-dnd';
|
||||
import { Editable, EditableInput, EditablePreview, Tooltip } from '@chakra-ui/react';
|
||||
import { IoOptions } from '@react-icons/all-files/io5/IoOptions';
|
||||
import { IoPeople } from '@react-icons/all-files/io5/IoPeople';
|
||||
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
|
||||
import { IoPlayOutline } from '@react-icons/all-files/io5/IoPlayOutline';
|
||||
import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward';
|
||||
import { IoReload } from '@react-icons/all-files/io5/IoReload';
|
||||
import { IoRemoveCircle } from '@react-icons/all-files/io5/IoRemoveCircle';
|
||||
import { IoRemoveCircleOutline } from '@react-icons/all-files/io5/IoRemoveCircleOutline';
|
||||
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
|
||||
import { editorEventId } from '../../../common/atoms/LocalEventSettings';
|
||||
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { useAtom } from 'jotai';
|
||||
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import { setEventPlayback } from '../../../common/hooks/useSocket';
|
||||
import { Playback } from '../../../common/models/OntimeTypes';
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
import { EventItemActions } from '../RundownEntry';
|
||||
|
||||
import BlockActionMenu from './composite/BlockActionMenu';
|
||||
import EventBlockProgressBar from './composite/EventBlockProgressBar';
|
||||
import EventBlockTimers from './composite/EventBlockTimers';
|
||||
|
||||
import style from './EventBlock.module.scss';
|
||||
|
||||
const blockBtnStyle = {
|
||||
size: 'sm',
|
||||
};
|
||||
|
||||
const tooltipProps = {
|
||||
openDelay: tooltipDelayMid,
|
||||
};
|
||||
|
||||
interface EventBlockProps {
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
duration: number;
|
||||
index: number;
|
||||
eventIndex: number;
|
||||
eventId: string;
|
||||
isPublic: boolean;
|
||||
title: string;
|
||||
note: string;
|
||||
delay: number;
|
||||
previousEnd: number;
|
||||
colour: string;
|
||||
next: boolean;
|
||||
skip: boolean;
|
||||
selected: boolean;
|
||||
hasCursor: boolean;
|
||||
playback?: Playback;
|
||||
actionHandler: (action: EventItemActions, payload?: any) => void;
|
||||
}
|
||||
|
||||
export default function EventBlock(props: EventBlockProps) {
|
||||
const {
|
||||
timeStart,
|
||||
timeEnd,
|
||||
duration,
|
||||
index,
|
||||
eventIndex,
|
||||
eventId,
|
||||
isPublic = true,
|
||||
title,
|
||||
note,
|
||||
delay,
|
||||
previousEnd,
|
||||
colour,
|
||||
next,
|
||||
skip = false,
|
||||
selected,
|
||||
hasCursor,
|
||||
playback,
|
||||
actionHandler,
|
||||
} = props;
|
||||
|
||||
const [openId, setOpenId] = useAtom(editorEventId);
|
||||
const { updateEvent } = useEventAction();
|
||||
const [blockTitle, setBlockTitle] = useState<string>(title || '');
|
||||
const onFocusRef = useRef<null | HTMLSpanElement>(null);
|
||||
|
||||
const binderColours = colour && getAccessibleColour(colour);
|
||||
|
||||
// Todo: could I re-render the item without causing a state change here?
|
||||
// ?? use refs instead?
|
||||
useEffect(() => {
|
||||
setBlockTitle(title);
|
||||
}, [title]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasCursor) {
|
||||
onFocusRef?.current?.focus();
|
||||
}
|
||||
}, [hasCursor]);
|
||||
|
||||
const handleTitle = useCallback(
|
||||
(text: string) => {
|
||||
if (text === title) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanVal = text.trim();
|
||||
setBlockTitle(cleanVal);
|
||||
|
||||
updateEvent({ id: eventId, title: cleanVal });
|
||||
},
|
||||
[title, updateEvent, eventId],
|
||||
);
|
||||
|
||||
const eventIsPlaying = selected && playback === 'play';
|
||||
const playBtnStyles = { _hover: {} };
|
||||
if (!skip && eventIsPlaying) {
|
||||
playBtnStyles._hover = { bg: '#c05621' };
|
||||
} else if (!skip && !eventIsPlaying) {
|
||||
playBtnStyles._hover = {};
|
||||
}
|
||||
|
||||
const blockClasses = cx([
|
||||
style.eventBlock,
|
||||
skip ? style.skip : null,
|
||||
selected ? style.selected : null,
|
||||
hasCursor ? style.hasCursor : null,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Draggable key={eventId} draggableId={eventId} index={index}>
|
||||
{(provided) => (
|
||||
<div
|
||||
className={blockClasses}
|
||||
{...provided.draggableProps}
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
<div
|
||||
className={style.binder}
|
||||
style={{ ...binderColours }}
|
||||
tabIndex={-1}
|
||||
onClick={() => actionHandler('set-cursor', index)}
|
||||
>
|
||||
<span className={style.drag} {...provided.dragHandleProps} ref={onFocusRef}>
|
||||
<IoReorderTwo />
|
||||
</span>
|
||||
{eventIndex}
|
||||
</div>
|
||||
<div className={style.playbackActions}>
|
||||
<TooltipActionBtn
|
||||
variant='ontime-subtle-white'
|
||||
aria-label='Skip event'
|
||||
tooltip='Skip event'
|
||||
icon={skip ? <IoRemoveCircle /> : <IoRemoveCircleOutline />}
|
||||
{...tooltipProps}
|
||||
{...blockBtnStyle}
|
||||
clickHandler={() => actionHandler('update', { field: 'skip', value: !skip })}
|
||||
tabIndex={-1}
|
||||
disabled={selected}
|
||||
/>
|
||||
<TooltipActionBtn
|
||||
variant='ontime-subtle-white'
|
||||
aria-label='Load event'
|
||||
tooltip='Load event'
|
||||
icon={<IoReload className={style.flip} />}
|
||||
disabled={skip}
|
||||
{...tooltipProps}
|
||||
{...blockBtnStyle}
|
||||
clickHandler={() => setEventPlayback.loadEvent(eventId)}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<TooltipActionBtn
|
||||
variant='ontime-subtle-white'
|
||||
aria-label='Start event'
|
||||
tooltip='Start event'
|
||||
icon={eventIsPlaying ? <IoPlay /> : <IoPlayOutline />}
|
||||
disabled={skip}
|
||||
{...tooltipProps}
|
||||
{...blockBtnStyle}
|
||||
clickHandler={() => setEventPlayback.startEvent(eventId)}
|
||||
backgroundColor={eventIsPlaying ? '#58A151' : undefined}
|
||||
_hover={{ backgroundColor: eventIsPlaying ? '#58A151' : undefined }}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</div>
|
||||
<EventBlockTimers
|
||||
timeStart={timeStart}
|
||||
timeEnd={timeEnd}
|
||||
duration={duration}
|
||||
delay={delay}
|
||||
actionHandler={actionHandler}
|
||||
previousEnd={previousEnd}
|
||||
/>
|
||||
<Editable
|
||||
variant='ontime'
|
||||
value={blockTitle}
|
||||
className={`${style.eventTitle} ${!title ? style.noTitle : ''}`}
|
||||
placeholder='Event title'
|
||||
onChange={(value) => setBlockTitle(value)}
|
||||
onSubmit={(value) => handleTitle(value)}
|
||||
>
|
||||
<EditablePreview className={style.preview} />
|
||||
<EditableInput />
|
||||
</Editable>
|
||||
<div className={style.statusElements}>
|
||||
<span className={style.eventNote}>{note}</span>
|
||||
<div className={selected ? style.progressBg : `${style.progressBg} ${style.hidden}`}>
|
||||
<EventBlockProgressBar playback={playback} />
|
||||
</div>
|
||||
<div className={style.eventStatus} tabIndex={-1}
|
||||
>
|
||||
<Tooltip
|
||||
label='Next event'
|
||||
isDisabled={!next}
|
||||
{...tooltipProps}
|
||||
>
|
||||
<span>
|
||||
<IoPlaySkipForward
|
||||
className={`${style.statusIcon} ${next ? style.active : ''}`} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label={`${isPublic ? 'Event is public' : 'Event is private'}`}
|
||||
{...tooltipProps}
|
||||
>
|
||||
<span>
|
||||
<IoPeople className={`${style.statusIcon} ${isPublic ? style.active : ''}`} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.eventActions}>
|
||||
<TooltipActionBtn
|
||||
{...blockBtnStyle}
|
||||
variant='ontime-subtle-white'
|
||||
size='sm'
|
||||
icon={<IoOptions />}
|
||||
clickHandler={() => setOpenId((prev) => prev === eventId ? null : eventId)}
|
||||
tooltip='Event options'
|
||||
aria-label='Event options'
|
||||
tabIndex={-1}
|
||||
backgroundColor={openId === eventId ? '#2B5ABC' : undefined}
|
||||
color={openId === eventId ? 'white' : '#f6f6f6'}
|
||||
/>
|
||||
<BlockActionMenu
|
||||
showAdd
|
||||
showDelay
|
||||
showBlock
|
||||
showClone
|
||||
enableDelete={!selected}
|
||||
actionHandler={actionHandler}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuDivider,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Tooltip,
|
||||
} from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { IoDuplicateOutline } from '@react-icons/all-files/io5/IoDuplicateOutline';
|
||||
import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHorizontal';
|
||||
import { IoRemoveCircleOutline } from '@react-icons/all-files/io5/IoRemoveCircleOutline';
|
||||
import { IoTimerOutline } from '@react-icons/all-files/io5/IoTimerOutline';
|
||||
import { IoTrashBinSharp } from '@react-icons/all-files/io5/IoTrashBinSharp';
|
||||
|
||||
import { tooltipDelayMid } from '../../../../ontimeConfig';
|
||||
import { EventItemActions } from '../../RundownEntry';
|
||||
|
||||
interface BlockActionMenuProps {
|
||||
showAdd?: boolean;
|
||||
showDelay?: boolean;
|
||||
showBlock?: boolean;
|
||||
enableDelete?: boolean;
|
||||
showClone?: boolean;
|
||||
actionHandler: (action: EventItemActions, payload?: any) => void;
|
||||
className?: string;
|
||||
}
|
||||
export default function BlockActionMenu(props: BlockActionMenuProps) {
|
||||
const { showAdd, showDelay, showBlock, enableDelete, showClone, actionHandler, className } = props;
|
||||
|
||||
const handleAddEvent = useCallback(() => actionHandler("event"), [actionHandler])
|
||||
const handleAddDelay = useCallback(() => actionHandler("delay"), [actionHandler])
|
||||
const handleAddBlock = useCallback(() => actionHandler("block"), [actionHandler])
|
||||
const handleClone = useCallback(() => actionHandler("clone"), [actionHandler])
|
||||
const handleDelete = useCallback(() => actionHandler("delete"), [actionHandler])
|
||||
|
||||
return (
|
||||
<Menu isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
|
||||
<Tooltip label='Add ...' openDelay={tooltipDelayMid}>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
aria-label='Event options'
|
||||
icon={<IoEllipsisHorizontal />}
|
||||
tabIndex={-1}
|
||||
variant='ontime-subtle'
|
||||
color='#f6f6f6'
|
||||
size='sm'
|
||||
className={className}
|
||||
/>
|
||||
</Tooltip>
|
||||
<MenuList>
|
||||
<MenuItem icon={<IoAdd />} onClick={handleAddEvent} isDisabled={!showAdd}>
|
||||
Add Event after
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<IoTimerOutline />}
|
||||
onClick={handleAddDelay}
|
||||
isDisabled={!showDelay}
|
||||
>
|
||||
Add Delay after
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<IoRemoveCircleOutline />}
|
||||
onClick={handleAddBlock}
|
||||
isDisabled={!showBlock}
|
||||
>
|
||||
Add Block after
|
||||
</MenuItem>
|
||||
{showClone && (
|
||||
<MenuItem
|
||||
icon={<IoDuplicateOutline />}
|
||||
onClick={handleClone}
|
||||
isDisabled={!showBlock}
|
||||
>
|
||||
Clone event
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuDivider />
|
||||
<MenuItem
|
||||
icon={<IoTrashBinSharp />}
|
||||
onClick={handleDelete}
|
||||
isDisabled={!enableDelete}
|
||||
color='#D20300'
|
||||
>
|
||||
Delete event
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
@use '../../../../theme/v2Styles' as *;
|
||||
|
||||
.progressBar {
|
||||
// layout
|
||||
height: 100%;
|
||||
width: 0;
|
||||
border-radius: 1px 0 0 1px;
|
||||
|
||||
// animations
|
||||
transition: 1s linear;
|
||||
transition-property: width;
|
||||
|
||||
&.play {
|
||||
background-color: $playback-start;
|
||||
}
|
||||
|
||||
&.pause {
|
||||
background-color: $ontime-paused;
|
||||
}
|
||||
|
||||
&.roll {
|
||||
background-color: $ontime-roll;
|
||||
}
|
||||
|
||||
&.overtime {
|
||||
background-color: $playback-negative;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useTimer } from '../../../../common/hooks/useSocket';
|
||||
import { Playback } from '../../../../common/models/OntimeTypes';
|
||||
import { clamp } from '../../../../common/utils/math';
|
||||
|
||||
import style from './EventBlockProgressBar.module.scss';
|
||||
|
||||
interface EventBlockProgressBarProps {
|
||||
playback?: Playback;
|
||||
}
|
||||
|
||||
export default function EventBlockProgressBar(props: EventBlockProgressBarProps) {
|
||||
const { playback } = props;
|
||||
const { data: timer } = useTimer();
|
||||
const now = Math.floor(Math.max((timer?.current ?? 1) / 1000, 0));
|
||||
const complete = (timer?.duration ?? 1) / 1000;
|
||||
const elapsed = clamp(100 - (now * 100) / complete, 0, 100);
|
||||
const progress = `${elapsed}%`;
|
||||
|
||||
if ((timer?.current ?? 0) < 0) {
|
||||
return (
|
||||
<div
|
||||
className={`${style.progressBar} ${style.overtime}`}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${style.progressBar} ${playback ? style[playback] : ''}`}
|
||||
style={{ width: progress }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useCallback, useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
||||
import { LoggingContext } from '../../../../common/context/LoggingContext';
|
||||
import { millisToMinutes } from '../../../../common/utils/dateConfig';
|
||||
import { stringFromMillis } from '../../../../common/utils/time';
|
||||
import { validateEntry } from '../../../../common/utils/timesManager';
|
||||
|
||||
import style from '../EventBlock.module.scss';
|
||||
|
||||
export default function EventBlockTimers(props) {
|
||||
const { timeStart, timeEnd, duration, delay, actionHandler, previousEnd } = props;
|
||||
const { emitWarning } = useContext(LoggingContext);
|
||||
|
||||
const delayTime = `${delay >= 0 ? '+' : '-'} ${millisToMinutes(Math.abs(delay))}`;
|
||||
const newTime = stringFromMillis(timeStart + delay);
|
||||
|
||||
/**
|
||||
* @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 handleValidation = useCallback(
|
||||
(field, value) => {
|
||||
const valid = validateEntry(field, value, timeStart, timeEnd);
|
||||
if (valid.catch) {
|
||||
emitWarning(`Time Input Warning: ${valid.catch}`);
|
||||
}
|
||||
return valid.value;
|
||||
},
|
||||
[emitWarning, timeEnd, timeStart]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(field, value) => {
|
||||
actionHandler('update', { field, value });
|
||||
},
|
||||
[actionHandler]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={style.eventTimers}>
|
||||
<TimeInput
|
||||
name='timeStart'
|
||||
submitHandler={handleSubmit}
|
||||
validationHandler={handleValidation}
|
||||
time={timeStart}
|
||||
delay={delay}
|
||||
placeholder='Start'
|
||||
previousEnd={previousEnd}
|
||||
/>
|
||||
<TimeInput
|
||||
name='timeEnd'
|
||||
submitHandler={handleSubmit}
|
||||
validationHandler={handleValidation}
|
||||
time={timeEnd}
|
||||
delay={delay}
|
||||
placeholder='End'
|
||||
previousEnd={previousEnd}
|
||||
/>
|
||||
<TimeInput
|
||||
name='durationOverride'
|
||||
submitHandler={handleSubmit}
|
||||
validationHandler={handleValidation}
|
||||
time={duration}
|
||||
placeholder='Duration'
|
||||
previousEnd={previousEnd}
|
||||
/>
|
||||
{delay !== 0 && delay !== null && (
|
||||
<div className={style.delayNote}>
|
||||
{`${delayTime} minutes`}
|
||||
<br />
|
||||
{`New start: ${newTime}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
EventBlockTimers.propTypes = {
|
||||
timeStart: PropTypes.number,
|
||||
timeEnd: PropTypes.number,
|
||||
duration: PropTypes.number,
|
||||
delay: PropTypes.number,
|
||||
actionHandler: PropTypes.func,
|
||||
previousEnd: PropTypes.number,
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
|
||||
.quickAdd {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
margin: 4px 0;
|
||||
font-size: 12px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.btnRow {
|
||||
justify-self: center;
|
||||
display: flex;
|
||||
gap: 10%;
|
||||
|
||||
.quickBtn {
|
||||
width: auto;
|
||||
padding: 0 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.keyboard {
|
||||
margin-left: 8px;
|
||||
padding: 0 4px;
|
||||
color: $label-gray;
|
||||
border-radius: 2px;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useCallback, useContext, useRef } from 'react';
|
||||
import { Button, Checkbox, Tooltip } from '@chakra-ui/react';
|
||||
import { defaultPublicAtom, startTimeIsLastEndAtom } from '../../../common/atoms/LocalEventSettings';
|
||||
import { LoggingContext } from '../../../common/context/LoggingContext';
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import { SupportedEvent } from '../../../common/models/EventTypes';
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
|
||||
import style from './QuickAddBlock.module.scss';
|
||||
|
||||
interface QuickAddBlockProps {
|
||||
showKbd: boolean;
|
||||
eventId: string;
|
||||
previousEventId?: string;
|
||||
disableAddDelay?: boolean;
|
||||
disableAddBlock: boolean;
|
||||
}
|
||||
|
||||
export default function QuickAddBlock(props: QuickAddBlockProps) {
|
||||
const {
|
||||
showKbd,
|
||||
eventId,
|
||||
previousEventId,
|
||||
disableAddDelay = true,
|
||||
disableAddBlock,
|
||||
} = props;
|
||||
const { addEvent } = useEventAction();
|
||||
const { emitError } = useContext(LoggingContext);
|
||||
const startTimeIsLastEnd = useAtomValue(startTimeIsLastEndAtom);
|
||||
const defaultPublic = useAtomValue(defaultPublicAtom);
|
||||
const doStartTime = useRef<HTMLInputElement | null>(null);
|
||||
const doPublic = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const handleCreateEvent = useCallback((eventType: SupportedEvent) => {
|
||||
switch (eventType) {
|
||||
case 'event': {
|
||||
const isPublicOption = doPublic?.current?.checked;
|
||||
const startTimeIsLastEndOption = doStartTime?.current?.checked;
|
||||
|
||||
const newEvent = { type: SupportedEvent.Event };
|
||||
const options = {
|
||||
defaultPublic: isPublicOption,
|
||||
startTimeIsLastEnd: startTimeIsLastEndOption,
|
||||
lastEventId: previousEventId,
|
||||
after: eventId,
|
||||
};
|
||||
addEvent(newEvent, options);
|
||||
break;
|
||||
}
|
||||
case 'delay': {
|
||||
const options = {
|
||||
lastEventId: previousEventId,
|
||||
after: eventId,
|
||||
}
|
||||
addEvent({ type: SupportedEvent.Delay }, options);
|
||||
break;
|
||||
}
|
||||
case 'block': {
|
||||
const options= {
|
||||
lastEventId: previousEventId,
|
||||
after: eventId,
|
||||
}
|
||||
addEvent({ type: SupportedEvent.Block }, options);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
emitError(`Cannot create unknown event type: ${eventType}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
}, [previousEventId, eventId, addEvent, emitError]);
|
||||
|
||||
return (
|
||||
<div className={style.quickAdd}>
|
||||
<div className={style.btnRow}>
|
||||
<Tooltip label='Add Event' openDelay={tooltipDelayMid}>
|
||||
<Button
|
||||
onClick={() => handleCreateEvent(SupportedEvent.Event)}
|
||||
size='xs'
|
||||
variant='ontime-subtle-white'
|
||||
className={style.quickBtn}
|
||||
>
|
||||
Event {showKbd && <span className={style.keyboard}>Alt + E</span>}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label='Add Delay' openDelay={tooltipDelayMid}>
|
||||
<Button
|
||||
onClick={() => handleCreateEvent(SupportedEvent.Delay)}
|
||||
size='xs'
|
||||
variant='ontime-subtle-white'
|
||||
disabled={disableAddDelay}
|
||||
className={style.quickBtn}
|
||||
>
|
||||
Delay {showKbd && <span className={style.keyboard}>Alt + D</span>}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label='Add Block' openDelay={tooltipDelayMid}>
|
||||
<Button
|
||||
onClick={() => handleCreateEvent(SupportedEvent.Block)}
|
||||
size='xs'
|
||||
variant='ontime-subtle-white'
|
||||
disabled={disableAddBlock}
|
||||
className={style.quickBtn}
|
||||
>
|
||||
Block {showKbd && <span className={style.keyboard}>Alt + B</span>}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={style.options}>
|
||||
<Checkbox
|
||||
ref={doStartTime}
|
||||
size='sm'
|
||||
variant='ontime-ondark'
|
||||
defaultChecked={startTimeIsLastEnd}
|
||||
>
|
||||
Start time is last end
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
ref={doPublic}
|
||||
size='sm'
|
||||
variant='ontime-ondark'
|
||||
defaultChecked={defaultPublic}
|
||||
>
|
||||
Event is public
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { useCallback, useContext, useEffect, useMemo } from 'react';
|
||||
import { useBlockLayout, useColumnOrder, useResizeColumns, useTable } from 'react-table';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
horizontalListSortingStrategy,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
} from '@dnd-kit/sortable';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { TableSettingsContext } from '../../common/context/TableSettingsContext';
|
||||
import { useLocalStorage } from '../../common/hooks/useLocalStorage';
|
||||
import { tooltipDelayFast } from '../../ontimeConfig';
|
||||
|
||||
import SortableCell from './tableElements/SortableCell';
|
||||
import TableSettings from './tableElements/TableSettings';
|
||||
import BlockRow from './tableRows/BlockRow';
|
||||
import DelayRow from './tableRows/DelayRow';
|
||||
import EventRow from './tableRows/EventRow';
|
||||
import { makeColumns } from './columns';
|
||||
import { defaultColumnOrder, defaultHiddenColumns } from './defaults';
|
||||
|
||||
import style from './Table.module.scss';
|
||||
|
||||
export default function OntimeTable({ tableData, userFields, selectedId, handleUpdate }) {
|
||||
const { followSelected, showSettings } = useContext(TableSettingsContext);
|
||||
const [columnOrder, saveColumnOrder] = useLocalStorage('table-order', defaultColumnOrder);
|
||||
const [columnSize, saveColumnSize] = useLocalStorage('table-sizes', {});
|
||||
const [hiddenColumns, saveHiddenColumns] = useLocalStorage('table-hidden', defaultHiddenColumns);
|
||||
const columns = useMemo(() => makeColumns(columnSize, userFields), [columnSize, userFields]);
|
||||
|
||||
const {
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
rows,
|
||||
prepareRow,
|
||||
setColumnOrder,
|
||||
allColumns,
|
||||
setHiddenColumns,
|
||||
toggleHideAllColumns,
|
||||
state,
|
||||
} = useTable(
|
||||
{
|
||||
columns,
|
||||
data: tableData,
|
||||
initialState: {
|
||||
hiddenColumns,
|
||||
},
|
||||
handleUpdate,
|
||||
},
|
||||
useColumnOrder,
|
||||
useBlockLayout,
|
||||
useResizeColumns
|
||||
);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
delay: 100,
|
||||
tolerance: 50,
|
||||
},
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 100,
|
||||
tolerance: 50,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const handleResetReordering = useCallback(() => {
|
||||
saveColumnOrder(defaultColumnOrder);
|
||||
setColumnOrder(defaultColumnOrder);
|
||||
}, [saveColumnOrder, setColumnOrder]);
|
||||
|
||||
const handleResetResizing = useCallback(() => {
|
||||
saveColumnSize({});
|
||||
}, [saveColumnSize]);
|
||||
|
||||
const handleResetToggles = useCallback(() => {
|
||||
setHiddenColumns(defaultHiddenColumns);
|
||||
saveHiddenColumns(defaultHiddenColumns);
|
||||
}, [saveHiddenColumns, setHiddenColumns]);
|
||||
|
||||
const clearToggles = useCallback(() => {
|
||||
toggleHideAllColumns(false);
|
||||
saveHiddenColumns([]);
|
||||
}, [saveHiddenColumns, toggleHideAllColumns]);
|
||||
|
||||
const handleOnDragEnd = useCallback((event) => {
|
||||
const { delta, active, over } = event;
|
||||
|
||||
// cancel if delta y is greater than 200
|
||||
if (delta.y > 200) return;
|
||||
|
||||
const cols = [...columnOrder];
|
||||
|
||||
// get index of from
|
||||
const fromIndex = cols.findIndex((i) => i === active.id);
|
||||
|
||||
// get index of to
|
||||
const toIndex = cols.findIndex((i) => i === over.id);
|
||||
|
||||
if (toIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// reorder
|
||||
const [reorderedItem] = cols.splice(fromIndex, 1);
|
||||
cols.splice(toIndex, 0, reorderedItem);
|
||||
|
||||
saveColumnOrder(cols);
|
||||
setColumnOrder(cols);
|
||||
}, [columnOrder, saveColumnOrder, setColumnOrder]);
|
||||
|
||||
// save hidden columns object to local storage
|
||||
useEffect(() => {
|
||||
saveHiddenColumns(state.hiddenColumns);
|
||||
}, [saveHiddenColumns, state.hiddenColumns]);
|
||||
|
||||
// save column sizes to local storage
|
||||
useEffect(() => {
|
||||
// property changes from title of column to null on resize end
|
||||
if (state.columnResizing?.isResizingColumn !== null) {
|
||||
return;
|
||||
}
|
||||
const cols = state.columnResizing.columnWidths;
|
||||
saveColumnSize((prev) => ({ ...prev, ...cols }));
|
||||
}, [saveColumnSize, state.columnResizing]);
|
||||
|
||||
// scroll to active cue
|
||||
useEffect(() => {
|
||||
if (followSelected) {
|
||||
const el = document.getElementById(selectedId);
|
||||
if (el) {
|
||||
el.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [followSelected, selectedId]);
|
||||
|
||||
// keep order of events
|
||||
let eventIndex = 0;
|
||||
// keep delay (ms)
|
||||
let cumulativeDelay = 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showSettings && (
|
||||
<TableSettings
|
||||
columns={allColumns}
|
||||
handleResetResizing={handleResetResizing}
|
||||
handleResetReordering={handleResetReordering}
|
||||
handleResetToggles={handleResetToggles}
|
||||
handleClearToggles={clearToggles}
|
||||
/>
|
||||
)}
|
||||
<table {...getTableProps()} className={style.ontimeTable}>
|
||||
<thead className={style.tableHeader}>
|
||||
{headerGroups.map((headerGroup) => {
|
||||
const { key, ...restHeaderGroupProps } = headerGroup.getHeaderGroupProps();
|
||||
return (
|
||||
<DndContext
|
||||
key={key}
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleOnDragEnd}
|
||||
>
|
||||
<tr {...restHeaderGroupProps}>
|
||||
<th className={style.indexColumn}>
|
||||
<Tooltip label='Event Order' openDelay={tooltipDelayFast}>
|
||||
#
|
||||
</Tooltip>
|
||||
</th>
|
||||
<SortableContext
|
||||
key={key}
|
||||
items={headerGroup.headers}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{headerGroup.headers.map((column) => {
|
||||
const { key } = column.getHeaderProps();
|
||||
return <SortableCell key={key} column={column} />;
|
||||
})}
|
||||
</SortableContext>
|
||||
</tr>
|
||||
</DndContext>
|
||||
);
|
||||
})}
|
||||
</thead>
|
||||
<tbody {...getTableBodyProps} className={style.tableBody}>
|
||||
{/*This is saving in place of a default component*/}
|
||||
{/* eslint-disable-next-line array-callback-return */}
|
||||
{rows.map((row) => {
|
||||
prepareRow(row);
|
||||
const { key } = row.getRowProps();
|
||||
const type = row.original.type;
|
||||
if (type === 'event') {
|
||||
eventIndex++;
|
||||
return (
|
||||
<EventRow
|
||||
key={key}
|
||||
row={row}
|
||||
index={eventIndex}
|
||||
selectedId={selectedId}
|
||||
delay={cumulativeDelay}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (type === 'delay') {
|
||||
if (row.original.duration != null) {
|
||||
cumulativeDelay += row.original.duration;
|
||||
}
|
||||
return <DelayRow key={key} row={row} />;
|
||||
}
|
||||
if (type === 'block') {
|
||||
cumulativeDelay = 0;
|
||||
return <BlockRow key={key} row={row} />;
|
||||
}
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
OntimeTable.propTypes = {
|
||||
tableData: PropTypes.array,
|
||||
userFields: PropTypes.object,
|
||||
handleUpdate: PropTypes.func.isRequired,
|
||||
selectedId: PropTypes.string,
|
||||
showSettings: PropTypes.bool,
|
||||
followSelected: PropTypes.bool,
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
|
||||
import { TableSettingsProvider } from '../../common/context/TableSettingsContext';
|
||||
|
||||
import TableWrapper from './TableWrapper';
|
||||
|
||||
export default function ProtectedTable() {
|
||||
return (
|
||||
<ProtectRoute>
|
||||
<TableSettingsProvider>
|
||||
<TableWrapper />
|
||||
</TableSettingsProvider>
|
||||
</ProtectRoute>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
@use '../../theme/main' as *;
|
||||
|
||||
.tableWrapper,
|
||||
.tableWrapper__dark {
|
||||
font-family: "Open Sans", sans-serif;
|
||||
font-size: 16px;
|
||||
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
padding: 2rem;
|
||||
display: grid;
|
||||
|
||||
grid-template-columns: calc(100vw - 4rem);
|
||||
grid-template-rows: auto auto 1fr;
|
||||
grid-template-areas:
|
||||
'header'
|
||||
'settings'
|
||||
'table';
|
||||
gap: 1rem;
|
||||
overflow: scroll;
|
||||
|
||||
& > * {
|
||||
width: 100%;
|
||||
border: 1px solid $ontime-pink;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.header {
|
||||
grid-area: header;
|
||||
display: grid;
|
||||
height: max-content;
|
||||
grid-template-areas:
|
||||
'name playback running time actions'
|
||||
'now playback running time actions';
|
||||
grid-template-columns: 1fr auto 10em 12.5em auto;
|
||||
align-items: center;
|
||||
padding: 0.25em 1em;
|
||||
|
||||
.headerName {
|
||||
grid-area: name;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.headerName:after {
|
||||
content: '\200b';
|
||||
}
|
||||
|
||||
.headerNow {
|
||||
grid-area: now;
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
.headerNow:after {
|
||||
content: '\200b';
|
||||
}
|
||||
|
||||
.headerPlayback {
|
||||
grid-area: playback;
|
||||
color: $ontime-pink;
|
||||
text-align: center;
|
||||
|
||||
svg {
|
||||
font-size: 2em;
|
||||
}
|
||||
}
|
||||
|
||||
.headerRunning {
|
||||
grid-area: running;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.headerClock {
|
||||
grid-area: time;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
grid-area: actions;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 1.5em;
|
||||
padding-left: 10vw;
|
||||
color: darken($ontime-pink, 8%);
|
||||
}
|
||||
}
|
||||
|
||||
.tableSettings {
|
||||
grid-area: settings;
|
||||
padding: 1rem;
|
||||
|
||||
.options,
|
||||
.buttonRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.options {
|
||||
padding-left: 0.5em;
|
||||
}
|
||||
|
||||
.buttonRow {
|
||||
padding-top: 2em;
|
||||
}
|
||||
}
|
||||
|
||||
.ontimeTable {
|
||||
grid-area: table;
|
||||
|
||||
border-collapse: separate;
|
||||
border-spacing: 16px;
|
||||
padding: 1rem;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
|
||||
th, td {
|
||||
touch-action: auto;
|
||||
padding: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
td {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
position: sticky;
|
||||
top: -1em;
|
||||
z-index: 10;
|
||||
|
||||
th {
|
||||
font-size: 0.9em;
|
||||
font-weight: 200;
|
||||
text-align: left;
|
||||
|
||||
.resizer {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transform: translateX(50%);
|
||||
z-index: 1;
|
||||
touch-action: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tableBody {
|
||||
tr {
|
||||
td:hover {
|
||||
background-color: lighten($ontime-pink, 10%) !important;
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
|
||||
.selected > td {
|
||||
background-color: rgba($ontime-accent, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
.indexColumn {
|
||||
font-weight: 200;
|
||||
text-align: right;
|
||||
width: 2.5em;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.blockCell,
|
||||
.delayCell {
|
||||
width: 100%;
|
||||
font-weight: 400;
|
||||
font-size: 0.9em;
|
||||
text-align: center;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.delayCell {
|
||||
background-color: $block-delay-color;
|
||||
}
|
||||
|
||||
.blockCell {
|
||||
background-color: $block-block-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$bg-theme-light: #fcfcfc;
|
||||
$cell-theme-light: #ececec;
|
||||
$text-theme-light: #202020;
|
||||
$bg-theme-dark: #121212;
|
||||
$bg2-theme-dark: #1c1c1c;
|
||||
$cell-theme-dark: #2d2d2d;
|
||||
$text-theme-dark: white;
|
||||
|
||||
.tableWrapper {
|
||||
background-color: $bg-theme-light;
|
||||
color: black;
|
||||
|
||||
* {
|
||||
background-color: $bg-theme-light;
|
||||
scrollbar-color: $ontime-pink rgba(0, 0, 0, 0.13);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
background-color: rgba(0, 0, 0, 0.07);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: lighten($ontime-pink, 15%);
|
||||
}
|
||||
|
||||
td {
|
||||
background-color: $cell-theme-light;
|
||||
border: 1px solid $bg-theme-light;
|
||||
color: #121212;
|
||||
}
|
||||
.actionText:hover,
|
||||
.actionIcon:hover,
|
||||
.actionDisabled:hover {
|
||||
color: black;
|
||||
transition: 300ms;
|
||||
}
|
||||
}
|
||||
|
||||
.tableWrapper__dark {
|
||||
background-color: $bg-theme-dark;
|
||||
color: white;
|
||||
|
||||
* {
|
||||
background-color: $bg-theme-dark;
|
||||
scrollbar-color: $ontime-pink rgba(255, 255, 255, 0.13);
|
||||
}
|
||||
|
||||
td {
|
||||
background-color: $cell-theme-dark;
|
||||
border: 1px solid $bg-theme-dark;
|
||||
color: #ececec;
|
||||
}
|
||||
.actionText:hover,
|
||||
.actionIcon:hover,
|
||||
.actionDisabled:hover {
|
||||
color: white;
|
||||
transition: 300ms;
|
||||
}
|
||||
}
|
||||
|
||||
.timer {
|
||||
font-size: 1.7em;
|
||||
letter-spacing: 1px;
|
||||
line-height: 1.2em;
|
||||
font-weight: 200;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: $ontime-pink;
|
||||
font-size: 0.8em;
|
||||
font-weight: 200;
|
||||
line-height: 0.75em;
|
||||
}
|
||||
|
||||
svg {
|
||||
background-color: inherit !important;
|
||||
}
|
||||
|
||||
@mixin action-element() {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.actionIcon {
|
||||
@include action-element();
|
||||
}
|
||||
|
||||
.actionText {
|
||||
@include action-element();
|
||||
font-size: 0.65em;
|
||||
}
|
||||
|
||||
.actionDisabled {
|
||||
@include action-element();
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.dragging {
|
||||
border: 1px solid $ontime-pink;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.check {
|
||||
font-size: 1.5em;
|
||||
background-color: transparent !important;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@keyframes rotation {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useContext } from 'react';
|
||||
import { Divider, Tooltip } from '@chakra-ui/react';
|
||||
import { FiSettings } from '@react-icons/all-files/fi/FiSettings';
|
||||
import { FiTarget } from '@react-icons/all-files/fi/FiTarget';
|
||||
import { IoContract } from '@react-icons/all-files/io5/IoContract';
|
||||
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
|
||||
import { IoMoon } from '@react-icons/all-files/io5/IoMoon';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { TableSettingsContext } from '../../common/context/TableSettingsContext';
|
||||
import useFullscreen from '../../common/hooks/useFullscreen';
|
||||
import { useTimer } from '../../common/hooks/useSocket';
|
||||
import useEvent from '../../common/hooks-query/useEvent';
|
||||
import { formatDisplay, millisToSeconds } from '../../common/utils/dateConfig';
|
||||
import { formatTime } from '../../common/utils/time';
|
||||
import { tooltipDelayFast } from '../../ontimeConfig';
|
||||
|
||||
import PlaybackIcon from './tableElements/PlaybackIcon';
|
||||
|
||||
import style from './Table.module.scss';
|
||||
|
||||
export default function TableHeader({ handleCSVExport, featureData }) {
|
||||
const { followSelected, showSettings, toggleTheme, toggleSettings, toggleFollow } =
|
||||
useContext(TableSettingsContext);
|
||||
const { data: timer } = useTimer();
|
||||
const { isFullScreen, toggleFullScreen } = useFullscreen();
|
||||
const { data: event } = useEvent();
|
||||
|
||||
const selected = !featureData.numEvents
|
||||
? 'No events'
|
||||
: `Event ${featureData.selectedEventIndex != null ? featureData.selectedEventIndex + 1 : '-'}/${
|
||||
featureData.numEvents ? featureData.numEvents : '-'
|
||||
}`;
|
||||
|
||||
// prepare presentation variables
|
||||
const isOvertime = timer.current < 0;
|
||||
const timerNow = `${isOvertime ? '-' : ''}${formatDisplay(millisToSeconds(timer.current))}`;
|
||||
const timeNow = formatTime(timer.clock, {
|
||||
showSeconds: true,
|
||||
format: 'hh:mm:ss a',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={style.header}>
|
||||
<div className={style.headerName}>{event?.title || ''}</div>
|
||||
<div className={style.headerNow}>{featureData.titleNow}</div>
|
||||
<div className={style.headerPlayback}>
|
||||
<span className={style.label}>{selected}</span>
|
||||
<br />
|
||||
<PlaybackIcon state={featureData.playback} />
|
||||
</div>
|
||||
<div className={style.headerRunning}>
|
||||
<span className={style.label}>Running Timer</span>
|
||||
<br />
|
||||
<span className={style.timer}>{timerNow}</span>
|
||||
</div>
|
||||
<div className={style.headerClock}>
|
||||
<span className={style.label}>Time Now</span>
|
||||
<br />
|
||||
<span className={style.timer}>{timeNow}</span>
|
||||
</div>
|
||||
<div className={style.headerActions}>
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Follow selected'>
|
||||
<span className={followSelected ? style.actionIcon : style.actionDisabled}>
|
||||
<FiTarget onClick={() => toggleFollow()} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Show settings'>
|
||||
<span className={showSettings ? style.actionIcon : style.actionDisabled}>
|
||||
<FiSettings onClick={() => toggleSettings()} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Toggle dark mode'>
|
||||
<span className={style.actionIcon}>
|
||||
<IoMoon onClick={() => toggleTheme()} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Toggle Fullscreen'>
|
||||
<span className={style.actionIcon}>
|
||||
{isFullScreen ? (
|
||||
<IoContract onClick={() => toggleFullScreen()} />
|
||||
) : (
|
||||
<IoExpand onClick={() => toggleFullScreen()} />
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Divider />
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Export to CSV'>
|
||||
<span className={style.actionText} onClick={() => handleCSVExport(event)}>
|
||||
CSV
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TableHeader.propTypes = {
|
||||
handleCSVExport: PropTypes.func.isRequired,
|
||||
featureData: PropTypes.shape({
|
||||
playback: PropTypes.string,
|
||||
selectedEventId: PropTypes.string,
|
||||
selectedEventIndex: PropTypes.number,
|
||||
numEvents: PropTypes.number,
|
||||
titleNow: PropTypes.string,
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useCallback, useContext, useEffect } from 'react';
|
||||
|
||||
import { TableSettingsContext } from '../../common/context/TableSettingsContext';
|
||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||
import { useCuesheet } from '../../common/hooks/useSocket';
|
||||
import useRundown from '../../common/hooks-query/useRundown';
|
||||
import useUserFields from '../../common/hooks-query/useUserFields';
|
||||
|
||||
import OntimeTable from './OntimeTable';
|
||||
import TableHeader from './TableHeader';
|
||||
import { makeCSV, makeTable } from './utils';
|
||||
|
||||
import style from './Table.module.scss';
|
||||
|
||||
export default function TableWrapper() {
|
||||
const { data: rundown } = useRundown();
|
||||
const { data: userFields } = useUserFields();
|
||||
const { data: featureData } = useCuesheet();
|
||||
const { updateEvent } = useEventAction();
|
||||
const { theme } = useContext(TableSettingsContext);
|
||||
|
||||
// Set window title
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Cuesheet';
|
||||
}, []);
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
async (rowIndex, accessor, payload) => {
|
||||
if (rowIndex == null || accessor == null || payload == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// check if value is the same
|
||||
const event = rundown[rowIndex];
|
||||
if (event == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event[accessor] === payload) {
|
||||
return;
|
||||
}
|
||||
// check if value is valid
|
||||
// as of now, the fields do not have any validation
|
||||
if (typeof payload !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
// cleanup
|
||||
const cleanVal = payload.trim();
|
||||
const mutationObject = {
|
||||
id: event.id,
|
||||
[accessor]: cleanVal,
|
||||
};
|
||||
|
||||
// submit
|
||||
try {
|
||||
await updateEvent(mutationObject);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [updateEvent, rundown]);
|
||||
|
||||
const exportHandler = useCallback(
|
||||
(headerData) => {
|
||||
if (!headerData || !rundown || !userFields) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sheetData = makeTable(headerData, rundown, userFields);
|
||||
const csvContent = makeCSV(sheetData);
|
||||
const encodedUri = encodeURI(csvContent);
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', encodedUri);
|
||||
link.setAttribute('download', 'ontime export.csv');
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
},
|
||||
[rundown, userFields]
|
||||
);
|
||||
|
||||
if (typeof rundown === 'undefined' || typeof userFields === 'undefined') {
|
||||
return <span>loading...</span>;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={theme === 'dark' ? style.tableWrapper__dark : style.tableWrapper}
|
||||
data-testid="cuesheet"
|
||||
>
|
||||
<TableHeader handleCSVExport={exportHandler} featureData={featureData} />
|
||||
<OntimeTable
|
||||
tableData={rundown}
|
||||
userFields={userFields}
|
||||
handleUpdate={handleUpdate}
|
||||
selectedId={featureData.selectedEventId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Vitest Snapshot v1
|
||||
|
||||
exports[`makeTable() > returns array of arrays with given fields 1`] = `
|
||||
[
|
||||
[
|
||||
"Ontime · Schedule Template",
|
||||
],
|
||||
[
|
||||
"Event Name",
|
||||
"",
|
||||
],
|
||||
[
|
||||
"Event URL",
|
||||
"",
|
||||
],
|
||||
[],
|
||||
[
|
||||
"Time Start",
|
||||
"Time End",
|
||||
"Event Title",
|
||||
"Presenter Name",
|
||||
"Event Subtitle",
|
||||
"Is Public? (x)",
|
||||
"Notes",
|
||||
"Colour",
|
||||
"user0:test",
|
||||
],
|
||||
[
|
||||
"00:00:00",
|
||||
"00:00:00",
|
||||
"test title 1",
|
||||
"",
|
||||
"",
|
||||
"x",
|
||||
"",
|
||||
"",
|
||||
"test",
|
||||
"test",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
],
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`makeTable() returns array of arrays with given fields 1`] = `
|
||||
Array [
|
||||
Array [
|
||||
"Ontime · Schedule Template",
|
||||
],
|
||||
Array [
|
||||
"Event Name",
|
||||
"",
|
||||
],
|
||||
Array [
|
||||
"Event URL",
|
||||
"",
|
||||
],
|
||||
Array [],
|
||||
Array [
|
||||
"Time Start",
|
||||
"Time End",
|
||||
"Event Title",
|
||||
"Presenter Name",
|
||||
"Event Subtitle",
|
||||
"Is Public? (x)",
|
||||
"Notes",
|
||||
"Colour",
|
||||
"user0:test",
|
||||
],
|
||||
Array [
|
||||
"00:00:00",
|
||||
"00:00:00",
|
||||
"test title 1",
|
||||
"",
|
||||
"",
|
||||
"x",
|
||||
"",
|
||||
"",
|
||||
"test",
|
||||
"test",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
],
|
||||
]
|
||||
`;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { makeCSV, makeTable, parseField } from '../utils';
|
||||
|
||||
describe('parseField()', () => {
|
||||
it('returns a string from given millis on timeStart and TimeEnd', () => {
|
||||
const testData1 = 1000;
|
||||
const testData2 = 60000;
|
||||
expect(parseField('timeStart', testData1)).toBe('00:00:01');
|
||||
expect(parseField('timeEnd', testData2)).toBe('00:01:00');
|
||||
expect(parseField('timeEnd', testData2)).toBe('00:01:00');
|
||||
});
|
||||
|
||||
describe('returns an x when isPublic is truthy, empty string otherwise', () => {
|
||||
const testTruthy = [1, true, 'x', 'test'];
|
||||
const testFalsy = ['', null, undefined, false, 0];
|
||||
|
||||
testTruthy.forEach((value) => {
|
||||
test(`${value}`, () => {
|
||||
expect(parseField('isPublic', value)).toBe('x');
|
||||
});
|
||||
});
|
||||
testFalsy.forEach((value) => {
|
||||
test(`${value}`, () => {
|
||||
expect(parseField('isPublic', value)).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty string on undefined fields', () => {
|
||||
expect(parseField('presenter', undefined)).toBe('');
|
||||
});
|
||||
|
||||
describe('simply returns any other value in any other field', () => {
|
||||
const testFields = [
|
||||
{ field: 'nothing', value: 123 },
|
||||
{ field: 'title', value: 'test' },
|
||||
{ field: 'presenter', value: 'test' },
|
||||
{ field: 'subtitle', value: 'test' },
|
||||
{ field: 'notes', value: 'test' },
|
||||
{ field: 'colour', value: 'test' },
|
||||
{ field: 'user0', value: 'test' },
|
||||
{ field: 'user1', value: 'test' },
|
||||
{ field: 'user2', value: 'test' },
|
||||
{ field: 'user3', value: 'test' },
|
||||
{ field: 'user4', value: 'test' },
|
||||
{ field: 'user5', value: 'test' },
|
||||
{ field: 'user6', value: 'test' },
|
||||
{ field: 'user7', value: 'test' },
|
||||
{ field: 'user8', value: 'test' },
|
||||
{ field: 'user9', value: 'test' },
|
||||
];
|
||||
|
||||
testFields.forEach((testCase) => {
|
||||
test(`${testCase.field}:${testCase.value}`, () => {
|
||||
expect(parseField(testCase.field, testCase.value)).toBe(testCase.value);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeTable()', () => {
|
||||
it('returns array of arrays with given fields', () => {
|
||||
const headerData = {};
|
||||
const tableData = [
|
||||
{
|
||||
title: 'test title 1',
|
||||
presenter: '',
|
||||
timeStart: 0,
|
||||
timeEnd: 0,
|
||||
isPublic: 'x',
|
||||
user0: 'test',
|
||||
user1: 'test',
|
||||
},
|
||||
];
|
||||
const userFields = {
|
||||
user0: 'test',
|
||||
};
|
||||
|
||||
const table = makeTable(headerData, tableData, userFields);
|
||||
expect(table).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('make CSV()', () => {
|
||||
it('joins an array of arrays with commas and newlines', () => {
|
||||
const testdata = [['field'], ['after newline', 'after comma'], ['', 'after empty']];
|
||||
expect(makeCSV(testdata)).toMatchInlineSnapshot(`
|
||||
"data:text/csv;charset=utf-8,field
|
||||
after newline,after comma
|
||||
,after empty
|
||||
"
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { FiCheck } from '@react-icons/all-files/fi/FiCheck';
|
||||
|
||||
import { stringFromMillis } from '../../common/utils/time.js';
|
||||
|
||||
import EditableCell from './tableElements/EditableCell';
|
||||
|
||||
import style from './Table.module.scss';
|
||||
|
||||
/**
|
||||
* React - Table column object
|
||||
* @param sizes
|
||||
* @param userFields
|
||||
*/
|
||||
export const makeColumns = (sizes, userFields) => {
|
||||
return [
|
||||
{
|
||||
Header: 'Public',
|
||||
accessor: 'isPublic',
|
||||
Cell: ({ cell: { value } }) => (value ? <FiCheck className={style.check} /> : ''),
|
||||
width: sizes?.isPublic || 50,
|
||||
},
|
||||
{
|
||||
Header: 'Start',
|
||||
accessor: 'timeStart',
|
||||
Cell: ({ cell: { value, delayed } }) => stringFromMillis(delayed || value),
|
||||
width: sizes?.timeStart || 90,
|
||||
},
|
||||
{
|
||||
Header: 'End',
|
||||
accessor: 'timeEnd',
|
||||
Cell: ({ cell: { value, delayed } }) => stringFromMillis(delayed || value),
|
||||
width: sizes?.timeEnd || 90,
|
||||
},
|
||||
{
|
||||
Header: 'Duration',
|
||||
accessor: 'duration',
|
||||
Cell: ({ cell: { value } }) => stringFromMillis(value),
|
||||
width: sizes?.duration || 90,
|
||||
},
|
||||
{ Header: 'Title', accessor: 'title', width: sizes?.title || 400 },
|
||||
{ Header: 'Subtitle', accessor: 'subtitle', width: sizes?.subtitle || 350 },
|
||||
{ Header: 'Presenter', accessor: 'presenter', width: sizes?.presenter || 250 },
|
||||
{ Header: 'Notes', accessor: 'note', width: sizes?.note || 500 },
|
||||
{
|
||||
Header: userFields.user0 || 'User 0',
|
||||
accessor: 'user0',
|
||||
Cell: EditableCell,
|
||||
width: sizes?.user0 || 200,
|
||||
},
|
||||
{
|
||||
Header: userFields.user1 || 'User 1',
|
||||
accessor: 'user1',
|
||||
Cell: EditableCell,
|
||||
width: sizes?.user1 || 200,
|
||||
},
|
||||
{
|
||||
Header: userFields.user2 || 'User 2',
|
||||
accessor: 'user2',
|
||||
Cell: EditableCell,
|
||||
width: sizes?.user2 || 200,
|
||||
},
|
||||
{
|
||||
Header: userFields.user3 || 'User 3',
|
||||
accessor: 'user3',
|
||||
Cell: EditableCell,
|
||||
width: sizes?.user3 || 200,
|
||||
},
|
||||
{
|
||||
Header: userFields.user4 || 'User 4',
|
||||
accessor: 'user4',
|
||||
Cell: EditableCell,
|
||||
width: sizes?.user4 || 200,
|
||||
},
|
||||
{
|
||||
Header: userFields.user5 || 'User 5',
|
||||
accessor: 'user5',
|
||||
Cell: EditableCell,
|
||||
width: sizes?.user5 || 200,
|
||||
},
|
||||
{
|
||||
Header: userFields.user6 || 'User 6',
|
||||
accessor: 'user6',
|
||||
Cell: EditableCell,
|
||||
width: sizes?.user6 || 200,
|
||||
},
|
||||
{
|
||||
Header: userFields.user7 || 'User 7',
|
||||
accessor: 'user7',
|
||||
Cell: EditableCell,
|
||||
width: sizes?.user7 || 200,
|
||||
},
|
||||
{
|
||||
Header: userFields.user8 || 'User 8',
|
||||
accessor: 'user8',
|
||||
Cell: EditableCell,
|
||||
width: sizes?.user8 || 200,
|
||||
},
|
||||
{
|
||||
Header: userFields.user9 || 'User 9',
|
||||
accessor: 'user9',
|
||||
Cell: EditableCell,
|
||||
width: sizes?.user9 || 200,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @description set default column order
|
||||
*/
|
||||
export const defaultColumnOrder = [
|
||||
'isPublic',
|
||||
'timeStart',
|
||||
'timeEnd',
|
||||
'duration',
|
||||
'title',
|
||||
'subtitle',
|
||||
'presenter',
|
||||
'note',
|
||||
'user0',
|
||||
'user1',
|
||||
'user2',
|
||||
'user3',
|
||||
'user4',
|
||||
'user5',
|
||||
'user6',
|
||||
'user7',
|
||||
'user8',
|
||||
'user9',
|
||||
];
|
||||
|
||||
/**
|
||||
* @description set default hidden columns
|
||||
*/
|
||||
export const defaultHiddenColumns = [
|
||||
'user0',
|
||||
'user1',
|
||||
'user2',
|
||||
'user3',
|
||||
'user4',
|
||||
'user5',
|
||||
'user6',
|
||||
'user7',
|
||||
'user8',
|
||||
'user9',
|
||||
];
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { AutoTextArea } from '@/common/components/input/auto-text-area/AutoTextArea';
|
||||
import { TableSettingsContext } from '@/common/context/TableSettingsContext';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* Shamelessly copied from react-table docs
|
||||
* Plugged into chakra-ui editable component
|
||||
* @description Custom editable field for table component
|
||||
* @param props
|
||||
* @return {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
export default function EditableCell(props) {
|
||||
const {
|
||||
value: initialValue,
|
||||
row: { index },
|
||||
column: { id },
|
||||
handleUpdate,
|
||||
} = props;
|
||||
const { theme } = useContext(TableSettingsContext);
|
||||
|
||||
// We need to keep and update the state of the cell normally
|
||||
const [value, setValue] = useState(initialValue);
|
||||
|
||||
const onChange = useCallback((e) => setValue(e.target.value), []);
|
||||
|
||||
// We'll only update the external data when the input is blurred
|
||||
const onBlur = useCallback(() => handleUpdate(index, id, value), [handleUpdate, id, index, value]);
|
||||
|
||||
|
||||
// If the initialValue is changed external, sync it up with our state
|
||||
useEffect(() => {
|
||||
setValue(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
return (
|
||||
<AutoTextArea
|
||||
size='sm'
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
rows={3}
|
||||
transition='none'
|
||||
spellCheck={false}
|
||||
isDark={theme === "dark"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
EditableCell.propTypes = {
|
||||
value: PropTypes.string,
|
||||
row: PropTypes.object,
|
||||
column: PropTypes.object,
|
||||
handleUpdate: PropTypes.func,
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { IoPause } from '@react-icons/all-files/io5/IoPause';
|
||||
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
|
||||
import { IoStop } from '@react-icons/all-files/io5/IoStop';
|
||||
import { IoTimeOutline } from '@react-icons/all-files/io5/IoTimeOutline';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
|
||||
export default function PlaybackIcon(props) {
|
||||
const { state } = props;
|
||||
|
||||
if (state === 'stop') {
|
||||
return (
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Timer Stopped' shouldWrapChildren>
|
||||
<IoStop />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'start') {
|
||||
return (
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Timer Playing' shouldWrapChildren>
|
||||
<IoPlay />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'pause') {
|
||||
return (
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Timer Paused' shouldWrapChildren>
|
||||
<IoPause />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'roll') {
|
||||
return (
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Timer Rolling' shouldWrapChildren>
|
||||
<IoTimeOutline />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
PlaybackIcon.propTypes = {
|
||||
state: PropTypes.string,
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
|
||||
import styles from '../Table.module.scss';
|
||||
|
||||
export default function SortableCell({ column }) {
|
||||
const { style, ...restColumn } = column.getHeaderProps();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: column.id,
|
||||
});
|
||||
|
||||
// prevent scaling on drag
|
||||
const cssTransform = {
|
||||
...transform,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
}
|
||||
|
||||
// build drag styles
|
||||
const dragStyle = {
|
||||
transform: CSS.Transform.toString(cssTransform),
|
||||
transition,
|
||||
...style,
|
||||
};
|
||||
|
||||
return (
|
||||
<th {...restColumn} ref={setNodeRef} style={{...dragStyle}} className={isDragging ? styles.dragging: ''}>
|
||||
<div {...attributes} {...listeners}>
|
||||
<Tooltip label={column.Header} openDelay={tooltipDelayFast}>
|
||||
{column.render('Header')}
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div {...column.getResizerProps()} className={styles.resizer} />
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
SortableCell.propTypes = {
|
||||
column: PropTypes.object.isRequired,
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import style from '../Table.module.scss';
|
||||
|
||||
// reusable button styles
|
||||
const buttonProps = {
|
||||
colorScheme: 'blue',
|
||||
size: 'sm',
|
||||
variant: 'ghost',
|
||||
};
|
||||
|
||||
export default function TableSettings(props) {
|
||||
const {
|
||||
columns,
|
||||
handleResetResizing,
|
||||
handleResetReordering,
|
||||
handleResetToggles,
|
||||
handleClearToggles,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className={style.tableSettings}>
|
||||
<div className={style.hSeparator}>Select and order fields to show in table</div>
|
||||
<div className={style.options}>
|
||||
{columns.map((column) => (
|
||||
<label key={column.id}>
|
||||
<input type='checkbox' {...column.getToggleHiddenProps()} /> {column.Header}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className={style.buttonRow}>
|
||||
<Button onClick={handleResetResizing} {...buttonProps}>
|
||||
Reset Resizing
|
||||
</Button>
|
||||
<Button onClick={handleResetReordering} {...buttonProps}>
|
||||
Reset Reordering
|
||||
</Button>
|
||||
<Button onClick={handleResetToggles} {...buttonProps}>
|
||||
Reset Toggles
|
||||
</Button>
|
||||
<Button onClick={handleClearToggles} {...buttonProps}>
|
||||
Show All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TableSettings.propTypes = {
|
||||
columns: PropTypes.array,
|
||||
handleResetResizing: PropTypes.func.isRequired,
|
||||
handleResetReordering: PropTypes.func.isRequired,
|
||||
handleResetToggles: PropTypes.func.isRequired,
|
||||
handleClearToggles: PropTypes.func.isRequired,
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import style from '../Table.module.scss';
|
||||
|
||||
export default function BlockRow(props) {
|
||||
const { row } = props;
|
||||
return (
|
||||
<tr {...row.getRowProps()}>
|
||||
<td className={style.blockCell}>Delay Block</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
BlockRow.propTypes = {
|
||||
row: PropTypes.object.isRequired,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { millisToMinutes } from '../../../common/utils/dateConfig';
|
||||
|
||||
import style from '../Table.module.scss';
|
||||
|
||||
export default function DelayRow(props) {
|
||||
const { row } = props;
|
||||
const delayVal = row.original.duration;
|
||||
const minutesDelayed = Math.abs(millisToMinutes(delayVal));
|
||||
const labelText = `${minutesDelayed} minutes ${delayVal >= 0 ? 'delayed' : 'ahead'}`;
|
||||
|
||||
return (
|
||||
<tr {...row.getRowProps()}>
|
||||
<td className={style.delayCell}>{labelText}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
DelayRow.propTypes = {
|
||||
row: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
|
||||
import style from '../Table.module.scss';
|
||||
|
||||
export default function EventRow(props) {
|
||||
const { row, index, selectedId, delay } = props;
|
||||
const selected = row.original.id === selectedId;
|
||||
|
||||
const colours = row.original.colour
|
||||
? getAccessibleColour(row.original.colour)
|
||||
: {};
|
||||
|
||||
return (
|
||||
<tr {...row.getRowProps()} className={selected ? style.selected : ''} id={row.original.id}>
|
||||
<td className={style.indexColumn}>{index}</td>
|
||||
{row.cells.map((cell) => {
|
||||
const { key, style, ...restCellProps } = cell.getCellProps();
|
||||
const dynamicStyles = { ...style, ...colours };
|
||||
|
||||
|
||||
// Inject delay value if exits
|
||||
if (delay !== 0 && delay != null) {
|
||||
const col = cell.column.Header;
|
||||
if (col === 'End' || col === 'Start') {
|
||||
cell.delayed = cell.value + delay;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<td key={key} style={{ ...dynamicStyles }} {...restCellProps}>
|
||||
{cell.render('Cell')}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
EventRow.propTypes = {
|
||||
row: PropTypes.object.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
selectedId: PropTypes.string,
|
||||
delay: PropTypes.number,
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import { stringify } from 'csv-stringify/browser/esm/sync';
|
||||
|
||||
/**
|
||||
* @description parses a field for export
|
||||
* @param {string} field
|
||||
* @param {*} data
|
||||
* @return {string}
|
||||
*/
|
||||
import { stringFromMillis } from '../../common/utils/time';
|
||||
|
||||
export const parseField = (field, data) => {
|
||||
let val;
|
||||
switch (field) {
|
||||
case 'timeStart':
|
||||
case 'timeEnd':
|
||||
val = stringFromMillis(data);
|
||||
break;
|
||||
case 'isPublic':
|
||||
val = data ? 'x' : '';
|
||||
break;
|
||||
default:
|
||||
val = data;
|
||||
break;
|
||||
}
|
||||
if (typeof data === 'undefined') {
|
||||
return ''
|
||||
}
|
||||
return val;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Creates an array of arrays usable by xlsx for export
|
||||
* @param {object} headerData
|
||||
* @param {array} tableData
|
||||
* @param {object} userFields
|
||||
* @return {(string[])[]}
|
||||
*/
|
||||
export const makeTable = (headerData, tableData, userFields) => {
|
||||
const data = [
|
||||
['Ontime · Schedule Template'],
|
||||
['Event Name', headerData?.title || ''],
|
||||
['Event URL', headerData?.url || ''],
|
||||
[],
|
||||
];
|
||||
|
||||
const fieldOrder = [
|
||||
'timeStart',
|
||||
'timeEnd',
|
||||
'title',
|
||||
'presenter',
|
||||
'subtitle',
|
||||
'isPublic',
|
||||
'notes',
|
||||
'colour',
|
||||
'user0',
|
||||
'user1',
|
||||
'user2',
|
||||
'user3',
|
||||
'user4',
|
||||
'user5',
|
||||
'user6',
|
||||
'user7',
|
||||
'user8',
|
||||
'user9',
|
||||
];
|
||||
|
||||
const fieldTitles = [
|
||||
'Time Start',
|
||||
'Time End',
|
||||
'Event Title',
|
||||
'Presenter Name',
|
||||
'Event Subtitle',
|
||||
'Is Public? (x)',
|
||||
'Notes',
|
||||
'Colour',
|
||||
];
|
||||
|
||||
for (const field in userFields) {
|
||||
const fieldValue = userFields[field];
|
||||
const displayName = `${field}${
|
||||
fieldValue !== field && fieldValue !== '' ? `:${fieldValue}` : ''
|
||||
}`;
|
||||
fieldTitles.push(displayName);
|
||||
}
|
||||
|
||||
data.push(fieldTitles);
|
||||
|
||||
tableData.forEach((entry) => {
|
||||
const row = [];
|
||||
fieldOrder.forEach((field) => row.push(parseField(field, entry[field])));
|
||||
data.push(row);
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Converts an array of arrays to a csv file
|
||||
* @param {array[]} arrayOfArrays
|
||||
* @return {string}
|
||||
*/
|
||||
export const makeCSV = (arrayOfArrays) => {
|
||||
let csvData = 'data:text/csv;charset=utf-8,';
|
||||
const stringifiedData = stringify(arrayOfArrays);
|
||||
return csvData + stringifiedData;
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
/* eslint-disable react/display-name */
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useMessageControl } from '../../common/hooks/useSocket';
|
||||
import useSubscription from '../../common/hooks/useSubscription';
|
||||
import useEvent from '../../common/hooks-query/useEvent';
|
||||
import useRundown from '../../common/hooks-query/useRundown';
|
||||
import useViewSettings from '../../common/hooks-query/useViewSettings';
|
||||
import socket from '../../common/utils/socket';
|
||||
|
||||
const withSocket = (Component) => {
|
||||
return (props) => {
|
||||
const { data: eventsData } = useRundown();
|
||||
const { data: genData } = useEvent();
|
||||
const { data: viewSettings } = useViewSettings();
|
||||
const { data: messages } = useMessageControl();
|
||||
|
||||
const [publicSelectedId, setPublicSelectedId] = useState(null);
|
||||
|
||||
const [timer] = useSubscription('ontime-timer', {
|
||||
clock: null,
|
||||
current: null,
|
||||
elapsed: null ,
|
||||
expectedFinish: null,
|
||||
addedTime: 0,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
secondaryTimer: null,
|
||||
});
|
||||
const [titles] = useSubscription('titles', {
|
||||
titleNow: '',
|
||||
subtitleNow: '',
|
||||
presenterNow: '',
|
||||
titleNext: '',
|
||||
subtitleNext: '',
|
||||
presenterNext: '',
|
||||
});
|
||||
const [publicTitles] = useSubscription('publictitles', {
|
||||
titleNow: '',
|
||||
subtitleNow: '',
|
||||
presenterNow: '',
|
||||
titleNext: '',
|
||||
subtitleNext: '',
|
||||
presenterNext: '',
|
||||
});
|
||||
const [selectedId] = useSubscription('selected-id', null);
|
||||
const [nextId] = useSubscription('next-id', null);
|
||||
const [playback] = useSubscription('playback', null);
|
||||
|
||||
// Ask for update on load
|
||||
useEffect(() => {
|
||||
// todo: remove
|
||||
socket.on('publicselected-id', (data) => {
|
||||
setPublicSelectedId(data);
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
const publicEvents = useMemo(() => {
|
||||
if (Array.isArray(eventsData)) {
|
||||
return eventsData.filter((d) => d.type === 'event' && d.title !== '' && d.isPublic);
|
||||
}
|
||||
return [];
|
||||
}, [eventsData]);
|
||||
|
||||
/********************************************/
|
||||
/*** + titleManager ***/
|
||||
/*** WRAP INFORMATION RELATED TO TITLES ***/
|
||||
/*** ---------------------------------- ***/
|
||||
/********************************************/
|
||||
// is there a now field?
|
||||
let showNow = true;
|
||||
if (!titles.titleNow && !titles.subtitleNow && !titles.presenterNow) showNow = false;
|
||||
|
||||
// is there a next field?
|
||||
let showNext = true;
|
||||
if (!titles.titleNext && !titles.subtitleNext && !titles.presenterNext) showNext = false;
|
||||
|
||||
const titleManager = { ...titles, showNow: showNow, showNext: showNext };
|
||||
|
||||
/********************************************/
|
||||
/*** + publicTitleManager ***/
|
||||
/*** WRAP INFORMATION RELATED TO TITLES ***/
|
||||
/*** ---------------------------------- ***/
|
||||
/********************************************/
|
||||
// is there a now field?
|
||||
let showPublicNow = true;
|
||||
if (!publicTitles.titleNow && !publicTitles.subtitleNow && !publicTitles.presenterNow)
|
||||
showPublicNow = false;
|
||||
|
||||
// is there a next field?
|
||||
let showPublicNext = true;
|
||||
if (!publicTitles.titleNext && !publicTitles.subtitleNext && !publicTitles.presenterNext)
|
||||
showPublicNext = false;
|
||||
|
||||
const publicTitleManager = {
|
||||
...publicTitles,
|
||||
showNow: showPublicNow,
|
||||
showNext: showPublicNext,
|
||||
};
|
||||
|
||||
/******************************************/
|
||||
/*** + TimeManagerType ***/
|
||||
/*** WRAP INFORMATION RELATED TO TIME ***/
|
||||
/*** -------------------------------- ***/
|
||||
/******************************************/
|
||||
|
||||
// inject info:
|
||||
// is timer finished
|
||||
// get clock string
|
||||
const TimeManagerType = {
|
||||
...timer,
|
||||
finished: playback === 'play' && timer.current < 0 && timer.startedAt,
|
||||
playback,
|
||||
};
|
||||
|
||||
// prevent render until we get all the data we need
|
||||
if (!viewSettings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Component.displayName = 'ComponentWithData';
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
pres={messages.presenter}
|
||||
publ={messages.public}
|
||||
lower={messages.lower}
|
||||
title={titleManager}
|
||||
publicTitle={publicTitleManager}
|
||||
time={TimeManagerType}
|
||||
events={publicEvents}
|
||||
backstageEvents={eventsData}
|
||||
selectedId={selectedId}
|
||||
publicSelectedId={publicSelectedId}
|
||||
viewSettings={viewSettings}
|
||||
nextId={nextId}
|
||||
general={genData}
|
||||
onAir={messages.onAir}
|
||||
/>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export default withSocket;
|
||||
@@ -0,0 +1,149 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
.backstage {
|
||||
margin: 0;
|
||||
box-sizing: border-box; /* reset */
|
||||
overflow: hidden;
|
||||
width: 100%; /* restrict the page width to viewport */
|
||||
height: 100vh;
|
||||
|
||||
font-family: var(--font-family-override, $viewer-font-family);
|
||||
background: var(--background-color-override, $viewer-background-color);
|
||||
color: var(--color-override, $viewer-color);
|
||||
gap: min(2vh, 16px);
|
||||
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 40vw;
|
||||
grid-template-rows: auto 12px 1fr auto;
|
||||
grid-template-areas:
|
||||
' header header header'
|
||||
' progress progress schedule-nav'
|
||||
' now now schedule'
|
||||
' message message info';
|
||||
|
||||
/* =================== HEADER + EXTRAS ===================*/
|
||||
|
||||
.event-header {
|
||||
grid-area: header;
|
||||
font-size: clamp(32px, 4.5vw, 64px);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.clock-container {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.public-container {
|
||||
grid-area: message;
|
||||
|
||||
&--hidden {
|
||||
opacity: 0;
|
||||
transition: $viewer-transition-time;
|
||||
transition-property: opacity;
|
||||
}
|
||||
}
|
||||
|
||||
.clock-container,
|
||||
.public-container {
|
||||
.label {
|
||||
font-size: clamp(16px, 1.5vw, 24px);
|
||||
font-weight: 600;
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: clamp(32px, 3.5vw, 50px);
|
||||
font-weight: 600;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: clamp(16px, 1.5vw, 24px);
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
/* =================== MAIN - NOW ===================*/
|
||||
|
||||
.progress-container {
|
||||
grid-area: progress;
|
||||
width: 100%;
|
||||
margin: 0 auto -8px;
|
||||
}
|
||||
|
||||
.now-container {
|
||||
grid-area: now;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: min(2vh, 16px);
|
||||
}
|
||||
|
||||
.event {
|
||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
padding: 16px 24px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.timer-group {
|
||||
grid-area: timer;
|
||||
border-top: 2px solid var(--background-color-override, $viewer-background-color);
|
||||
margin-top: 2em;
|
||||
padding-top: 1em;
|
||||
display: flex;
|
||||
gap: 7.5em;
|
||||
}
|
||||
|
||||
.aux-timers {
|
||||
font-size: max(1vw, 16px);
|
||||
|
||||
&__label {
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__value {
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
font-size: clamp(24px, 2vw, 32px);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
}
|
||||
|
||||
/* =================== MAIN - SCHEDULE ===================*/
|
||||
|
||||
.schedule-container {
|
||||
grid-area: schedule;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.schedule-nav-container {
|
||||
grid-area: schedule-nav;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.info {
|
||||
grid-area: info;
|
||||
display: flex;
|
||||
gap: max(1vw, 16px);
|
||||
|
||||
&__message {
|
||||
font-size: clamp(16px, 1.5vw, 24px);
|
||||
line-height: 1.3em;
|
||||
white-space: pre-line;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.qr {
|
||||
margin-left: auto;
|
||||
padding: 4px;
|
||||
background-color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useEffect } from 'react';
|
||||
import QRCode from 'react-qr-code';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useAtom } from 'jotai';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/apiConstants';
|
||||
import { mirrorViewersAtom } from '../../../common/atoms/ViewerSettings';
|
||||
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
||||
import ProgressBar from '../../../common/components/progress-bar/ProgressBar';
|
||||
import Schedule from '../../../common/components/schedule/Schedule';
|
||||
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
||||
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { formatDisplay, millisToSeconds } from '../../../common/utils/dateConfig';
|
||||
import { getEventsWithDelay } from '../../../common/utils/eventsManager';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
import { titleVariants } from '../common/animation';
|
||||
|
||||
import './Backstage.scss';
|
||||
|
||||
const formatOptions = {
|
||||
showSeconds: true,
|
||||
format: 'hh:mm:ss a',
|
||||
};
|
||||
|
||||
Backstage.propTypes = {
|
||||
publ: PropTypes.object,
|
||||
title: PropTypes.object,
|
||||
time: PropTypes.object,
|
||||
backstageEvents: PropTypes.array,
|
||||
selectedId: PropTypes.string,
|
||||
general: PropTypes.object,
|
||||
viewSettings: PropTypes.object,
|
||||
};
|
||||
|
||||
// @ts-expect-error unable to type just yet
|
||||
export default function Backstage(props) {
|
||||
const { publ, title, time, backstageEvents, selectedId, general, viewSettings } = props;
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const [isMirrored] = useAtom(mirrorViewersAtom);
|
||||
|
||||
// Set window title
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Backstage Screen';
|
||||
}, []);
|
||||
|
||||
// defer rendering until we load stylesheets
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clock = formatTime(time.clock, formatOptions);
|
||||
const startedAt = formatTime(time.startedAt, formatOptions);
|
||||
const isNegative = (time.current ?? 0) < 0;
|
||||
const expectedFinish = isNegative ? 'In overtime' : formatTime(time.expectedFinish, formatOptions);
|
||||
|
||||
const qrSize = Math.max(window.innerWidth / 15, 128);
|
||||
const filteredEvents = getEventsWithDelay(backstageEvents);
|
||||
const showPublicMessage = publ.text && publ.visible;
|
||||
const showProgress = time.playback !== 'stop';
|
||||
|
||||
let stageTimer;
|
||||
if (time.current === null) {
|
||||
stageTimer = '- - : - -';
|
||||
} else {
|
||||
stageTimer = formatDisplay(Math.abs(millisToSeconds(time.current)), true);
|
||||
if (isNegative) {
|
||||
stageTimer = `-${stageTimer}`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
|
||||
<NavigationMenu />
|
||||
|
||||
<div className='event-header'>
|
||||
{general.title}
|
||||
<div className='clock-container'>
|
||||
<div className='label'>Time Now</div>
|
||||
<div className='time'>{clock}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProgressBar
|
||||
className='progress-container'
|
||||
now={time.current}
|
||||
complete={time.duration}
|
||||
hidden={!showProgress}
|
||||
/>
|
||||
|
||||
<div className='now-container'>
|
||||
<AnimatePresence>
|
||||
{title.showNow && (
|
||||
<motion.div
|
||||
className='event now'
|
||||
key='now'
|
||||
variants={titleVariants}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
>
|
||||
<TitleCard
|
||||
label='now'
|
||||
title={title.titleNow}
|
||||
subtitle={title.subtitleNow}
|
||||
presenter={title.presenterNow}
|
||||
/>
|
||||
<div className='timer-group'>
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>Started At</div>
|
||||
<div className='aux-timers__value'>{startedAt}</div>
|
||||
</div>
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>Expected Finish</div>
|
||||
<div className='aux-timers__value'>{expectedFinish}</div>
|
||||
</div>
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>Stage Timer</div>
|
||||
<div className='aux-timers__value'>{stageTimer}</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{title.showNext && (
|
||||
<motion.div
|
||||
className='event next'
|
||||
key='next'
|
||||
variants={titleVariants}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
>
|
||||
<TitleCard
|
||||
label='next'
|
||||
title={title.titleNext}
|
||||
subtitle={title.subtitleNext}
|
||||
presenter={title.presenterNext}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<ScheduleProvider
|
||||
events={filteredEvents}
|
||||
selectedEventId={selectedId}
|
||||
isBackstage
|
||||
>
|
||||
<ScheduleNav className='schedule-nav-container' />
|
||||
<Schedule className='schedule-container' />
|
||||
</ScheduleProvider>
|
||||
|
||||
<div
|
||||
className={showPublicMessage ? 'public-container' : 'public-container public-container--hidden'}>
|
||||
<div className='label'>Public message</div>
|
||||
<div className='message'>{publ.text}</div>
|
||||
</div>
|
||||
|
||||
<div className='info'>
|
||||
<div className='qr'>
|
||||
{general.url != null && general.url !== '' && (
|
||||
<QRCode value={general.url} size={qrSize} level='L' />
|
||||
)}
|
||||
</div>
|
||||
{general.backstageInfo && (
|
||||
<div className='info__message'>{general.backstageInfo}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
.clock-view {
|
||||
margin: 0;
|
||||
box-sizing: border-box; /* reset */
|
||||
overflow: hidden;
|
||||
width: 100%; /* restrict the page width to viewport */
|
||||
height: 100vh;
|
||||
|
||||
background: var(--background-color-override, $viewer-background-color);
|
||||
color: var(--color-override, $viewer-color);
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
.clock {
|
||||
font-family: var(--font-family-bold-override, $timer-bold-font-family) ;
|
||||
font-size: 20vw;
|
||||
position: relative;
|
||||
color: var(--timer-color-override, $timer-color);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useAtom } from 'jotai';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/apiConstants';
|
||||
import { mirrorViewersAtom } from '../../../common/atoms/ViewerSettings';
|
||||
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||
import { ViewSettingsType } from '../../../common/models/ViewSettings.type';
|
||||
import { OverridableOptions } from '../../../common/models/ViewTypes';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
|
||||
import './Clock.scss';
|
||||
|
||||
interface ClockProps {
|
||||
time: TimeManagerType;
|
||||
viewSettings: ViewSettingsType;
|
||||
}
|
||||
|
||||
const formatOptions = {
|
||||
showSeconds: true,
|
||||
format: 'hh:mm:ss a',
|
||||
};
|
||||
|
||||
export default function Clock(props: ClockProps) {
|
||||
const { time, viewSettings } = props;
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const [searchParams] = useSearchParams();
|
||||
const [isMirrored] = useAtom(mirrorViewersAtom);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Clock';
|
||||
}, []);
|
||||
|
||||
// defer rendering until we load stylesheets
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// get config from url: key, text, font, size, hidenav, hideovertime
|
||||
// eg. http://localhost:3000/minimal?key=f00&text=fff
|
||||
// Check for user options
|
||||
const userOptions: OverridableOptions = {
|
||||
size: 1,
|
||||
};
|
||||
|
||||
// key: string
|
||||
// Should be a hex string '#00FF00' with key colour
|
||||
const key = searchParams.get('key');
|
||||
if (key) {
|
||||
userOptions.keyColour = `#${key}`;
|
||||
}
|
||||
|
||||
// textColour: string
|
||||
// Should be a hex string '#ffffff'
|
||||
const textColour = searchParams.get('text');
|
||||
if (textColour) {
|
||||
userOptions.textColour = `#${textColour}`;
|
||||
}
|
||||
|
||||
// textBackground: string
|
||||
// Should be a hex string '#ffffff'
|
||||
const textBackground = searchParams.get('textbg');
|
||||
if (textBackground) {
|
||||
userOptions.textBackground = `#${textBackground}`;
|
||||
}
|
||||
|
||||
// font: string
|
||||
// Should be a string with a font name 'arial'
|
||||
const font = searchParams.get('font');
|
||||
if (font) {
|
||||
userOptions.font = font;
|
||||
}
|
||||
|
||||
// size: multiplier
|
||||
// Should be a number 0.0-n
|
||||
const size = searchParams.get('size');
|
||||
if (size !== null && typeof size !== 'undefined') {
|
||||
if (!Number.isNaN(Number(size))) {
|
||||
userOptions.size = Number(size);
|
||||
}
|
||||
}
|
||||
|
||||
// alignX: flex justification
|
||||
// start | center | end
|
||||
const alignX = searchParams.get('alignx');
|
||||
if (alignX) {
|
||||
if (alignX === 'start' || alignX === 'center' || alignX === 'end') {
|
||||
userOptions.justifyContent = alignX;
|
||||
}
|
||||
}
|
||||
|
||||
// alignX: flex alignment
|
||||
// start | center | end
|
||||
const alignY = searchParams.get('aligny');
|
||||
if (alignY) {
|
||||
if (alignY === 'start' || alignY === 'center' || alignY === 'end') {
|
||||
userOptions.alignItems = alignY;
|
||||
}
|
||||
}
|
||||
|
||||
// offsetX: position in pixels
|
||||
// Should be a number 0 - 1920
|
||||
const offsetX = searchParams.get('offsetx');
|
||||
if (offsetX) {
|
||||
const pixels = Number(offsetX);
|
||||
if (!isNaN(pixels)) {
|
||||
userOptions.left = `${pixels}px`;
|
||||
}
|
||||
}
|
||||
|
||||
// offsetX: position in pixels
|
||||
// Should be a number 0 - 1920
|
||||
const offsetY = searchParams.get('offsety');
|
||||
if (offsetY) {
|
||||
const pixels = Number(offsetY);
|
||||
if (!isNaN(pixels)) {
|
||||
userOptions.top = `${pixels}px`;
|
||||
}
|
||||
}
|
||||
|
||||
const clock = formatTime(time.clock, formatOptions);
|
||||
const clean = clock.replace('/:/g', '');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`clock-view ${isMirrored ? 'mirror' : ''}`}
|
||||
style={{
|
||||
backgroundColor: userOptions.keyColour,
|
||||
color: userOptions.textColour,
|
||||
justifyContent: userOptions.justifyContent,
|
||||
alignItems: userOptions.alignItems,
|
||||
}}
|
||||
data-testid='clock-view'
|
||||
>
|
||||
<NavigationMenu />
|
||||
<div
|
||||
className='clock'
|
||||
style={{
|
||||
fontSize: `${(89 / (clean.length - 1)) * (userOptions.size || 1)}vw`,
|
||||
fontFamily: userOptions.font,
|
||||
top: userOptions.top,
|
||||
left: userOptions.left,
|
||||
backgroundColor: userOptions.textBackground,
|
||||
}}
|
||||
>
|
||||
{clock}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// used in both sm and public views
|
||||
export const titleVariants = {
|
||||
hidden: {
|
||||
x: -1500,
|
||||
},
|
||||
visible: {
|
||||
x: 0,
|
||||
transition: {
|
||||
duration: 1,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
x: -1500,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
.countdown {
|
||||
margin: 0;
|
||||
box-sizing: border-box; /* reset */
|
||||
overflow: hidden;
|
||||
width: 100%; /* restrict the page width to viewport */
|
||||
height: 100vh;
|
||||
|
||||
font-family: var(--font-family-override, $viewer-font-family);
|
||||
background: var(--background-color-override, $viewer-background-color);
|
||||
color: var(--color-override, $viewer-color);
|
||||
|
||||
/* =================== MAIN - SELECT ===================*/
|
||||
|
||||
.event-select {
|
||||
display: flex;
|
||||
margin-top: 8vh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
|
||||
&__title {
|
||||
font-size: clamp(24px, 2vw, 32px);
|
||||
}
|
||||
|
||||
&__events {
|
||||
font-size: clamp(16px, 1.5vw, 24px);
|
||||
margin-top: 1em;
|
||||
overflow-y: auto;
|
||||
height: 70vh;
|
||||
width: 60vw;
|
||||
}
|
||||
}
|
||||
|
||||
/* =================== MAIN - EVENT CONTAINER ===================*/
|
||||
|
||||
.countdown-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
gap: min(2vh, 16px);
|
||||
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
|
||||
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto auto 1fr;
|
||||
grid-template-columns: 100%;
|
||||
grid-template-areas:
|
||||
'header'
|
||||
'status'
|
||||
'clock'
|
||||
'title'
|
||||
'timers';
|
||||
|
||||
/* =================== HEADER + EXTRAS ===================*/
|
||||
|
||||
.clock-container {
|
||||
grid-area: header;
|
||||
margin-left: auto;
|
||||
font-weight: 600;
|
||||
|
||||
.label {
|
||||
font-size: clamp(16px, 1.5vw, 24px);
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: clamp(32px, 3.5vw, 50px);
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
grid-area: status;
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
font-size: clamp(32px, 3.5vw, 50px);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* =================== TIMER + TITLE ===================*/
|
||||
|
||||
.timer {
|
||||
grid-area: clock;
|
||||
|
||||
font-family: var(--font-family-override, $viewer-font-family);
|
||||
color: var(--timer-color-override, $timer-color);
|
||||
font-size: 15vw;
|
||||
line-height: 0.9em;
|
||||
text-align: center;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
opacity: 1;
|
||||
|
||||
&--paused {
|
||||
opacity: $viewer-opacity-disabled;
|
||||
}
|
||||
|
||||
&--finished {
|
||||
color: $timer-finished-color;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: title;
|
||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
padding: 16px 24px;
|
||||
border-radius: 8px;
|
||||
|
||||
font-weight: 600;
|
||||
font-size: clamp(40px, 4.5vw, 80px);
|
||||
color: var(--accent-color-override, $accent-color);
|
||||
line-height: 1.1em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* =================== FOOTER TIMERS ===================*/
|
||||
|
||||
.timer-group {
|
||||
grid-area: timers;
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
align-items: flex-end;
|
||||
|
||||
.aux-timers {
|
||||
text-align: center;
|
||||
font-size: clamp(24px, 1.75vw, 32px);
|
||||
|
||||
&__label {
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: clamp(32px, 3.5vw, 50px);
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
&--delayed {
|
||||
color: $delay-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useAtom } from 'jotai';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/apiConstants';
|
||||
import { mirrorViewersAtom } from '../../../common/atoms/ViewerSettings';
|
||||
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { OntimeEvent, OntimeRundownEntry, SupportedEvent } from '../../../common/models/EventTypes';
|
||||
import { formatDisplay, millisToSeconds } from '../../../common/utils/dateConfig';
|
||||
import getDelayTo from '../../../common/utils/getDelayTo';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
|
||||
import { fetchTimerData, TimerMessage } from './countdown.helpers';
|
||||
import CountdownSelect from './CountdownSelect';
|
||||
|
||||
import './Countdown.scss';
|
||||
|
||||
const formatOptions = {
|
||||
showSeconds: true,
|
||||
format: 'hh:mm:ss a',
|
||||
};
|
||||
|
||||
const formatOptionsFinished = {
|
||||
showSeconds: false,
|
||||
format: 'hh:mm a',
|
||||
};
|
||||
|
||||
Countdown.propTypes = {
|
||||
backstageEvents: PropTypes.array,
|
||||
time: PropTypes.object,
|
||||
selectedId: PropTypes.string,
|
||||
viewSettings: PropTypes.object,
|
||||
};
|
||||
|
||||
// @ts-expect-error we are unable to type this just yet
|
||||
export default function Countdown(props) {
|
||||
const { backstageEvents, time, selectedId, viewSettings } = props;
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const [searchParams] = useSearchParams();
|
||||
const [isMirrored] = useAtom(mirrorViewersAtom);
|
||||
|
||||
const [follow, setFollow] = useState<OntimeEvent | null>(null);
|
||||
const [runningTimer, setRunningTimer] = useState(0);
|
||||
const [runningMessage, setRunningMessage] = useState<TimerMessage>(TimerMessage.unhandled);
|
||||
const [delay, setDelay] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Countdown';
|
||||
}, []);
|
||||
|
||||
// eg. http://localhost:4001/countdown?eventId=ei0us
|
||||
// Check for user options
|
||||
useEffect(() => {
|
||||
if (!backstageEvents) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventId = searchParams.get('eventid');
|
||||
const eventIndex = searchParams.get('event');
|
||||
|
||||
let followThis: OntimeEvent | null = null;
|
||||
const events: OntimeEvent[] = [...backstageEvents].filter((event) => event.type === SupportedEvent.Event);
|
||||
|
||||
if (eventId !== null) {
|
||||
followThis = events.find((event) => event.id === eventId) || null;
|
||||
} else if (eventIndex !== null) {
|
||||
followThis = events?.[Number(eventIndex) - 1];
|
||||
}
|
||||
if (followThis !== null) {
|
||||
setFollow(followThis);
|
||||
const idx: number = backstageEvents.findIndex((event: OntimeRundownEntry) => event.id === followThis?.id);
|
||||
const delayToEvent = getDelayTo(backstageEvents, idx);
|
||||
setDelay(delayToEvent);
|
||||
}
|
||||
}, [backstageEvents, searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!follow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { message, timer } = fetchTimerData(time, follow, selectedId);
|
||||
setRunningMessage(message);
|
||||
setRunningTimer(timer);
|
||||
}, [follow, selectedId, time]);
|
||||
|
||||
// defer rendering until we load stylesheets
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const standby = time.playback !== 'play' && selectedId === follow?.id;
|
||||
const isRunningFinished = time.finished && runningMessage === TimerMessage.running;
|
||||
const isSelected = runningMessage === TimerMessage.running;
|
||||
const delayedTimerStyles = delay > 0 ? 'aux-timers__value--delayed' : '';
|
||||
|
||||
const clock = formatTime(time.clock, formatOptions);
|
||||
const startTime =
|
||||
follow === null
|
||||
? '...'
|
||||
: formatTime(follow.timeStart + delay, formatOptions);
|
||||
const endTime =
|
||||
follow === null
|
||||
? '...'
|
||||
: formatTime(follow.timeEnd + delay, formatOptions);
|
||||
const formattedTimer = runningMessage === TimerMessage.ended
|
||||
? formatTime(runningTimer, formatOptionsFinished)
|
||||
: formatDisplay(
|
||||
isSelected ? millisToSeconds(runningTimer) : millisToSeconds(runningTimer + delay),
|
||||
isSelected || time.waiting,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
|
||||
<NavigationMenu />
|
||||
{follow === null ? (
|
||||
<CountdownSelect events={backstageEvents} />
|
||||
) : (
|
||||
<div className='countdown-container' data-testid='countdown-event'>
|
||||
|
||||
<div className='clock-container'>
|
||||
<div className='label'>Time Now</div>
|
||||
<div className='time'>{clock}</div>
|
||||
</div>
|
||||
|
||||
<div className='status'>{runningMessage}</div>
|
||||
|
||||
<span
|
||||
className={`timer ${standby ? 'timer--paused' : ''} ${
|
||||
isRunningFinished ? 'timer--finished' : ''
|
||||
}`}
|
||||
>
|
||||
{formattedTimer}
|
||||
</span>
|
||||
<div className='title'>{follow?.title || 'Untitled Event'}</div>
|
||||
|
||||
<div className='timer-group'>
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>Start Time</div>
|
||||
<span className={`aux-timers__value ${delayedTimerStyles}`}>
|
||||
{startTime}
|
||||
</span>
|
||||
</div>
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>End Time</div>
|
||||
<span className={`aux-timers__value ${delayedTimerStyles}`}>
|
||||
{endTime}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import Empty from '../../../common/components/state/Empty';
|
||||
import { OntimeEvent, OntimeRundownEntry, SupportedEvent } from '../../../common/models/EventTypes';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
|
||||
import { sanitiseTitle } from './countdown.helpers';
|
||||
|
||||
import './Countdown.scss';
|
||||
|
||||
|
||||
interface CountdownSelectProps {
|
||||
events: OntimeRundownEntry[];
|
||||
}
|
||||
|
||||
export default function CountdownSelect(props: CountdownSelectProps) {
|
||||
const { events } = props;
|
||||
const filteredEvents = events.filter((event: OntimeRundownEntry) => event.type === SupportedEvent.Event) as OntimeEvent[];
|
||||
|
||||
return (
|
||||
<div className='event-select' data-testid='countdown-select'>
|
||||
<span className='event-select__title'>Select an event to follow</span>
|
||||
<ul className='event-select__events'>
|
||||
{!events.length ? (
|
||||
<Empty text='No events in database' />
|
||||
) : (
|
||||
filteredEvents.map((event: OntimeEvent, counter: number) => {
|
||||
const index = counter + 1;
|
||||
const title = sanitiseTitle(event.title);
|
||||
const start = formatTime(event.timeStart, { format: 'hh:mm' });
|
||||
const end = formatTime(event.timeEnd, { format: 'hh:mm' });
|
||||
|
||||
return (
|
||||
<li key={event.id}>
|
||||
<Link to={`/countdown?eventid=${event.id}`}>
|
||||
{`${index}. ${start} → ${end} | ${title}`}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
},
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { DAY_TO_MS } from '../../../../common/utils/timeConstants';
|
||||
import { fetchTimerData, sanitiseTitle, TimerMessage } from '../countdown.helpers';
|
||||
|
||||
describe('sanitiseTitle() function', () => {
|
||||
it('should return a title when valid', () => {
|
||||
const validTitles = ['Test', 'test', 'test000', '...', 'test0999', 'test%&'];
|
||||
|
||||
for (const title of validTitles) {
|
||||
expect(sanitiseTitle(title)).toBe(title);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return {no title} when invalid', () => {
|
||||
const invalidTitles = ['', undefined, null];
|
||||
for (const title of invalidTitles) {
|
||||
expect(sanitiseTitle(title)).toBe('{no title}');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchTimerData() function', () => {
|
||||
it('shows current timer if current is the one we follow', () => {
|
||||
const followId = 'testId';
|
||||
const currentMockValue = 13;
|
||||
const follow = { id: followId };
|
||||
const time = { current: currentMockValue };
|
||||
|
||||
const { message, timer } = fetchTimerData(time, follow, followId);
|
||||
expect(message).toBe(TimerMessage.running);
|
||||
expect(timer).toBe(currentMockValue);
|
||||
});
|
||||
|
||||
it('shows the countdown to an upcoming event', () => {
|
||||
const startMockValue = 10000;
|
||||
const timeNow = 1000;
|
||||
const follow = { id: 'anotherevent', timeStart: startMockValue };
|
||||
const time = { clock: timeNow };
|
||||
|
||||
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
|
||||
expect(message).toBe(TimerMessage.toStart);
|
||||
expect(timer).toBe(startMockValue - timeNow);
|
||||
});
|
||||
|
||||
it('shows the timer of a scheduled event that hasnt started', () => {
|
||||
const startMockValue = 10000;
|
||||
const endMockValue = 20000;
|
||||
const timeNow = 15000;
|
||||
const followId = 'testId';
|
||||
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue };
|
||||
const time = { clock: timeNow, current: endMockValue - startMockValue };
|
||||
|
||||
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
|
||||
expect(message).toBe(TimerMessage.waiting);
|
||||
expect(timer).toBe(endMockValue - startMockValue);
|
||||
});
|
||||
|
||||
it('shows the end time of a finished event', () => {
|
||||
const startMockValue = 10000;
|
||||
const endMockValue = 20000;
|
||||
const timeNow = 30000;
|
||||
const followId = 'testId';
|
||||
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue };
|
||||
const time = { clock: timeNow, current: endMockValue - startMockValue };
|
||||
|
||||
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
|
||||
expect(message).toBe(TimerMessage.ended);
|
||||
expect(timer).toBe(endMockValue);
|
||||
});
|
||||
|
||||
it('handle an idle event that finishes after midnight', () => {
|
||||
const startMockValue = 10000;
|
||||
const endMockValue = 1000;
|
||||
const timeNow = 15000;
|
||||
const followId = 'testId';
|
||||
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue };
|
||||
const time = { clock: timeNow, current: DAY_TO_MS + endMockValue - startMockValue };
|
||||
|
||||
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
|
||||
expect(message).toBe(TimerMessage.waiting);
|
||||
expect(timer).toBe(DAY_TO_MS + endMockValue - startMockValue);
|
||||
});
|
||||
|
||||
it('handle an current event that finishes after midnight', () => {
|
||||
const startMockValue = 10000;
|
||||
const endMockValue = 1000;
|
||||
const timeNow = 15000;
|
||||
const followId = 'testId';
|
||||
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue };
|
||||
const time = { clock: timeNow, current: DAY_TO_MS + endMockValue - startMockValue };
|
||||
|
||||
const { message, timer } = fetchTimerData(time, follow, followId);
|
||||
expect(message).toBe(TimerMessage.running);
|
||||
expect(timer).toBe(DAY_TO_MS + endMockValue - startMockValue);
|
||||
});
|
||||
|
||||
it('handle an event that finishes after midnight but hasnt started', () => {
|
||||
const startMockValue = 10000;
|
||||
const endMockValue = 1000;
|
||||
const timeNow = 2000;
|
||||
const followId = 'testId';
|
||||
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue };
|
||||
const time = { clock: timeNow, current: DAY_TO_MS + endMockValue - startMockValue };
|
||||
|
||||
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
|
||||
expect(message).toBe(TimerMessage.toStart);
|
||||
expect(timer).toBe(startMockValue - timeNow);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { OntimeEvent } from '../../../common/models/EventTypes';
|
||||
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||
|
||||
export enum TimerMessage {
|
||||
toStart = 'Time to start',
|
||||
waiting = 'Waiting for event start',
|
||||
running = 'Event running',
|
||||
ended = 'Event ended at',
|
||||
unhandled = '',
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses string as a title
|
||||
*/
|
||||
export const sanitiseTitle = (title: string | null) =>
|
||||
title ? title : '{no title}';
|
||||
|
||||
/**
|
||||
* Returns a parsed timer and relevant status message
|
||||
*/
|
||||
export const fetchTimerData = (time: TimeManagerType, follow: OntimeEvent, selectedId: string): { message: TimerMessage, timer: number } => {
|
||||
let message;
|
||||
let timer;
|
||||
|
||||
if (selectedId === follow.id) {
|
||||
// check that is not running
|
||||
message = time.playback === 'pause' ? TimerMessage.waiting : TimerMessage.running;
|
||||
timer = time.current ?? 0;
|
||||
|
||||
} else if (time.clock < follow.timeStart) {
|
||||
// if it hasnt started, we count to start
|
||||
message = TimerMessage.toStart;
|
||||
timer = follow.timeStart - time.clock;
|
||||
|
||||
} else if (follow.timeStart <= time.clock && time.clock <= follow.timeEnd) {
|
||||
// if it has started, we show running timer
|
||||
message = TimerMessage.waiting;
|
||||
timer = time.current ?? 0;
|
||||
|
||||
} else {
|
||||
// running timer timer is not the one we are following
|
||||
|
||||
if (follow.timeStart > follow.timeEnd) {
|
||||
// ends day after
|
||||
|
||||
if (follow.timeStart > time.clock) {
|
||||
// if it hasnt started, we count to start
|
||||
message = TimerMessage.toStart;
|
||||
timer = follow.timeStart - time.clock;
|
||||
|
||||
} else if (follow.timeStart <= time.clock) {
|
||||
// if it has started, we show running timer
|
||||
message = TimerMessage.waiting;
|
||||
timer = time.current ?? 0;
|
||||
|
||||
} else {
|
||||
// if it has ended, we show how long ago
|
||||
message = TimerMessage.ended;
|
||||
timer = follow.timeEnd;
|
||||
}
|
||||
|
||||
} else {
|
||||
// if it has ended, we show how long ago
|
||||
message = TimerMessage.ended;
|
||||
timer = follow.timeEnd;
|
||||
}
|
||||
}
|
||||
return { message, timer };
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
||||
|
||||
import './LowerClean.scss';
|
||||
|
||||
export default function LowerClean(props) {
|
||||
const { lower, title, options } = props;
|
||||
const defaults = {
|
||||
size: 1,
|
||||
transitionIn: 3,
|
||||
textColour: '#fffffa',
|
||||
posX: 50,
|
||||
posY: 700,
|
||||
};
|
||||
|
||||
const [showLower, setShowLower] = useState(true);
|
||||
// Unmount if fadeOut
|
||||
useEffect(() => {
|
||||
if (!options.fadeOut) return;
|
||||
|
||||
// Calculate time
|
||||
const fadeOutTime =
|
||||
(parseInt(options.fadeOut, 10) +
|
||||
(options.transitionIn || defaults.transitionIn)) *
|
||||
1000;
|
||||
if (isNaN(fadeOutTime)) return;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setShowLower(false);
|
||||
}, fadeOutTime);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [options.fadeOut, options.transitionIn, defaults.transitionIn]);
|
||||
|
||||
// Format messages
|
||||
const showLowerMessage = lower.text !== '' && lower.visible;
|
||||
|
||||
// motion
|
||||
// transition segments
|
||||
const t = options.transitionIn || defaults.transitionIn;
|
||||
const quarter = t / 4;
|
||||
const third = t / 3;
|
||||
const half = t / 2;
|
||||
|
||||
const lowerThirdVariants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: third,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: third,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const titleVariants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
delay: quarter,
|
||||
duration: half,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sizeMultiplier = (options.size || 1) * 4;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='lower-third clean'
|
||||
style={{
|
||||
backgroundColor: options.keyColour || defaults.keyColour,
|
||||
color: options.textColour || defaults.textColour,
|
||||
fontSize: `${sizeMultiplier}vh`,
|
||||
}}
|
||||
>
|
||||
|
||||
<NavigationMenu />
|
||||
|
||||
<AnimatePresence>
|
||||
{showLower && (
|
||||
<motion.div
|
||||
className='lower-container'
|
||||
style={{
|
||||
backgroundColor: options.bgColour || defaults.bgColour,
|
||||
top: options.posY || defaults.posY,
|
||||
left: options.posX || defaults.posX,
|
||||
}}
|
||||
variants={lowerThirdVariants}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
>
|
||||
<motion.div className='title' variants={titleVariants}>
|
||||
{title.titleNow}
|
||||
</motion.div>
|
||||
<motion.div className='subtitle' variants={titleVariants}>
|
||||
{title.presenterNow}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{showLowerMessage && (
|
||||
<motion.div
|
||||
className='message-container'
|
||||
style={{
|
||||
backgroundColor: options.bgColour || defaults.bgColour,
|
||||
}}
|
||||
key='modal'
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ scaleY: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className='message'>{lower.text}</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
.lower-third.clean {
|
||||
margin: 0;
|
||||
box-sizing: border-box; /* reset */
|
||||
overflow: hidden;
|
||||
width: 100%; /* restrict the page width to viewport */
|
||||
height: 100%;
|
||||
color: #fffffa;
|
||||
font-size: 4vh;
|
||||
|
||||
.lower-container {
|
||||
position: absolute;
|
||||
padding: 1vh 2vh;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.message-container {
|
||||
position: absolute;
|
||||
bottom: 2vh;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
|
||||
.message {
|
||||
font-size: 3.5vh;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
||||
|
||||
import './LowerLines.scss';
|
||||
|
||||
export default function LowerLines(props) {
|
||||
const { lower, title, options } = props;
|
||||
const defaults = {
|
||||
size: 1,
|
||||
transitionIn: 3,
|
||||
textColour: '#fffffa',
|
||||
bgColour: '#00000033',
|
||||
};
|
||||
const [showLower, setShowLower] = useState(true);
|
||||
|
||||
// Unmount if fadeOut
|
||||
useEffect(() => {
|
||||
if (!options.fadeOut) return;
|
||||
|
||||
// Calculate time
|
||||
const fadeOutTime =
|
||||
(parseInt(options.fadeOut, 10) +
|
||||
(options.transitionIn || defaults.transitionIn)) *
|
||||
1000;
|
||||
if (isNaN(fadeOutTime)) return;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setShowLower(false);
|
||||
}, fadeOutTime);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [options.fadeOut, options.transitionIn, defaults.transitionIn]);
|
||||
|
||||
useEffect(() => {
|
||||
setShowLower(title.showNow);
|
||||
}, [title.showNow]);
|
||||
|
||||
// Format messages
|
||||
const showLowerMessage = lower.text !== '' && lower.visible;
|
||||
|
||||
// motion
|
||||
// transition segments
|
||||
const t = options.transitionIn || defaults.transitionIn;
|
||||
const eight = t / 8;
|
||||
const quarter = t / 4;
|
||||
const third = t / 3;
|
||||
const half = t / 2;
|
||||
|
||||
const lowerThirdVariants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: third,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
x: -1000,
|
||||
transition: {
|
||||
duration: third,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const titleContainerVariants = {
|
||||
hidden: {
|
||||
left: -1000,
|
||||
},
|
||||
visible: {
|
||||
left: 0,
|
||||
transition: {
|
||||
duration: third,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const titleVariants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
delay: quarter,
|
||||
duration: half,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const subtitleContainerVariants = {
|
||||
hidden: {
|
||||
left: -1000,
|
||||
},
|
||||
visible: {
|
||||
left: 0,
|
||||
transition: {
|
||||
delay: eight,
|
||||
duration: third,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const subtitleVariants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
delay: third,
|
||||
duration: third * 2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sizeMultiplier = (options.size || 1) * 4;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='lower-third lines'
|
||||
style={{
|
||||
backgroundColor: options.keyColour || defaults.keyColour,
|
||||
color: options.textColour || defaults.textColour,
|
||||
fontSize: `${sizeMultiplier}vh`,
|
||||
}}
|
||||
>
|
||||
|
||||
<NavigationMenu />
|
||||
|
||||
<AnimatePresence>
|
||||
{showLower && (
|
||||
<motion.div
|
||||
className='lower-container'
|
||||
style={{ backgroundColor: options.bgColour || defaults.bgColour }}
|
||||
variants={lowerThirdVariants}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
>
|
||||
<motion.div
|
||||
className='title-container'
|
||||
variants={titleContainerVariants}
|
||||
>
|
||||
<motion.div className='title' variants={titleVariants}>
|
||||
{title.titleNow}
|
||||
</motion.div>
|
||||
<div className='title-decor' />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className='subtitle-container'
|
||||
variants={subtitleContainerVariants}
|
||||
>
|
||||
<div className='sub-decor' />
|
||||
<motion.div
|
||||
className='subtitle'
|
||||
variants={subtitleVariants}
|
||||
>
|
||||
{title.presenterNow}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{showLowerMessage && (
|
||||
<motion.div
|
||||
className='message-container'
|
||||
key='modal'
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ scaleY: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className='message'>{lower.text}</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
.lower-third.lines {
|
||||
margin: 0;
|
||||
box-sizing: border-box; /* reset */
|
||||
overflow: hidden;
|
||||
width: 100%; /* restrict the page width to viewport */
|
||||
height: 100%;
|
||||
color: #fffffa;
|
||||
font-size: 4vh;
|
||||
|
||||
.lower-container {
|
||||
position: absolute;
|
||||
top: 75vh;
|
||||
background-color: #0003;
|
||||
padding: 1vh 1vw 1vh 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 45vw;
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
.message-container {
|
||||
position: absolute;
|
||||
bottom: 2vh;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
background-color: #0003;
|
||||
|
||||
.message {
|
||||
font-size: 3.5vh;
|
||||
}
|
||||
}
|
||||
|
||||
.title-container,
|
||||
.subtitle-container {
|
||||
display: grid;
|
||||
grid-template-columns: auto max-content;
|
||||
grid-template-areas: 'decor text';
|
||||
align-items: center;
|
||||
position: relative;
|
||||
margin-bottom: 1vh;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.title-decor,
|
||||
.sub-decor {
|
||||
grid-area: decor;
|
||||
height: 4vh;
|
||||
width: 100%;
|
||||
background-color: #ff6969;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgb(255 105 105 / 100%) 0%,
|
||||
rgb(255 132 132 / 100%) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.title,
|
||||
.subtitle {
|
||||
grid-area: text;
|
||||
padding-left: 1vw;
|
||||
justify-self: right;
|
||||
width: fit-content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import isEqual from 'react-fast-compare';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { overrideStylesURL } from '../../../common/api/apiConstants';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import LowerClean from './LowerClean';
|
||||
import LowerLines from './LowerLines';
|
||||
|
||||
const areEqual = (prevProps, nextProps) => {
|
||||
return isEqual(prevProps.title, nextProps.title) && isEqual(prevProps.lower, nextProps.lower);
|
||||
};
|
||||
|
||||
const Lower = (props) => {
|
||||
const { title, lower, viewSettings } = props;
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const [searchParams] = useSearchParams();
|
||||
const [titles, setTitles] = useState({
|
||||
titleNow: '',
|
||||
titleNext: '',
|
||||
subtitleNow: '',
|
||||
subtitleNext: '',
|
||||
presenterNow: '',
|
||||
presenterNext: '',
|
||||
showNow: false,
|
||||
showNext: false,
|
||||
});
|
||||
|
||||
// Set window title
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Lower Thirds';
|
||||
}, []);
|
||||
|
||||
// reload if data changes
|
||||
useEffect(() => {
|
||||
// clear titles if necessary
|
||||
// will trigger an animation out in the component
|
||||
let timeout = null;
|
||||
if (
|
||||
title?.titleNow !== titles?.titleNow ||
|
||||
title?.subtitleNow !== titles?.subtitleNow ||
|
||||
title?.presenterNow !== titles?.presenterNow
|
||||
) {
|
||||
setTitles((t) => ({ ...t, showNow: false }));
|
||||
|
||||
const transitionTime = 2000;
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
setTitles(title);
|
||||
}, transitionTime);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeout != null) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line
|
||||
}, [title.titleNow, title.subtitleNow, title.presenterNow]);
|
||||
|
||||
// defer rendering until we load stylesheets
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: sanitize data
|
||||
// getting config from URL: preset, size, transition, bg, text, key
|
||||
// eg. http://localhost:3000/lower?bg=ff2&text=f00&size=0.6&transition=5
|
||||
// Check for user options
|
||||
// create aux
|
||||
const options = {};
|
||||
|
||||
// preset: selector
|
||||
// Should be a number 1-n
|
||||
const p = parseInt(searchParams.get('preset'), 10);
|
||||
const preset = !isNaN(p) ? 1 : p;
|
||||
|
||||
// size: multiplier
|
||||
// Should be a number 0.0-n
|
||||
const s = searchParams.get('size');
|
||||
if (s) options.size = s;
|
||||
|
||||
// transitionIn: seconds
|
||||
// Should be a number 0-n
|
||||
const t = parseInt(searchParams.get('transition'), 10);
|
||||
if (!isNaN(t)) options.transitionIn = t;
|
||||
|
||||
// textColour: string
|
||||
// Should be a hex string '#ffffff'
|
||||
const c = searchParams.get('text');
|
||||
if (c) options.textColour = `#${c}`;
|
||||
|
||||
// bgColour: string
|
||||
// Should be a hex string '#ffffff'
|
||||
const b = searchParams.get('bg');
|
||||
if (b) options.bgColour = `#${b}`;
|
||||
|
||||
// key: string
|
||||
// Should be a hex string '#00FF00' with key colour
|
||||
const k = searchParams.get('key');
|
||||
if (k) options.keyColour = `#${k}`;
|
||||
|
||||
// fadeOut: seconds
|
||||
// Should be a number 0-n
|
||||
const f = parseInt(searchParams.get('fadeout'), 10);
|
||||
if (!isNaN(f)) options.fadeOut = f;
|
||||
|
||||
// x: pixels
|
||||
// Should be a number 0-n
|
||||
const x = parseInt(searchParams.get('x'), 10);
|
||||
if (!isNaN(x)) options.posX = x;
|
||||
|
||||
// y: pixels
|
||||
// Should be a number 0-n
|
||||
const y = parseInt(searchParams.get('y'), 10);
|
||||
if (!isNaN(y)) options.posY = y;
|
||||
|
||||
switch (preset) {
|
||||
case 0:
|
||||
return <LowerClean lower={lower} title={titles} options={options} />;
|
||||
case 1:
|
||||
return <LowerLines lower={lower} title={titles} options={options} />;
|
||||
default:
|
||||
return <LowerLines lower={lower} title={titles} options={options} />;
|
||||
}
|
||||
};
|
||||
|
||||
export default memo(Lower, areEqual);
|
||||
|
||||
Lower.propTypes = {
|
||||
title: PropTypes.object,
|
||||
lower: PropTypes.object,
|
||||
viewSettings: PropTypes.object,
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
.minimal-timer {
|
||||
margin: 0;
|
||||
box-sizing: border-box; /* reset */
|
||||
overflow: hidden;
|
||||
width: 100%; /* restrict the page width to viewport */
|
||||
height: 100vh;
|
||||
|
||||
background: var(--background-color-override, $viewer-background-color);
|
||||
color: var(--color-override, $viewer-color);
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
&--finished {
|
||||
outline: clamp(4px, 1vw, 16px) solid $timer-finished-color;
|
||||
outline-offset: calc(clamp(4px, 1vw, 16px) * -1);
|
||||
transition: $viewer-transition-time;
|
||||
}
|
||||
|
||||
.timer {
|
||||
font-family: var(--font-family-bold-override, $timer-bold-font-family) ;
|
||||
font-size: 20vw;
|
||||
position: relative;
|
||||
color: var(--timer-color-override, $timer-color);
|
||||
opacity: 1;
|
||||
transition: $viewer-transition-time;
|
||||
transition-property: opacity;
|
||||
background-color: transparent;
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
&--paused {
|
||||
opacity: $viewer-opacity-disabled;
|
||||
transition: $viewer-transition-time;
|
||||
}
|
||||
|
||||
&--finished {
|
||||
color: $timer-finished-color;
|
||||
}
|
||||
}
|
||||
|
||||
/* =================== OVERLAY ===================*/
|
||||
|
||||
.message-overlay {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: $viewer-overlay-bg-color;
|
||||
z-index: -1;
|
||||
opacity: 0;
|
||||
transition: $viewer-transition-time;
|
||||
|
||||
&--active {
|
||||
opacity: 1;
|
||||
transition: $viewer-transition-time;
|
||||
transition-property: opacity;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.message {
|
||||
width: inherit;
|
||||
padding: 2vw;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
color: white;
|
||||
transform: translate(-50%, -50%);
|
||||
-ms-transform: translate(-50%, -50%);
|
||||
font-size: 15vw;
|
||||
line-height: 30vh;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useAtom } from 'jotai';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/apiConstants';
|
||||
import { mirrorViewersAtom } from '../../../common/atoms/ViewerSettings';
|
||||
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { PresenterMessageType } from '../../../common/models/PresenterMessage.type';
|
||||
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||
import { ViewSettingsType } from '../../../common/models/ViewSettings.type';
|
||||
import { OverridableOptions } from '../../../common/models/ViewTypes';
|
||||
import { formatDisplay, millisToSeconds } from '../../../common/utils/dateConfig';
|
||||
|
||||
import './MinimalTimer.scss';
|
||||
|
||||
interface MinimalTimerProps {
|
||||
pres: PresenterMessageType;
|
||||
time: TimeManagerType;
|
||||
viewSettings: ViewSettingsType;
|
||||
}
|
||||
|
||||
export default function MinimalTimer(props: MinimalTimerProps) {
|
||||
const { pres, time, viewSettings } = props;
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const [searchParams] = useSearchParams();
|
||||
const [isMirrored] = useAtom(mirrorViewersAtom);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Minimal Timer';
|
||||
}, []);
|
||||
|
||||
// defer rendering until we load stylesheets
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// get config from url: key, text, font, size, hidenav, hideovertime
|
||||
// eg. http://localhost:3000/minimal?key=f00&text=fff
|
||||
// Check for user options
|
||||
const userOptions: OverridableOptions = {
|
||||
size: 1,
|
||||
};
|
||||
|
||||
// key: string
|
||||
// Should be a hex string '#00FF00' with key colour
|
||||
const key = searchParams.get('key');
|
||||
if (key) {
|
||||
userOptions.keyColour = `#${key}`;
|
||||
}
|
||||
|
||||
// textColour: string
|
||||
// Should be a hex string '#ffffff'
|
||||
const textColour = searchParams.get('text');
|
||||
if (textColour) {
|
||||
userOptions.textColour = `#${textColour}`;
|
||||
}
|
||||
|
||||
// textBackground: string
|
||||
// Should be a hex string '#ffffff'
|
||||
const textBackground = searchParams.get('textbg');
|
||||
if (textBackground) {
|
||||
userOptions.textBackground = `#${textBackground}`;
|
||||
}
|
||||
|
||||
// font: string
|
||||
// Should be a string with a font name 'arial'
|
||||
const font = searchParams.get('font');
|
||||
if (font) {
|
||||
userOptions.font = font;
|
||||
}
|
||||
|
||||
// size: multiplier
|
||||
// Should be a number 0.0-n
|
||||
const size = searchParams.get('size');
|
||||
if (size !== null && typeof size !== 'undefined') {
|
||||
if (!Number.isNaN(Number(size))) {
|
||||
userOptions.size = Number(size);
|
||||
}
|
||||
}
|
||||
|
||||
// alignX: flex justification
|
||||
// start | center | end
|
||||
const alignX = searchParams.get('alignx');
|
||||
if (alignX) {
|
||||
if (alignX === 'start' || alignX === 'center' || alignX === 'end') {
|
||||
userOptions.justifyContent = alignX;
|
||||
}
|
||||
}
|
||||
|
||||
// alignX: flex alignment
|
||||
// start | center | end
|
||||
const alignY = searchParams.get('aligny');
|
||||
if (alignY) {
|
||||
if (alignY === 'start' || alignY === 'center' || alignY === 'end') {
|
||||
userOptions.alignItems = alignY;
|
||||
}
|
||||
}
|
||||
|
||||
// offsetX: position in pixels
|
||||
// Should be a number 0 - 1920
|
||||
const offsetX = searchParams.get('offsetx');
|
||||
if (offsetX) {
|
||||
const pixels = Number(offsetX);
|
||||
if (!isNaN(pixels)) {
|
||||
userOptions.left = `${pixels}px`;
|
||||
}
|
||||
}
|
||||
|
||||
// offsetX: position in pixels
|
||||
// Should be a number 0 - 1920
|
||||
const offsetY = searchParams.get('offsety');
|
||||
if (offsetY) {
|
||||
const pixels = Number(offsetY);
|
||||
if (!isNaN(pixels)) {
|
||||
userOptions.top = `${pixels}px`;
|
||||
}
|
||||
}
|
||||
|
||||
const hideOvertime = searchParams.get('hideovertime');
|
||||
userOptions.hideOvertime = Boolean(hideOvertime);
|
||||
|
||||
const hideMessagesOverlay = searchParams.get('hidemessages');
|
||||
userOptions.hideMessagesOverlay = Boolean(hideMessagesOverlay);
|
||||
|
||||
const showOverlay = pres.text !== '' && pres.visible;
|
||||
const isPlaying = time.playback !== 'pause';
|
||||
const isNegative = (time.current ?? 0) < 0;
|
||||
const showFinished = isNegative && !userOptions?.hideOvertime;
|
||||
|
||||
const baseClasses = `minimal-timer ${isMirrored ? 'mirror' : ''}`;
|
||||
|
||||
let stageTimer;
|
||||
if (time.current === null) {
|
||||
stageTimer = '- - : - -';
|
||||
} else {
|
||||
stageTimer = formatDisplay(Math.abs(millisToSeconds(time.current)), true);
|
||||
if (time.current < 0) {
|
||||
stageTimer = `-${stageTimer}`;
|
||||
}
|
||||
}
|
||||
const stageTimerCharacters = stageTimer.replace('/:/g', '').length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={showFinished ? `${baseClasses} minimal-timer--finished` : baseClasses}
|
||||
style={{
|
||||
backgroundColor: userOptions.keyColour,
|
||||
justifyContent: userOptions.justifyContent,
|
||||
alignItems: userOptions.alignItems,
|
||||
}}
|
||||
data-testid='minimal-timer'
|
||||
>
|
||||
<NavigationMenu />
|
||||
{!hideMessagesOverlay && (
|
||||
<div
|
||||
className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}
|
||||
>
|
||||
<div className='message'>{pres.text}</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`timer ${!isPlaying ? 'timer--paused' : ''} ${
|
||||
showFinished ? 'timer--finished' : ''
|
||||
}`}
|
||||
style={{
|
||||
color: userOptions.textColour,
|
||||
fontSize: `${(89 / (stageTimerCharacters - 1)) * (userOptions.size || 1)}vw`,
|
||||
fontFamily: userOptions.font,
|
||||
top: userOptions.top,
|
||||
left: userOptions.left,
|
||||
backgroundColor: userOptions.textBackground,
|
||||
}}
|
||||
>
|
||||
{stageTimer}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
.public-screen {
|
||||
margin: 0;
|
||||
box-sizing: border-box; /* reset */
|
||||
overflow: hidden;
|
||||
width: 100%; /* restrict the page width to viewport */
|
||||
height: 100vh;
|
||||
|
||||
font-family: var(--font-family-override, $viewer-font-family);
|
||||
background: var(--background-color-override, $viewer-background-color);
|
||||
color: var(--color-override, $viewer-color);
|
||||
gap: min(2vh, 16px);
|
||||
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 40vw;
|
||||
grid-template-rows: auto 12px 1fr auto;
|
||||
grid-template-areas:
|
||||
' header header header'
|
||||
' progress progress schedule-nav'
|
||||
' now now schedule'
|
||||
' message message info';
|
||||
|
||||
/* =================== HEADER + EXTRAS ===================*/
|
||||
|
||||
.event-header {
|
||||
grid-area: header;
|
||||
font-size: clamp(32px, 4.5vw, 64px);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.clock-container {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.public-container {
|
||||
grid-area: message;
|
||||
|
||||
&--hidden {
|
||||
opacity: 0;
|
||||
transition: $viewer-transition-time;
|
||||
transition-property: opacity;
|
||||
}
|
||||
}
|
||||
|
||||
.clock-container,
|
||||
.public-container {
|
||||
.label {
|
||||
font-size: clamp(16px, 1.5vw, 24px);
|
||||
font-weight: 600;
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: clamp(32px, 3.5vw, 50px);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: clamp(16px, 1.5vw, 24px);
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
/* =================== MAIN - NOW ===================*/
|
||||
|
||||
.now-container {
|
||||
grid-area: now;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: min(2vh, 16px);
|
||||
}
|
||||
|
||||
.event {
|
||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
padding: 16px 24px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* =================== MAIN - SCHEDULE ===================*/
|
||||
|
||||
.schedule-container {
|
||||
grid-area: schedule;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.schedule-nav-container {
|
||||
grid-area: schedule-nav;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.info {
|
||||
grid-area: info;
|
||||
display: flex;
|
||||
gap: max(1vw, 16px);
|
||||
|
||||
&__message {
|
||||
font-size: clamp(16px, 1.5vw, 24px);
|
||||
line-height: 1.3em;
|
||||
white-space: pre-line;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.qr {
|
||||
margin-left: auto;
|
||||
padding: 4px;
|
||||
background-color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useEffect } from 'react';
|
||||
import QRCode from 'react-qr-code';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useAtom } from 'jotai';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/apiConstants';
|
||||
import { mirrorViewersAtom } from '../../../common/atoms/ViewerSettings';
|
||||
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
||||
import Schedule from '../../../common/components/schedule/Schedule';
|
||||
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
||||
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
import { titleVariants } from '../common/animation';
|
||||
|
||||
import './Public.scss';
|
||||
|
||||
const formatOptions = {
|
||||
showSeconds: true,
|
||||
format: 'hh:mm:ss a',
|
||||
};
|
||||
|
||||
Public.propTypes = {
|
||||
publ: PropTypes.object,
|
||||
publicTitle: PropTypes.object,
|
||||
time: PropTypes.object,
|
||||
events: PropTypes.array,
|
||||
publicSelectedId: PropTypes.string,
|
||||
general: PropTypes.object,
|
||||
viewSettings: PropTypes.object,
|
||||
};
|
||||
|
||||
// @ts-expect-error unable to type just yet
|
||||
export default function Public(props) {
|
||||
const { publ, publicTitle, time, events, publicSelectedId, general, viewSettings } = props;
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const [isMirrored] = useAtom(mirrorViewersAtom);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Public Screen';
|
||||
}, []);
|
||||
|
||||
// defer rendering until we load stylesheets
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const showPublicMessage = publ.text && publ.visible;
|
||||
const clock = formatTime(time.clock, formatOptions);
|
||||
const qrSize = Math.max(window.innerWidth / 15, 128);
|
||||
|
||||
return (
|
||||
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
|
||||
<NavigationMenu />
|
||||
|
||||
<div className='event-header'>
|
||||
{general.title}
|
||||
<div className='clock-container'>
|
||||
<div className='label'>Time Now</div>
|
||||
<div className='time'>{clock}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='now-container'>
|
||||
|
||||
<AnimatePresence>
|
||||
{publicTitle.showNow && (
|
||||
<motion.div
|
||||
className='event now'
|
||||
key='now'
|
||||
variants={titleVariants}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
>
|
||||
<TitleCard
|
||||
label='now'
|
||||
title={publicTitle.titleNow}
|
||||
subtitle={publicTitle.subtitleNow}
|
||||
presenter={publicTitle.presenterNow}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{publicTitle.showNext && (
|
||||
<motion.div
|
||||
className='event next'
|
||||
key='next'
|
||||
variants={titleVariants}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
>
|
||||
<TitleCard
|
||||
label='next'
|
||||
title={publicTitle.titleNext}
|
||||
subtitle={publicTitle.subtitleNext}
|
||||
presenter={publicTitle.presenterNext}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<ScheduleProvider
|
||||
events={events}
|
||||
selectedEventId={publicSelectedId}
|
||||
>
|
||||
<ScheduleNav className='schedule-nav-container' />
|
||||
<Schedule className='schedule-container' />
|
||||
</ScheduleProvider>
|
||||
|
||||
<div
|
||||
className={showPublicMessage ? 'public-container' : 'public-container public-container--hidden'}>
|
||||
<div className='label'>Public message</div>
|
||||
<div className='message'>{publ.text}</div>
|
||||
</div>
|
||||
|
||||
<div className='info'>
|
||||
<div className='qr'>
|
||||
{general.url != null && general.url !== '' && (
|
||||
<QRCode value={general.url} size={qrSize} level='L' />
|
||||
)}
|
||||
</div>
|
||||
{general.backstageInfo && (
|
||||
<div className='info__message'>{general.backstageInfo}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useAtom } from 'jotai';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/apiConstants';
|
||||
import { mirrorViewersAtom } from '../../../common/atoms/ViewerSettings';
|
||||
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
||||
import useFitText from '../../../common/hooks/useFitText';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { formatDisplay } from '../../../common/utils/dateConfig';
|
||||
import { formatEventList, getEventsWithDelay, trimEventlist } from '../../../common/utils/eventsManager';
|
||||
import { formatTime, stringFromMillis } from '../../../common/utils/time';
|
||||
|
||||
import './StudioClock.scss';
|
||||
|
||||
const formatOptions = {
|
||||
showSeconds: false,
|
||||
format: 'hh:mm',
|
||||
};
|
||||
|
||||
StudioClock.propTypes = {
|
||||
title: PropTypes.object,
|
||||
time: PropTypes.object,
|
||||
backstageEvents: PropTypes.array,
|
||||
selectedId: PropTypes.string,
|
||||
nextId: PropTypes.string,
|
||||
onAir: PropTypes.bool,
|
||||
viewSettings: PropTypes.object,
|
||||
};
|
||||
|
||||
export default function StudioClock(props) {
|
||||
const { title, time, backstageEvents, selectedId, nextId, onAir, viewSettings } = props;
|
||||
|
||||
// deferring rendering seems to affect styling (font and useFitText)
|
||||
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const { fontSize: titleFontSize, ref: titleRef } = useFitText({ maxFontSize: 500 });
|
||||
|
||||
const [schedule, setSchedule] = useState([]);
|
||||
const [isMirrored] = useAtom(mirrorViewersAtom);
|
||||
|
||||
const activeIndicators = [...Array(12).keys()];
|
||||
const secondsIndicators = [...Array(60).keys()];
|
||||
const MAX_TITLES = 12;
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const showSeconds = searchParams.get('seconds');
|
||||
formatOptions.showSeconds = Boolean(showSeconds);
|
||||
formatOptions.format = `hh:mm${formatOptions.showSeconds ? 'mm' : ''}`;
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Studio Clock';
|
||||
}, []);
|
||||
|
||||
// Prepare event list
|
||||
useEffect(() => {
|
||||
if (!backstageEvents) {
|
||||
return;
|
||||
}
|
||||
|
||||
const delayed = getEventsWithDelay(backstageEvents);
|
||||
const events = delayed.filter((e) => e.type === 'event');
|
||||
const trimmed = trimEventlist(events, selectedId, MAX_TITLES);
|
||||
const formatted = formatEventList(trimmed, selectedId, nextId, {
|
||||
showEnd: false,
|
||||
});
|
||||
setSchedule(formatted);
|
||||
}, [backstageEvents, nextId, selectedId]);
|
||||
|
||||
const clock = formatTime(time.clock, formatOptions);
|
||||
const [, , secondsNow] = stringFromMillis(time.clock).split(':');
|
||||
const isNegative = (time.current ?? 0) < 0;
|
||||
|
||||
return (
|
||||
<div className={`studio-clock ${isMirrored ? 'mirror' : ''}`} data-testid='studio-view'>
|
||||
<NavigationMenu />
|
||||
<div className='clock-container'>
|
||||
<div className={`studio-timer ${showSeconds ? 'studio-timer--with-seconds' : ''}`}>{clock}</div>
|
||||
<div
|
||||
ref={titleRef}
|
||||
className='next-title'
|
||||
style={{ fontSize: titleFontSize, height: '10vh', width: '100%', maxWidth: '75%' }}
|
||||
>
|
||||
{title.titleNext}
|
||||
</div>
|
||||
<div className={isNegative ? 'next-countdown' : 'next-countdown next-countdown--overtime'}>
|
||||
{selectedId != null && formatDisplay(time.current)}
|
||||
</div>
|
||||
<div className='clock-indicators'>
|
||||
{activeIndicators.map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='hours hours--active'
|
||||
style={{
|
||||
transform: `rotate(${(360 / 12) * i - 90}deg) translateX(40vh)`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{secondsIndicators.map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={i <= secondsNow ? 'min min--active' : 'min'}
|
||||
style={{
|
||||
transform: `rotate(${(360 / 60) * i - 90}deg) translateX(43vh)`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className='schedule-container'>
|
||||
<div
|
||||
className={onAir ? 'onAir' : 'onAir onAir--idle'}
|
||||
data-testid={onAir ? 'on-air-enabled' : 'on-air-disabled'}
|
||||
>
|
||||
ON AIR
|
||||
</div>
|
||||
<div className='schedule'>
|
||||
<ul>
|
||||
{schedule.map((s) => (
|
||||
<li key={s.id} className={s.isNow ? 'now' : s.isNext ? 'next' : ''}>
|
||||
<div className='user-colour' style={{ backgroundColor: `${s.colour !== '' ? s.colour : ''}` }} />
|
||||
{`${s.time} ${s.title}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user