mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-08 08:53:51 +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;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+6
-5
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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:10 → 00:20').click();
|
||||
await page.getByText('00:10→00:20').click();
|
||||
|
||||
// delay is shown in the backstage view
|
||||
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