refactor: backstage design review

refactor: improve empty state

refactor: review schedule design

- fit as many elements as possible
- expose cycle interval as an option
- stabilise rundown reference
- style tweaks
This commit is contained in:
Carlos Valente
2025-02-04 22:27:54 +01:00
committed by Carlos Valente
parent f99ad83049
commit 946b6717d2
34 changed files with 693 additions and 340 deletions
@@ -0,0 +1,21 @@
import { memo } from 'react';
import { MaybeString } from 'ontime-types';
import Schedule from './Schedule';
import { ScheduleProvider } from './ScheduleContext';
import ScheduleNav from './ScheduleNav';
interface BackstageScheduleProps {
selectedId: MaybeString;
}
export default memo(BackstageSchedule);
function BackstageSchedule(props: BackstageScheduleProps) {
const { selectedId } = props;
return (
<ScheduleProvider selectedEventId={selectedId} isBackstage>
<ScheduleNav className='schedule-nav-container' />
<Schedule isProduction className='schedule-container' />
</ScheduleProvider>
);
}
@@ -0,0 +1,21 @@
import { memo } from 'react';
import { MaybeString } from 'ontime-types';
import Schedule from './Schedule';
import { ScheduleProvider } from './ScheduleContext';
import ScheduleNav from './ScheduleNav';
interface PublicScheduleProps {
selectedId: MaybeString;
}
export default memo(PublicSchedule);
function PublicSchedule(props: PublicScheduleProps) {
const { selectedId } = props;
return (
<ScheduleProvider selectedEventId={selectedId}>
<ScheduleNav className='schedule-nav-container' />
<Schedule className='schedule-container' />
</ScheduleProvider>
);
}
@@ -2,75 +2,82 @@
.schedule {
width: 100%;
border-spacing: 50px;
position: relative;
list-style: none;
.entry {
font-size: clamp(16px, 1.5vw, 24px);
position: absolute;
padding-bottom: 1em;
.entry-colour {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
height: clamp(8px, 0.75vw, 12px);
width: clamp(8px, 0.75vw, 12px);
border-radius: 6px;
display: inline-block;
&--skip {
text-decoration: line-through;
text-decoration-color: var(--secondary-color-override, $viewer-secondary-color);
}
}
.entry-times {
font-family: $viewer-font-family;
color: var(--secondary-color-override, $viewer-secondary-color);
font-weight: 300;
letter-spacing: 0.05em;
.entry-colour {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
height: clamp(8px, 0.75vw, 12px);
width: clamp(8px, 0.75vw, 12px);
border-radius: 6px;
margin-right: 0.25rem;
}
.entry-times {
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
font-size: $timer-label-size;
display: flex;
align-items: center;
gap: 0.2rem;
&--delayed,
&--delay {
display: flex;
align-items: center;
gap: 8px;
gap: 0.25rem;
}
.entry-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.entry-secondary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:not(:last-child) {
padding-bottom: 8px;
}
&--past {
color: var(--secondary-color-override, $viewer-secondary-color);
}
&--now {
.entry-title {
color: var(--accent-color-override, $accent-color);
font-weight: 600;
}
}
&.skip {
&--delayed {
text-decoration: line-through;
text-decoration-color: $ontime-delay;
}
&--delay {
color: $ontime-delay-text;
}
}
.entry-title {
font-size: clamp(16px, 1.5vw, 24px);
font-size: $base-font-size;
line-height: 1.2em;
}
}
.schedule-nav {
display: flex;
justify-content: flex-end;
.schedule-nav__item {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
width: 12px;
height: 12px;
background-color: var(--color-override, $viewer-color);
opacity: 0.2;
height: clamp(8px, 0.75vw, 12px);
width: clamp(8px, 0.75vw, 12px);
border-radius: 6px;
margin-left: 8px;
margin-right: 8px;
transition-property: opacity;
transition-duration: 1s;
&--selected {
background-color: var(--color-override, $viewer-color);
transition-property: opacity;
transition-duration: $viewer-transition-time;
opacity: 1;
}
&--indeterminate {
width: clamp(32px, 3vw, 48px);
}
}
}
@@ -1,3 +1,6 @@
import { cx } from '../../../common/utils/styleUtils';
import { getScheduledTimes } from './schedule.utils';
import { useSchedule } from './ScheduleContext';
import ScheduleItem from './ScheduleItem';
@@ -9,41 +12,27 @@ interface ScheduleProps {
}
export default function Schedule({ isProduction, className }: ScheduleProps) {
const { paginatedEvents, selectedEventId, isBackstage, scheduleType } = useSchedule();
const { events, isBackstage, containerRef } = useSchedule();
// TODO: design a nice placeholder for empty schedules
if (paginatedEvents?.length < 1) {
if (events?.length < 1) {
return null;
}
let selectedState: 'past' | 'now' | 'future' = 'past';
return (
<ul className={`schedule ${className}`}>
{paginatedEvents.map((event) => {
if (scheduleType === 'past' || scheduleType === 'future') {
selectedState = scheduleType;
} else {
if (event.id === selectedEventId) {
selectedState = 'now';
} else if (selectedState === 'now') {
selectedState = 'future';
}
}
const timeStart = isProduction ? event.timeStart + (event?.delay ?? 0) : event.timeStart;
const timeEnd = isProduction ? event.timeEnd + (event?.delay ?? 0) : event.timeEnd;
<ul className={cx(['schedule', className])} ref={containerRef}>
{events.map((event) => {
const { timeStart, timeEnd, delay } = getScheduledTimes(event, isProduction);
return (
<ScheduleItem
key={event.id}
selected={selectedState}
timeStart={timeStart}
timeEnd={timeEnd}
title={event.title}
colour={isBackstage ? event.colour : ''}
colour={isBackstage ? event.colour : undefined}
backstageEvent={!event.isPublic}
skip={event.skip}
delay={delay}
/>
);
})}
@@ -1,91 +1,155 @@
import { createContext, PropsWithChildren, useContext, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { OntimeEvent } from 'ontime-types';
import {
createContext,
PropsWithChildren,
RefObject,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
import { useInterval } from '../../../common/hooks/useInterval';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import { usePartialRundown } from '../../../common/hooks-query/useRundown';
import { useScheduleOptions } from '../../backstage/backstage.options';
interface ScheduleContextState {
events: OntimeEvent[];
paginatedEvents: OntimeEvent[];
selectedEventId: string | null;
scheduleType: 'past' | 'now' | 'future';
numPages: number;
visiblePage: number;
isBackstage: boolean;
containerRef: RefObject<HTMLUListElement>;
}
const ScheduleContext = createContext<ScheduleContextState | undefined>(undefined);
interface ScheduleProviderProps {
events: OntimeEvent[];
selectedEventId: string | null;
isBackstage?: boolean;
time?: number;
}
const numEventsPerPage = 8;
export const ScheduleProvider = ({
children,
events,
selectedEventId,
isBackstage = false,
time = 10,
}: PropsWithChildren<ScheduleProviderProps>) => {
const [visiblePage, setVisiblePage] = useState(0);
const [searchParams] = useSearchParams();
const { cycleInterval, stopCycle } = useScheduleOptions();
const { data: events } = usePartialRundown((event: OntimeRundownEntry) => {
if (isBackstage) {
return isOntimeEvent(event);
}
return isOntimeEvent(event) && event.isPublic && !event.skip;
});
// look for overrides from views
const hidePast = isStringBoolean(searchParams.get('hidePast'));
const stopCycle = isStringBoolean(searchParams.get('stopCycle'));
const eventsPerPage = Number(searchParams.get('eventsPerPage') ?? numEventsPerPage);
const [firstIndex, setFirstIndex] = useState(-1);
const [numPages, setNumPages] = useState(0);
const [visiblePage, setVisiblePage] = useState(0);
const lastIndex = useRef(-1);
const paginator = useRef<NodeJS.Timeout>();
const containerRef = useRef<HTMLUListElement>(null);
useLayoutEffect(() => {
if (!containerRef.current) return;
const children = Array.from(containerRef.current.children) as HTMLElement[];
if (children.length === 0) {
return;
}
const containerHeight = containerRef.current.clientHeight;
let currentPageHeight = 0; // used to check when we need to paginate
let currentPage = 1;
let numPages = 1;
let lastVisibleIndex = -1; // keep track of last index on screen
let isShowingElements = false;
for (let i = 0; i < children.length; i++) {
const currentElementHeight = children[i].clientHeight;
// can we fit this element in the current page?
const isNextPage = currentPageHeight + currentElementHeight > containerHeight;
if (isNextPage) {
currentPageHeight = 0;
numPages += 1;
}
// we hide elements that are before and after the first element to show
if (i < firstIndex) {
hideElement(children[i]);
} else if (lastVisibleIndex === -1) {
isShowingElements = true;
currentPage = numPages;
} else if (isNextPage) {
isShowingElements = false;
}
if (!isShowingElements) {
hideElement(children[i]);
} else {
lastVisibleIndex = i;
showElement(children[i], currentPageHeight);
}
currentPageHeight += currentElementHeight;
}
setVisiblePage(currentPage);
setNumPages(numPages);
lastIndex.current = lastVisibleIndex;
function showElement(element: HTMLElement, yPosition: number) {
element.style.top = `${yPosition}px`;
}
function hideElement(element: HTMLElement) {
element.style.top = `${-1000}px`;
}
// we need to add the events to make sure the effect runs on first render
}, [firstIndex, events]);
// schedule cycling through events
useEffect(() => {
if (stopCycle) {
setVisiblePage(1);
setFirstIndex(0);
return;
}
if (paginator.current) {
clearInterval(paginator.current);
}
const interval = setInterval(() => {
// ensure we cycle back to the first event
if (visiblePage === numPages) {
setFirstIndex(0);
} else {
setFirstIndex(lastIndex.current + 1);
}
}, cycleInterval * 1000);
paginator.current = interval;
return () => clearInterval(paginator.current);
}, [cycleInterval, numPages, stopCycle, visiblePage]);
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
const viewEvents = [...events];
if (hidePast) {
// we want to show the event after the next
viewEvents.splice(0, selectedEventIndex + 2);
selectedEventIndex = 0;
}
const numPages = Math.ceil(viewEvents.length / eventsPerPage);
const eventStart = eventsPerPage * visiblePage;
const eventEnd = eventsPerPage * (visiblePage + 1);
const paginatedEvents = viewEvents.slice(eventStart, eventEnd);
const resolveScheduleType = () => {
if (selectedEventIndex >= eventStart && selectedEventIndex < eventEnd) {
return 'now';
}
if (selectedEventIndex > eventEnd) {
return 'past';
}
return 'future';
};
const scheduleType = resolveScheduleType();
// every SCROLL_TIME go to the next array
useInterval(() => {
if (stopCycle) {
setVisiblePage(0);
} else if (events.length > eventsPerPage) {
const next = (visiblePage + 1) % numPages;
setVisiblePage(next);
}
}, time * 1000);
// we want to show the event after the current
const viewEvents = events.toSpliced(0, selectedEventIndex + 1);
selectedEventIndex = 0;
return (
<ScheduleContext.Provider
value={{
events,
paginatedEvents,
events: viewEvents as OntimeEvent[],
selectedEventId,
scheduleType,
numPages,
visiblePage,
isBackstage,
containerRef,
}}
>
{children}
@@ -1,3 +1,4 @@
import { cx } from '../../../common/utils/styleUtils';
import { formatTime } from '../../../common/utils/time';
import SuperscriptTime from '../../../features/viewers/common/superscript-time/SuperscriptTime';
@@ -9,33 +10,55 @@ const formatOptions = {
};
interface ScheduleItemProps {
selected: 'past' | 'now' | 'future';
timeStart: number;
timeEnd: number;
title: string;
backstageEvent: boolean;
colour: string;
skip: boolean;
colour?: string;
skip?: boolean;
delay: number;
}
export default function ScheduleItem(props: ScheduleItemProps) {
const { selected, timeStart, timeEnd, title, backstageEvent, colour, skip } = props;
const { timeStart, timeEnd, title, backstageEvent, colour, skip, delay } = props;
const start = formatTime(timeStart, formatOptions);
const end = formatTime(timeEnd, formatOptions);
const userColour = colour !== '' ? colour : '';
const selectStyle = `entry--${selected}`;
if (delay > 0) {
const delayedStart = formatTime(timeStart + delay, formatOptions);
const delayedEnd = formatTime(timeEnd + delay, formatOptions);
return (
<li className={cx(['entry', skip && 'entry--skip'])}>
<div className='entry-times'>
<span className='entry-times--delayed'>
<span className='entry-colour' style={{ backgroundColor: colour }} />
<SuperscriptTime time={start} />
{' → '}
<SuperscriptTime time={end} />
{backstageEvent && '*'}
</span>
<span className='entry-times--delay'>
<SuperscriptTime time={delayedStart} />
{' → '}
<SuperscriptTime time={delayedEnd} />
{backstageEvent && '*'}
</span>
</div>
<div className='entry-title'>{title}</div>
</li>
);
}
return (
<li className={`entry ${selectStyle} ${skip ? 'skip' : ''}`}>
<li className={cx(['entry', skip && 'entry--skip'])}>
<div className='entry-times'>
<span className='entry-colour' style={{ backgroundColor: userColour }} />
<div style={{ display: 'flex' }}>
<SuperscriptTime time={start} />
{' → '}
<SuperscriptTime time={end} />
{backstageEvent ? '*' : ''}
</div>
<span className='entry-colour' style={{ backgroundColor: colour }} />
<SuperscriptTime time={start} />
{' → '}
<SuperscriptTime time={end} />
{backstageEvent && '*'}
</div>
<div className='entry-title'>{title}</div>
</li>
@@ -1,3 +1,5 @@
import { cx } from '../../../common/utils/styleUtils';
import { useSchedule } from './ScheduleContext';
import './Schedule.scss';
@@ -9,13 +11,38 @@ interface ScheduleNavProps {
export default function ScheduleNav({ className }: ScheduleNavProps) {
const { numPages, visiblePage } = useSchedule();
// cap the amount of elements to 11
if (numPages > 10) {
return (
<div className={cx(['schedule-nav', className])}>
<div className={cx(['schedule-nav__item', visiblePage === 1 && 'schedule-nav__item--selected'])} />
<div className={cx(['schedule-nav__item', visiblePage === 2 && 'schedule-nav__item--selected'])} />
<div className={cx(['schedule-nav__item', visiblePage === 3 && 'schedule-nav__item--selected'])} />
<div className={cx(['schedule-nav__item', visiblePage === 4 && 'schedule-nav__item--selected'])} />
<div className={cx(['schedule-nav__item', visiblePage === 5 && 'schedule-nav__item--selected'])} />
<div
className={cx([
'schedule-nav__item',
'schedule-nav__item--indeterminate',
visiblePage > 5 && visiblePage < numPages - 4 && 'schedule-nav__item--selected',
])}
/>
<div className={cx(['schedule-nav__item', visiblePage === numPages - 4 && 'schedule-nav__item--selected'])} />
<div className={cx(['schedule-nav__item', visiblePage === numPages - 3 && 'schedule-nav__item--selected'])} />
<div className={cx(['schedule-nav__item', visiblePage === numPages - 2 && 'schedule-nav__item--selected'])} />
<div className={cx(['schedule-nav__item', visiblePage === numPages - 1 && 'schedule-nav__item--selected'])} />
<div className={cx(['schedule-nav__item', visiblePage === numPages && 'schedule-nav__item--selected'])} />
</div>
);
}
return (
<div className={`schedule-nav ${className}`}>
<div className={cx(['schedule-nav', className])}>
{numPages > 1 &&
[...Array(numPages).keys()].map((i) => (
<div
key={i}
className={i === visiblePage ? 'schedule-nav__item schedule-nav__item--selected' : 'schedule-nav__item'}
className={cx(['schedule-nav__item', i + 1 === visiblePage && 'schedule-nav__item--selected'])}
/>
))}
</div>
@@ -0,0 +1,19 @@
import { OntimeEvent } from 'ontime-types';
/**
* Gather rules for how to present scheduled times
*/
export function getScheduledTimes(event: OntimeEvent, isProduction?: boolean) {
if (isProduction) {
return {
timeStart: event.timeStart,
timeEnd: event.timeEnd,
delay: event.skip ? 0 : event.delay,
};
}
return {
timeStart: event.timeStart,
timeEnd: event.timeEnd,
delay: 0,
};
}