mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-30 19:39:10 +00:00
Timer/blinking and blackout buttons (#406)
*feat: timer blink *feat: blackout --------- Co-authored-by: Fabian Posenau <fabian@fphome.de> Co-authored-by: cv <34649812+cpvalente@users.noreply.github.com>
This commit is contained in:
@@ -32,6 +32,8 @@ export const setMessage = {
|
|||||||
lowerText: (payload: string) => socketSendJson('set-lower-message-text', payload),
|
lowerText: (payload: string) => socketSendJson('set-lower-message-text', payload),
|
||||||
lowerVisible: (payload: boolean) => socketSendJson('set-lower-message-visible', payload),
|
lowerVisible: (payload: boolean) => socketSendJson('set-lower-message-visible', payload),
|
||||||
onAir: (payload: boolean) => socketSendJson('set-onAir', payload),
|
onAir: (payload: boolean) => socketSendJson('set-onAir', payload),
|
||||||
|
timerBlink: (payload: boolean) => socketSendJson('set-timer-blink', payload),
|
||||||
|
timerBlackout: (payload: boolean) => socketSendJson('set-timer-blackout', payload),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const usePlaybackControl = () => {
|
export const usePlaybackControl = () => {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export const runtimeStorePlaceholder = {
|
|||||||
timerMessage: {
|
timerMessage: {
|
||||||
text: '',
|
text: '',
|
||||||
visible: false,
|
visible: false,
|
||||||
|
timerBlink: false,
|
||||||
|
timerBlackout: false,
|
||||||
},
|
},
|
||||||
publicMessage: {
|
publicMessage: {
|
||||||
text: '',
|
text: '',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { IoEye } from '@react-icons/all-files/io5/IoEye';
|
|||||||
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
|
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
|
||||||
|
|
||||||
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
|
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
|
||||||
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||||
|
|
||||||
import style from './InputRow.module.scss';
|
import style from './InputRow.module.scss';
|
||||||
@@ -14,17 +15,19 @@ interface InputRowProps {
|
|||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
actionHandler: (action: string, payload: object) => void;
|
actionHandler: (action: string, payload: object) => void;
|
||||||
changeHandler: (newValue: string) => void;
|
changeHandler: (newValue: string) => void;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function InputRow(props: InputRowProps) {
|
export default function InputRow(props: InputRowProps) {
|
||||||
const { label, placeholder, text, visible, actionHandler, changeHandler } = props;
|
const { label, placeholder, text, visible, actionHandler, changeHandler, className } = props;
|
||||||
|
|
||||||
const handleInputChange = (newValue: string) => {
|
const handleInputChange = (newValue: string) => {
|
||||||
changeHandler(newValue);
|
changeHandler(newValue);
|
||||||
};
|
};
|
||||||
|
const classes = cx([style.inputRow, className]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.inputRow}>
|
<div className={classes}>
|
||||||
<label className={`${style.label} ${visible ? style.active : ''}`}>{label}</label>
|
<label className={`${style.label} ${visible ? style.active : ''}`}>{label}</label>
|
||||||
<div className={style.inputItems}>
|
<div className={style.inputItems}>
|
||||||
<Input
|
<Input
|
||||||
@@ -39,7 +42,7 @@ export default function InputRow(props: InputRowProps) {
|
|||||||
tooltip={visible ? 'Make invisible' : 'Make visible'}
|
tooltip={visible ? 'Make invisible' : 'Make visible'}
|
||||||
aria-label={`Toggle ${label}`}
|
aria-label={`Toggle ${label}`}
|
||||||
openDelay={tooltipDelayMid}
|
openDelay={tooltipDelayMid}
|
||||||
icon={visible? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
|
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
|
||||||
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
|
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
|
||||||
size='sm'
|
size='sm'
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,12 +7,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.onAirSection {
|
.onAirSection {
|
||||||
margin-top: $section-spacing;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $element-spacing;
|
gap: $element-spacing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.buttonSection {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: $element-spacing;
|
||||||
|
margin-top: -0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: $inner-section-text-size;
|
font-size: $inner-section-text-size;
|
||||||
color: $label-gray;
|
color: $label-gray;
|
||||||
@@ -21,3 +27,7 @@
|
|||||||
color: $action-text-color;
|
color: $action-text-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.padTop {
|
||||||
|
margin-top: $section-spacing;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { Button } from '@chakra-ui/react';
|
import { Button } from '@chakra-ui/react';
|
||||||
|
import { IoEye } from '@react-icons/all-files/io5/IoEye';
|
||||||
|
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
|
||||||
import { IoMicOffOutline } from '@react-icons/all-files/io5/IoMicOffOutline';
|
import { IoMicOffOutline } from '@react-icons/all-files/io5/IoMicOffOutline';
|
||||||
import { IoMicSharp } from '@react-icons/all-files/io5/IoMicSharp';
|
import { IoMicSharp } from '@react-icons/all-files/io5/IoMicSharp';
|
||||||
|
import { IoSunny } from '@react-icons/all-files/io5/IoSunny';
|
||||||
|
import { IoSunnyOutline } from '@react-icons/all-files/io5/IoSunnyOutline';
|
||||||
|
|
||||||
import { setMessage, useMessageControl } from '../../../common/hooks/useSocket';
|
import { setMessage, useMessageControl } from '../../../common/hooks/useSocket';
|
||||||
|
|
||||||
@@ -13,14 +17,6 @@ export default function MessageControl() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.messageContainer}>
|
<div className={style.messageContainer}>
|
||||||
<InputRow
|
|
||||||
label='Timer screen message'
|
|
||||||
placeholder='Shown in stage timer'
|
|
||||||
text={data.timerMessage.text || ''}
|
|
||||||
visible={data.timerMessage.visible || false}
|
|
||||||
changeHandler={(newValue) => setMessage.presenterText(newValue)}
|
|
||||||
actionHandler={() => setMessage.presenterVisible(!data.timerMessage.visible)}
|
|
||||||
/>
|
|
||||||
<InputRow
|
<InputRow
|
||||||
label='Public / Backstage screen message'
|
label='Public / Backstage screen message'
|
||||||
placeholder='Shown in public and backstage screens'
|
placeholder='Shown in public and backstage screens'
|
||||||
@@ -37,7 +33,33 @@ export default function MessageControl() {
|
|||||||
changeHandler={(newValue) => setMessage.lowerText(newValue)}
|
changeHandler={(newValue) => setMessage.lowerText(newValue)}
|
||||||
actionHandler={() => setMessage.lowerVisible(!data.lowerMessage.visible)}
|
actionHandler={() => setMessage.lowerVisible(!data.lowerMessage.visible)}
|
||||||
/>
|
/>
|
||||||
<div className={style.onAirSection}>
|
<InputRow
|
||||||
|
label='Timer message'
|
||||||
|
placeholder='Shown in stage timer'
|
||||||
|
text={data.timerMessage.text || ''}
|
||||||
|
visible={data.timerMessage.visible || false}
|
||||||
|
changeHandler={(newValue) => setMessage.presenterText(newValue)}
|
||||||
|
actionHandler={() => setMessage.presenterVisible(!data.timerMessage.visible)}
|
||||||
|
/>
|
||||||
|
<div className={style.buttonSection}>
|
||||||
|
<label className={style.label}>Timer messsage blink</label>
|
||||||
|
<label className={style.label}>Blackout timer screens</label>
|
||||||
|
<Button
|
||||||
|
className={`${data.timerMessage.timerBlink ? style.blink : ''}`}
|
||||||
|
variant={data.timerMessage.timerBlink ? 'ontime-filled' : 'ontime-subtle'}
|
||||||
|
leftIcon={data.timerMessage.timerBlink ? <IoSunny size='24px' /> : <IoSunnyOutline size='24px' />}
|
||||||
|
onClick={() => setMessage.timerBlink(!data.timerMessage.timerBlink)}
|
||||||
|
data-testid='toggle timer blink'
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className={style.blackoutButton}
|
||||||
|
variant={data.timerMessage.timerBlackout ? 'ontime-filled' : 'ontime-subtle'}
|
||||||
|
leftIcon={data.timerMessage.timerBlackout ? <IoEye size='24px' /> : <IoEyeOffOutline size='24px' />}
|
||||||
|
onClick={() => setMessage.timerBlackout(!data.timerMessage.timerBlackout)}
|
||||||
|
data-testid='toggle timer blackout'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={`${style.onAirSection} ${style.padTop}`}>
|
||||||
<label className={style.label}>Toggle On Air state</label>
|
<label className={style.label}>Toggle On Air state</label>
|
||||||
<Button
|
<Button
|
||||||
variant={data.onAir ? 'ontime-filled' : 'ontime-subtle'}
|
variant={data.onAir ? 'ontime-filled' : 'ontime-subtle'}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: 100%; /* restrict the page width to viewport */
|
width: 100%; /* restrict the page width to viewport */
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
transition: opacity 0.5s ease-in-out;
|
||||||
|
|
||||||
background: var(--background-color-override, $viewer-background-color);
|
background: var(--background-color-override, $viewer-background-color);
|
||||||
color: var(--color-override, $viewer-color);
|
color: var(--color-override, $viewer-color);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { EventData, Message, Playback, TimerType, ViewSettings } from 'ontime-types';
|
import { EventData, Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
|
||||||
|
|
||||||
import { overrideStylesURL } from '../../../common/api/apiConstants';
|
import { overrideStylesURL } from '../../../common/api/apiConstants';
|
||||||
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
||||||
@@ -14,7 +14,7 @@ import './MinimalTimer.scss';
|
|||||||
|
|
||||||
interface MinimalTimerProps {
|
interface MinimalTimerProps {
|
||||||
isMirrored: boolean;
|
isMirrored: boolean;
|
||||||
pres: Message;
|
pres: TimerMessage;
|
||||||
time: TimeManagerType;
|
time: TimeManagerType;
|
||||||
viewSettings: ViewSettings;
|
viewSettings: ViewSettings;
|
||||||
general: EventData;
|
general: EventData;
|
||||||
@@ -136,6 +136,9 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
|||||||
const showProgress = time.playback !== Playback.Stop;
|
const showProgress = time.playback !== Playback.Stop;
|
||||||
const showWarning = (time.current ?? 1) < viewSettings.warningThreshold;
|
const showWarning = (time.current ?? 1) < viewSettings.warningThreshold;
|
||||||
const showDanger = (time.current ?? 1) < viewSettings.dangerThreshold;
|
const showDanger = (time.current ?? 1) < viewSettings.dangerThreshold;
|
||||||
|
const showBlinking = pres.timerBlink;
|
||||||
|
const showBlackout = pres.timerBlackout;
|
||||||
|
|
||||||
const timerColor = userOptions.textColour
|
const timerColor = userOptions.textColour
|
||||||
? userOptions.textColour
|
? userOptions.textColour
|
||||||
: showProgress && showDanger
|
: showProgress && showDanger
|
||||||
@@ -153,8 +156,10 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
|||||||
|
|
||||||
const timerFontSize = (89 / (stageTimerCharacters - 1)) * (userOptions.size || 1);
|
const timerFontSize = (89 / (stageTimerCharacters - 1)) * (userOptions.size || 1);
|
||||||
|
|
||||||
const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''}`;
|
const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''} ${
|
||||||
const baseClasses = `minimal-timer ${isMirrored ? 'mirror' : ''}`;
|
showBlinking ? (showOverlay ? '' : 'blink') : ''
|
||||||
|
}`;
|
||||||
|
const baseClasses = `minimal-timer ${isMirrored ? 'mirror' : ''} ${showBlackout ? 'blackout' : ''}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -169,11 +174,13 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
|||||||
<NavigationMenu />
|
<NavigationMenu />
|
||||||
{!hideMessagesOverlay && (
|
{!hideMessagesOverlay && (
|
||||||
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
|
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
|
||||||
<div className='message'>{pres.text}</div>
|
<div className={`message ${showBlinking ? 'blink' : ''}`}>{pres.text}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showEndMessage ? (
|
{showEndMessage ? (
|
||||||
<div className='end-message'>{viewSettings.endMessage}</div>
|
<div className={`end-message ${showBlinking ? (showOverlay ? '' : 'blink') : ''}`}>
|
||||||
|
{viewSettings.endMessage}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={timerClasses}
|
className={timerClasses}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: 100%; /* restrict the page width to viewport */
|
width: 100%; /* restrict the page width to viewport */
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
transition: opacity 0.5s ease-in-out;
|
||||||
|
|
||||||
font-family: var(--font-family-override, $viewer-font-family);
|
font-family: var(--font-family-override, $viewer-font-family);
|
||||||
background: var(--background-color-override, $viewer-background-color);
|
background: var(--background-color-override, $viewer-background-color);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { EventData, Message, Playback, TimerType, ViewSettings } from 'ontime-types';
|
import { EventData, Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
|
||||||
|
|
||||||
import { overrideStylesURL } from '../../../common/api/apiConstants';
|
import { overrideStylesURL } from '../../../common/api/apiConstants';
|
||||||
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
||||||
@@ -39,7 +39,7 @@ const titleVariants = {
|
|||||||
interface TimerProps {
|
interface TimerProps {
|
||||||
isMirrored: boolean;
|
isMirrored: boolean;
|
||||||
general: EventData;
|
general: EventData;
|
||||||
pres: Message;
|
pres: TimerMessage;
|
||||||
title: TitleManager;
|
title: TitleManager;
|
||||||
time: TimeManagerType;
|
time: TimeManagerType;
|
||||||
viewSettings: ViewSettings;
|
viewSettings: ViewSettings;
|
||||||
@@ -73,14 +73,16 @@ export default function Timer(props: TimerProps) {
|
|||||||
const showFinished = finished && (time.timerType !== TimerType.Clock || showEndMessage);
|
const showFinished = finished && (time.timerType !== TimerType.Clock || showEndMessage);
|
||||||
const showWarning = (time.current ?? 1) < viewSettings.warningThreshold;
|
const showWarning = (time.current ?? 1) < viewSettings.warningThreshold;
|
||||||
const showDanger = (time.current ?? 1) < viewSettings.dangerThreshold;
|
const showDanger = (time.current ?? 1) < viewSettings.dangerThreshold;
|
||||||
|
const showBlinking = pres.timerBlink;
|
||||||
|
const showBlackout = pres.timerBlackout;
|
||||||
|
const showClock = time.timerType !== TimerType.Clock;
|
||||||
|
|
||||||
const timerColor =
|
const timerColor =
|
||||||
showProgress && showDanger
|
showProgress && showDanger
|
||||||
? viewSettings.dangerColor
|
? viewSettings.dangerColor
|
||||||
: showProgress && showWarning
|
: showProgress && showWarning
|
||||||
? viewSettings.warningColor
|
? viewSettings.warningColor
|
||||||
: viewSettings.normalColor;
|
: viewSettings.normalColor;
|
||||||
const showClock = time.timerType !== TimerType.Clock;
|
|
||||||
const baseClasses = `stage-timer ${isMirrored ? 'mirror' : ''}`;
|
|
||||||
|
|
||||||
const stageTimer = getTimerByType(time);
|
const stageTimer = getTimerByType(time);
|
||||||
let display = formatTimerDisplay(stageTimer);
|
let display = formatTimerDisplay(stageTimer);
|
||||||
@@ -89,6 +91,7 @@ export default function Timer(props: TimerProps) {
|
|||||||
display = `-${display}`;
|
display = `-${display}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const baseClasses = `stage-timer ${isMirrored ? 'mirror' : ''} ${showBlackout ? 'blackout' : ''}`;
|
||||||
const timerFontSize = 89 / (stageTimerCharacters - 1);
|
const timerFontSize = 89 / (stageTimerCharacters - 1);
|
||||||
const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''}`;
|
const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''}`;
|
||||||
|
|
||||||
@@ -96,7 +99,7 @@ export default function Timer(props: TimerProps) {
|
|||||||
<div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'>
|
<div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'>
|
||||||
<NavigationMenu />
|
<NavigationMenu />
|
||||||
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
|
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
|
||||||
<div className='message'>{pres.text}</div>
|
<div className={`message ${showBlinking ? 'blink' : ''}`}>{pres.text}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`clock-container ${showClock ? '' : 'clock-container--hidden'}`}>
|
<div className={`clock-container ${showClock ? '' : 'clock-container--hidden'}`}>
|
||||||
@@ -104,7 +107,7 @@ export default function Timer(props: TimerProps) {
|
|||||||
<div className='clock'>{clock}</div>
|
<div className='clock'>{clock}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='timer-container'>
|
<div className={`timer-container ${showBlinking ? (showOverlay ? '' : 'blink') : ''}`}>
|
||||||
{showEndMessage ? (
|
{showEndMessage ? (
|
||||||
<div className='end-message'>{viewSettings.endMessage}</div>
|
<div className='end-message'>{viewSettings.endMessage}</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
$transition-time-action: 0.1s;
|
$transition-time-action: 0.1s;
|
||||||
$transition-time-feedback: 0.3s;
|
$transition-time-feedback: 0.3s;
|
||||||
|
|
||||||
|
$blinking-time: 1.2s;
|
||||||
|
|
||||||
$component-border-radius-md: 3px;
|
$component-border-radius-md: 3px;
|
||||||
$component-border-radius-sm: 2px;
|
$component-border-radius-sm: 2px;
|
||||||
|
|
||||||
@@ -51,3 +53,13 @@ $secondary-text-gray: $gray-400;
|
|||||||
$section-white: $ui-white;
|
$section-white: $ui-white;
|
||||||
$inner-section-text-size: 14px;
|
$inner-section-text-size: 14px;
|
||||||
$text-body-size: 15px;
|
$text-body-size: 15px;
|
||||||
|
|
||||||
|
.blink {
|
||||||
|
animation: blink $blinking-time linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
50% {
|
||||||
|
opacity: 20%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
.mirror {
|
.mirror {
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blackout {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
// General
|
// General
|
||||||
$viewer-transition-time: 0.5s;
|
$viewer-transition-time: 0.5s;
|
||||||
|
|
||||||
|
|
||||||
// Text
|
// Text
|
||||||
$viewer-font-family: "Open Sans", "Segoe UI", sans-serif; // --font-family-override
|
$viewer-font-family: "Open Sans", "Segoe UI", sans-serif; // --font-family-override
|
||||||
$viewer-opacity-disabled: 0.6;
|
$viewer-opacity-disabled: 0.6;
|
||||||
@@ -26,3 +27,4 @@ $element-border-radius: 8px;
|
|||||||
$timer-color: rgba(white, 80%); // --timer-color-override
|
$timer-color: rgba(white, 80%); // --timer-color-override
|
||||||
$timer-finished-color: $playback-negative;
|
$timer-finished-color: $playback-negative;
|
||||||
$timer-bold-font-family: "Arial Black", sans-serif; // --card-background-color-override
|
$timer-bold-font-family: "Arial Black", sans-serif; // --card-background-color-override
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,20 @@ export function dispatchFromAdapter(type: string, payload: unknown, source?: 'os
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'set-timer-blink': {
|
||||||
|
if (typeof payload !== 'undefined') {
|
||||||
|
messageService.setTimerBlink(Boolean(payload));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'set-timer-blackout': {
|
||||||
|
if (typeof payload !== 'undefined') {
|
||||||
|
messageService.setTimerBlackout(Boolean(payload));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'set-timer-message-text': {
|
case 'set-timer-message-text': {
|
||||||
if (typeof payload !== 'string') {
|
if (typeof payload !== 'string') {
|
||||||
throw new Error(`Unable to parse payload: ${payload}`);
|
throw new Error(`Unable to parse payload: ${payload}`);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Message } from 'ontime-types';
|
import { Message } from 'ontime-types';
|
||||||
|
|
||||||
import { eventStore } from '../../stores/EventStore.js';
|
import { eventStore } from '../../stores/EventStore.js';
|
||||||
|
import { TimerMessage } from 'ontime-types/src/definitions/runtime/MessageControl.type.js';
|
||||||
|
|
||||||
let instance;
|
let instance;
|
||||||
|
|
||||||
class MessageService {
|
class MessageService {
|
||||||
timerMessage: Message;
|
timerMessage: TimerMessage;
|
||||||
publicMessage: Message;
|
publicMessage: Message;
|
||||||
lowerMessage: Message;
|
lowerMessage: Message;
|
||||||
onAir: boolean;
|
onAir: boolean;
|
||||||
@@ -21,6 +22,8 @@ class MessageService {
|
|||||||
this.timerMessage = {
|
this.timerMessage = {
|
||||||
text: '',
|
text: '',
|
||||||
visible: false,
|
visible: false,
|
||||||
|
timerBlink: false,
|
||||||
|
timerBlackout: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.publicMessage = {
|
this.publicMessage = {
|
||||||
@@ -103,6 +106,34 @@ class MessageService {
|
|||||||
return this.getAll();
|
return this.getAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description set state of timer blink, toggles if parameters are offered
|
||||||
|
*/
|
||||||
|
|
||||||
|
setTimerBlink(status?: boolean) {
|
||||||
|
if (typeof status === 'undefined') {
|
||||||
|
this.timerMessage.timerBlink = !this.timerMessage.timerBlink;
|
||||||
|
} else {
|
||||||
|
this.timerMessage.timerBlink = status;
|
||||||
|
}
|
||||||
|
eventStore.set('timerMessage', this.timerMessage);
|
||||||
|
return this.getAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description set state of timer blackout, toggles if parameters are offered
|
||||||
|
*/
|
||||||
|
|
||||||
|
setTimerBlackout(status?: boolean) {
|
||||||
|
if (typeof status === 'undefined') {
|
||||||
|
this.timerMessage.timerBlackout = !this.timerMessage.timerBlackout;
|
||||||
|
} else {
|
||||||
|
this.timerMessage.timerBlackout = status;
|
||||||
|
}
|
||||||
|
eventStore.set('timerMessage', this.timerMessage);
|
||||||
|
return this.getAll();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Returns feature data
|
* @description Returns feature data
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"subtitle": "",
|
"subtitle": "",
|
||||||
"presenter": "",
|
"presenter": "",
|
||||||
"note": "",
|
"note": "",
|
||||||
"endAction": "continue",
|
"endAction": "none",
|
||||||
"timerType": "count-down",
|
"timerType": "count-down",
|
||||||
"timeStart": 32400000,
|
"timeStart": 32400000,
|
||||||
"timeEnd": 36000000,
|
"timeEnd": 36000000,
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
"subtitle": "",
|
"subtitle": "",
|
||||||
"presenter": "",
|
"presenter": "",
|
||||||
"note": "",
|
"note": "",
|
||||||
"endAction": "continue",
|
"endAction": "none",
|
||||||
"timerType": "count-down",
|
"timerType": "count-down",
|
||||||
"timeStart": 36000000,
|
"timeStart": 36000000,
|
||||||
"timeEnd": 39600000,
|
"timeEnd": 39600000,
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
"subtitle": "",
|
"subtitle": "",
|
||||||
"presenter": "",
|
"presenter": "",
|
||||||
"note": "",
|
"note": "",
|
||||||
"endAction": "continue",
|
"endAction": "none",
|
||||||
"timerType": "count-down",
|
"timerType": "count-down",
|
||||||
"timeStart": 39600000,
|
"timeStart": 39600000,
|
||||||
"timeEnd": 720000,
|
"timeEnd": 720000,
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ test.describe('pages routes are available', () => {
|
|||||||
await page.goto('http://localhost:4001/editor');
|
await page.goto('http://localhost:4001/editor');
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/ontime/);
|
await expect(page).toHaveTitle(/ontime/);
|
||||||
await page.getByTestId('event-editor').click();
|
await expect(page.getByTestId('event-editor')).toBeVisible()
|
||||||
await page.getByTestId('panel-rundown').click();
|
await expect(page.getByTestId('panel-rundown')).toBeVisible()
|
||||||
await page.getByTestId('panel-timer-control').click();
|
await expect(page.getByTestId('panel-timer-control')).toBeVisible()
|
||||||
await page.getByTestId('panel-messages-control').click();
|
await expect(page.getByTestId('panel-messages-control')).toBeVisible()
|
||||||
await page.getByTestId('panel-info').click();
|
await expect(page.getByTestId('panel-info')).toBeVisible()
|
||||||
});
|
});
|
||||||
test('cuesheet', async ({ page }) => {
|
test('cuesheet', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/cuesheet');
|
await page.goto('http://localhost:4001/cuesheet');
|
||||||
@@ -24,19 +24,19 @@ test.describe('pages routes are available', () => {
|
|||||||
test.describe('detached views', () => {
|
test.describe('detached views', () => {
|
||||||
test('rundown', async ({ page }) => {
|
test('rundown', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/rundown');
|
await page.goto('http://localhost:4001/rundown');
|
||||||
await page.getByTestId('panel-rundown').click();
|
await expect(page.getByTestId('panel-rundown')).toBeVisible()
|
||||||
});
|
});
|
||||||
test('timer control', async ({ page }) => {
|
test('timer control', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/timercontrol');
|
await page.goto('http://localhost:4001/timercontrol');
|
||||||
await page.getByTestId('panel-timer-control').click();
|
await expect(page.getByTestId('panel-timer-control')).toBeVisible()
|
||||||
});
|
});
|
||||||
test('message control', async ({ page }) => {
|
test('message control', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/messagecontrol');
|
await page.goto('http://localhost:4001/messagecontrol');
|
||||||
await page.getByTestId('panel-messages-control').click();
|
await expect(page.getByTestId('panel-messages-control')).toBeVisible()
|
||||||
});
|
});
|
||||||
test('info', async ({ page }) => {
|
test('info', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/info');
|
await page.goto('http://localhost:4001/info');
|
||||||
await page.getByTestId('panel-info').click();
|
await expect(page.getByTestId('panel-info')).toBeVisible()
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,34 +6,36 @@ test('test', async ({ context }) => {
|
|||||||
|
|
||||||
await editorPage.goto('http://localhost:4001/messagecontrol');
|
await editorPage.goto('http://localhost:4001/messagecontrol');
|
||||||
|
|
||||||
// stage timer message
|
|
||||||
await editorPage.getByPlaceholder('Shown in stage timer').click();
|
|
||||||
await expect(editorPage.getByPlaceholder('Shown in stage timer')).toBeEnabled();
|
|
||||||
|
|
||||||
await editorPage.getByPlaceholder('Shown in stage timer').fill('testing stage');
|
|
||||||
await editorPage.getByRole('button', { name: /toggle timer screen message/i }).click({ timeout: 5000 });
|
|
||||||
|
|
||||||
await featurePage.goto('http://localhost:4001/timer');
|
|
||||||
await featurePage.getByText('testing stage').click();
|
|
||||||
|
|
||||||
// public screen message
|
// public screen message
|
||||||
await editorPage.getByPlaceholder('Shown in public and backstage screens').click();
|
await editorPage.getByPlaceholder('Shown in public and backstage screens').click();
|
||||||
await editorPage.getByPlaceholder('Shown in public and backstage screens').fill('testing public');
|
await editorPage.getByPlaceholder('Shown in public and backstage screens').fill('testing public');
|
||||||
await editorPage.getByRole('button', { name: 'Toggle Public / Backstage screen message' }).click();
|
await editorPage.getByRole('button', { name: 'Toggle Public / Backstage screen message' }).click();
|
||||||
|
|
||||||
await featurePage.goto('http://localhost:4001/public');
|
await featurePage.goto('http://localhost:4001/public');
|
||||||
await featurePage.getByText('testing public').click({ timeout: 10000 });
|
await featurePage.waitForLoadState('load', { timeout: 5000 });
|
||||||
|
await featurePage.getByText('testing public').click({ timeout: 5000 });
|
||||||
|
|
||||||
// lower third message
|
// lower third message
|
||||||
await editorPage.getByPlaceholder('Shown in lower third').click();
|
await editorPage.getByPlaceholder('Shown in lower third').click();
|
||||||
await editorPage.getByPlaceholder('Shown in lower third').fill('testing lower');
|
await editorPage.getByPlaceholder('Shown in lower third').fill('testing lower');
|
||||||
await editorPage.getByRole('button', { name: /toggle lower third message/i }).click();
|
await editorPage.getByRole('button', { name: /toggle lower third message/i }).click();
|
||||||
|
|
||||||
await featurePage.goto('http://localhost:4001/lower');
|
await featurePage.goto('http://localhost:4001/lower');
|
||||||
// increase the timeout to accommodate animation
|
await featurePage.waitForLoadState('load', { timeout: 5000 });
|
||||||
await featurePage.getByText('testing lower').click({ timeout: 10000 });
|
await featurePage.getByText('testing lower').click({ timeout: 5000 });
|
||||||
|
|
||||||
|
// stage timer message
|
||||||
|
await editorPage.getByPlaceholder('Shown in stage timer').click();
|
||||||
|
await editorPage.getByPlaceholder('Shown in stage timer').fill('testing stage');
|
||||||
|
await editorPage.getByRole('button', { name: /toggle timer message/i }).click({ timeout: 5000 });
|
||||||
|
|
||||||
|
await featurePage.goto('http://localhost:4001/timer');
|
||||||
|
await featurePage.waitForLoadState('load', { timeout: 5000 });
|
||||||
|
await featurePage.getByText('testing stage').click();
|
||||||
|
|
||||||
// on air state
|
// on air state
|
||||||
await editorPage.getByTestId('toggle on air').click();
|
await editorPage.getByTestId('toggle on air').click();
|
||||||
|
|
||||||
await featurePage.goto('http://localhost:4001/studio');
|
await featurePage.goto('http://localhost:4001/studio');
|
||||||
await featurePage.getByText('ON AIR').click();
|
await featurePage.getByText('ON AIR').click();
|
||||||
const onAirActive = await featurePage.locator('data-testid=on-air-enabled');
|
const onAirActive = await featurePage.locator('data-testid=on-air-enabled');
|
||||||
|
|||||||
Vendored
+3
-3
@@ -5,7 +5,7 @@
|
|||||||
"subtitle": "",
|
"subtitle": "",
|
||||||
"presenter": "",
|
"presenter": "",
|
||||||
"note": "",
|
"note": "",
|
||||||
"endAction": "continue",
|
"endAction": "none",
|
||||||
"timerType": "count-down",
|
"timerType": "count-down",
|
||||||
"timeStart": 32400000,
|
"timeStart": 32400000,
|
||||||
"timeEnd": 36000000,
|
"timeEnd": 36000000,
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
"subtitle": "",
|
"subtitle": "",
|
||||||
"presenter": "",
|
"presenter": "",
|
||||||
"note": "",
|
"note": "",
|
||||||
"endAction": "continue",
|
"endAction": "none",
|
||||||
"timerType": "count-down",
|
"timerType": "count-down",
|
||||||
"timeStart": 36000000,
|
"timeStart": 36000000,
|
||||||
"timeEnd": 39600000,
|
"timeEnd": 39600000,
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
"subtitle": "",
|
"subtitle": "",
|
||||||
"presenter": "",
|
"presenter": "",
|
||||||
"note": "",
|
"note": "",
|
||||||
"endAction": "continue",
|
"endAction": "none",
|
||||||
"timerType": "count-down",
|
"timerType": "count-down",
|
||||||
"timeStart": 39600000,
|
"timeStart": 39600000,
|
||||||
"timeEnd": 720000,
|
"timeEnd": 720000,
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@
|
|||||||
"cleanup": "rm -rf node_modules && rm -rf **/node_modules && rm -rf **/**/node_modules"
|
"cleanup": "rm -rf node_modules && rm -rf **/node_modules && rm -rf **/**/node_modules"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.32.3",
|
"@playwright/test": "^1.34.3",
|
||||||
"@types/node": "^18.11.18",
|
"@types/node": "^18.11.18",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.48.0",
|
"@typescript-eslint/eslint-plugin": "^5.48.0",
|
||||||
"@typescript-eslint/parser": "^5.48.0",
|
"@typescript-eslint/parser": "^5.48.0",
|
||||||
|
|||||||
@@ -2,3 +2,8 @@ export type Message = {
|
|||||||
text: string;
|
text: string;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TimerMessage = Message & {
|
||||||
|
timerBlink: boolean;
|
||||||
|
timerBlackout: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Playback } from './Playback.type.js';
|
import { Playback } from './Playback.type.js';
|
||||||
import { Message } from './MessageControl.type.js';
|
import { Message, TimerMessage } from './MessageControl.type.js';
|
||||||
import { TimerState } from './TimerState.type.js';
|
import { TimerState } from './TimerState.type.js';
|
||||||
import { TitleBlock } from './TitleBlock.type.js';
|
import { TitleBlock } from './TitleBlock.type.js';
|
||||||
import { Loaded } from './Playlist.type.js';
|
import { Loaded } from './Playlist.type.js';
|
||||||
@@ -10,7 +10,7 @@ export type RuntimeStore = {
|
|||||||
playback: Playback;
|
playback: Playback;
|
||||||
|
|
||||||
// messages service
|
// messages service
|
||||||
timerMessage: Message;
|
timerMessage: TimerMessage;
|
||||||
publicMessage: Message;
|
publicMessage: Message;
|
||||||
lowerMessage: Message;
|
lowerMessage: Message;
|
||||||
onAir: boolean;
|
onAir: boolean;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Alias } from './definitions/core/Alias.type.js';
|
|||||||
import { DatabaseModel } from './definitions/DataModel.type.js';
|
import { DatabaseModel } from './definitions/DataModel.type.js';
|
||||||
import { EndAction } from './definitions/EndAction.type.js';
|
import { EndAction } from './definitions/EndAction.type.js';
|
||||||
import { EventData } from './definitions/core/EventData.type.js';
|
import { EventData } from './definitions/core/EventData.type.js';
|
||||||
import { Message } from './definitions/runtime/MessageControl.type.js';
|
import { Message, TimerMessage } from './definitions/runtime/MessageControl.type.js';
|
||||||
import {
|
import {
|
||||||
OntimeBaseEvent,
|
OntimeBaseEvent,
|
||||||
OntimeBlock,
|
OntimeBlock,
|
||||||
@@ -61,6 +61,7 @@ export { Playback };
|
|||||||
export { TimerLifeCycle };
|
export { TimerLifeCycle };
|
||||||
|
|
||||||
export type { Message };
|
export type { Message };
|
||||||
|
export type { TimerMessage };
|
||||||
export type { Loaded };
|
export type { Loaded };
|
||||||
export type { RuntimeStore };
|
export type { RuntimeStore };
|
||||||
export type { TimerState };
|
export type { TimerState };
|
||||||
|
|||||||
@@ -23,11 +23,15 @@ const config: PlaywrightTestConfig = {
|
|||||||
},
|
},
|
||||||
use: {
|
use: {
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
|
video: 'retain-on-failure',
|
||||||
viewport: { width: 1920, height: 1080 },
|
viewport: { width: 1920, height: 1080 },
|
||||||
actionTimeout: 5000,
|
actionTimeout: 5000,
|
||||||
baseURL: 'http://localhost:4001',
|
baseURL: 'http://localhost:4001',
|
||||||
ignoreHTTPSErrors: true,
|
ignoreHTTPSErrors: true,
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
|
launchOptions: {
|
||||||
|
slowMo: process.env.CI ? undefined : 250
|
||||||
|
}
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
|
|||||||
Generated
+12
-8
@@ -4,7 +4,7 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
specifiers:
|
specifiers:
|
||||||
'@playwright/test': ^1.32.3
|
'@playwright/test': ^1.34.3
|
||||||
'@types/node': ^18.11.18
|
'@types/node': ^18.11.18
|
||||||
'@typescript-eslint/eslint-plugin': ^5.48.0
|
'@typescript-eslint/eslint-plugin': ^5.48.0
|
||||||
'@typescript-eslint/parser': ^5.48.0
|
'@typescript-eslint/parser': ^5.48.0
|
||||||
@@ -16,7 +16,7 @@ importers:
|
|||||||
turbo: ^1.8.8
|
turbo: ^1.8.8
|
||||||
typescript: ^4.8.3
|
typescript: ^4.8.3
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@playwright/test': 1.32.3
|
'@playwright/test': 1.34.3
|
||||||
'@types/node': 18.11.18
|
'@types/node': 18.11.18
|
||||||
'@typescript-eslint/eslint-plugin': 5.48.0_k73wpmdolxikpyqun3p36akaaq
|
'@typescript-eslint/eslint-plugin': 5.48.0_k73wpmdolxikpyqun3p36akaaq
|
||||||
'@typescript-eslint/parser': 5.48.0_iukboom6ndih5an6iafl45j2fe
|
'@typescript-eslint/parser': 5.48.0_iukboom6ndih5an6iafl45j2fe
|
||||||
@@ -2167,13 +2167,13 @@ packages:
|
|||||||
fastq: 1.15.0
|
fastq: 1.15.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/@playwright/test/1.32.3:
|
/@playwright/test/1.34.3:
|
||||||
resolution: {integrity: sha512-BvWNvK0RfBriindxhLVabi8BRe3X0J9EVjKlcmhxjg4giWBD/xleLcg2dz7Tx0agu28rczjNIPQWznwzDwVsZQ==}
|
resolution: {integrity: sha512-zPLef6w9P6T/iT6XDYG3mvGOqOyb6eHaV9XtkunYs0+OzxBtrPAAaHotc0X+PJ00WPPnLfFBTl7mf45Mn8DBmw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 18.15.11
|
'@types/node': 18.16.16
|
||||||
playwright-core: 1.32.3
|
playwright-core: 1.34.3
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.2
|
fsevents: 2.3.2
|
||||||
dev: true
|
dev: true
|
||||||
@@ -2804,6 +2804,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q==}
|
resolution: {integrity: sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@types/node/18.16.16:
|
||||||
|
resolution: {integrity: sha512-NpaM49IGQQAUlBhHMF82QH80J08os4ZmyF9MkpCzWAGuOHqE4gTEbhzd7L3l5LmWuZ6E0OiC1FweQ4tsiW35+g==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@types/normalize-package-data/2.4.1:
|
/@types/normalize-package-data/2.4.1:
|
||||||
resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==}
|
resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==}
|
||||||
dev: true
|
dev: true
|
||||||
@@ -6786,8 +6790,8 @@ packages:
|
|||||||
pathe: 1.1.0
|
pathe: 1.1.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/playwright-core/1.32.3:
|
/playwright-core/1.34.3:
|
||||||
resolution: {integrity: sha512-SB+cdrnu74ZIn5Ogh/8278ngEh9NEEV0vR4sJFmK04h2iZpybfbqBY0bX6+BLYWVdV12JLLI+JEFtSnYgR+mWg==}
|
resolution: {integrity: sha512-2pWd6G7OHKemc5x1r1rp8aQcpvDh7goMBZlJv6Co5vCNLVcQJdhxRL09SGaY6HcyHH9aT4tiynZabMofVasBYw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
dev: true
|
dev: true
|
||||||
|
|||||||
Reference in New Issue
Block a user