mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-18 21:54:09 +00:00
refactor: backstage design review
refactor: improve empty state refactor: review schedule design - fit as many elements as possible - expose cycle interval as an option - stabilise rundown reference - style tweaks
This commit is contained in:
committed by
Carlos Valente
parent
f99ad83049
commit
946b6717d2
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user