mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-20 22:49:18 +00:00
refactor: backstage design review
refactor: improve empty state refactor: review schedule design - fit as many elements as possible - expose cycle interval as an option - stabilise rundown reference - style tweaks
This commit is contained in:
committed by
Carlos Valente
parent
f99ad83049
commit
946b6717d2
@@ -10,23 +10,31 @@
|
||||
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);
|
||||
gap: $view-element-gap;
|
||||
padding: $view-outer-padding;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 40vw;
|
||||
grid-template-columns: 1fr 40vw;
|
||||
grid-template-rows: auto 12px 1fr auto;
|
||||
grid-template-areas:
|
||||
' header header header'
|
||||
' progress progress schedule-nav'
|
||||
' now now schedule'
|
||||
' info info schedule';
|
||||
'header header'
|
||||
'progress schedule-nav'
|
||||
'card schedule'
|
||||
'info schedule';
|
||||
|
||||
.empty-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 25vh;
|
||||
}
|
||||
|
||||
/* =================== HEADER + EXTRAS ===================*/
|
||||
|
||||
.project-header {
|
||||
grid-area: header;
|
||||
font-size: clamp(32px, 4.5vw, 64px);
|
||||
font-size: $header-font-size;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
@@ -37,19 +45,22 @@
|
||||
max-height: min(100px, 20vh);
|
||||
}
|
||||
|
||||
.title {
|
||||
line-height: 1.1em;
|
||||
}
|
||||
|
||||
.clock-container {
|
||||
margin-left: auto;
|
||||
font-weight: 600;
|
||||
|
||||
.label {
|
||||
font-size: clamp(16px, 1.5vw, 24px);
|
||||
font-weight: 600;
|
||||
font-size: $timer-label-size;
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: clamp(32px, 3.5vw, 50px);
|
||||
font-weight: 600;
|
||||
font-size: $timer-value-size;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
letter-spacing: 0.05em;
|
||||
line-height: 0.95em;
|
||||
@@ -64,17 +75,27 @@
|
||||
margin: 0 auto -8px;
|
||||
}
|
||||
|
||||
.now-container {
|
||||
grid-area: now;
|
||||
.card-container {
|
||||
grid-area: card;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: min(2vh, 16px);
|
||||
gap: $view-element-gap;
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-size: clamp(24px, 2vw, 32px);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
text-align: center;
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
}
|
||||
|
||||
.event {
|
||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
padding: 16px 24px;
|
||||
border-radius: 8px;
|
||||
padding: $view-card-padding;
|
||||
border-radius: $element-border-radius;
|
||||
|
||||
&.blink {
|
||||
animation-name: blink;
|
||||
@@ -87,9 +108,10 @@
|
||||
.timer-group {
|
||||
grid-area: timer;
|
||||
border-top: 2px solid var(--background-color-override, $viewer-background-color);
|
||||
margin-top: 2em;
|
||||
padding-top: 1em;
|
||||
margin-top: max(1vh, 16px);
|
||||
padding-top: max(1vh, 16px);
|
||||
display: flex;
|
||||
row-gap: 0.5em;
|
||||
}
|
||||
|
||||
.timer-gap {
|
||||
@@ -98,58 +120,106 @@
|
||||
}
|
||||
|
||||
.aux-timers {
|
||||
font-size: max(1vw, 16px);
|
||||
|
||||
&__label {
|
||||
font-size: $timer-label-size;
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: $base-font-size;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
font-size: clamp(24px, 2vw, 32px);
|
||||
letter-spacing: 0.05em;
|
||||
line-height: 0.95em;
|
||||
}
|
||||
|
||||
&--pending {
|
||||
color: var(--timer-pending-color-override, $ontime-roll);
|
||||
}
|
||||
}
|
||||
|
||||
/* =================== MAIN - SCHEDULE ===================*/
|
||||
|
||||
$schedule-left-spacing: clamp(16px, 4vw, 64px);
|
||||
.schedule-container {
|
||||
grid-area: schedule;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
margin-left: clamp(16px, 5vw, 64px);
|
||||
padding-left: $schedule-left-spacing;
|
||||
}
|
||||
|
||||
.schedule-nav-container {
|
||||
grid-area: schedule-nav;
|
||||
align-self: center;
|
||||
padding-left: $schedule-left-spacing;
|
||||
}
|
||||
|
||||
/* =================== MAIN - INFO ===================*/
|
||||
|
||||
.info {
|
||||
grid-area: info;
|
||||
display: flex;
|
||||
gap: max(1vw, 16px);
|
||||
align-self: flex-end;
|
||||
overflow: hidden;
|
||||
align-items: end;
|
||||
|
||||
&__message {
|
||||
font-size: clamp(16px, 1.5vw, 24px);
|
||||
line-height: 1.3em;
|
||||
font-size: $base-font-size;
|
||||
line-height: 1.2em;
|
||||
white-space: pre-line;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.qr {
|
||||
padding: 4px;
|
||||
background-color: white;
|
||||
padding: 0.5rem;
|
||||
background-color: $ui-white;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* =================== AMIMATION ===================*/
|
||||
/* =================== MOBILE ===================*/
|
||||
@media screen and (max-width: 768px) {
|
||||
.backstage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
|
||||
.project-header {
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: min(50px, 10vh);
|
||||
}
|
||||
|
||||
.timer-group {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.clock-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.schedule-container {
|
||||
margin-left: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* =================== ANIMATION ===================*/
|
||||
|
||||
@keyframes blink {
|
||||
0% {
|
||||
|
||||
@@ -1,32 +1,27 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import QRCode from 'react-qr-code';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { CustomFields, OntimeEvent, ProjectData, Settings, SupportedEvent } from 'ontime-types';
|
||||
import { useViewportSize } from '@mantine/hooks';
|
||||
import { CustomFields, OntimeEvent, ProjectData, Runtime, Settings } from 'ontime-types';
|
||||
import { millisToString, removeLeadingZero } from 'ontime-utils';
|
||||
|
||||
import ProgressBar from '../../common/components/progress-bar/ProgressBar';
|
||||
import Empty from '../../common/components/state/Empty';
|
||||
import TitleCard from '../../common/components/title-card/TitleCard';
|
||||
import ViewLogo from '../../common/components/view-logo/ViewLogo';
|
||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import { ViewExtendedTimer } from '../../common/models/TimeManager.type';
|
||||
import { timerPlaceholderMin } from '../../common/utils/styleUtils';
|
||||
import { cx, timerPlaceholderMin } from '../../common/utils/styleUtils';
|
||||
import { formatTime, getDefaultFormat } from '../../common/utils/time';
|
||||
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
|
||||
import { getPropertyValue } from '../../features/viewers/common/viewUtils';
|
||||
import { useTranslation } from '../../translation/TranslationProvider';
|
||||
import Schedule from '../common/schedule/Schedule';
|
||||
import { ScheduleProvider } from '../common/schedule/ScheduleContext';
|
||||
import ScheduleNav from '../common/schedule/ScheduleNav';
|
||||
import { titleVariants } from '../timer/timer.animations';
|
||||
import BackstageSchedule from '../common/schedule/BackstageSchedule';
|
||||
|
||||
import { getBackstageOptions } from './backstage.options';
|
||||
import { getBackstageOptions, useBackstageOptions } from './backstage.options';
|
||||
import { getCardData, getIsPendingStart, getShowProgressBar, isOvertime } from './backstage.utils';
|
||||
|
||||
import './Backstage.scss';
|
||||
|
||||
export const MotionTitleCard = motion(TitleCard);
|
||||
|
||||
interface BackstageProps {
|
||||
backstageEvents: OntimeEvent[];
|
||||
customFields: CustomFields;
|
||||
@@ -35,16 +30,29 @@ interface BackstageProps {
|
||||
general: ProjectData;
|
||||
isMirrored: boolean;
|
||||
time: ViewExtendedTimer;
|
||||
runtime: Runtime;
|
||||
selectedId: string | null;
|
||||
settings: Settings | undefined;
|
||||
}
|
||||
|
||||
export default function Backstage(props: BackstageProps) {
|
||||
const { backstageEvents, customFields, eventNext, eventNow, general, time, isMirrored, selectedId, settings } = props;
|
||||
const {
|
||||
backstageEvents,
|
||||
customFields,
|
||||
eventNext,
|
||||
eventNow,
|
||||
general,
|
||||
time,
|
||||
isMirrored,
|
||||
runtime,
|
||||
selectedId,
|
||||
settings,
|
||||
} = props;
|
||||
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const { secondarySource } = useBackstageOptions();
|
||||
const [blinkClass, setBlinkClass] = useState(false);
|
||||
const [searchParams] = useSearchParams();
|
||||
const { height: screenHeight } = useViewportSize();
|
||||
|
||||
useWindowTitle('Backstage');
|
||||
|
||||
@@ -59,22 +67,31 @@ export default function Backstage(props: BackstageProps) {
|
||||
return () => clearTimeout(timer);
|
||||
}, [selectedId]);
|
||||
|
||||
// gather card data
|
||||
const { showNow, nowMain, nowSecondary, showNext, nextMain, nextSecondary } = getCardData(
|
||||
eventNow,
|
||||
eventNext,
|
||||
'title',
|
||||
secondarySource,
|
||||
time.playback,
|
||||
);
|
||||
|
||||
// gather timer data
|
||||
const clock = formatTime(time.clock);
|
||||
const startedAt = formatTime(time.startedAt);
|
||||
const isNegative = (time.current ?? 0) < 0;
|
||||
const expectedFinish = isNegative ? getLocalizedString('countdown.overtime') : formatTime(time.expectedFinish);
|
||||
|
||||
const qrSize = Math.max(window.innerWidth / 15, 128);
|
||||
const filteredEvents = backstageEvents.filter((event) => event.type === SupportedEvent.Event);
|
||||
const showProgress = time.playback !== 'stop';
|
||||
|
||||
const secondarySource = searchParams.get('secondary-src');
|
||||
const secondaryTextNext = getPropertyValue(eventNext, secondarySource);
|
||||
const secondaryTextNow = getPropertyValue(eventNow, secondarySource);
|
||||
const isPendingStart = getIsPendingStart(time.playback, time.phase);
|
||||
const startedAt = isPendingStart ? formatTime(time.secondaryTimer) : formatTime(time.startedAt);
|
||||
const scheduledStart = showNow ? '' : formatTime(runtime.plannedStart, { format12: 'hh:mm a', format24: 'HH:mm' });
|
||||
const scheduledEnd = showNow ? '' : formatTime(runtime.plannedEnd, { format12: 'hh:mm a', format24: 'HH:mm' });
|
||||
|
||||
let stageTimer = millisToString(time.current, { fallback: timerPlaceholderMin });
|
||||
stageTimer = removeLeadingZero(stageTimer);
|
||||
|
||||
// gather presentation styles
|
||||
const qrSize = Math.max(window.innerWidth / 15, 72);
|
||||
const showProgress = getShowProgressBar(time.playback);
|
||||
const showSchedule = screenHeight > 700; // in vertical screens we may not have space
|
||||
|
||||
// gather option data
|
||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||
const backstageOptions = getBackstageOptions(defaultFormat, customFields);
|
||||
|
||||
@@ -82,8 +99,8 @@ export default function Backstage(props: BackstageProps) {
|
||||
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
|
||||
<ViewParamsEditor viewOptions={backstageOptions} />
|
||||
<div className='project-header'>
|
||||
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
|
||||
{general.title}
|
||||
{general?.projectLogo ? <ViewLogo name={general.projectLogo} className='logo' /> : <div className='logo' />}
|
||||
<div className='title'>{general.title}</div>
|
||||
<div className='clock-container'>
|
||||
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
||||
<SuperscriptTime time={clock} className='time' />
|
||||
@@ -97,63 +114,62 @@ export default function Backstage(props: BackstageProps) {
|
||||
hidden={!showProgress}
|
||||
/>
|
||||
|
||||
<div className='now-container'>
|
||||
<AnimatePresence>
|
||||
{eventNow && (
|
||||
<motion.div
|
||||
className={`event now ${blinkClass ? 'blink' : ''}`}
|
||||
key='now'
|
||||
variants={titleVariants}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
>
|
||||
<TitleCard title={eventNow.title} secondary={secondaryTextNow} />
|
||||
<div className='timer-group'>
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>{getLocalizedString('common.started_at')}</div>
|
||||
<SuperscriptTime time={startedAt} className='aux-timers__value' />
|
||||
</div>
|
||||
<div className='timer-gap' />
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>{getLocalizedString('common.expected_finish')}</div>
|
||||
{isNegative ? (
|
||||
<div className='aux-timers__value'>{expectedFinish}</div>
|
||||
) : (
|
||||
<SuperscriptTime time={expectedFinish} className='aux-timers__value' />
|
||||
)}
|
||||
</div>
|
||||
<div className='timer-gap' />
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>{getLocalizedString('common.stage_timer')}</div>
|
||||
<div className='aux-timers__value'>{stageTimer}</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{backstageEvents.length === 0 && (
|
||||
<div className='empty-container'>
|
||||
<Empty text={getLocalizedString('common.no_data')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{eventNext && (
|
||||
<MotionTitleCard
|
||||
className='event next'
|
||||
key='next'
|
||||
variants={titleVariants}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
label='next'
|
||||
title={eventNext.title}
|
||||
secondary={secondaryTextNext}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div className='card-container'>
|
||||
{showNow ? (
|
||||
<div className={cx(['event', 'now', blinkClass && 'blink'])}>
|
||||
<TitleCard title={nowMain} secondary={nowSecondary} />
|
||||
<div className='timer-group'>
|
||||
<div className='aux-timers'>
|
||||
<div className={cx(['aux-timers__label', isPendingStart && 'aux-timers--pending'])}>
|
||||
{isPendingStart ? getLocalizedString('countdown.waiting') : getLocalizedString('common.started_at')}
|
||||
</div>
|
||||
<SuperscriptTime time={startedAt} className='aux-timers__value' />
|
||||
</div>
|
||||
<div className='timer-gap' />
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>{getLocalizedString('common.expected_finish')}</div>
|
||||
{isOvertime(time.current) ? (
|
||||
<div className='aux-timers__value'>{getLocalizedString('countdown.overtime')}</div>
|
||||
) : (
|
||||
<SuperscriptTime time={formatTime(time.expectedFinish)} className='aux-timers__value' />
|
||||
)}
|
||||
</div>
|
||||
<div className='timer-gap' />
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>{getLocalizedString('common.stage_timer')}</div>
|
||||
<div className='aux-timers__value'>{stageTimer}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='event'>
|
||||
<div className='title-card__placeholder'>{getLocalizedString('countdown.waiting')}</div>
|
||||
<div className='timer-group'>
|
||||
<div className='aux-timers'>
|
||||
<div className={cx(['aux-timers__label', isPendingStart && 'aux-timers--pending'])}>
|
||||
{getLocalizedString('common.scheduled_start')}
|
||||
</div>
|
||||
<SuperscriptTime time={scheduledStart} className='aux-timers__value' />
|
||||
</div>
|
||||
<div className='timer-gap' />
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>{getLocalizedString('common.scheduled_end')}</div>
|
||||
<SuperscriptTime time={scheduledEnd} className='aux-timers__value' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNext && <TitleCard className='event' label='next' title={nextMain} secondary={nextSecondary} />}
|
||||
</div>
|
||||
|
||||
<ScheduleProvider events={filteredEvents} selectedEventId={selectedId} isBackstage>
|
||||
<ScheduleNav className='schedule-nav-container' />
|
||||
<Schedule isProduction className='schedule-container' />
|
||||
</ScheduleProvider>
|
||||
{showSchedule && <BackstageSchedule selectedId={selectedId} />}
|
||||
|
||||
<div className='info'>
|
||||
{general.backstageUrl && <QRCode value={general.backstageUrl} size={qrSize} level='L' className='qr' />}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { CustomFields } from 'ontime-types';
|
||||
import { useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { CustomFields, OntimeEvent } from 'ontime-types';
|
||||
|
||||
import {
|
||||
getTimeOption,
|
||||
@@ -6,6 +8,7 @@ import {
|
||||
OptionTitle,
|
||||
} from '../../common/components/view-params-editor/constants';
|
||||
import { ViewOption } from '../../common/components/view-params-editor/types';
|
||||
import { isStringBoolean } from '../../features/viewers/common/viewUtils';
|
||||
|
||||
export const getBackstageOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
|
||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
|
||||
@@ -26,25 +29,10 @@ export const getBackstageOptions = (timeFormat: string, customFields: CustomFiel
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
title: OptionTitle.Schedule,
|
||||
collapsible: true,
|
||||
options: [
|
||||
{
|
||||
id: 'eventsPerPage',
|
||||
title: 'Events per page',
|
||||
description: 'Sets the number of events on the page, can cause overflow',
|
||||
type: 'number',
|
||||
placeholder: '8 (default)',
|
||||
},
|
||||
{
|
||||
id: 'hidePast',
|
||||
title: 'Hide past events',
|
||||
description: 'Scheduler will only show upcoming events',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'stopCycle',
|
||||
title: 'Stop cycling through event pages',
|
||||
@@ -52,7 +40,59 @@ export const getBackstageOptions = (timeFormat: string, customFields: CustomFiel
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'cycleInterval',
|
||||
title: 'Cycle interval',
|
||||
description: 'How long (in seconds) should each schedule page be shown.',
|
||||
type: 'number',
|
||||
defaultValue: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
type BackstageOptions = {
|
||||
secondarySource: keyof OntimeEvent | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility extract the view options from URL Params
|
||||
* the names and fallback are manually matched with timerOptions
|
||||
*/
|
||||
function getOptionsFromParams(searchParams: URLSearchParams): BackstageOptions {
|
||||
// we manually make an object that matches the key above
|
||||
return {
|
||||
secondarySource: searchParams.get('secondary-src') as keyof OntimeEvent | null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook exposes the backstage view options
|
||||
*/
|
||||
export function useBackstageOptions(): BackstageOptions {
|
||||
const [searchParams] = useSearchParams();
|
||||
const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
|
||||
return options;
|
||||
}
|
||||
|
||||
type ScheduleOptions = {
|
||||
cycleInterval: number;
|
||||
stopCycle: boolean;
|
||||
};
|
||||
|
||||
function getScheduleOptionsFromParams(searchParams: URLSearchParams): ScheduleOptions {
|
||||
return {
|
||||
cycleInterval: Number(searchParams.get('cycleInterval')) || 10,
|
||||
stopCycle: isStringBoolean(searchParams.get('stopCycle')),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook exposes the schedule component options
|
||||
*/
|
||||
export function useScheduleOptions() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const options = useMemo(() => getScheduleOptionsFromParams(searchParams), [searchParams]);
|
||||
return options;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { MaybeNumber, OntimeEvent, Playback, TimerPhase } from 'ontime-types';
|
||||
|
||||
import { getPropertyValue } from '../../features/viewers/common/viewUtils';
|
||||
|
||||
/**
|
||||
* Whether the current time is in overtime
|
||||
*/
|
||||
export function isOvertime(current: MaybeNumber): boolean {
|
||||
return (current ?? 0) < 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the progress bar should be shown
|
||||
*/
|
||||
export function getShowProgressBar(playback: Playback): boolean {
|
||||
return playback !== Playback.Stop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the playback is pending start (ie: Roll mode waiting to start)
|
||||
*/
|
||||
export function getIsPendingStart(playback: Playback, phase: TimerPhase): boolean {
|
||||
return playback === Playback.Roll && phase === TimerPhase.Pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* What should we be showing in the cards?
|
||||
*/
|
||||
export function getCardData(
|
||||
eventNow: OntimeEvent | null,
|
||||
eventNext: OntimeEvent | null,
|
||||
mainSource: keyof OntimeEvent | null,
|
||||
secondarySource: keyof OntimeEvent | null,
|
||||
playback: Playback,
|
||||
) {
|
||||
if (playback === Playback.Stop) {
|
||||
return {
|
||||
showNow: false,
|
||||
nowMain: undefined,
|
||||
nowSecondary: undefined,
|
||||
showNext: false,
|
||||
nextMain: undefined,
|
||||
nextSecondary: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// if we are loaded, we show the upcoming event as next
|
||||
const nowMain = getPropertyValue(eventNow, mainSource ?? 'title');
|
||||
const nowSecondary = getPropertyValue(eventNow, secondarySource);
|
||||
const nextMain = getPropertyValue(eventNext, mainSource ?? 'title');
|
||||
const nextSecondary = getPropertyValue(eventNext, secondarySource);
|
||||
|
||||
return {
|
||||
showNow: Boolean(nowMain) || Boolean(nowSecondary),
|
||||
nowMain,
|
||||
nowSecondary,
|
||||
showNext: Boolean(nextMain) || Boolean(nextSecondary),
|
||||
nextMain,
|
||||
nextSecondary,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { memo } from 'react';
|
||||
import { MaybeString } from 'ontime-types';
|
||||
|
||||
import Schedule from './Schedule';
|
||||
import { ScheduleProvider } from './ScheduleContext';
|
||||
import ScheduleNav from './ScheduleNav';
|
||||
|
||||
interface BackstageScheduleProps {
|
||||
selectedId: MaybeString;
|
||||
}
|
||||
|
||||
export default memo(BackstageSchedule);
|
||||
function BackstageSchedule(props: BackstageScheduleProps) {
|
||||
const { selectedId } = props;
|
||||
return (
|
||||
<ScheduleProvider selectedEventId={selectedId} isBackstage>
|
||||
<ScheduleNav className='schedule-nav-container' />
|
||||
<Schedule isProduction className='schedule-container' />
|
||||
</ScheduleProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { memo } from 'react';
|
||||
import { MaybeString } from 'ontime-types';
|
||||
|
||||
import Schedule from './Schedule';
|
||||
import { ScheduleProvider } from './ScheduleContext';
|
||||
import ScheduleNav from './ScheduleNav';
|
||||
|
||||
interface PublicScheduleProps {
|
||||
selectedId: MaybeString;
|
||||
}
|
||||
|
||||
export default memo(PublicSchedule);
|
||||
function PublicSchedule(props: PublicScheduleProps) {
|
||||
const { selectedId } = props;
|
||||
return (
|
||||
<ScheduleProvider selectedEventId={selectedId}>
|
||||
<ScheduleNav className='schedule-nav-container' />
|
||||
<Schedule className='schedule-container' />
|
||||
</ScheduleProvider>
|
||||
);
|
||||
}
|
||||
@@ -2,75 +2,82 @@
|
||||
|
||||
.schedule {
|
||||
width: 100%;
|
||||
border-spacing: 50px;
|
||||
position: relative;
|
||||
list-style: none;
|
||||
|
||||
.entry {
|
||||
font-size: clamp(16px, 1.5vw, 24px);
|
||||
position: absolute;
|
||||
padding-bottom: 1em;
|
||||
|
||||
.entry-colour {
|
||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
height: clamp(8px, 0.75vw, 12px);
|
||||
width: clamp(8px, 0.75vw, 12px);
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
&--skip {
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.entry-times {
|
||||
font-family: $viewer-font-family;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
font-weight: 300;
|
||||
letter-spacing: 0.05em;
|
||||
.entry-colour {
|
||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
height: clamp(8px, 0.75vw, 12px);
|
||||
width: clamp(8px, 0.75vw, 12px);
|
||||
border-radius: 6px;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.entry-times {
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
letter-spacing: 0.05em;
|
||||
font-size: $timer-label-size;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
|
||||
&--delayed,
|
||||
&--delay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.entry-title {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.entry-secondary {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
&--past {
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
}
|
||||
|
||||
&--now {
|
||||
.entry-title {
|
||||
color: var(--accent-color-override, $accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&.skip {
|
||||
&--delayed {
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: $ontime-delay;
|
||||
}
|
||||
|
||||
&--delay {
|
||||
color: $ontime-delay-text;
|
||||
}
|
||||
}
|
||||
|
||||
.entry-title {
|
||||
font-size: clamp(16px, 1.5vw, 24px);
|
||||
font-size: $base-font-size;
|
||||
line-height: 1.2em;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-nav {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
.schedule-nav__item {
|
||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: var(--color-override, $viewer-color);
|
||||
opacity: 0.2;
|
||||
|
||||
height: clamp(8px, 0.75vw, 12px);
|
||||
width: clamp(8px, 0.75vw, 12px);
|
||||
border-radius: 6px;
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
|
||||
transition-property: opacity;
|
||||
transition-duration: 1s;
|
||||
|
||||
&--selected {
|
||||
background-color: var(--color-override, $viewer-color);
|
||||
transition-property: opacity;
|
||||
transition-duration: $viewer-transition-time;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&--indeterminate {
|
||||
width: clamp(32px, 3vw, 48px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
|
||||
import { getScheduledTimes } from './schedule.utils';
|
||||
import { useSchedule } from './ScheduleContext';
|
||||
import ScheduleItem from './ScheduleItem';
|
||||
|
||||
@@ -9,41 +12,27 @@ interface ScheduleProps {
|
||||
}
|
||||
|
||||
export default function Schedule({ isProduction, className }: ScheduleProps) {
|
||||
const { paginatedEvents, selectedEventId, isBackstage, scheduleType } = useSchedule();
|
||||
const { events, isBackstage, containerRef } = useSchedule();
|
||||
|
||||
// TODO: design a nice placeholder for empty schedules
|
||||
if (paginatedEvents?.length < 1) {
|
||||
if (events?.length < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let selectedState: 'past' | 'now' | 'future' = 'past';
|
||||
|
||||
return (
|
||||
<ul className={`schedule ${className}`}>
|
||||
{paginatedEvents.map((event) => {
|
||||
if (scheduleType === 'past' || scheduleType === 'future') {
|
||||
selectedState = scheduleType;
|
||||
} else {
|
||||
if (event.id === selectedEventId) {
|
||||
selectedState = 'now';
|
||||
} else if (selectedState === 'now') {
|
||||
selectedState = 'future';
|
||||
}
|
||||
}
|
||||
|
||||
const timeStart = isProduction ? event.timeStart + (event?.delay ?? 0) : event.timeStart;
|
||||
const timeEnd = isProduction ? event.timeEnd + (event?.delay ?? 0) : event.timeEnd;
|
||||
<ul className={cx(['schedule', className])} ref={containerRef}>
|
||||
{events.map((event) => {
|
||||
const { timeStart, timeEnd, delay } = getScheduledTimes(event, isProduction);
|
||||
|
||||
return (
|
||||
<ScheduleItem
|
||||
key={event.id}
|
||||
selected={selectedState}
|
||||
timeStart={timeStart}
|
||||
timeEnd={timeEnd}
|
||||
title={event.title}
|
||||
colour={isBackstage ? event.colour : ''}
|
||||
colour={isBackstage ? event.colour : undefined}
|
||||
backstageEvent={!event.isPublic}
|
||||
skip={event.skip}
|
||||
delay={delay}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,91 +1,155 @@
|
||||
import { createContext, PropsWithChildren, useContext, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
import {
|
||||
createContext,
|
||||
PropsWithChildren,
|
||||
RefObject,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
|
||||
|
||||
import { useInterval } from '../../../common/hooks/useInterval';
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
import { usePartialRundown } from '../../../common/hooks-query/useRundown';
|
||||
import { useScheduleOptions } from '../../backstage/backstage.options';
|
||||
|
||||
interface ScheduleContextState {
|
||||
events: OntimeEvent[];
|
||||
paginatedEvents: OntimeEvent[];
|
||||
selectedEventId: string | null;
|
||||
scheduleType: 'past' | 'now' | 'future';
|
||||
numPages: number;
|
||||
visiblePage: number;
|
||||
isBackstage: boolean;
|
||||
containerRef: RefObject<HTMLUListElement>;
|
||||
}
|
||||
|
||||
const ScheduleContext = createContext<ScheduleContextState | undefined>(undefined);
|
||||
|
||||
interface ScheduleProviderProps {
|
||||
events: OntimeEvent[];
|
||||
selectedEventId: string | null;
|
||||
isBackstage?: boolean;
|
||||
time?: number;
|
||||
}
|
||||
|
||||
const numEventsPerPage = 8;
|
||||
|
||||
export const ScheduleProvider = ({
|
||||
children,
|
||||
events,
|
||||
selectedEventId,
|
||||
isBackstage = false,
|
||||
time = 10,
|
||||
}: PropsWithChildren<ScheduleProviderProps>) => {
|
||||
const [visiblePage, setVisiblePage] = useState(0);
|
||||
const [searchParams] = useSearchParams();
|
||||
const { cycleInterval, stopCycle } = useScheduleOptions();
|
||||
const { data: events } = usePartialRundown((event: OntimeRundownEntry) => {
|
||||
if (isBackstage) {
|
||||
return isOntimeEvent(event);
|
||||
}
|
||||
return isOntimeEvent(event) && event.isPublic && !event.skip;
|
||||
});
|
||||
|
||||
// look for overrides from views
|
||||
const hidePast = isStringBoolean(searchParams.get('hidePast'));
|
||||
const stopCycle = isStringBoolean(searchParams.get('stopCycle'));
|
||||
const eventsPerPage = Number(searchParams.get('eventsPerPage') ?? numEventsPerPage);
|
||||
const [firstIndex, setFirstIndex] = useState(-1);
|
||||
const [numPages, setNumPages] = useState(0);
|
||||
const [visiblePage, setVisiblePage] = useState(0);
|
||||
|
||||
const lastIndex = useRef(-1);
|
||||
const paginator = useRef<NodeJS.Timeout>();
|
||||
|
||||
const containerRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const children = Array.from(containerRef.current.children) as HTMLElement[];
|
||||
if (children.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerHeight = containerRef.current.clientHeight;
|
||||
let currentPageHeight = 0; // used to check when we need to paginate
|
||||
let currentPage = 1;
|
||||
let numPages = 1;
|
||||
let lastVisibleIndex = -1; // keep track of last index on screen
|
||||
let isShowingElements = false;
|
||||
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const currentElementHeight = children[i].clientHeight;
|
||||
|
||||
// can we fit this element in the current page?
|
||||
const isNextPage = currentPageHeight + currentElementHeight > containerHeight;
|
||||
if (isNextPage) {
|
||||
currentPageHeight = 0;
|
||||
numPages += 1;
|
||||
}
|
||||
|
||||
// we hide elements that are before and after the first element to show
|
||||
if (i < firstIndex) {
|
||||
hideElement(children[i]);
|
||||
} else if (lastVisibleIndex === -1) {
|
||||
isShowingElements = true;
|
||||
currentPage = numPages;
|
||||
} else if (isNextPage) {
|
||||
isShowingElements = false;
|
||||
}
|
||||
|
||||
if (!isShowingElements) {
|
||||
hideElement(children[i]);
|
||||
} else {
|
||||
lastVisibleIndex = i;
|
||||
showElement(children[i], currentPageHeight);
|
||||
}
|
||||
|
||||
currentPageHeight += currentElementHeight;
|
||||
}
|
||||
|
||||
setVisiblePage(currentPage);
|
||||
setNumPages(numPages);
|
||||
lastIndex.current = lastVisibleIndex;
|
||||
|
||||
function showElement(element: HTMLElement, yPosition: number) {
|
||||
element.style.top = `${yPosition}px`;
|
||||
}
|
||||
|
||||
function hideElement(element: HTMLElement) {
|
||||
element.style.top = `${-1000}px`;
|
||||
}
|
||||
// we need to add the events to make sure the effect runs on first render
|
||||
}, [firstIndex, events]);
|
||||
|
||||
// schedule cycling through events
|
||||
useEffect(() => {
|
||||
if (stopCycle) {
|
||||
setVisiblePage(1);
|
||||
setFirstIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (paginator.current) {
|
||||
clearInterval(paginator.current);
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
// ensure we cycle back to the first event
|
||||
if (visiblePage === numPages) {
|
||||
setFirstIndex(0);
|
||||
} else {
|
||||
setFirstIndex(lastIndex.current + 1);
|
||||
}
|
||||
}, cycleInterval * 1000);
|
||||
paginator.current = interval;
|
||||
|
||||
return () => clearInterval(paginator.current);
|
||||
}, [cycleInterval, numPages, stopCycle, visiblePage]);
|
||||
|
||||
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
|
||||
|
||||
const viewEvents = [...events];
|
||||
if (hidePast) {
|
||||
// we want to show the event after the next
|
||||
viewEvents.splice(0, selectedEventIndex + 2);
|
||||
selectedEventIndex = 0;
|
||||
}
|
||||
|
||||
const numPages = Math.ceil(viewEvents.length / eventsPerPage);
|
||||
const eventStart = eventsPerPage * visiblePage;
|
||||
const eventEnd = eventsPerPage * (visiblePage + 1);
|
||||
const paginatedEvents = viewEvents.slice(eventStart, eventEnd);
|
||||
|
||||
const resolveScheduleType = () => {
|
||||
if (selectedEventIndex >= eventStart && selectedEventIndex < eventEnd) {
|
||||
return 'now';
|
||||
}
|
||||
if (selectedEventIndex > eventEnd) {
|
||||
return 'past';
|
||||
}
|
||||
return 'future';
|
||||
};
|
||||
const scheduleType = resolveScheduleType();
|
||||
|
||||
// every SCROLL_TIME go to the next array
|
||||
useInterval(() => {
|
||||
if (stopCycle) {
|
||||
setVisiblePage(0);
|
||||
} else if (events.length > eventsPerPage) {
|
||||
const next = (visiblePage + 1) % numPages;
|
||||
setVisiblePage(next);
|
||||
}
|
||||
}, time * 1000);
|
||||
// we want to show the event after the current
|
||||
const viewEvents = events.toSpliced(0, selectedEventIndex + 1);
|
||||
selectedEventIndex = 0;
|
||||
|
||||
return (
|
||||
<ScheduleContext.Provider
|
||||
value={{
|
||||
events,
|
||||
paginatedEvents,
|
||||
events: viewEvents as OntimeEvent[],
|
||||
selectedEventId,
|
||||
scheduleType,
|
||||
numPages,
|
||||
visiblePage,
|
||||
isBackstage,
|
||||
containerRef,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
import SuperscriptTime from '../../../features/viewers/common/superscript-time/SuperscriptTime';
|
||||
|
||||
@@ -9,33 +10,55 @@ const formatOptions = {
|
||||
};
|
||||
|
||||
interface ScheduleItemProps {
|
||||
selected: 'past' | 'now' | 'future';
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
title: string;
|
||||
backstageEvent: boolean;
|
||||
colour: string;
|
||||
skip: boolean;
|
||||
colour?: string;
|
||||
skip?: boolean;
|
||||
delay: number;
|
||||
}
|
||||
|
||||
export default function ScheduleItem(props: ScheduleItemProps) {
|
||||
const { selected, timeStart, timeEnd, title, backstageEvent, colour, skip } = props;
|
||||
const { timeStart, timeEnd, title, backstageEvent, colour, skip, delay } = props;
|
||||
|
||||
const start = formatTime(timeStart, formatOptions);
|
||||
const end = formatTime(timeEnd, formatOptions);
|
||||
const userColour = colour !== '' ? colour : '';
|
||||
const selectStyle = `entry--${selected}`;
|
||||
|
||||
if (delay > 0) {
|
||||
const delayedStart = formatTime(timeStart + delay, formatOptions);
|
||||
const delayedEnd = formatTime(timeEnd + delay, formatOptions);
|
||||
|
||||
return (
|
||||
<li className={cx(['entry', skip && 'entry--skip'])}>
|
||||
<div className='entry-times'>
|
||||
<span className='entry-times--delayed'>
|
||||
<span className='entry-colour' style={{ backgroundColor: colour }} />
|
||||
<SuperscriptTime time={start} />
|
||||
{' → '}
|
||||
<SuperscriptTime time={end} />
|
||||
{backstageEvent && '*'}
|
||||
</span>
|
||||
<span className='entry-times--delay'>
|
||||
<SuperscriptTime time={delayedStart} />
|
||||
{' → '}
|
||||
<SuperscriptTime time={delayedEnd} />
|
||||
{backstageEvent && '*'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='entry-title'>{title}</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={`entry ${selectStyle} ${skip ? 'skip' : ''}`}>
|
||||
<li className={cx(['entry', skip && 'entry--skip'])}>
|
||||
<div className='entry-times'>
|
||||
<span className='entry-colour' style={{ backgroundColor: userColour }} />
|
||||
<div style={{ display: 'flex' }}>
|
||||
<SuperscriptTime time={start} />
|
||||
{' → '}
|
||||
<SuperscriptTime time={end} />
|
||||
{backstageEvent ? '*' : ''}
|
||||
</div>
|
||||
<span className='entry-colour' style={{ backgroundColor: colour }} />
|
||||
<SuperscriptTime time={start} />
|
||||
{' → '}
|
||||
<SuperscriptTime time={end} />
|
||||
{backstageEvent && '*'}
|
||||
</div>
|
||||
<div className='entry-title'>{title}</div>
|
||||
</li>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
|
||||
import { useSchedule } from './ScheduleContext';
|
||||
|
||||
import './Schedule.scss';
|
||||
@@ -9,13 +11,38 @@ interface ScheduleNavProps {
|
||||
export default function ScheduleNav({ className }: ScheduleNavProps) {
|
||||
const { numPages, visiblePage } = useSchedule();
|
||||
|
||||
// cap the amount of elements to 11
|
||||
if (numPages > 10) {
|
||||
return (
|
||||
<div className={cx(['schedule-nav', className])}>
|
||||
<div className={cx(['schedule-nav__item', visiblePage === 1 && 'schedule-nav__item--selected'])} />
|
||||
<div className={cx(['schedule-nav__item', visiblePage === 2 && 'schedule-nav__item--selected'])} />
|
||||
<div className={cx(['schedule-nav__item', visiblePage === 3 && 'schedule-nav__item--selected'])} />
|
||||
<div className={cx(['schedule-nav__item', visiblePage === 4 && 'schedule-nav__item--selected'])} />
|
||||
<div className={cx(['schedule-nav__item', visiblePage === 5 && 'schedule-nav__item--selected'])} />
|
||||
<div
|
||||
className={cx([
|
||||
'schedule-nav__item',
|
||||
'schedule-nav__item--indeterminate',
|
||||
visiblePage > 5 && visiblePage < numPages - 4 && 'schedule-nav__item--selected',
|
||||
])}
|
||||
/>
|
||||
<div className={cx(['schedule-nav__item', visiblePage === numPages - 4 && 'schedule-nav__item--selected'])} />
|
||||
<div className={cx(['schedule-nav__item', visiblePage === numPages - 3 && 'schedule-nav__item--selected'])} />
|
||||
<div className={cx(['schedule-nav__item', visiblePage === numPages - 2 && 'schedule-nav__item--selected'])} />
|
||||
<div className={cx(['schedule-nav__item', visiblePage === numPages - 1 && 'schedule-nav__item--selected'])} />
|
||||
<div className={cx(['schedule-nav__item', visiblePage === numPages && 'schedule-nav__item--selected'])} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`schedule-nav ${className}`}>
|
||||
<div className={cx(['schedule-nav', className])}>
|
||||
{numPages > 1 &&
|
||||
[...Array(numPages).keys()].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={i === visiblePage ? 'schedule-nav__item schedule-nav__item--selected' : 'schedule-nav__item'}
|
||||
className={cx(['schedule-nav__item', i + 1 === visiblePage && 'schedule-nav__item--selected'])}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
|
||||
/**
|
||||
* Gather rules for how to present scheduled times
|
||||
*/
|
||||
export function getScheduledTimes(event: OntimeEvent, isProduction?: boolean) {
|
||||
if (isProduction) {
|
||||
return {
|
||||
timeStart: event.timeStart,
|
||||
timeEnd: event.timeEnd,
|
||||
delay: event.skip ? 0 : event.delay,
|
||||
};
|
||||
}
|
||||
return {
|
||||
timeStart: event.timeStart,
|
||||
timeEnd: event.timeEnd,
|
||||
delay: 0,
|
||||
};
|
||||
}
|
||||
@@ -198,8 +198,8 @@
|
||||
/* =================== LOGO ===================*/
|
||||
.logo {
|
||||
position: absolute;
|
||||
top: min(2vh, 16px);
|
||||
left: min(2vw, 16px);
|
||||
top: $view-block-padding;
|
||||
left: $view-inline-padding;
|
||||
max-width: min(200px, 20vw);
|
||||
max-height: min(100px, 20vh);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user