refactor: public design review

This commit is contained in:
Carlos Valente
2025-02-15 12:59:06 +01:00
committed by Carlos Valente
parent 1edde03bb4
commit 9c41f8ff6b
20 changed files with 420 additions and 259 deletions
@@ -9,10 +9,6 @@ $progress-bar-br: 3px;
border-radius: $progress-bar-br;
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
overflow: clip;
&--hidden {
display: none;
}
}
.progress-bar__indicator {
@@ -7,16 +7,15 @@ import './ProgressBar.scss';
interface ProgressBarProps {
current: MaybeNumber;
duration: MaybeNumber;
hidden?: boolean;
className?: string;
}
export default function ProgressBar(props: ProgressBarProps) {
const { current, duration, hidden, className = '' } = props;
const { current, duration, className } = props;
const progress = getProgress(current, duration);
return (
<div className={`progress-bar__bg ${hidden ? 'progress-bar__bg--hidden' : ''} ${className}`}>
<div className={`progress-bar__bg ${className}`}>
<div className='progress-bar__indicator' style={{ width: `${progress}%` }} />
</div>
);
@@ -5,7 +5,7 @@
.empty {
width: 100%;
opacity: 0.6;
opacity: 0.8;
}
.text {
@@ -0,0 +1,5 @@
.viewLogo {
max-width: 100%;
max-height: 100%;
display: block;
}
@@ -1,5 +1,7 @@
import { projectLogoPath } from '../../api/constants';
import './ViewLogo.scss';
interface ViewLogoProps {
name: string;
className: string;
@@ -7,5 +9,11 @@ interface ViewLogoProps {
export default function ViewLogo(props: ViewLogoProps) {
const { name, className } = props;
return <img alt='' src={`${projectLogoPath}/${name}`} className={className} />;
// we wrap the image in a div to help maintain the aspect ratio
return (
<div className={className}>
<img alt='' src={`${projectLogoPath}/${name}`} className='viewLogo' />
</div>
);
}
+18 -14
View File
@@ -12,6 +12,7 @@
color: var(--color-override, $viewer-color);
gap: $view-element-gap;
padding: $view-outer-padding;
font-size: $base-font-size;
display: grid;
grid-template-columns: 1fr 40vw;
@@ -28,6 +29,7 @@
left: 0;
right: 0;
margin-top: 25vh;
color: var(--label-color-override, $viewer-label-color);
}
/* =================== HEADER + EXTRAS ===================*/
@@ -82,16 +84,6 @@
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: $view-card-padding;
@@ -106,7 +98,6 @@
}
.timer-group {
grid-area: timer;
border-top: 2px solid var(--background-color-override, $viewer-background-color);
margin-top: max(1vh, 16px);
padding-top: max(1vh, 16px);
@@ -119,7 +110,7 @@
max-width: 7.5em;
}
.aux-timers {
.time-entry {
&__label {
font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color);
@@ -207,14 +198,27 @@
right: 0;
}
.schedule-nav-container {
padding-left: 0;
justify-content: center;
}
.schedule-container {
margin-left: 0;
padding-left: 0;
flex: 1;
}
.info {
flex: 1;
width: 100%;
text-align: right;
}
.qr {
display: none;
}
.info--stretch {
flex: 1;
}
}
}
+46 -48
View File
@@ -15,7 +15,7 @@ import { cx, timerPlaceholderMin } from '../../common/utils/styleUtils';
import { formatTime, getDefaultFormat } from '../../common/utils/time';
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
import { useTranslation } from '../../translation/TranslationProvider';
import BackstageSchedule from '../common/schedule/BackstageSchedule';
import ScheduleExport from '../common/schedule/ScheduleExport';
import { getBackstageOptions, useBackstageOptions } from './backstage.options';
import { getCardData, getIsPendingStart, getShowProgressBar, isOvertime } from './backstage.utils';
@@ -68,6 +68,7 @@ export default function Backstage(props: BackstageProps) {
}, [selectedId]);
// gather card data
const hasEvents = backstageEvents.length > 0;
const { showNow, nowMain, nowSecondary, showNext, nextMain, nextSecondary } = getCardData(
eventNow,
eventNext,
@@ -80,16 +81,18 @@ export default function Backstage(props: BackstageProps) {
const clock = formatTime(time.clock);
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' });
const scheduledStart =
hasEvents && showNow ? '' : formatTime(runtime.plannedStart, { format12: 'hh:mm a', format24: 'HH:mm' });
const scheduledEnd =
hasEvents && showNow ? '' : formatTime(runtime.plannedEnd, { format12: 'hh:mm a', format24: 'HH:mm' });
let stageTimer = millisToString(time.current, { fallback: timerPlaceholderMin });
stageTimer = removeLeadingZero(stageTimer);
let displayTimer = millisToString(time.current, { fallback: timerPlaceholderMin });
displayTimer = removeLeadingZero(displayTimer);
// 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
const showSchedule = hasEvents && screenHeight > 700; // in vertical screens we may not have space
// gather option data
const defaultFormat = getDefaultFormat(settings?.timeFormat);
@@ -107,71 +110,66 @@ export default function Backstage(props: BackstageProps) {
</div>
</div>
<ProgressBar
className='progress-container'
current={time.current}
duration={time.duration}
hidden={!showProgress}
/>
{showProgress && <ProgressBar className='progress-container' current={time.current} duration={time.duration} />}
{backstageEvents.length === 0 && (
<div className='empty-container'>
<Empty text={getLocalizedString('common.no_data')} />
</div>
)}
{!hasEvents && <Empty text={getLocalizedString('common.no_data')} className='empty-container' />}
<div className='card-container'>
{showNow ? (
{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'])}>
<div className='time-entry'>
<div className={cx(['time-entry__label', isPendingStart && 'time-entry--pending'])}>
{isPendingStart ? getLocalizedString('countdown.waiting') : getLocalizedString('common.started_at')}
</div>
<SuperscriptTime time={startedAt} className='aux-timers__value' />
<SuperscriptTime time={startedAt} className='time-entry__value' />
</div>
<div className='timer-gap' />
<div className='aux-timers'>
<div className='aux-timers__label'>{getLocalizedString('common.expected_finish')}</div>
<div className='time-entry'>
<div className='time-entry__label'>{getLocalizedString('common.expected_finish')}</div>
{isOvertime(time.current) ? (
<div className='aux-timers__value'>{getLocalizedString('countdown.overtime')}</div>
<div className='time-entry__value'>{getLocalizedString('countdown.overtime')}</div>
) : (
<SuperscriptTime time={formatTime(time.expectedFinish)} className='aux-timers__value' />
<SuperscriptTime time={formatTime(time.expectedFinish)} className='time-entry__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 className='time-entry'>
<div className='time-entry__label'>{getLocalizedString('common.stage_timer')}</div>
<div className='time-entry__value'>{displayTimer}</div>
</div>
</div>
</div>
)}
{showNext && <TitleCard className='event' label='next' title={nextMain} secondary={nextSecondary} />}
{!showNow && hasEvents && (
<div className='event'>
<div className='title-card__placeholder'>{getLocalizedString('countdown.waiting')}</div>
<div className='timer-group'>
<div className='time-entry'>
<div className={cx(['time-entry__label', isPendingStart && 'time-entry--pending'])}>
{getLocalizedString('common.scheduled_start')}
</div>
<SuperscriptTime time={scheduledStart} className='time-entry__value' />
</div>
<div className='timer-gap' />
<div className='time-entry'>
<div className='time-entry__label'>{getLocalizedString('common.scheduled_end')}</div>
<SuperscriptTime time={scheduledEnd} className='time-entry__value' />
</div>
</div>
</div>
)}
{showNext && hasEvents && (
<TitleCard className='event' label='next' title={nextMain} secondary={nextSecondary} />
)}
</div>
{showSchedule && <BackstageSchedule selectedId={selectedId} />}
{showSchedule && <ScheduleExport selectedId={selectedId} isBackstage />}
<div className='info'>
<div className={cx(['info', !showSchedule && 'info--stretch'])}>
{general.backstageUrl && <QRCode value={general.backstageUrl} size={qrSize} level='L' className='qr' />}
{general.backstageInfo && <div className='info__message'>{general.backstageInfo}</div>}
</div>
@@ -8,7 +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';
import { scheduleOptions } from '../common/schedule/schedule.options';
export const getBackstageOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
@@ -29,26 +29,7 @@ export const getBackstageOptions = (timeFormat: string, customFields: CustomFiel
},
],
},
{
title: OptionTitle.Schedule,
collapsible: true,
options: [
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
{
id: 'cycleInterval',
title: 'Cycle interval',
description: 'How long (in seconds) should each schedule page be shown.',
type: 'number',
defaultValue: 10,
},
],
},
scheduleOptions,
];
};
@@ -75,24 +56,3 @@ export function useBackstageOptions(): BackstageOptions {
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;
}
@@ -1,5 +1,6 @@
import { MaybeNumber, OntimeEvent, Playback, TimerPhase } from 'ontime-types';
import { enDash } from '../../common/utils/styleUtils';
import { getPropertyValue } from '../../features/viewers/common/viewUtils';
/**
@@ -45,16 +46,16 @@ export function getCardData(
}
// if we are loaded, we show the upcoming event as next
const nowMain = getPropertyValue(eventNow, mainSource ?? 'title');
const nowMain = getPropertyValue(eventNow, mainSource ?? 'title') || enDash;
const nowSecondary = getPropertyValue(eventNow, secondarySource);
const nextMain = getPropertyValue(eventNext, mainSource ?? 'title');
const nextMain = getPropertyValue(eventNext, mainSource ?? 'title') || enDash;
const nextSecondary = getPropertyValue(eventNext, secondarySource);
return {
showNow: Boolean(nowMain) || Boolean(nowSecondary),
showNow: eventNow !== null,
nowMain,
nowSecondary,
showNext: Boolean(nextMain) || Boolean(nextSecondary),
showNext: eventNext !== null,
nextMain,
nextSecondary,
};
@@ -1,21 +0,0 @@
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>
);
}
@@ -1,5 +1,8 @@
@use '../../../theme/viewerDefs' as *;
$circle-size: clamp(8px, 0.75vw, 12px);
$indeterminate-width: clamp(32px, 3vw, 48px);
.schedule {
width: 100%;
position: relative;
@@ -17,9 +20,9 @@
.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;
height: $circle-size;
width: $circle-size;
border-radius: $component-border-radius-full;
margin-right: 0.25rem;
}
@@ -49,7 +52,6 @@
}
.entry-title {
font-size: clamp(16px, 1.5vw, 24px);
font-size: $base-font-size;
line-height: 1.2em;
}
@@ -62,10 +64,10 @@
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-right: 8px;
height: $circle-size;
width: $circle-size;
border-radius: $component-border-radius-full;
margin-right: 0.5em;
transition-property: opacity;
transition-duration: 1s;
@@ -77,7 +79,7 @@
}
&--indeterminate {
width: clamp(32px, 3vw, 48px);
width: $indeterminate-width;
}
}
}
@@ -11,7 +11,8 @@ import {
import { isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
import { usePartialRundown } from '../../../common/hooks-query/useRundown';
import { useScheduleOptions } from '../../backstage/backstage.options';
import { useScheduleOptions } from './schedule.options';
interface ScheduleContextState {
events: OntimeEvent[];
@@ -51,6 +52,7 @@ export const ScheduleProvider = ({
const containerRef = useRef<HTMLUListElement>(null);
// After the view is rendered, we paginate by hiding elements that dont fit
useLayoutEffect(() => {
if (!containerRef.current) return;
@@ -5,17 +5,18 @@ import Schedule from './Schedule';
import { ScheduleProvider } from './ScheduleContext';
import ScheduleNav from './ScheduleNav';
interface BackstageScheduleProps {
interface ScheduleExportProps {
selectedId: MaybeString;
isBackstage?: boolean;
}
export default memo(BackstageSchedule);
function BackstageSchedule(props: BackstageScheduleProps) {
const { selectedId } = props;
export default memo(ScheduleExport);
function ScheduleExport(props: ScheduleExportProps) {
const { selectedId, isBackstage } = props;
return (
<ScheduleProvider selectedEventId={selectedId} isBackstage>
<ScheduleNav className='schedule-nav-container' />
<Schedule isProduction className='schedule-container' />
<Schedule isProduction={isBackstage} className='schedule-container' />
</ScheduleProvider>
);
}
@@ -35,13 +35,13 @@ export default function ScheduleItem(props: ScheduleItemProps) {
<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>
@@ -56,7 +56,7 @@ export default function ScheduleItem(props: ScheduleItemProps) {
<div className='entry-times'>
<span className='entry-colour' style={{ backgroundColor: colour }} />
<SuperscriptTime time={start} />
{' → '}
<SuperscriptTime time={end} />
{backstageEvent && '*'}
</div>
@@ -0,0 +1,48 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
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 scheduleOptions: ViewOption = {
title: OptionTitle.Schedule,
collapsible: true,
options: [
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
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 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;
}
+120 -21
View File
@@ -10,23 +10,32 @@
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;
font-size: $base-font-size;
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'
'now schedule-nav'
'info schedule';
.empty-container {
position: absolute;
top: 0;
left: 0;
right: 0;
margin-top: 25vh;
color: var(--label-color-override, $viewer-label-color);
}
/* =================== 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,18 +46,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-size: $timer-value-size;
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
line-height: 0.95em;
@@ -57,50 +70,136 @@
/* =================== MAIN - NOW ===================*/
.now-container {
.card-container {
grid-area: now;
display: flex;
flex-direction: column;
gap: min(2vh, 16px);
gap: $view-element-gap;
}
.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;
}
.timer-group {
border-top: 2px solid var(--background-color-override, $viewer-background-color);
margin-top: max(1vh, 16px);
padding-top: max(1vh, 16px);
display: flex;
row-gap: 0.5em;
}
.time-entry {
&__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);
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;
}
}
}
/* =================== MOBILE ===================*/
@media screen and (max-width: 768px) {
.public-screen {
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-nav-container {
padding-left: 0;
justify-content: center;
}
.schedule-container {
padding-left: 0;
flex: 1;
}
.info {
width: 100%;
text-align: right;
}
.qr {
display: none;
}
.info--stretch {
flex: 1;
}
}
}
+65 -52
View File
@@ -1,28 +1,28 @@
import QRCode from 'react-qr-code';
import { useSearchParams } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { useViewportSize } from '@mantine/hooks';
import { CustomFields, OntimeEvent, ProjectData, Settings } from 'ontime-types';
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 { cx } 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 PublicSchedule from '../common/schedule/PublicSchedule';
import { titleVariants } from '../timer/timer.animations';
import { getIsPendingStart } from '../backstage/backstage.utils';
import ScheduleExport from '../common/schedule/ScheduleExport';
import { getPublicOptions } from './public.options';
import { getPublicOptions, usePublicOptions } from './public.options';
import { getCardData, getFirstStartTime } from './public.utils';
import './Public.scss';
export const MotionTitleCard = motion(TitleCard);
interface BackstageProps {
customFields: CustomFields;
events: OntimeEvent[];
general: ProjectData;
isMirrored: boolean;
publicEventNow: OntimeEvent | null;
@@ -33,73 +33,86 @@ interface BackstageProps {
}
export default function Public(props: BackstageProps) {
const { customFields, general, isMirrored, publicEventNow, publicEventNext, time, publicSelectedId, settings } =
props;
const {
customFields,
events,
general,
isMirrored,
publicEventNow,
publicEventNext,
time,
publicSelectedId,
settings,
} = props;
const { getLocalizedString } = useTranslation();
const [searchParams] = useSearchParams();
const { secondarySource } = usePublicOptions();
const { height: screenHeight } = useViewportSize();
useWindowTitle('Public Schedule');
const clock = formatTime(time.clock);
const qrSize = Math.max(window.innerWidth / 15, 128);
// gather card data
const hasEvents = events.length > 0;
const { showNow, nowMain, nowSecondary, showNext, nextMain, nextSecondary } = getCardData(
publicEventNow,
publicEventNext,
'title',
secondarySource,
time.playback,
);
// gather timer data
const clock = formatTime(time.clock);
const isPendingStart = getIsPendingStart(time.playback, time.phase);
const scheduledStart = hasEvents && showNow ? '' : getFirstStartTime(events[0]);
// gather presentation styles
const qrSize = Math.max(window.innerWidth / 15, 72);
const showSchedule = hasEvents && screenHeight > 700; // in vertical screens we may not have space
// gather option data
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const publicOptions = getPublicOptions(defaultFormat, customFields);
const secondarySource = searchParams.get('secondary-src');
const secondaryTextNext = getPropertyValue(publicEventNext, secondarySource);
const secondaryTextNow = getPropertyValue(publicEventNow, secondarySource);
return (
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
<ViewParamsEditor viewOptions={publicOptions} />
<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' />
</div>
</div>
<div className='now-container'>
<AnimatePresence>
{publicEventNow && (
<MotionTitleCard
className='event now'
key='now'
variants={titleVariants}
initial='hidden'
animate='visible'
exit='exit'
label='now'
title={publicEventNow.title}
secondary={secondaryTextNow}
/>
)}
</AnimatePresence>
{!hasEvents && <Empty text={getLocalizedString('countdown.waiting')} className='empty-container' />}
<AnimatePresence>
{publicEventNext && (
<MotionTitleCard
className='event next'
key='next'
variants={titleVariants}
initial='hidden'
animate='visible'
exit='exit'
label='next'
title={publicEventNext.title}
secondary={secondaryTextNext}
/>
)}
</AnimatePresence>
<div className='card-container'>
{showNow && hasEvents && (
<TitleCard className='event now' label='now' title={nowMain} secondary={nowSecondary} />
)}
{!showNow && scheduledStart && (
<div className='event'>
<div className='title-card__placeholder'>{getLocalizedString('countdown.waiting')}</div>
<div className='timer-group'>
<div className='time-entry'>
<div className={cx(['time-entry__label', isPendingStart && 'time-entry--pending'])}>
{getLocalizedString('common.scheduled_start')}
</div>
<SuperscriptTime time={formatTime(scheduledStart)} className='time-entry__value' />
</div>
</div>
</div>
)}
{showNext && hasEvents && (
<TitleCard className='event next' label='next' title={nextMain} secondary={nextSecondary} />
)}
</div>
<PublicSchedule selectedId={publicSelectedId} />
{showSchedule && <ScheduleExport selectedId={publicSelectedId} />}
<div className='info'>
<div className={cx(['info', !showSchedule && 'info--stretch'])}>
{general.publicUrl && <QRCode value={general.publicUrl} size={qrSize} level='L' className='qr' />}
{general.publicInfo && <div className='info__message'>{general.publicInfo}</div>}
</div>
+29 -28
View File
@@ -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 { scheduleOptions } from '../common/schedule/schedule.options';
export const getPublicOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
@@ -26,32 +29,30 @@ export const getPublicOptions = (timeFormat: string, customFields: CustomFields)
},
],
},
{
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',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
],
},
scheduleOptions,
];
};
type PublicOptions = {
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): PublicOptions {
// 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 usePublicOptions(): PublicOptions {
const [searchParams] = useSearchParams();
const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
return options;
}
@@ -0,0 +1,45 @@
import { OntimeEvent, Playback } from 'ontime-types';
import { enDash } from '../../common/utils/styleUtils';
import { getPropertyValue } from '../../features/viewers/common/viewUtils';
/**
* 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') || enDash;
const nowSecondary = getPropertyValue(eventNow, secondarySource);
const nextMain = getPropertyValue(eventNext, mainSource ?? 'title') || enDash;
const nextSecondary = getPropertyValue(eventNext, secondarySource);
return {
showNow: eventNow !== null,
nowMain,
nowSecondary,
showNext: eventNext !== null,
nextMain,
nextSecondary,
};
}
export function getFirstStartTime(firstPublicEvent: OntimeEvent | null): number | undefined {
return firstPublicEvent?.timeStart;
}
+2 -2
View File
@@ -84,9 +84,9 @@ test('delays are show correctly', async ({ page }) => {
// delay is NOT shown in the public view
await page.goto('http://localhost:4001/public');
await page.getByText('00:1000:20').click();
await page.getByText('00:1000:20').click();
// delay is shown in the backstage view
await page.goto('http://localhost:4001/backstage');
await page.getByText('00:1100:21').click();
await page.getByText('00:1100:21').click();
});