mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-22 15:39:11 +00:00
refactor: public design review
This commit is contained in:
committed by
Carlos Valente
parent
1edde03bb4
commit
9c41f8ff6b
@@ -9,10 +9,6 @@ $progress-bar-br: 3px;
|
|||||||
border-radius: $progress-bar-br;
|
border-radius: $progress-bar-br;
|
||||||
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
|
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
|
||||||
overflow: clip;
|
overflow: clip;
|
||||||
|
|
||||||
&--hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar__indicator {
|
.progress-bar__indicator {
|
||||||
|
|||||||
@@ -7,16 +7,15 @@ import './ProgressBar.scss';
|
|||||||
interface ProgressBarProps {
|
interface ProgressBarProps {
|
||||||
current: MaybeNumber;
|
current: MaybeNumber;
|
||||||
duration: MaybeNumber;
|
duration: MaybeNumber;
|
||||||
hidden?: boolean;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProgressBar(props: ProgressBarProps) {
|
export default function ProgressBar(props: ProgressBarProps) {
|
||||||
const { current, duration, hidden, className = '' } = props;
|
const { current, duration, className } = props;
|
||||||
const progress = getProgress(current, duration);
|
const progress = getProgress(current, duration);
|
||||||
|
|
||||||
return (
|
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 className='progress-bar__indicator' style={{ width: `${progress}%` }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
opacity: 0.6;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.viewLogo {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { projectLogoPath } from '../../api/constants';
|
import { projectLogoPath } from '../../api/constants';
|
||||||
|
|
||||||
|
import './ViewLogo.scss';
|
||||||
|
|
||||||
interface ViewLogoProps {
|
interface ViewLogoProps {
|
||||||
name: string;
|
name: string;
|
||||||
className: string;
|
className: string;
|
||||||
@@ -7,5 +9,11 @@ interface ViewLogoProps {
|
|||||||
|
|
||||||
export default function ViewLogo(props: ViewLogoProps) {
|
export default function ViewLogo(props: ViewLogoProps) {
|
||||||
const { name, className } = props;
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
color: var(--color-override, $viewer-color);
|
color: var(--color-override, $viewer-color);
|
||||||
gap: $view-element-gap;
|
gap: $view-element-gap;
|
||||||
padding: $view-outer-padding;
|
padding: $view-outer-padding;
|
||||||
|
font-size: $base-font-size;
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 40vw;
|
grid-template-columns: 1fr 40vw;
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
margin-top: 25vh;
|
margin-top: 25vh;
|
||||||
|
color: var(--label-color-override, $viewer-label-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =================== HEADER + EXTRAS ===================*/
|
/* =================== HEADER + EXTRAS ===================*/
|
||||||
@@ -82,16 +84,6 @@
|
|||||||
gap: $view-element-gap;
|
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 {
|
.event {
|
||||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||||
padding: $view-card-padding;
|
padding: $view-card-padding;
|
||||||
@@ -106,7 +98,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.timer-group {
|
.timer-group {
|
||||||
grid-area: timer;
|
|
||||||
border-top: 2px solid var(--background-color-override, $viewer-background-color);
|
border-top: 2px solid var(--background-color-override, $viewer-background-color);
|
||||||
margin-top: max(1vh, 16px);
|
margin-top: max(1vh, 16px);
|
||||||
padding-top: max(1vh, 16px);
|
padding-top: max(1vh, 16px);
|
||||||
@@ -119,7 +110,7 @@
|
|||||||
max-width: 7.5em;
|
max-width: 7.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aux-timers {
|
.time-entry {
|
||||||
&__label {
|
&__label {
|
||||||
font-size: $timer-label-size;
|
font-size: $timer-label-size;
|
||||||
color: var(--label-color-override, $viewer-label-color);
|
color: var(--label-color-override, $viewer-label-color);
|
||||||
@@ -207,14 +198,27 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.schedule-nav-container {
|
||||||
|
padding-left: 0;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.schedule-container {
|
.schedule-container {
|
||||||
margin-left: 0;
|
padding-left: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info {
|
.info {
|
||||||
flex: 1;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info--stretch {
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { cx, timerPlaceholderMin } from '../../common/utils/styleUtils';
|
|||||||
import { formatTime, getDefaultFormat } from '../../common/utils/time';
|
import { formatTime, getDefaultFormat } from '../../common/utils/time';
|
||||||
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
|
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
|
||||||
import { useTranslation } from '../../translation/TranslationProvider';
|
import { useTranslation } from '../../translation/TranslationProvider';
|
||||||
import BackstageSchedule from '../common/schedule/BackstageSchedule';
|
import ScheduleExport from '../common/schedule/ScheduleExport';
|
||||||
|
|
||||||
import { getBackstageOptions, useBackstageOptions } from './backstage.options';
|
import { getBackstageOptions, useBackstageOptions } from './backstage.options';
|
||||||
import { getCardData, getIsPendingStart, getShowProgressBar, isOvertime } from './backstage.utils';
|
import { getCardData, getIsPendingStart, getShowProgressBar, isOvertime } from './backstage.utils';
|
||||||
@@ -68,6 +68,7 @@ export default function Backstage(props: BackstageProps) {
|
|||||||
}, [selectedId]);
|
}, [selectedId]);
|
||||||
|
|
||||||
// gather card data
|
// gather card data
|
||||||
|
const hasEvents = backstageEvents.length > 0;
|
||||||
const { showNow, nowMain, nowSecondary, showNext, nextMain, nextSecondary } = getCardData(
|
const { showNow, nowMain, nowSecondary, showNext, nextMain, nextSecondary } = getCardData(
|
||||||
eventNow,
|
eventNow,
|
||||||
eventNext,
|
eventNext,
|
||||||
@@ -80,16 +81,18 @@ export default function Backstage(props: BackstageProps) {
|
|||||||
const clock = formatTime(time.clock);
|
const clock = formatTime(time.clock);
|
||||||
const isPendingStart = getIsPendingStart(time.playback, time.phase);
|
const isPendingStart = getIsPendingStart(time.playback, time.phase);
|
||||||
const startedAt = isPendingStart ? formatTime(time.secondaryTimer) : formatTime(time.startedAt);
|
const startedAt = isPendingStart ? formatTime(time.secondaryTimer) : formatTime(time.startedAt);
|
||||||
const scheduledStart = showNow ? '' : formatTime(runtime.plannedStart, { format12: 'hh:mm a', format24: 'HH:mm' });
|
const scheduledStart =
|
||||||
const scheduledEnd = showNow ? '' : formatTime(runtime.plannedEnd, { format12: 'hh:mm a', format24: 'HH:mm' });
|
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 });
|
let displayTimer = millisToString(time.current, { fallback: timerPlaceholderMin });
|
||||||
stageTimer = removeLeadingZero(stageTimer);
|
displayTimer = removeLeadingZero(displayTimer);
|
||||||
|
|
||||||
// gather presentation styles
|
// gather presentation styles
|
||||||
const qrSize = Math.max(window.innerWidth / 15, 72);
|
const qrSize = Math.max(window.innerWidth / 15, 72);
|
||||||
const showProgress = getShowProgressBar(time.playback);
|
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
|
// gather option data
|
||||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||||
@@ -107,71 +110,66 @@ export default function Backstage(props: BackstageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ProgressBar
|
{showProgress && <ProgressBar className='progress-container' current={time.current} duration={time.duration} />}
|
||||||
className='progress-container'
|
|
||||||
current={time.current}
|
|
||||||
duration={time.duration}
|
|
||||||
hidden={!showProgress}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{backstageEvents.length === 0 && (
|
{!hasEvents && <Empty text={getLocalizedString('common.no_data')} className='empty-container' />}
|
||||||
<div className='empty-container'>
|
|
||||||
<Empty text={getLocalizedString('common.no_data')} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='card-container'>
|
<div className='card-container'>
|
||||||
{showNow ? (
|
{showNow && (
|
||||||
<div className={cx(['event', 'now', blinkClass && 'blink'])}>
|
<div className={cx(['event', 'now', blinkClass && 'blink'])}>
|
||||||
<TitleCard title={nowMain} secondary={nowSecondary} />
|
<TitleCard title={nowMain} secondary={nowSecondary} />
|
||||||
<div className='timer-group'>
|
<div className='timer-group'>
|
||||||
<div className='aux-timers'>
|
<div className='time-entry'>
|
||||||
<div className={cx(['aux-timers__label', isPendingStart && 'aux-timers--pending'])}>
|
<div className={cx(['time-entry__label', isPendingStart && 'time-entry--pending'])}>
|
||||||
{isPendingStart ? getLocalizedString('countdown.waiting') : getLocalizedString('common.started_at')}
|
{isPendingStart ? getLocalizedString('countdown.waiting') : getLocalizedString('common.started_at')}
|
||||||
</div>
|
</div>
|
||||||
<SuperscriptTime time={startedAt} className='aux-timers__value' />
|
<SuperscriptTime time={startedAt} className='time-entry__value' />
|
||||||
</div>
|
</div>
|
||||||
<div className='timer-gap' />
|
<div className='timer-gap' />
|
||||||
<div className='aux-timers'>
|
<div className='time-entry'>
|
||||||
<div className='aux-timers__label'>{getLocalizedString('common.expected_finish')}</div>
|
<div className='time-entry__label'>{getLocalizedString('common.expected_finish')}</div>
|
||||||
{isOvertime(time.current) ? (
|
{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>
|
||||||
<div className='timer-gap' />
|
<div className='timer-gap' />
|
||||||
<div className='aux-timers'>
|
<div className='time-entry'>
|
||||||
<div className='aux-timers__label'>{getLocalizedString('common.stage_timer')}</div>
|
<div className='time-entry__label'>{getLocalizedString('common.stage_timer')}</div>
|
||||||
<div className='aux-timers__value'>{stageTimer}</div>
|
<div className='time-entry__value'>{displayTimer}</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>
|
</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>
|
</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.backstageUrl && <QRCode value={general.backstageUrl} size={qrSize} level='L' className='qr' />}
|
||||||
{general.backstageInfo && <div className='info__message'>{general.backstageInfo}</div>}
|
{general.backstageInfo && <div className='info__message'>{general.backstageInfo}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
OptionTitle,
|
OptionTitle,
|
||||||
} from '../../common/components/view-params-editor/constants';
|
} from '../../common/components/view-params-editor/constants';
|
||||||
import { ViewOption } from '../../common/components/view-params-editor/types';
|
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[] => {
|
export const getBackstageOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
|
||||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
|
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
|
||||||
@@ -29,26 +29,7 @@ export const getBackstageOptions = (timeFormat: string, customFields: CustomFiel
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
scheduleOptions,
|
||||||
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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,24 +56,3 @@ export function useBackstageOptions(): BackstageOptions {
|
|||||||
const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
|
const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
|
||||||
return options;
|
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 { MaybeNumber, OntimeEvent, Playback, TimerPhase } from 'ontime-types';
|
||||||
|
|
||||||
|
import { enDash } from '../../common/utils/styleUtils';
|
||||||
import { getPropertyValue } from '../../features/viewers/common/viewUtils';
|
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
|
// 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 nowSecondary = getPropertyValue(eventNow, secondarySource);
|
||||||
const nextMain = getPropertyValue(eventNext, mainSource ?? 'title');
|
const nextMain = getPropertyValue(eventNext, mainSource ?? 'title') || enDash;
|
||||||
const nextSecondary = getPropertyValue(eventNext, secondarySource);
|
const nextSecondary = getPropertyValue(eventNext, secondarySource);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
showNow: Boolean(nowMain) || Boolean(nowSecondary),
|
showNow: eventNow !== null,
|
||||||
nowMain,
|
nowMain,
|
||||||
nowSecondary,
|
nowSecondary,
|
||||||
showNext: Boolean(nextMain) || Boolean(nextSecondary),
|
showNext: eventNext !== null,
|
||||||
nextMain,
|
nextMain,
|
||||||
nextSecondary,
|
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 *;
|
@use '../../../theme/viewerDefs' as *;
|
||||||
|
|
||||||
|
$circle-size: clamp(8px, 0.75vw, 12px);
|
||||||
|
$indeterminate-width: clamp(32px, 3vw, 48px);
|
||||||
|
|
||||||
.schedule {
|
.schedule {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -17,9 +20,9 @@
|
|||||||
|
|
||||||
.entry-colour {
|
.entry-colour {
|
||||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||||
height: clamp(8px, 0.75vw, 12px);
|
height: $circle-size;
|
||||||
width: clamp(8px, 0.75vw, 12px);
|
width: $circle-size;
|
||||||
border-radius: 6px;
|
border-radius: $component-border-radius-full;
|
||||||
margin-right: 0.25rem;
|
margin-right: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +52,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.entry-title {
|
.entry-title {
|
||||||
font-size: clamp(16px, 1.5vw, 24px);
|
|
||||||
font-size: $base-font-size;
|
font-size: $base-font-size;
|
||||||
line-height: 1.2em;
|
line-height: 1.2em;
|
||||||
}
|
}
|
||||||
@@ -62,10 +64,10 @@
|
|||||||
background-color: var(--color-override, $viewer-color);
|
background-color: var(--color-override, $viewer-color);
|
||||||
opacity: 0.2;
|
opacity: 0.2;
|
||||||
|
|
||||||
height: clamp(8px, 0.75vw, 12px);
|
height: $circle-size;
|
||||||
width: clamp(8px, 0.75vw, 12px);
|
width: $circle-size;
|
||||||
border-radius: 6px;
|
border-radius: $component-border-radius-full;
|
||||||
margin-right: 8px;
|
margin-right: 0.5em;
|
||||||
|
|
||||||
transition-property: opacity;
|
transition-property: opacity;
|
||||||
transition-duration: 1s;
|
transition-duration: 1s;
|
||||||
@@ -77,7 +79,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&--indeterminate {
|
&--indeterminate {
|
||||||
width: clamp(32px, 3vw, 48px);
|
width: $indeterminate-width;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
import { isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
|
import { isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
|
||||||
|
|
||||||
import { usePartialRundown } from '../../../common/hooks-query/useRundown';
|
import { usePartialRundown } from '../../../common/hooks-query/useRundown';
|
||||||
import { useScheduleOptions } from '../../backstage/backstage.options';
|
|
||||||
|
import { useScheduleOptions } from './schedule.options';
|
||||||
|
|
||||||
interface ScheduleContextState {
|
interface ScheduleContextState {
|
||||||
events: OntimeEvent[];
|
events: OntimeEvent[];
|
||||||
@@ -51,6 +52,7 @@ export const ScheduleProvider = ({
|
|||||||
|
|
||||||
const containerRef = useRef<HTMLUListElement>(null);
|
const containerRef = useRef<HTMLUListElement>(null);
|
||||||
|
|
||||||
|
// After the view is rendered, we paginate by hiding elements that dont fit
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
|||||||
+6
-5
@@ -5,17 +5,18 @@ import Schedule from './Schedule';
|
|||||||
import { ScheduleProvider } from './ScheduleContext';
|
import { ScheduleProvider } from './ScheduleContext';
|
||||||
import ScheduleNav from './ScheduleNav';
|
import ScheduleNav from './ScheduleNav';
|
||||||
|
|
||||||
interface BackstageScheduleProps {
|
interface ScheduleExportProps {
|
||||||
selectedId: MaybeString;
|
selectedId: MaybeString;
|
||||||
|
isBackstage?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(BackstageSchedule);
|
export default memo(ScheduleExport);
|
||||||
function BackstageSchedule(props: BackstageScheduleProps) {
|
function ScheduleExport(props: ScheduleExportProps) {
|
||||||
const { selectedId } = props;
|
const { selectedId, isBackstage } = props;
|
||||||
return (
|
return (
|
||||||
<ScheduleProvider selectedEventId={selectedId} isBackstage>
|
<ScheduleProvider selectedEventId={selectedId} isBackstage>
|
||||||
<ScheduleNav className='schedule-nav-container' />
|
<ScheduleNav className='schedule-nav-container' />
|
||||||
<Schedule isProduction className='schedule-container' />
|
<Schedule isProduction={isBackstage} className='schedule-container' />
|
||||||
</ScheduleProvider>
|
</ScheduleProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -35,13 +35,13 @@ export default function ScheduleItem(props: ScheduleItemProps) {
|
|||||||
<span className='entry-times--delayed'>
|
<span className='entry-times--delayed'>
|
||||||
<span className='entry-colour' style={{ backgroundColor: colour }} />
|
<span className='entry-colour' style={{ backgroundColor: colour }} />
|
||||||
<SuperscriptTime time={start} />
|
<SuperscriptTime time={start} />
|
||||||
{' → '}
|
→
|
||||||
<SuperscriptTime time={end} />
|
<SuperscriptTime time={end} />
|
||||||
{backstageEvent && '*'}
|
{backstageEvent && '*'}
|
||||||
</span>
|
</span>
|
||||||
<span className='entry-times--delay'>
|
<span className='entry-times--delay'>
|
||||||
<SuperscriptTime time={delayedStart} />
|
<SuperscriptTime time={delayedStart} />
|
||||||
{' → '}
|
→
|
||||||
<SuperscriptTime time={delayedEnd} />
|
<SuperscriptTime time={delayedEnd} />
|
||||||
{backstageEvent && '*'}
|
{backstageEvent && '*'}
|
||||||
</span>
|
</span>
|
||||||
@@ -56,7 +56,7 @@ export default function ScheduleItem(props: ScheduleItemProps) {
|
|||||||
<div className='entry-times'>
|
<div className='entry-times'>
|
||||||
<span className='entry-colour' style={{ backgroundColor: colour }} />
|
<span className='entry-colour' style={{ backgroundColor: colour }} />
|
||||||
<SuperscriptTime time={start} />
|
<SuperscriptTime time={start} />
|
||||||
{' → '}
|
→
|
||||||
<SuperscriptTime time={end} />
|
<SuperscriptTime time={end} />
|
||||||
{backstageEvent && '*'}
|
{backstageEvent && '*'}
|
||||||
</div>
|
</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;
|
||||||
|
}
|
||||||
@@ -10,23 +10,32 @@
|
|||||||
font-family: var(--font-family-override, $viewer-font-family);
|
font-family: var(--font-family-override, $viewer-font-family);
|
||||||
background: var(--background-color-override, $viewer-background-color);
|
background: var(--background-color-override, $viewer-background-color);
|
||||||
color: var(--color-override, $viewer-color);
|
color: var(--color-override, $viewer-color);
|
||||||
gap: min(2vh, 16px);
|
gap: $view-element-gap;
|
||||||
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
|
padding: $view-outer-padding;
|
||||||
|
font-size: $base-font-size;
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr 40vw;
|
grid-template-columns: 1fr 40vw;
|
||||||
grid-template-rows: auto 12px 1fr auto;
|
grid-template-rows: auto 12px 1fr auto;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
' header header header'
|
'header header'
|
||||||
' progress progress schedule-nav'
|
'now schedule-nav'
|
||||||
' now now schedule'
|
'info schedule';
|
||||||
' info 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 ===================*/
|
/* =================== HEADER + EXTRAS ===================*/
|
||||||
|
|
||||||
.project-header {
|
.project-header {
|
||||||
grid-area: header;
|
grid-area: header;
|
||||||
font-size: clamp(32px, 4.5vw, 64px);
|
font-size: $header-font-size;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -37,18 +46,22 @@
|
|||||||
max-height: min(100px, 20vh);
|
max-height: min(100px, 20vh);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
line-height: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
.clock-container {
|
.clock-container {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: clamp(16px, 1.5vw, 24px);
|
font-size: $timer-label-size;
|
||||||
font-weight: 600;
|
|
||||||
color: var(--label-color-override, $viewer-label-color);
|
color: var(--label-color-override, $viewer-label-color);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time {
|
.time {
|
||||||
font-size: clamp(32px, 3.5vw, 50px);
|
font-size: $timer-value-size;
|
||||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
line-height: 0.95em;
|
line-height: 0.95em;
|
||||||
@@ -57,50 +70,136 @@
|
|||||||
|
|
||||||
/* =================== MAIN - NOW ===================*/
|
/* =================== MAIN - NOW ===================*/
|
||||||
|
|
||||||
.now-container {
|
.card-container {
|
||||||
grid-area: now;
|
grid-area: now;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: min(2vh, 16px);
|
gap: $view-element-gap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event {
|
.event {
|
||||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||||
padding: 16px 24px;
|
padding: $view-card-padding;
|
||||||
border-radius: 8px;
|
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 ===================*/
|
/* =================== MAIN - SCHEDULE ===================*/
|
||||||
|
|
||||||
|
$schedule-left-spacing: clamp(16px, 4vw, 64px);
|
||||||
.schedule-container {
|
.schedule-container {
|
||||||
grid-area: schedule;
|
grid-area: schedule;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin-left: clamp(16px, 5vw, 64px);
|
padding-left: $schedule-left-spacing;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-nav-container {
|
.schedule-nav-container {
|
||||||
grid-area: schedule-nav;
|
grid-area: schedule-nav;
|
||||||
align-self: center;
|
padding-left: $schedule-left-spacing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =================== MAIN - INFO ===================*/
|
||||||
|
|
||||||
.info {
|
.info {
|
||||||
grid-area: info;
|
grid-area: info;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: max(1vw, 16px);
|
gap: max(1vw, 16px);
|
||||||
|
align-self: flex-end;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
align-items: end;
|
||||||
|
|
||||||
&__message {
|
&__message {
|
||||||
font-size: clamp(16px, 1.5vw, 24px);
|
font-size: $base-font-size;
|
||||||
line-height: 1.3em;
|
line-height: 1.2em;
|
||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr {
|
.qr {
|
||||||
padding: 4px;
|
padding: 0.5rem;
|
||||||
background-color: white;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
import QRCode from 'react-qr-code';
|
import QRCode from 'react-qr-code';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useViewportSize } from '@mantine/hooks';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
import { CustomFields, OntimeEvent, ProjectData, Settings } from 'ontime-types';
|
import { CustomFields, OntimeEvent, ProjectData, Settings } from 'ontime-types';
|
||||||
|
|
||||||
|
import Empty from '../../common/components/state/Empty';
|
||||||
import TitleCard from '../../common/components/title-card/TitleCard';
|
import TitleCard from '../../common/components/title-card/TitleCard';
|
||||||
import ViewLogo from '../../common/components/view-logo/ViewLogo';
|
import ViewLogo from '../../common/components/view-logo/ViewLogo';
|
||||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||||
import { ViewExtendedTimer } from '../../common/models/TimeManager.type';
|
import { ViewExtendedTimer } from '../../common/models/TimeManager.type';
|
||||||
|
import { cx } from '../../common/utils/styleUtils';
|
||||||
import { formatTime, getDefaultFormat } from '../../common/utils/time';
|
import { formatTime, getDefaultFormat } from '../../common/utils/time';
|
||||||
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
|
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
|
||||||
import { getPropertyValue } from '../../features/viewers/common/viewUtils';
|
|
||||||
import { useTranslation } from '../../translation/TranslationProvider';
|
import { useTranslation } from '../../translation/TranslationProvider';
|
||||||
import PublicSchedule from '../common/schedule/PublicSchedule';
|
import { getIsPendingStart } from '../backstage/backstage.utils';
|
||||||
import { titleVariants } from '../timer/timer.animations';
|
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';
|
import './Public.scss';
|
||||||
|
|
||||||
export const MotionTitleCard = motion(TitleCard);
|
|
||||||
|
|
||||||
interface BackstageProps {
|
interface BackstageProps {
|
||||||
customFields: CustomFields;
|
customFields: CustomFields;
|
||||||
|
events: OntimeEvent[];
|
||||||
general: ProjectData;
|
general: ProjectData;
|
||||||
isMirrored: boolean;
|
isMirrored: boolean;
|
||||||
publicEventNow: OntimeEvent | null;
|
publicEventNow: OntimeEvent | null;
|
||||||
@@ -33,73 +33,86 @@ interface BackstageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Public(props: BackstageProps) {
|
export default function Public(props: BackstageProps) {
|
||||||
const { customFields, general, isMirrored, publicEventNow, publicEventNext, time, publicSelectedId, settings } =
|
const {
|
||||||
props;
|
customFields,
|
||||||
|
events,
|
||||||
|
general,
|
||||||
|
isMirrored,
|
||||||
|
publicEventNow,
|
||||||
|
publicEventNext,
|
||||||
|
time,
|
||||||
|
publicSelectedId,
|
||||||
|
settings,
|
||||||
|
} = props;
|
||||||
|
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
const [searchParams] = useSearchParams();
|
const { secondarySource } = usePublicOptions();
|
||||||
|
const { height: screenHeight } = useViewportSize();
|
||||||
|
|
||||||
useWindowTitle('Public Schedule');
|
useWindowTitle('Public Schedule');
|
||||||
|
|
||||||
const clock = formatTime(time.clock);
|
// gather card data
|
||||||
const qrSize = Math.max(window.innerWidth / 15, 128);
|
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 defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||||
const publicOptions = getPublicOptions(defaultFormat, customFields);
|
const publicOptions = getPublicOptions(defaultFormat, customFields);
|
||||||
|
|
||||||
const secondarySource = searchParams.get('secondary-src');
|
|
||||||
const secondaryTextNext = getPropertyValue(publicEventNext, secondarySource);
|
|
||||||
const secondaryTextNow = getPropertyValue(publicEventNow, secondarySource);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
|
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
|
||||||
<ViewParamsEditor viewOptions={publicOptions} />
|
<ViewParamsEditor viewOptions={publicOptions} />
|
||||||
<div className='project-header'>
|
<div className='project-header'>
|
||||||
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
|
{general?.projectLogo ? <ViewLogo name={general.projectLogo} className='logo' /> : <div className='logo' />}
|
||||||
{general.title}
|
<div className='title'>{general.title}</div>
|
||||||
<div className='clock-container'>
|
<div className='clock-container'>
|
||||||
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
||||||
<SuperscriptTime time={clock} className='time' />
|
<SuperscriptTime time={clock} className='time' />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='now-container'>
|
{!hasEvents && <Empty text={getLocalizedString('countdown.waiting')} className='empty-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>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
<div className='card-container'>
|
||||||
{publicEventNext && (
|
{showNow && hasEvents && (
|
||||||
<MotionTitleCard
|
<TitleCard className='event now' label='now' title={nowMain} secondary={nowSecondary} />
|
||||||
className='event next'
|
)}
|
||||||
key='next'
|
{!showNow && scheduledStart && (
|
||||||
variants={titleVariants}
|
<div className='event'>
|
||||||
initial='hidden'
|
<div className='title-card__placeholder'>{getLocalizedString('countdown.waiting')}</div>
|
||||||
animate='visible'
|
<div className='timer-group'>
|
||||||
exit='exit'
|
<div className='time-entry'>
|
||||||
label='next'
|
<div className={cx(['time-entry__label', isPendingStart && 'time-entry--pending'])}>
|
||||||
title={publicEventNext.title}
|
{getLocalizedString('common.scheduled_start')}
|
||||||
secondary={secondaryTextNext}
|
</div>
|
||||||
/>
|
<SuperscriptTime time={formatTime(scheduledStart)} className='time-entry__value' />
|
||||||
)}
|
</div>
|
||||||
</AnimatePresence>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showNext && hasEvents && (
|
||||||
|
<TitleCard className='event next' label='next' title={nextMain} secondary={nextSecondary} />
|
||||||
|
)}
|
||||||
</div>
|
</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.publicUrl && <QRCode value={general.publicUrl} size={qrSize} level='L' className='qr' />}
|
||||||
{general.publicInfo && <div className='info__message'>{general.publicInfo}</div>}
|
{general.publicInfo && <div className='info__message'>{general.publicInfo}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
getTimeOption,
|
getTimeOption,
|
||||||
@@ -6,6 +8,7 @@ import {
|
|||||||
OptionTitle,
|
OptionTitle,
|
||||||
} from '../../common/components/view-params-editor/constants';
|
} from '../../common/components/view-params-editor/constants';
|
||||||
import { ViewOption } from '../../common/components/view-params-editor/types';
|
import { ViewOption } from '../../common/components/view-params-editor/types';
|
||||||
|
import { scheduleOptions } from '../common/schedule/schedule.options';
|
||||||
|
|
||||||
export const getPublicOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
|
export const getPublicOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
|
||||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
|
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
|
||||||
@@ -26,32 +29,30 @@ export const getPublicOptions = (timeFormat: string, customFields: CustomFields)
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
scheduleOptions,
|
||||||
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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -84,9 +84,9 @@ test('delays are show correctly', async ({ page }) => {
|
|||||||
|
|
||||||
// delay is NOT shown in the public view
|
// delay is NOT shown in the public view
|
||||||
await page.goto('http://localhost:4001/public');
|
await page.goto('http://localhost:4001/public');
|
||||||
await page.getByText('00:10 → 00:20').click();
|
await page.getByText('00:10→00:20').click();
|
||||||
|
|
||||||
// delay is shown in the backstage view
|
// delay is shown in the backstage view
|
||||||
await page.goto('http://localhost:4001/backstage');
|
await page.goto('http://localhost:4001/backstage');
|
||||||
await page.getByText('00:11 → 00:21').click();
|
await page.getByText('00:11→00:21').click();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user