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
parent 959a2b6d4a
commit cbabec2b1d
34 changed files with 693 additions and 340 deletions
@@ -1,18 +1,20 @@
import { CSSProperties } from 'react';
import EmptyImage from '../../../assets/images/empty.svg?react';
import { cx } from '../../utils/styleUtils';
import style from './Empty.module.scss';
interface EmptyProps {
text?: string;
style?: CSSProperties;
className?: string;
}
export default function Empty(props: EmptyProps) {
const { text, ...rest } = props;
const { text, className, ...rest } = props;
return (
<div className={style.emptyContainer} {...rest}>
<div className={cx([style.emptyContainer, className])} {...rest}>
<EmptyImage className={style.empty} />
{text && <span className={style.text}>{text}</span>}
</div>
@@ -1,8 +1,5 @@
@use '../../../theme/viewerDefs' as *;
$title-primary-size: clamp(1.5rem, 3vw, 3rem);
$title-secondary-size: clamp(1rem, 2vw, 2.25rem);
.title-card {
position: relative;
display: flex;
@@ -10,21 +7,23 @@ $title-secondary-size: clamp(1rem, 2vw, 2.25rem);
}
.title-card__title,
.title-card__secondary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.title-card__title {
.title-card__placeholder {
font-weight: 600;
font-size: $title-primary-size;
color: var(--color-override, $viewer-color);
font-size: $title-font-size;
line-height: 1.2em;
}
.title-card__title {
color: var(--color-override, $viewer-color);
padding-right: 1em;
}
.title-card__placeholder {
color: var(--label-color-override, $viewer-label-color);
}
.title-card__secondary {
font-size: $title-secondary-size;
font-size: $base-font-size;
color: var(--secondary-color-override, $viewer-secondary-color);
line-height: 1.2em;
}
@@ -35,7 +34,6 @@ $title-secondary-size: clamp(1rem, 2vw, 2.25rem);
top: 0.5rem;
font-size: $timer-label-size;
color: var(--secondary-color-override, $viewer-secondary-color);
margin-left: auto;
text-transform: uppercase;
&--accent {
@@ -1,6 +1,7 @@
import { ForwardedRef, forwardRef } from 'react';
import { useTranslation } from '../../../translation/TranslationProvider';
import { cx } from '../../utils/styleUtils';
import './TitleCard.scss';
@@ -18,9 +19,9 @@ const TitleCard = forwardRef((props: TitleCardProps, ref: ForwardedRef<HTMLDivEl
const accent = label === 'now';
return (
<div className={`title-card ${className}`} ref={ref}>
<div className={cx(['title-card', className])} ref={ref}>
<span className='title-card__title'>{title}</span>
<span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}>
<span className={cx(['title-card__label', accent && 'title-card__label--accent'])}>
{label && getLocalizedString(`common.${label}`)}
</span>
<div className='title-card__secondary'>{secondary}</div>
@@ -7,5 +7,5 @@ interface ViewLogoProps {
export default function ViewLogo(props: ViewLogoProps) {
const { name, className } = props;
return <img src={`${projectLogoPath}/${name}`} className={className} />;
return <img alt='' src={`${projectLogoPath}/${name}`} className={className} />;
}
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
import { NormalisedRundown, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { RUNDOWN } from '../api/constants';
@@ -11,6 +11,9 @@ import useProjectData from './useProjectData';
// revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 };
/**
* Normalised rundown data
*/
export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
queryKey: RUNDOWN,
@@ -24,6 +27,10 @@ export default function useRundown() {
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
/**
* Provides access to a flat rundown
* built from the order and rundown fields
*/
export function useFlatRundown() {
const { data, status } = useRundown();
const { data: projectData } = useProjectData();
@@ -52,3 +59,15 @@ export function useFlatRundown() {
return { data: flatRunDown, status };
}
/**
* Provides access to a partial rundown based on a filter callback
*/
export function usePartialRundown(cb: (event: OntimeRundownEntry) => boolean) {
const { data, status } = useFlatRundown();
const filteredData = useMemo(() => {
return data.filter(cb);
}, [data, cb]);
return { data: filteredData, status };
}
@@ -1,27 +0,0 @@
import { useEffect, useRef } from 'react';
/**
* @description utility hook to around setInterval
* @param callback
* @param delay
*/
export const useInterval = (callback, delay) => {
const savedCallback = useRef();
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
/**
* @description function to be called
*/
function tick() {
savedCallback.current();
}
if (delay !== null) {
const id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
};
@@ -1,6 +1,6 @@
sup.period {
top: -1em;
font-size: 0.4em;
font-size: 0.5em;
}
.subscript {
@@ -10,9 +10,7 @@ import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import Schedule from '../../../views/common/schedule/Schedule';
import { ScheduleProvider } from '../../../views/common/schedule/ScheduleContext';
import ScheduleNav from '../../../views/common/schedule/ScheduleNav';
import PublicSchedule from '../../../views/common/schedule/PublicSchedule';
import { titleVariants } from '../common/animation';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getPropertyValue } from '../common/viewUtils';
@@ -30,23 +28,13 @@ interface BackstageProps {
publicEventNow: OntimeEvent | null;
publicEventNext: OntimeEvent | null;
time: ViewExtendedTimer;
events: OntimeEvent[];
publicSelectedId: string | null;
settings: Settings | undefined;
}
export default function Public(props: BackstageProps) {
const {
customFields,
general,
isMirrored,
publicEventNow,
publicEventNext,
time,
events,
publicSelectedId,
settings,
} = props;
const { customFields, general, isMirrored, publicEventNow, publicEventNext, time, publicSelectedId, settings } =
props;
const { getLocalizedString } = useTranslation();
const [searchParams] = useSearchParams();
@@ -109,10 +97,7 @@ export default function Public(props: BackstageProps) {
</AnimatePresence>
</div>
<ScheduleProvider events={events} selectedEventId={publicSelectedId}>
<ScheduleNav className='schedule-nav-container' />
<Schedule className='schedule-container' />
</ScheduleProvider>
<PublicSchedule selectedId={publicSelectedId} />
<div className='info'>
{general.publicUrl && <QRCode value={general.publicUrl} size={qrSize} level='L' className='qr' />}
+10 -5
View File
@@ -9,6 +9,12 @@ $viewer-transition-time: 0.5s;
$viewer-font-family: 'Open Sans', 'Segoe UI', sans-serif; // --font-family-override
$viewer-opacity-disabled: 0.6;
$timer-label-size: clamp(12px, 1.25vw, 20px);
$base-font-size: clamp(15px, 1.5vw, 28px);
$title-font-size: clamp(18px, 2.25vw, 42px);
$timer-value-size: clamp(24px, 2.5vw, 48px);
$header-font-size: clamp(28px, 3.5vw, 64px);
// General styling
$accent-color: $red-500; // --accent-color-override
$delay-color: $ontime-delay-text;
@@ -23,16 +29,15 @@ $element-border-radius: 8px;
// Generic element sizes
$view-element-gap: min(2vh, 16px);
$view-block-padding: min(2vh, 16px);
$view-inline-padding: clamp(16px, 2vw, 24px);
$view-outer-padding: min(2vh, 16px) clamp(16px, 2vw, 24px);
$view-card-padding: min(2vh, 8px) clamp(16px, 2vw, 24px);
// Properties related to timer
$timer-color: rgba(white, 80%); // --timer-color-override
$timer-finished-color: $playback-negative;
$timer-bold-font-family: 'Arial Black', sans-serif; // --card-background-color-override
$timer-bold-font-family: 'Arial Black', sans-serif; // --font-family-bold-override
$external-color: rgba(white, 85%); // --external-color-override
// properties of other timers (clock and countdown)
$timer-label-size: clamp(16px, 1.5vw, 24px);
$timer-value-size: clamp(32px, 3.5vw, 50px);
@@ -13,6 +13,7 @@ export const langDe: TranslationObject = {
'common.stage_timer': 'Bühnen-Timer',
'common.started_at': 'Gestartet am',
'common.time_now': 'Aktuelle Zeit',
'common.no_data': 'Keine Daten',
'countdown.ended': 'Veranstaltung endete um',
'countdown.running': 'Veranstaltung läuft',
'countdown.select_event': 'Wählen Sie eine Veranstaltung aus, um sie zu verfolgen',
@@ -11,6 +11,7 @@ export const langEn = {
'common.stage_timer': 'Stage Timer',
'common.started_at': 'Started At',
'common.time_now': 'Time now',
'common.no_data': 'No data',
'countdown.ended': 'Event ended at',
'countdown.running': 'Event running',
'countdown.select_event': 'Select an event to follow',
@@ -13,6 +13,7 @@ export const langEs: TranslationObject = {
'common.stage_timer': 'Temporizador de presentador',
'common.started_at': 'Iniciado en',
'common.time_now': 'Ahora',
'common.no_data': 'Sin datos',
'countdown.ended': 'Evento finalizado a las',
'countdown.running': 'Evento en curso',
'countdown.select_event': 'Seleccionar un evento para seguir',
@@ -13,6 +13,7 @@ export const langFr: TranslationObject = {
'common.stage_timer': 'Minuteur de scène',
'common.started_at': 'Commencé à',
'common.time_now': 'Heure',
'common.no_data': 'Aucune donnée',
'countdown.ended': 'Évènement terminé à',
'countdown.running': 'Évènement en cours',
'countdown.select_event': 'Sélectionnez un évènement à suivre',
@@ -13,6 +13,7 @@ export const langHu: TranslationObject = {
'common.stage_timer': 'Színpadi időzítő',
'common.started_at': 'Kezdődött',
'common.time_now': 'Jelenlegi idő',
'common.no_data': 'Nincsenek adatok',
'countdown.ended': 'Esemény véget ért',
'countdown.running': 'Esemény folyamatban',
'countdown.select_event': 'Válassza ki a követendő eseményt',
@@ -13,6 +13,7 @@ export const langIt: TranslationObject = {
'common.stage_timer': 'Orologio Palco',
'common.started_at': 'Iniziato Alle',
'common.time_now': 'Ora attuale',
'common.no_data': 'Nessun dato disponibile',
'countdown.ended': 'Evento finito alle',
'countdown.running': 'Evento in corso',
'countdown.select_event': 'Seleziona un evento da seguire',
@@ -13,6 +13,7 @@ export const langNo: TranslationObject = {
'common.stage_timer': 'Scenetimer',
'common.started_at': 'Startet',
'common.time_now': 'Klokken nå',
'common.no_data': 'Ingen data tilgjengelig',
'countdown.ended': 'Hendelse avsluttet',
'countdown.running': 'Hendelse pågår',
'countdown.select_event': 'Velg en hendelse å følge',
@@ -13,6 +13,7 @@ export const langPl: TranslationObject = {
'common.stage_timer': 'Timer Scena',
'common.started_at': 'Rozpoczęte o',
'common.time_now': 'Aktualny czas',
'common.no_data': 'Brak danych',
'countdown.ended': 'Zakończone o',
'countdown.running': 'Trwa',
'countdown.select_event': 'Wybierz event który chcesz śledzić',
@@ -13,6 +13,7 @@ export const langPt: TranslationObject = {
'common.stage_timer': 'Temporizador do presentador',
'common.started_at': 'Iniciado em',
'common.time_now': 'Hora atual',
'common.no_data': 'Sem dados',
'countdown.ended': 'Evento encerrado às',
'countdown.running': 'Evento em andamento',
'countdown.select_event': 'Selecione um evento para acompanhar',
@@ -13,6 +13,7 @@ export const langSv: TranslationObject = {
'common.stage_timer': 'Timer för scenen',
'common.started_at': 'Började vid',
'common.time_now': 'Klockan nu',
'common.no_data': 'Inga tillgängliga data',
'countdown.ended': 'Evenemanget avslutades vid',
'countdown.running': 'Evenemang pågår',
'countdown.select_event': 'Välj ett evenemang att följa',
@@ -13,6 +13,7 @@ export const langZhCn: TranslationObject = {
'common.stage_timer': '舞台计时器',
'common.started_at': '开始于',
'common.time_now': '当前时间',
'common.no_data': '无数据',
'countdown.ended': '事件结束于',
'countdown.running': '事件进行中',
'countdown.select_event': '选择要关注的事件',
+99 -29
View File
@@ -10,23 +10,31 @@
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;
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'
'progress schedule-nav'
'card schedule'
'info schedule';
.empty-container {
position: absolute;
top: 0;
left: 0;
right: 0;
margin-top: 25vh;
}
/* =================== 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,19 +45,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-weight: 600;
font-size: $timer-value-size;
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
line-height: 0.95em;
@@ -64,17 +75,27 @@
margin: 0 auto -8px;
}
.now-container {
grid-area: now;
.card-container {
grid-area: card;
display: flex;
flex-direction: column;
gap: min(2vh, 16px);
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: 16px 24px;
border-radius: 8px;
padding: $view-card-padding;
border-radius: $element-border-radius;
&.blink {
animation-name: blink;
@@ -87,9 +108,10 @@
.timer-group {
grid-area: timer;
border-top: 2px solid var(--background-color-override, $viewer-background-color);
margin-top: 2em;
padding-top: 1em;
margin-top: max(1vh, 16px);
padding-top: max(1vh, 16px);
display: flex;
row-gap: 0.5em;
}
.timer-gap {
@@ -98,58 +120,106 @@
}
.aux-timers {
font-size: max(1vw, 16px);
&__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);
font-size: clamp(24px, 2vw, 32px);
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;
}
}
}
/* =================== AMIMATION ===================*/
/* =================== MOBILE ===================*/
@media screen and (max-width: 768px) {
.backstage {
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-container {
margin-left: 0;
flex: 1;
}
.info {
flex: 1;
width: 100%;
}
}
}
/* =================== ANIMATION ===================*/
@keyframes blink {
0% {
+97 -81
View File
@@ -1,32 +1,27 @@
import { useEffect, useState } from 'react';
import QRCode from 'react-qr-code';
import { useSearchParams } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { CustomFields, OntimeEvent, ProjectData, Settings, SupportedEvent } from 'ontime-types';
import { useViewportSize } from '@mantine/hooks';
import { CustomFields, OntimeEvent, ProjectData, Runtime, Settings } from 'ontime-types';
import { millisToString, removeLeadingZero } from 'ontime-utils';
import ProgressBar from '../../common/components/progress-bar/ProgressBar';
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 { timerPlaceholderMin } from '../../common/utils/styleUtils';
import { cx, timerPlaceholderMin } 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 Schedule from '../common/schedule/Schedule';
import { ScheduleProvider } from '../common/schedule/ScheduleContext';
import ScheduleNav from '../common/schedule/ScheduleNav';
import { titleVariants } from '../timer/timer.animations';
import BackstageSchedule from '../common/schedule/BackstageSchedule';
import { getBackstageOptions } from './backstage.options';
import { getBackstageOptions, useBackstageOptions } from './backstage.options';
import { getCardData, getIsPendingStart, getShowProgressBar, isOvertime } from './backstage.utils';
import './Backstage.scss';
export const MotionTitleCard = motion(TitleCard);
interface BackstageProps {
backstageEvents: OntimeEvent[];
customFields: CustomFields;
@@ -35,16 +30,29 @@ interface BackstageProps {
general: ProjectData;
isMirrored: boolean;
time: ViewExtendedTimer;
runtime: Runtime;
selectedId: string | null;
settings: Settings | undefined;
}
export default function Backstage(props: BackstageProps) {
const { backstageEvents, customFields, eventNext, eventNow, general, time, isMirrored, selectedId, settings } = props;
const {
backstageEvents,
customFields,
eventNext,
eventNow,
general,
time,
isMirrored,
runtime,
selectedId,
settings,
} = props;
const { getLocalizedString } = useTranslation();
const { secondarySource } = useBackstageOptions();
const [blinkClass, setBlinkClass] = useState(false);
const [searchParams] = useSearchParams();
const { height: screenHeight } = useViewportSize();
useWindowTitle('Backstage');
@@ -59,22 +67,31 @@ export default function Backstage(props: BackstageProps) {
return () => clearTimeout(timer);
}, [selectedId]);
// gather card data
const { showNow, nowMain, nowSecondary, showNext, nextMain, nextSecondary } = getCardData(
eventNow,
eventNext,
'title',
secondarySource,
time.playback,
);
// gather timer data
const clock = formatTime(time.clock);
const startedAt = formatTime(time.startedAt);
const isNegative = (time.current ?? 0) < 0;
const expectedFinish = isNegative ? getLocalizedString('countdown.overtime') : formatTime(time.expectedFinish);
const qrSize = Math.max(window.innerWidth / 15, 128);
const filteredEvents = backstageEvents.filter((event) => event.type === SupportedEvent.Event);
const showProgress = time.playback !== 'stop';
const secondarySource = searchParams.get('secondary-src');
const secondaryTextNext = getPropertyValue(eventNext, secondarySource);
const secondaryTextNow = getPropertyValue(eventNow, secondarySource);
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' });
let stageTimer = millisToString(time.current, { fallback: timerPlaceholderMin });
stageTimer = removeLeadingZero(stageTimer);
// 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
// gather option data
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const backstageOptions = getBackstageOptions(defaultFormat, customFields);
@@ -82,8 +99,8 @@ export default function Backstage(props: BackstageProps) {
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
<ViewParamsEditor viewOptions={backstageOptions} />
<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' />
@@ -97,63 +114,62 @@ export default function Backstage(props: BackstageProps) {
hidden={!showProgress}
/>
<div className='now-container'>
<AnimatePresence>
{eventNow && (
<motion.div
className={`event now ${blinkClass ? 'blink' : ''}`}
key='now'
variants={titleVariants}
initial='hidden'
animate='visible'
exit='exit'
>
<TitleCard title={eventNow.title} secondary={secondaryTextNow} />
<div className='timer-group'>
<div className='aux-timers'>
<div className='aux-timers__label'>{getLocalizedString('common.started_at')}</div>
<SuperscriptTime time={startedAt} className='aux-timers__value' />
</div>
<div className='timer-gap' />
<div className='aux-timers'>
<div className='aux-timers__label'>{getLocalizedString('common.expected_finish')}</div>
{isNegative ? (
<div className='aux-timers__value'>{expectedFinish}</div>
) : (
<SuperscriptTime time={expectedFinish} className='aux-timers__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>
</motion.div>
)}
</AnimatePresence>
{backstageEvents.length === 0 && (
<div className='empty-container'>
<Empty text={getLocalizedString('common.no_data')} />
</div>
)}
<AnimatePresence>
{eventNext && (
<MotionTitleCard
className='event next'
key='next'
variants={titleVariants}
initial='hidden'
animate='visible'
exit='exit'
label='next'
title={eventNext.title}
secondary={secondaryTextNext}
/>
)}
</AnimatePresence>
<div className='card-container'>
{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'])}>
{isPendingStart ? getLocalizedString('countdown.waiting') : getLocalizedString('common.started_at')}
</div>
<SuperscriptTime time={startedAt} className='aux-timers__value' />
</div>
<div className='timer-gap' />
<div className='aux-timers'>
<div className='aux-timers__label'>{getLocalizedString('common.expected_finish')}</div>
{isOvertime(time.current) ? (
<div className='aux-timers__value'>{getLocalizedString('countdown.overtime')}</div>
) : (
<SuperscriptTime time={formatTime(time.expectedFinish)} className='aux-timers__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>
</div>
</div>
)}
{showNext && <TitleCard className='event' label='next' title={nextMain} secondary={nextSecondary} />}
</div>
<ScheduleProvider events={filteredEvents} selectedEventId={selectedId} isBackstage>
<ScheduleNav className='schedule-nav-container' />
<Schedule isProduction className='schedule-container' />
</ScheduleProvider>
{showSchedule && <BackstageSchedule selectedId={selectedId} />}
<div className='info'>
{general.backstageUrl && <QRCode value={general.backstageUrl} size={qrSize} level='L' className='qr' />}
@@ -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 { isStringBoolean } from '../../features/viewers/common/viewUtils';
export const getBackstageOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
@@ -26,25 +29,10 @@ export const getBackstageOptions = (timeFormat: string, customFields: CustomFiel
},
],
},
{
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',
@@ -52,7 +40,59 @@ export const getBackstageOptions = (timeFormat: string, customFields: CustomFiel
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 BackstageOptions = {
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): BackstageOptions {
// 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 useBackstageOptions(): BackstageOptions {
const [searchParams] = useSearchParams();
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;
}
@@ -0,0 +1,61 @@
import { MaybeNumber, OntimeEvent, Playback, TimerPhase } from 'ontime-types';
import { getPropertyValue } from '../../features/viewers/common/viewUtils';
/**
* Whether the current time is in overtime
*/
export function isOvertime(current: MaybeNumber): boolean {
return (current ?? 0) < 0;
}
/**
* Whether the progress bar should be shown
*/
export function getShowProgressBar(playback: Playback): boolean {
return playback !== Playback.Stop;
}
/**
* Whether the playback is pending start (ie: Roll mode waiting to start)
*/
export function getIsPendingStart(playback: Playback, phase: TimerPhase): boolean {
return playback === Playback.Roll && phase === TimerPhase.Pending;
}
/**
* 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');
const nowSecondary = getPropertyValue(eventNow, secondarySource);
const nextMain = getPropertyValue(eventNext, mainSource ?? 'title');
const nextSecondary = getPropertyValue(eventNext, secondarySource);
return {
showNow: Boolean(nowMain) || Boolean(nowSecondary),
nowMain,
nowSecondary,
showNext: Boolean(nextMain) || Boolean(nextSecondary),
nextMain,
nextSecondary,
};
}
@@ -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,
};
}
+2 -2
View File
@@ -198,8 +198,8 @@
/* =================== LOGO ===================*/
.logo {
position: absolute;
top: min(2vh, 16px);
left: min(2vw, 16px);
top: $view-block-padding;
left: $view-inline-padding;
max-width: min(200px, 20vw);
max-height: min(100px, 20vh);
}
+1
View File
@@ -25,6 +25,7 @@
--timer-warning-color-override: #ffbc56;
--timer-danger-color-override: #e69000;
--timer-overtime-color-override: #fa5656;
--timer-pending-color-override: #578AF4;
/** Background for card elements on background */
--card-background-color-override: #fff;