mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-21 23:19:09 +00:00
v2: views (#278)
* config(sentry): instrumentation only in production * chore: upgrade deps * refactor: simplify and convert to ts * refactor: composition and styles * chore: remove deprecated deps * ux: close menu on click outside * style: small tweaks and clarifications * refactor: retire PiP view * ux: small tweaks * style: cleanup deprecated styles * chore: remove unused * refactor: remove usages of deprecated fields and improve null timer presentation * refactor: improve composition of countdown * style: cleanup overridable styles * ux: show event overtime status * style: correct logic in paused timers
This commit is contained in:
@@ -5,25 +5,27 @@
|
||||
box-sizing: border-box; /* reset */
|
||||
overflow: hidden;
|
||||
width: 100%; /* restrict the page width to viewport */
|
||||
|
||||
background: var(--background-color-override, $viewer-background-color);
|
||||
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);
|
||||
padding: 1vw;
|
||||
|
||||
/* =================== MAIN - SELECT ===================*/
|
||||
|
||||
.event-select {
|
||||
display: flex;
|
||||
margin-top: 8vh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: max(1.3vw, 12px);
|
||||
flex-direction: column;
|
||||
|
||||
&__title {
|
||||
font-size: max(2vw, 14px);
|
||||
font-size: clamp(24px, 2vw, 32px);
|
||||
}
|
||||
|
||||
&__events {
|
||||
font-size: clamp(16px, 1.5vw, 24px);
|
||||
margin-top: 1em;
|
||||
overflow-y: auto;
|
||||
height: 70vh;
|
||||
@@ -31,21 +33,88 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* =================== 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: 1fr auto auto auto 1fr auto;
|
||||
grid-template-rows: auto auto auto auto 1fr;
|
||||
grid-template-columns: 100%;
|
||||
grid-template-areas:
|
||||
'title'
|
||||
'header'
|
||||
'status'
|
||||
'clock'
|
||||
'y'
|
||||
'title'
|
||||
'timers';
|
||||
gap: 1vw;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
|
||||
/* =================== 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;
|
||||
@@ -55,16 +124,17 @@
|
||||
|
||||
.aux-timers {
|
||||
text-align: center;
|
||||
font-size: clamp(24px, 1.75vw, 32px);
|
||||
|
||||
&__label {
|
||||
font-size: max(1.3vw, 12px);
|
||||
color: var(--accent-color-override, $accent-color);
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-size: max(2vw, 14px);
|
||||
letter-spacing: 0.3px;
|
||||
font-size: clamp(32px, 3.5vw, 50px);
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
&--delayed {
|
||||
color: $delay-color;
|
||||
@@ -72,42 +142,5 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: title;
|
||||
font-size: max(4vw, 18px);
|
||||
align-self: center;
|
||||
color: var(--accent-color-override, $accent-color);
|
||||
background-color: var(--outdent-background-color-override, $viewer-outdent-bg-color);
|
||||
padding: 1vh 2vw;
|
||||
border-radius: $element-border-radius;
|
||||
}
|
||||
|
||||
.status {
|
||||
grid-area: status;
|
||||
padding-left: 5vw;
|
||||
font-size: max(2.5vw, 14px);
|
||||
justify-self: flex-start;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.timer {
|
||||
grid-area: clock;
|
||||
line-height: 20vw;
|
||||
font-size: 21vw;
|
||||
text-align: center;
|
||||
letter-spacing: 0.4vw;
|
||||
align-self: flex-start;
|
||||
opacity: 1.0;
|
||||
transition: opacity 0.5s;
|
||||
color: var(--timer-color-override, $viewer-color);
|
||||
|
||||
&--paused {
|
||||
opacity: 0.6;
|
||||
}
|
||||
&--finished {
|
||||
color: $timer-finished-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+53
-55
@@ -1,18 +1,19 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
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 Empty from '../../../common/components/state/Empty';
|
||||
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, sanitiseTitle, timerMessages } from './countdown.helpers';
|
||||
import { fetchTimerData, TimerMessage } from './countdown.helpers';
|
||||
import CountdownSelect from './CountdownSelect';
|
||||
|
||||
import './Countdown.scss';
|
||||
|
||||
@@ -21,15 +22,28 @@ const formatOptions = {
|
||||
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(null);
|
||||
const [follow, setFollow] = useState<OntimeEvent | null>(null);
|
||||
const [runningTimer, setRunningTimer] = useState(0);
|
||||
const [runningMessage, setRunningMessage] = useState('');
|
||||
const [runningMessage, setRunningMessage] = useState<TimerMessage>(TimerMessage.unhandled);
|
||||
const [delay, setDelay] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -46,17 +60,17 @@ export default function Countdown(props) {
|
||||
const eventId = searchParams.get('eventid');
|
||||
const eventIndex = searchParams.get('event');
|
||||
|
||||
let followThis;
|
||||
const events = [...backstageEvents].filter((event) => event.type === '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);
|
||||
followThis = events.find((event) => event.id === eventId) || null;
|
||||
} else if (eventIndex !== null) {
|
||||
followThis = events?.[eventIndex - 1];
|
||||
followThis = events?.[Number(eventIndex) - 1];
|
||||
}
|
||||
if (typeof followThis !== 'undefined') {
|
||||
if (followThis !== null) {
|
||||
setFollow(followThis);
|
||||
const idx = backstageEvents.findIndex((event) => event.id === followThis.id);
|
||||
const idx: number = backstageEvents.findIndex((event: OntimeRundownEntry) => event.id === followThis?.id);
|
||||
const delayToEvent = getDelayTo(backstageEvents, idx);
|
||||
setDelay(delayToEvent);
|
||||
}
|
||||
@@ -77,9 +91,9 @@ export default function Countdown(props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const standby = time.playback !== 'start' && selectedId === follow?.id;
|
||||
const isRunningFinished = time.finished && runningMessage === timerMessages.running;
|
||||
const isSelected = runningMessage === timerMessages.running;
|
||||
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);
|
||||
@@ -91,36 +105,38 @@ export default function Countdown(props) {
|
||||
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 ? (
|
||||
<div className='event-select' data-testid='countdown-select'>
|
||||
<span className='event-select__title'>Select an event to follow</span>
|
||||
<ul className='event-select__events'>
|
||||
{backstageEvents.length === 0 ? (
|
||||
<Empty text='No events in database' />
|
||||
) : (
|
||||
backstageEvents
|
||||
.filter((e) => e.type === 'event')
|
||||
.map((event, index) => (
|
||||
<li key={event.id}>
|
||||
<Link to={`/countdown?eventid=${event.id}`}>
|
||||
{`${index + 1}. ${sanitiseTitle(event.title)}`}
|
||||
</Link>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
<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'>Time Now</div>
|
||||
<span className='aux-timers__value'>{clock}</span>
|
||||
</div>
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>Start Time</div>
|
||||
<span className={`aux-timers__value ${delayedTimerStyles}`}>
|
||||
@@ -134,27 +150,9 @@ export default function Countdown(props) {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='status'>{runningMessage}</div>
|
||||
<span
|
||||
className={`timer ${standby ? 'timer--paused' : ''} ${
|
||||
isRunningFinished ? 'timer--finished' : ''
|
||||
}`}
|
||||
>
|
||||
{formatDisplay(
|
||||
isSelected ? runningTimer : runningTimer + millisToSeconds(delay),
|
||||
isSelected || time.waiting,
|
||||
)}
|
||||
</span>
|
||||
<div className='title'>{follow?.title || 'Untitled Event'}</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Countdown.propTypes = {
|
||||
backstageEvents: PropTypes.array,
|
||||
time: PropTypes.object,
|
||||
selectedId: PropTypes.string,
|
||||
viewSettings: PropTypes.object,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { millisToSeconds } from '../../../common/utils/dateConfig';
|
||||
|
||||
/**
|
||||
* @description parses string as a title
|
||||
* @param {string|null} title
|
||||
* @return {string}
|
||||
*/
|
||||
export const sanitiseTitle = (title) =>
|
||||
title === null || title === '' || typeof title === 'undefined' ? '{no title}' : title;
|
||||
|
||||
/**
|
||||
* @description object with possible timer messages
|
||||
* @type {{running: string, toStart: string, waiting: string, ended: string}}
|
||||
*/
|
||||
export const timerMessages = {
|
||||
toStart: 'Time to start',
|
||||
waiting: 'Waiting for event start',
|
||||
running: 'Event running',
|
||||
ended: 'Event ended at',
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Returns a parsed timer and relevant status message
|
||||
* @param {object} time
|
||||
* @param {object} follow
|
||||
* @param {string} selectedId
|
||||
* @return {{timer: number, message: string}}
|
||||
*/
|
||||
export const fetchTimerData = (time, follow, selectedId) => {
|
||||
let message = "";
|
||||
let timer = 0;
|
||||
|
||||
if (selectedId === follow.id) {
|
||||
// check that is not running
|
||||
message = time.playback === 'pause' ? timerMessages.waiting : timerMessages.running;
|
||||
timer = time.running;
|
||||
} else if (time.clock < follow.timeStart) {
|
||||
// if it hasnt started, we count to start
|
||||
message = timerMessages.toStart;
|
||||
timer = millisToSeconds(follow.timeStart - time.clock);
|
||||
} else if (follow.timeStart <= time.clock && time.clock <= follow.timeEnd) {
|
||||
// if it has started, we show running timer
|
||||
message = timerMessages.waiting;
|
||||
timer = time.running;
|
||||
} else {
|
||||
if (follow.timeStart > follow.timeEnd) {
|
||||
// ends day after
|
||||
if (follow.timeStart > time.clock ) {
|
||||
// if it hasnt started, we count to start
|
||||
message = timerMessages.toStart;
|
||||
timer = millisToSeconds(follow.timeStart - time.clock);
|
||||
} else if (follow.timeStart <= time.clock) {
|
||||
// if it has started, we show running timer
|
||||
message = timerMessages.waiting;
|
||||
timer = time.running;
|
||||
} else {
|
||||
// if it has ended, we show how long ago
|
||||
message = timerMessages.ended;
|
||||
timer = millisToSeconds(follow.timeEnd);
|
||||
}
|
||||
} else {
|
||||
// if it has ended, we show how long ago
|
||||
message = timerMessages.ended;
|
||||
timer = millisToSeconds(follow.timeEnd);
|
||||
}
|
||||
}
|
||||
return { message, timer };
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user