Compare commits

...

30 Commits

Author SHA1 Message Date
Carlos Valente e9f0bdef4d bump version to 3.11.1-beta.1 2025-02-16 22:28:51 +01:00
Carlos Valente 8386105b75 refactor: show projected time 2025-02-16 22:28:51 +01:00
Carlos Valente 459d584b35 refactor: public design review 2025-02-16 22:28:51 +01:00
Carlos Valente f98af929b3 chore: directory restructure 2025-02-15 12:20:19 +01:00
Carlos Valente cbabec2b1d 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
2025-02-15 12:20:19 +01:00
Carlos Valente 959a2b6d4a chore: folder structure 2025-02-13 21:40:15 +01:00
Carlos Valente 8ac5c0a9a2 Revert "Timeuntil in UI (#1461)"
This reverts commit 221ff7b3af.
2025-02-12 22:19:18 +01:00
Carlos Valente 649f0af2cf refactor: reword error 2025-02-12 22:19:18 +01:00
Carlos Valente fd9b944ecf bump version to 3.11.0 2025-02-12 22:19:18 +01:00
Alex Christoffer Rasmussen 0b36608134 Catch xlsx error (#1487)
* catch potential xlsx errors

* clear worksheet errors when starting new connections
2025-02-12 13:38:18 +01:00
Carlos Valente bac11adeb1 feat: add autocomplete to osc arguments 2025-02-10 20:01:36 +01:00
Carlos Valente 31799f8fab fix: project created without extension 2025-02-10 07:39:05 +01:00
Carlos Valente af177060ff refactor: add next to source fields 2025-02-06 21:20:25 +01:00
Carlos Valente d26a3cd393 refactor: change order of operations 2025-02-06 21:20:25 +01:00
Carlos Valente 38a3165a79 refactor: larger settings panels 2025-02-04 22:35:17 +01:00
Carlos Valente 1715c19747 refactor: clarify empty text 2025-02-04 22:35:17 +01:00
Carlos Valente 6be6e5fd8d refactor: handle overflow in menulist 2025-02-04 22:35:17 +01:00
Carlos Valente 44b0cfd928 refactor: allow locking cuesheet and operator 2025-02-04 22:35:00 +01:00
Carlos Valente 3d50b8ce49 refactor: simplify navigation lock 2025-02-04 22:35:00 +01:00
Carlos Valente c3d66f5bd1 fix: maintain locked and token params 2025-02-01 21:34:29 +01:00
Carlos Valente d08787a6ba refactor: allow presets for op and cuesheet 2025-02-01 21:34:29 +01:00
Carlos Valente f0e6231338 refactor: simplify url preset logic 2025-02-01 21:34:29 +01:00
Carlos Valente 430bfb38f2 chore: test is typescript 2025-02-01 21:34:29 +01:00
Carlos Valente 60354f8ea3 fix: prevent non alphanumeric in pincode 2025-02-01 11:38:58 +01:00
Carlos Valente bc12531729 refactor: show multiline text in custom fields 2025-02-01 11:38:58 +01:00
Carlos Valente 0b8a694e4f refactor: allow overriding operator styles 2025-02-01 11:38:58 +01:00
Carlos Valente 73ada99596 fix: update time in block (#1474)
* fix: update time in block

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2025-01-31 17:20:02 +01:00
Alex Christoffer Rasmussen f509655d31 update types/node and osc-min (#1475) 2025-01-30 22:25:25 +01:00
Carlos Valente fb1bbd6d5c refactor: fix roll next day 2025-01-27 19:24:11 +01:00
Alex Christoffer Rasmussen 221ff7b3af Timeuntil in UI (#1461)
* cherry pick EventBlockChip

* pull data down throug components

* add comment

* cleanup css

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-01-27 16:39:32 +01:00
96 changed files with 2430 additions and 1388 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "3.11.0-beta.1",
"version": "3.11.1-beta.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.11.0-beta.1",
"version": "3.11.1-beta.1",
"private": true,
"type": "module",
"dependencies": {
+6 -4
View File
@@ -27,9 +27,9 @@ const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-tim
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
const Backstage = React.lazy(() => import('./features/viewers/backstage/Backstage'));
const Backstage = React.lazy(() => import('./views/backstage/Backstage'));
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
const Public = React.lazy(() => import('./features/viewers/public/Public'));
const Public = React.lazy(() => import('./views/public/Public'));
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
@@ -44,6 +44,8 @@ const SPublic = withPreset(withData(Public));
const SLowerThird = withPreset(withData(Lower));
const SStudio = withPreset(withData(StudioClock));
const STimeline = withPreset(withData(Timeline));
const PCuesheet = withPreset(Cuesheet);
const POperator = withPreset(Operator);
const EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport'));
@@ -155,8 +157,8 @@ export default function AppRouter() {
{/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<Cuesheet />} />
<Route path='/op' element={<Operator />} />
<Route path='/cuesheet' element={<PCuesheet />} />
<Route path='/op' element={<POperator />} />
{/*/!* Protected Routes - Elements *!/*/}
<Route
@@ -2,6 +2,7 @@ import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
import { cx } from '../../utils/styleUtils';
import style from './NavigationMenu.module.scss';
@@ -15,7 +16,7 @@ export default function FloatingNavigation(props: FloatingNavigationProps) {
const isButtonShown = useFadeOutOnInactivity();
return (
<div className={`${style.buttonContainer} ${!isButtonShown ? style.hidden : ''}`}>
<div className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}>
<button
onClick={toggleMenu}
aria-label='toggle menu'
@@ -7,24 +7,38 @@ $icon-color: $ui-white;
$button-bg: $gray-1050;
$button-size: 3rem;
.fadeable {
opacity: 1;
transition-property: opacity;
transition-duration: 0.3s;
&.hidden {
opacity: 0;
}
}
.lockIcon {
font-size: 1.5rem;
color: $active-indicator;
padding: 0.5em;
position: fixed;
left: 0;
top: 0;
z-index: 12;
}
.buttonContainer {
display: flex;
flex-direction: column;
row-gap: 1rem;
padding: 0.5em;
transition-property: opacity;
transition-duration: 0.3s;
opacity: 1;
position: fixed;
left: 0;
top: 0;
z-index: 12;
&.hidden {
opacity: 0;
}
}
.button {
@@ -38,16 +52,6 @@ $button-size: 3rem;
border-radius: 3px;
}
.lockIcon {
font-size: 1.5rem;
display: grid;
place-content: center;
border-radius: 3px;
background-color: transparent;
color: $active-indicator;
cursor: default;
}
.navButton {
@extend .button;
z-index: 3;
@@ -1,6 +1,7 @@
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
import { cx } from '../../utils/styleUtils';
import style from './NavigationMenu.module.scss';
@@ -8,8 +9,8 @@ export default function ViewLockedIcon() {
const isLockIconShown = useFadeOutOnInactivity();
return (
<div className={`${style.buttonContainer} ${!isLockIconShown ? style.hidden : ''}`}>
<IoLockClosedOutline className={style.lockIcon} />
<div className={cx([style.fadeable, style.lockIcon, !isLockIconShown && style.hidden])}>
<IoLockClosedOutline />
</div>
);
}
@@ -1,23 +1,20 @@
import { memo, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { memo } from 'react';
import { useDisclosure } from '@chakra-ui/react';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import FloatingNavigation from './FloatingNavigation';
import NavigationMenu from './NavigationMenu';
import useViewEditor from './useViewEditor';
import ViewLockedIcon from './ViewLockedIcon';
function ViewNavigationMenu() {
const [searchParams, setSearchParams] = useSearchParams();
interface ViewNavigationMenuProps {
isLockable?: boolean;
}
function ViewNavigationMenu(props: ViewNavigationMenuProps) {
const { isLockable } = props;
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
const isViewLocked = isStringBoolean(searchParams.get('locked'));
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable });
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
@@ -0,0 +1,26 @@
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
interface EditorVisibilityOptions {
isLockable?: boolean;
}
export default function useViewEditor({ isLockable }: EditorVisibilityOptions) {
const [searchParams, setSearchParams] = useSearchParams();
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
const isViewLocked = useMemo(() => {
if (!isLockable) {
return false;
}
return isStringBoolean(searchParams.get('locked'));
}, [isLockable, searchParams]);
return { showEditFormDrawer, isViewLocked };
}
@@ -1,76 +0,0 @@
@use '../../../theme/viewerDefs' as *;
.schedule {
width: 100%;
border-spacing: 50px;
.entry {
font-size: clamp(16px, 1.5vw, 24px);
.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;
}
.entry-times {
font-family: $viewer-font-family;
color: var(--secondary-color-override, $viewer-secondary-color);
font-weight: 300;
letter-spacing: 0.05em;
display: flex;
align-items: center;
gap: 8px;
}
.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 {
text-decoration: line-through;
}
}
}
.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;
border-radius: 6px;
margin-left: 8px;
&--selected {
background-color: var(--color-override, $viewer-color);
}
}
}
@@ -1,52 +0,0 @@
import { useSchedule } from './ScheduleContext';
import ScheduleItem from './ScheduleItem';
import './Schedule.scss';
interface ScheduleProps {
isProduction?: boolean;
className?: string;
}
export default function Schedule({ isProduction, className }: ScheduleProps) {
const { paginatedEvents, selectedEventId, isBackstage, scheduleType } = useSchedule();
// TODO: design a nice placeholder for empty schedules
if (paginatedEvents?.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;
return (
<ScheduleItem
key={event.id}
selected={selectedState}
timeStart={timeStart}
timeEnd={timeEnd}
title={event.title}
colour={isBackstage ? event.colour : ''}
backstageEvent={!event.isPublic}
skip={event.skip}
/>
);
})}
</ul>
);
}
@@ -1,102 +0,0 @@
import { createContext, PropsWithChildren, useContext, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { OntimeEvent } from 'ontime-types';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import { useInterval } from '../../hooks/useInterval';
interface ScheduleContextState {
events: OntimeEvent[];
paginatedEvents: OntimeEvent[];
selectedEventId: string | null;
scheduleType: 'past' | 'now' | 'future';
numPages: number;
visiblePage: number;
isBackstage: boolean;
}
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();
// look for overrides from views
const hidePast = isStringBoolean(searchParams.get('hidePast'));
const stopCycle = isStringBoolean(searchParams.get('stopCycle'));
const eventsPerPage = Number(searchParams.get('eventsPerPage') ?? numEventsPerPage);
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);
return (
<ScheduleContext.Provider
value={{
events,
paginatedEvents,
selectedEventId,
scheduleType,
numPages,
visiblePage,
isBackstage,
}}
>
{children}
</ScheduleContext.Provider>
);
};
export const useSchedule = () => {
const context = useContext(ScheduleContext);
if (!context) {
throw new Error('useSchedule() can only be used inside a ScheduleContext');
}
return context;
};
@@ -1,43 +0,0 @@
import SuperscriptTime from '../../../features/viewers/common/superscript-time/SuperscriptTime';
import { formatTime } from '../../utils/time';
import './Schedule.scss';
const formatOptions = {
format12: 'hh:mm a',
format24: 'HH:mm',
};
interface ScheduleItemProps {
selected: 'past' | 'now' | 'future';
timeStart: number;
timeEnd: number;
title: string;
backstageEvent: boolean;
colour: string;
skip: boolean;
}
export default function ScheduleItem(props: ScheduleItemProps) {
const { selected, timeStart, timeEnd, title, backstageEvent, colour, skip } = props;
const start = formatTime(timeStart, formatOptions);
const end = formatTime(timeEnd, formatOptions);
const userColour = colour !== '' ? colour : '';
const selectStyle = `entry--${selected}`;
return (
<li className={`entry ${selectStyle} ${skip ? 'skip' : ''}`}>
<div className='entry-times'>
<span className='entry-colour' style={{ backgroundColor: userColour }} />
<div style={{ display: 'flex' }}>
<SuperscriptTime time={start} />
{' → '}
<SuperscriptTime time={end} />
{backstageEvent ? '*' : ''}
</div>
</div>
<div className='entry-title'>{title}</div>
</li>
);
}
@@ -1,23 +0,0 @@
import { useSchedule } from './ScheduleContext';
import './Schedule.scss';
interface ScheduleNavProps {
className?: string;
}
export default function ScheduleNav({ className }: ScheduleNavProps) {
const { numPages, visiblePage } = useSchedule();
return (
<div className={`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'}
/>
))}
</div>
);
}
@@ -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>
@@ -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 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>
);
}
@@ -119,7 +119,7 @@ function MultiOption(props: EditFormMultiOptionProps) {
<MenuButton as={Button} variant='ontime-subtle-white' position='relative' width='fit-content' fontWeight={400}>
{paramField.title} <IoChevronDown style={{ display: 'inline' }} />
</MenuButton>
<MenuList>
<MenuList overflow='auto' maxHeight='200px'>
<MenuOptionGroup
type='checkbox'
value={paramState}
@@ -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]);
};
@@ -234,6 +234,14 @@ export const useTimelineStatus = () => {
return useRuntimeStore(featureSelector);
};
export const useRuntimeOffset = () => {
const featureSelector = (state: RuntimeStore) => ({
offset: state.runtime.offset,
});
return useRuntimeStore(featureSelector);
};
export const usePing = () => {
const featureSelector = (state: RuntimeStore) => ({
ping: state.ping,
@@ -1,103 +0,0 @@
import { resolvePath } from 'react-router-dom';
import { generateUrlFromPreset, getRouteFromPreset, validateUrlPresetPath } from '../urlPresets';
describe('A preset fails if incorrect', () => {
const testsToFail = [
// no empty
'',
// no https, http or www
'https://www.test.com',
'http://www.test.com',
'www.test.com',
// no hostname
'localhost/test',
'127.0.0.1/test',
'0.0.0.0/test',
// no editor
'editor',
'editor?test',
];
testsToFail.forEach((t) =>
it(`${t}`, () => {
expect(validateUrlPresetPath(t).isValid).toBeFalsy();
}),
);
});
describe('generateUrlFromPreset and getRouteFromPreset function', () => {
test('generate the expected url from an alias', () => {
const testData = [
{
enabled: true,
alias: 'demopage',
pathAndParams: '/timer?user=guest',
},
];
const expected = [
{
url: '/timer?user=guest&alias=demopage',
},
];
expect(generateUrlFromPreset(testData[0])).toStrictEqual(expected[0].url);
});
test('generate the url to redirect to when the current URL is just the alias', () => {
const presets = [
{
enabled: true,
alias: 'demopage',
pathAndParams: '/timer?user=guest',
},
];
// let current location be the alias
const location = resolvePath(presets[0].alias);
const expected = [
{
url: '/timer?user=guest&alias=demopage',
},
];
expect(getRouteFromPreset(location, presets, null)).toStrictEqual(expected[0].url);
});
test('generate the url to redirect to when the current URL the same url but with a change of params', () => {
const presets = [
{
enabled: true,
alias: 'demopage',
pathAndParams: '/timer?user=guest',
},
];
// let current location be the actual url with alias attached to it
const location = resolvePath(presets[0].pathAndParams);
const urlSearchParams = new URLSearchParams(location.search);
urlSearchParams.append('alias', presets[0].alias); //
// update current alias with extra param
presets[0].pathAndParams += '&eventId=674';
const expected = [
{
url: '/timer?user=guest&eventId=674&alias=demopage',
},
];
expect(getRouteFromPreset(location, presets, urlSearchParams)).toStrictEqual(expected[0].url);
});
test('generate no url to redirect to when the current URL the same url', () => {
const presets = [
{
enabled: true,
alias: 'demopage',
pathAndParams: '/timer?user=guest',
},
];
// let current location be the actual url with alias attached to it
const location = resolvePath(presets[0].pathAndParams);
const urlSearchParams = new URLSearchParams(location.search);
urlSearchParams.append('alias', presets[0].alias); //
expect(getRouteFromPreset(location, presets, urlSearchParams)).toBeNull();
});
});
@@ -0,0 +1,107 @@
import { resolvePath } from 'react-router-dom';
import { arePathsEquivalent, generatePathFromPreset, getRouteFromPreset, validateUrlPresetPath } from '../urlPresets';
describe('validateUrlPresetPaths()', () => {
test.each([
// no empty
'',
// no https, http or www
'https://www.test.com',
'http://www.test.com',
'www.test.com',
// no hostname
'localhost/test',
'127.0.0.1/test',
'0.0.0.0/test',
// no editor
'editor',
'editor?test',
])('flags known edge cases: %s', (t) => {
expect(validateUrlPresetPath(t).isValid).toBeFalsy();
});
});
describe('getRouteFromPreset()', () => {
const presets = [
{
enabled: true,
alias: 'demopage',
pathAndParams: '/timer?user=guest',
},
];
it('checks if the current location matches an enabled preset', () => {
// we make the current location be the alias
const location = resolvePath('demopage');
expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage');
});
it('returns null if the current location is the exact match of an unwrapped alias', () => {
// we make the current location be the alias
const location = resolvePath('/timer?user=guest&alias=demopage');
expect(getRouteFromPreset(location, presets)).toEqual(null);
});
it('returns a new destination if the current location is an out-of-date unwrapped alias', () => {
// we make the current location be the alias
const location = resolvePath('/timer?user=admin&alias=demopage');
expect(getRouteFromPreset(location, presets)).toEqual('timer?user=guest&alias=demopage');
});
it('checks if the current location contains an unwrapped preset', () => {
// we make the current location be the alias
const location = resolvePath('/timer?user=guest&alias=demopage');
expect(getRouteFromPreset(location, presets)).toEqual(null);
});
it('ignores a location that has no presets', () => {
// we make the current location be the alias
const location = resolvePath('/unknown');
expect(getRouteFromPreset(location, presets)).toEqual(null);
});
describe('handle url sharing edge cases', () => {
it('finds the correct preset when the url contains extra arguments', () => {
const location = resolvePath('/demopage?locked=true&token=123');
expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy()
})
it('appends the feature params to the alias', () => {
const location = resolvePath('/demopage?locked=true&token=123');
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123')
})
});
});
describe('generatePathFromPreset()', () => {
test.each([
['timer?user=guest', 'demopage', 'timer?user=guest&alias=demopage'],
['timer?user=admin', 'demopage', 'timer?user=admin&alias=demopage'],
])('generates a path from a preset: %s', (path, alias, expected) => {
expect(generatePathFromPreset(path, alias, null, null)).toEqual(expected);
});
test('appends the feature params to the alias', () => {
expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe('timer?user=guest&alias=demopage&locked=true&token=123');
});
});
describe('arePathsEquivalent()', () => {
it("checks whether the paths match", () => {
expect(arePathsEquivalent('demopage', 'timer')).toBeFalsy();
expect(arePathsEquivalent('timer', 'timer')).toBeTruthy();
expect(arePathsEquivalent('timer?user=guest', 'timer?user=guest')).toBeTruthy();
})
it("checks whether the params match", () => {
expect(arePathsEquivalent('timer?test=a', 'timer?test=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a', 'timer?test=a')).toBeTruthy();
})
it("considers edge cases for the url sharing feature", () => {
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy();
})
});
+79 -36
View File
@@ -1,13 +1,11 @@
import isEqual from 'react-fast-compare';
import { Location, resolvePath } from 'react-router-dom';
import { Path, resolvePath } from 'react-router-dom';
import { URLPreset } from 'ontime-types';
/**
* Validates a preset against defined parameters
* @param {string} preset
* @returns {{message: string, isValid: boolean}}
* Used in the context of form validation
*/
export const validateUrlPresetPath = (preset: string): { message: string; isValid: boolean } => {
export function validateUrlPresetPath(preset: string): { message: string; isValid: boolean } {
if (preset === '' || preset == null) {
return { isValid: false, message: 'Path cannot be empty' };
}
@@ -26,7 +24,7 @@ export const validateUrlPresetPath = (preset: string): { message: string; isVali
}
return { isValid: true, message: 'ok' };
};
}
/**
* Utility removes trailing slash from a string
@@ -36,48 +34,93 @@ function removeTrailingSlash(text: string): string {
}
/**
* Gets the URL to send a preset to
* @param location
* @param data
* @param searchParams
* Checks whether the current location corresponds to a preset and returns the new path if necessary
*/
export const getRouteFromPreset = (location: Location, data: URLPreset[], searchParams: URLSearchParams) => {
export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): string | null {
// current url is the pathname without the leading slash
const currentURL = location.pathname.substring(1);
const searchParams = new URLSearchParams(location.search);
// we need to check if the whole url here is an alias, so we can redirect
const foundPreset = data.find((preset) => preset.alias === removeTrailingSlash(currentURL) && preset.enabled);
// check if we have token or locked in the search params
const locked = searchParams.get('locked');
const token = searchParams.get('token');
// we need to check if the whole url is an alias
const foundPreset = urlPresets.find((preset) => preset.alias === removeTrailingSlash(currentURL) && preset.enabled);
if (foundPreset) {
return generateUrlFromPreset(foundPreset);
// if so, we can redirect to the preset path
return generatePathFromPreset(foundPreset.pathAndParams, foundPreset.alias, locked, token);
}
// if the current url is not an alias, we check if the alias is in the search parameters
const presetOnPage = searchParams.get('alias');
for (const d of data) {
if (presetOnPage) {
// if the alias fits the preset on this page, but the URL is different, we redirect user to the new URL
// if we have the same alias and its enabled and its not empty
if (d.alias !== '' && d.enabled && d.alias === presetOnPage) {
const newPath = resolvePath(d.pathAndParams);
const urlParams = new URLSearchParams(newPath.search);
urlParams.set('alias', d.alias);
// we confirm either the url parameters does not match or the url path doesnt
if (!isEqual(urlParams, searchParams) || newPath.pathname !== location.pathname) {
// we then redirect to the alias route, since the view listening to this alias has an outdated URL
return `${newPath.pathname}?${urlParams}`;
}
if (!presetOnPage) {
return null;
}
const currentPath = `${location.pathname}${location.search}`.substring(1);
for (const preset of urlPresets) {
// if the page has a known enabled alias, we check if we need to redirect
if (preset.alias === presetOnPage && preset.enabled) {
const newPath = generatePathFromPreset(preset.pathAndParams, preset.alias, locked, token);
if (!arePathsEquivalent(currentPath, newPath)) {
// if current path is out of date
// return new path so we can redirect
return newPath;
}
}
}
return null;
};
}
/**
* Generate URL from an preset
* @param presetData
* Handles generating a path and search parameters from a preset
*/
export const generateUrlFromPreset = (presetData: URLPreset) => {
const newPresetPath = resolvePath(presetData.pathAndParams);
const urlParams = new URLSearchParams(newPresetPath.search);
urlParams.set('alias', presetData.alias);
export function generatePathFromPreset(pathAndParams: string, alias: string, locked: string | null, token: string | null ): string {
const path = resolvePath(pathAndParams);
const searchParams = new URLSearchParams(path.search);
return `${newPresetPath.pathname}?${urlParams}`;
};
// save the alias so we have a reference to it being a preset and can update if necessary
searchParams.set('alias', alias);
// maintain params from the URL search feature
if (locked) {
searchParams.set('locked', locked);
}
if (token) {
searchParams.set('token', token);
}
// return path concatenated without the leading slash
return `${path.pathname}?${searchParams}`.substring(1);
}
/**
* Utility checks if two paths are equivalent
* Considers the edge cases for url sharing where a path may contain extra arguments from the alias
* - token
* - locked
*/
export function arePathsEquivalent(currentPath: string, newPath: string): boolean {
const currentUrl = new URL(currentPath, document.location.origin);
const newUrl = new URL(newPath, document.location.origin);
// check path
if (currentUrl.pathname !== newUrl.pathname) {
return false
}
// check search params
// if the params match, we dont need further checks
if (currentUrl.searchParams.toString() === newUrl.searchParams.toString()) {
return true
}
// if there is no match, we check the edge cases for the url sharing feature
currentUrl.searchParams.delete('token');
currentUrl.searchParams.delete('locked');
return currentUrl.searchParams.toString() === newUrl.searchParams.toString();
}
+7 -7
View File
@@ -1,6 +1,6 @@
/* eslint-disable react/display-name */
import { ComponentType, useEffect } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import useUrlPresets from '../common/hooks-query/useUrlPresets';
import { getRouteFromPreset } from '../common/utils/urlPresets';
@@ -8,19 +8,19 @@ import { getRouteFromPreset } from '../common/utils/urlPresets';
const withPreset = <P extends object>(Component: ComponentType<P>) => {
return (props: Partial<P>) => {
const { data } = useUrlPresets();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const location = useLocation();
// navigate if is alias route
useEffect(() => {
if (!data) return;
const url = getRouteFromPreset(location, data, searchParams);
// navigate to this route if its not empty
if (url) {
navigate(url);
const destination = getRouteFromPreset(location, data);
// navigate to this destination if its not null
if (destination) {
navigate(destination);
}
}, [data, searchParams, navigate, location]);
}, [data, navigate, location]);
return <Component {...(props as P)} />;
};
@@ -38,7 +38,7 @@ $inner-padding: 1rem;
.section {
position: relative;
margin-top: 2rem;
max-width: 800px;
max-width: 850px;
}
.indent {
@@ -25,6 +25,7 @@ import { preventEscape } from '../../../../common/utils/keyEvent';
import { startsWithHttp } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils';
import TemplateInput from './template-input/TemplateInput';
import { isAutomation, makeFieldList } from './automationUtils';
import style from './AutomationForm.module.scss';
@@ -251,7 +252,7 @@ export default function AutomationForm(props: AutomationFormProps) {
{...register(`filters.${index}.value`)}
variant='ontime-filled'
size='sm'
placeholder='<no value>'
placeholder='<empty / no value>'
autoComplete='off'
/>
</label>
@@ -357,7 +358,7 @@ export default function AutomationForm(props: AutomationFormProps) {
</label>
<label>
Parameters
<Input
<TemplateInput
{...register(`outputs.${index}.args`)}
variant='ontime-filled'
size='sm'
@@ -27,12 +27,12 @@ export default function AutomationPanel({ location }: PanelBaseProps) {
oscPortIn={data.oscPortIn}
/>
</div>
<div ref={triggersRef}>
<TriggersList triggers={data.triggers} automations={data.automations} />
</div>
<div ref={automationsRef}>
<AutomationsList automations={data.automations} />
</div>
<div ref={triggersRef}>
<TriggersList triggers={data.triggers} automations={data.automations} />
</div>
</Panel.Section>
</>
);
@@ -36,6 +36,12 @@ const staticSelectProperties = [
{ value: 'eventNow.colour', label: 'Colour' },
];
const staticNextSelectProperties = [
{ value: 'eventNow.id', label: 'Next ID' },
{ value: 'eventNow.title', label: 'Next Title' },
{ value: 'eventNow.cue', label: 'Next Cue' },
];
type SelectableField = {
value: string; // string encodes path in runtime state object
label: string;
@@ -48,6 +54,11 @@ export function makeFieldList(customFields: CustomFields): SelectableField[] {
value: `eventNow.custom.${key}`,
label: `Custom: ${label}`,
})),
...staticNextSelectProperties,
...Object.entries(customFields).map(([key, { label }]) => ({
value: `eventNext.custom.${key}`,
label: `Next custom: ${label}`,
})),
];
}
@@ -0,0 +1,29 @@
.wrapper {
position: relative;
}
.suggestions {
background: $gray-1250;
color: $ui-white;
position: absolute;
top: 100%;
left: 0;
width: 100%;
margin: 0;
z-index: 5;
padding-block: 0.25rem;
max-height: 200px;
overflow-y: auto;
color: $label-gray;
li {
padding: 0.25rem;
}
li:hover {
color: $ui-white;
background: $blue-700;
}
}
@@ -0,0 +1,70 @@
import { useMemo, useState } from 'react';
import { type InputProps, Input } from '@chakra-ui/react';
import { useClickOutside } from '@mantine/hooks';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { makeAutoCompleteList, matchRemaining, selectFromLastTemplate } from './templateInput.utils';
import style from './TemplateInput.module.scss';
interface TemplateInputProps extends InputProps {}
export default function TemplateInput(props: TemplateInputProps) {
const { value, onChange, ...rest } = props;
const { data } = useCustomFields();
const ref = useClickOutside(() => setShowSuggestions(false));
const autocompleteList = useMemo(() => {
return makeAutoCompleteList(data);
}, [data]);
const [inputValue, setInputValue] = useState(value || '');
const [suggestions, setSuggestions] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const updateSuggestions = (value: string) => {
const template = selectFromLastTemplate(value);
return autocompleteList.filter((suggestion) => suggestion.startsWith(template));
};
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
if (event.target.value.endsWith('{{')) {
setShowSuggestions(true);
} else if (event.target.value === '' || event.target.value.endsWith('}}')) {
setShowSuggestions(false);
}
if (showSuggestions) {
const suggestions = updateSuggestions(event.target.value);
setSuggestions(suggestions);
}
onChange?.(event);
};
const handleSuggestion = (value: string) => {
setInputValue((prev) => {
const remaining = matchRemaining(prev as string, value);
return prev + remaining;
});
setShowSuggestions(false);
};
return (
<div className={style.wrapper} ref={ref}>
<Input {...rest} value={inputValue} onChange={handleInputChange} autoComplete='off' autoCorrect='off' />
{showSuggestions && suggestions.length > 0 && (
<ul className={style.suggestions}>
{suggestions.map((suggestion) => (
<li key={suggestion} onClick={() => handleSuggestion(suggestion)}>
{suggestion}
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,19 @@
import { matchRemaining } from '../templateInput.utils';
describe('matchRemaining()', () => {
it('should return a partial string needed for autocomplete', () => {
expect(matchRemaining('te', 'test')).toBe('st');
expect(matchRemaining('{{{hum', '{{human}}')).toBe('an}}');
expect(matchRemaining('send {', '{{human}}')).toBe('{human}}');
// we should be able to match the following
// however, the current implementation only needs to deal with strings that start with {{
// expect(matchRemaining('{', '{{human}}')).toBe('{human}}');
// expect(matchRemaining('{{', '{{human}}')).toBe('human}}');
});
it('should return an empty string if there are no matches or if it is complete', () => {
expect(matchRemaining('test', 'another')).toBe('');
expect(matchRemaining('test', 'test')).toBe('');
});
});
@@ -0,0 +1,105 @@
import { CustomFields } from 'ontime-types';
const humanConstants = [
'{{human.clock}}',
'{{human.duration}}',
'{{human.expectedEnd}}',
'{{human.runningTimer}}',
'{{human.elapsedTime}}',
'{{human.startedAt}}',
];
const staticAutocompleteOptions = [
'{{clock}}',
'{{timer.addedTime}}',
'{{timer.current}}',
'{{timer.duration}}',
'{{timer.elapsed}}',
'{{timer.expectedFinish}}',
'{{timer.finishedAt}}',
'{{timer.secondaryTimer}}',
'{{timer.startedAt}}',
'{{runtime.selectedEventIndex}}',
'{{runtime.numEvents}}',
'{{runtime.offset}}',
'{{runtime.plannedStart}}',
'{{runtime.plannedEnd}}',
'{{runtime.actualStart}}',
'{{runtime.expectedEnd}}',
'{{currentBlock.block}}',
'{{currentBlock.startedAt}}',
];
const eventStaticPropertiesNow = [
'{{eventNow.id}}',
'{{eventNow.cue}}',
'{{eventNow.title}}',
'{{eventNow.note}}',
'{{eventNow.timeStart}}',
'{{eventNow.timeEnd}}',
'{{eventNow.duration}}',
'{{eventNow.isPublic}}',
'{{eventNow.colour}}',
'{{eventNow.delay}}',
];
const eventStaticPropertiesNext = [
'{{eventNext.id}}',
'{{eventNext.cue}}',
'{{eventNext.title}}',
'{{eventNext.note}}',
'{{eventNext.timeStart}}',
'{{eventNext.timeEnd}}',
'{{eventNext.duration}}',
'{{eventNext.isPublic}}',
'{{eventNext.colour}}',
'{{eventNext.delay}}',
];
/**
* Creates a it of possible autocomplete suggestions
* Based on RuntimeState
* Appends the human readable variants to it
* It is manually kept in sync with the automation parseTemplate functions
*/
export function makeAutoCompleteList(customFields: CustomFields): string[] {
return [
...humanConstants,
...staticAutocompleteOptions,
...eventStaticPropertiesNow,
...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`),
...eventStaticPropertiesNext,
...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`),
];
}
/**
* Returns the partial string b needed to autocomplete string a
* @example matchRemaining('te', 'test') -> 'st'
* @example matchRemaining('{{', '{{human}}') -> 'man}}'
*/
export function matchRemaining(a: string, b: string) {
if (a === b) {
return '';
}
for (let i = 0; i < b.length; i++) {
const searchString = b.substring(0, i + 1);
if (a.endsWith(searchString)) {
return b.substring(i + 1);
}
}
return '';
}
/**
* Selects the last starting template in a string
*/
export function selectFromLastTemplate(text: string) {
const lastBraceIndex = text.lastIndexOf('{{');
if (lastBraceIndex !== -1) {
return text.slice(lastBraceIndex);
}
return '';
}
@@ -8,6 +8,7 @@ import { maybeAxiosError } from '../../../../common/api/utils';
import useSettings from '../../../../common/hooks-query/useSettings';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOnlyNumbers } from '../../../../common/utils/regex';
import { isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import GeneralPinInput from './GeneralPinInput';
@@ -25,6 +26,7 @@ export default function GeneralPanelForm() {
setError,
formState: { isSubmitting, isDirty, isValid, errors },
} = useForm<Settings>({
mode: 'onChange',
defaultValues: data,
values: data,
resetOptions: {
@@ -94,7 +96,11 @@ export default function GeneralPanelForm() {
<Panel.ListItem>
<Panel.Field
title='Ontime server port'
description='Port ontime server listens in. Defaults to 4001 (needs app restart)'
description={
isOntimeCloud
? 'Server port disabled for Ontime Cloud'
: 'Port ontime server listens in. Defaults to 4001 (needs app restart)'
}
error={errors.serverPort?.message}
/>
<Input
@@ -104,6 +110,7 @@ export default function GeneralPanelForm() {
variant='ontime-filled'
maxLength={5}
width='75px'
isDisabled={isOntimeCloud}
{...register('serverPort', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
@@ -4,6 +4,8 @@ import { IconButton, Input, InputGroup, InputRightElement } from '@chakra-ui/rea
import { IoEyeOutline } from '@react-icons/all-files/io5/IoEyeOutline';
import { Settings } from 'ontime-types';
import { isAlphanumeric } from '../../../../common/utils/regex';
interface GeneralPinInputProps {
register: UseFormRegister<Settings>;
formName: keyof Settings;
@@ -13,14 +15,18 @@ interface GeneralPinInputProps {
export default function GeneralPinInput(props: PropsWithChildren<GeneralPinInputProps>) {
const { register, formName, isDisabled } = props;
const [isVisible, setVisible] = useState(false);
return (
<InputGroup size='sm' width='100px'>
<Input
variant='ontime-filled'
type={isVisible ? 'text' : 'password'}
maxLength={4}
{...register(formName)}
{...register(formName, {
pattern: {
value: isAlphanumeric,
message: 'Only alphanumeric characters are allowed',
},
})}
placeholder='-'
isDisabled={isDisabled}
/>
@@ -76,6 +76,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
const handleConnect = async () => {
if (!file) return;
if (!sheetId) return;
patchStepData({ worksheet: { available: false, error: '' } });
setLoading('connect');
const result = await connect(file, sheetId);
@@ -51,8 +51,8 @@ const staticOptions = [
label: 'Automation',
secondary: [
{ id: 'automation__settings', label: 'Automation settings' },
{ id: 'automation__triggers', label: 'Manage triggers' },
{ id: 'automation__automations', label: 'Manage automations' },
{ id: 'automation__triggers', label: 'Manage triggers' },
],
},
{
+2 -2
View File
@@ -5,7 +5,7 @@ import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
import { useElectronListener } from '../../common/hooks/useElectronEvent';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import AppSettings from '../app-settings/AppSettings';
@@ -58,7 +58,7 @@ export default function Editor() {
<div className={styles.mainContainer} data-testid='event-editor'>
<WelcomePlacement />
<Finder isOpen={isFinderOpen} onClose={onFinderClose} />
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
<NavigationMenu isOpen={isMenuOpen} onClose={onClose} />
<EditorOverview>
<IconButton
aria-label='Toggle navigation'
@@ -1,28 +1,12 @@
import { useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useDisclosure } from '@chakra-ui/react';
import FloatingNavigation from '../../common/components/navigation-menu/FloatingNavigation';
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import Operator from './Operator';
export default function OperatorExport() {
const [searchParams, setSearchParams] = useSearchParams();
const { isOpen, onOpen, onClose } = useDisclosure();
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
const toggleMenu = isOpen ? onClose : onOpen;
return (
<ProtectRoute permission='operator'>
<FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
<ProductionNavigationMenu isMenuOpen={isOpen} onMenuClose={onClose} />
<ViewNavigationMenu isLockable />
<Operator />
</ProtectRoute>
);
@@ -30,7 +30,7 @@
&.running {
border-top: 1px solid $gray-1300;
background-color: $active-red;
background-color: var(--operator-running-bg-override, $active-red);
}
&.past {
@@ -92,12 +92,14 @@
.fields {
grid-area: fields;
font-size: 1.25rem;
font-size: var(--operator-customfield-font-size-override, 1.25rem);
font-weight: 400;
color: $ui-black;
margin: 0.25rem 0;
display: flex;
flex-wrap: wrap;
gap: 0.5em;
row-gap: 0.25em;
.field {
font-weight: 600;
@@ -112,10 +114,11 @@
}
.value {
display: inline-flex;
padding-inline: 0.25rem;
background-color: $gray-1250;
color: $ui-white;
margin-right: 1rem;
white-space: pre;
}
}
@@ -87,7 +87,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
return (
<>
<ViewNavigationMenu />
<ViewNavigationMenu isLockable />
<Component
{...props}
auxTimer={auxtimer1}
@@ -1,164 +0,0 @@
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 { millisToString, removeLeadingZero } from 'ontime-utils';
import ProgressBar from '../../../common/components/progress-bar/ProgressBar';
import Schedule from '../../../common/components/schedule/Schedule';
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
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 { formatTime, getDefaultFormat } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import { titleVariants } from '../common/animation';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getPropertyValue } from '../common/viewUtils';
import { getBackstageOptions } from './backstage.options';
import './Backstage.scss';
export const MotionTitleCard = motion(TitleCard);
interface BackstageProps {
backstageEvents: OntimeEvent[];
customFields: CustomFields;
eventNext: OntimeEvent | null;
eventNow: OntimeEvent | null;
general: ProjectData;
isMirrored: boolean;
time: ViewExtendedTimer;
selectedId: string | null;
settings: Settings | undefined;
}
export default function Backstage(props: BackstageProps) {
const { backstageEvents, customFields, eventNext, eventNow, general, time, isMirrored, selectedId, settings } = props;
const { getLocalizedString } = useTranslation();
const [blinkClass, setBlinkClass] = useState(false);
const [searchParams] = useSearchParams();
useWindowTitle('Backstage');
// blink on change
useEffect(() => {
setBlinkClass(false);
const timer = setTimeout(() => {
setBlinkClass(true);
}, 10);
return () => clearTimeout(timer);
}, [selectedId]);
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);
let stageTimer = millisToString(time.current, { fallback: timerPlaceholderMin });
stageTimer = removeLeadingZero(stageTimer);
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const backstageOptions = getBackstageOptions(defaultFormat, customFields);
return (
<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}
<div className='clock-container'>
<div className='label'>{getLocalizedString('common.time_now')}</div>
<SuperscriptTime time={clock} className='time' />
</div>
</div>
<ProgressBar
className='progress-container'
current={time.current}
duration={time.duration}
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>
<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>
<ScheduleProvider events={filteredEvents} selectedEventId={selectedId} isBackstage>
<ScheduleNav className='schedule-nav-container' />
<Schedule isProduction className='schedule-container' />
</ScheduleProvider>
<div className='info'>
{general.backstageUrl && <QRCode value={general.backstageUrl} size={qrSize} level='L' className='qr' />}
{general.backstageInfo && <div className='info__message'>{general.backstageInfo}</div>}
</div>
</div>
);
}
@@ -1,58 +0,0 @@
import { CustomFields } from 'ontime-types';
import {
getTimeOption,
makeOptionsFromCustomFields,
OptionTitle,
} from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getBackstageOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
return [
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
{
title: OptionTitle.DataSources,
collapsible: true,
options: [
{
id: 'secondary-src',
title: 'Event secondary text',
description: 'Select the data source for auxiliary text shown in now and next cards',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
],
},
{
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,
},
],
},
];
};
@@ -1,6 +1,6 @@
sup.period {
top: -1em;
font-size: 0.4em;
font-size: 0.5em;
}
.subscript {
@@ -1,106 +0,0 @@
@use '../../../theme/viewerDefs' as *;
.public-screen {
margin: 0;
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100vh;
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);
display: grid;
grid-template-columns: 1fr 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 + EXTRAS ===================*/
.project-header {
grid-area: header;
font-size: clamp(32px, 4.5vw, 64px);
font-weight: 600;
display: flex;
gap: 1rem;
}
.logo {
max-width: min(200px, 20vw);
max-height: min(100px, 20vh);
}
.clock-container {
margin-left: auto;
.label {
font-size: clamp(16px, 1.5vw, 24px);
font-weight: 600;
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
.time {
font-size: clamp(32px, 3.5vw, 50px);
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
line-height: 0.95em;
}
}
/* =================== MAIN - NOW ===================*/
.now-container {
grid-area: now;
display: flex;
flex-direction: column;
gap: min(2vh, 16px);
}
.event {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding: 16px 24px;
border-radius: 8px;
}
/* =================== MAIN - SCHEDULE ===================*/
.schedule-container {
grid-area: schedule;
overflow: hidden;
height: 100%;
margin-left: clamp(16px, 5vw, 64px);
}
.schedule-nav-container {
grid-area: schedule-nav;
align-self: center;
}
.info {
grid-area: info;
display: flex;
gap: max(1vw, 16px);
overflow: hidden;
&__message {
font-size: clamp(16px, 1.5vw, 24px);
line-height: 1.3em;
white-space: pre-line;
overflow: hidden;
flex: 1;
}
.qr {
padding: 4px;
background-color: white;
}
}
}
@@ -1,123 +0,0 @@
import QRCode from 'react-qr-code';
import { useSearchParams } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { CustomFields, OntimeEvent, ProjectData, Settings } from 'ontime-types';
import Schedule from '../../../common/components/schedule/Schedule';
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
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 { formatTime, getDefaultFormat } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import { titleVariants } from '../common/animation';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getPropertyValue } from '../common/viewUtils';
import { getPublicOptions } from './public.options';
import './Public.scss';
export const MotionTitleCard = motion(TitleCard);
interface BackstageProps {
customFields: CustomFields;
general: ProjectData;
isMirrored: boolean;
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 { getLocalizedString } = useTranslation();
const [searchParams] = useSearchParams();
useWindowTitle('Public Schedule');
const clock = formatTime(time.clock);
const qrSize = Math.max(window.innerWidth / 15, 128);
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}
<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>
<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>
<ScheduleProvider events={events} selectedEventId={publicSelectedId}>
<ScheduleNav className='schedule-nav-container' />
<Schedule className='schedule-container' />
</ScheduleProvider>
<div className='info'>
{general.publicUrl && <QRCode value={general.publicUrl} size={qrSize} level='L' className='qr' />}
{general.publicInfo && <div className='info__message'>{general.publicInfo}</div>}
</div>
</div>
);
}
@@ -1,57 +0,0 @@
import { CustomFields } from 'ontime-types';
import {
getTimeOption,
makeOptionsFromCustomFields,
OptionTitle,
} from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getPublicOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
return [
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
{
title: OptionTitle.DataSources,
collapsible: true,
options: [
{
id: 'secondary-src',
title: 'Event secondary text',
description: 'Select the data source for auxiliary text shown in now and next cards',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
],
},
{
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,
},
],
},
];
};
+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': '选择要关注的事件',
@@ -1,4 +1,4 @@
@use '../../../theme/viewerDefs' as *;
@use '../../theme/viewerDefs' as *;
.backstage {
margin: 0;
@@ -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;
@@ -85,11 +106,11 @@
}
.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 +119,119 @@
}
.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-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;
}
}
}
/* =================== ANIMATION ===================*/
@keyframes blink {
0% {
@@ -0,0 +1,180 @@
import { useEffect, useState } from 'react';
import QRCode from 'react-qr-code';
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 { 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 { getBackstageOptions, useBackstageOptions } from './backstage.options';
import { getCardData, getIsPendingStart, getShowProgressBar, isOvertime } from './backstage.utils';
import './Backstage.scss';
interface BackstageProps {
backstageEvents: OntimeEvent[];
customFields: CustomFields;
eventNext: OntimeEvent | null;
eventNow: OntimeEvent | null;
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,
runtime,
selectedId,
settings,
} = props;
const { getLocalizedString } = useTranslation();
const { secondarySource } = useBackstageOptions();
const [blinkClass, setBlinkClass] = useState(false);
const { height: screenHeight } = useViewportSize();
useWindowTitle('Backstage');
// blink on change
useEffect(() => {
setBlinkClass(false);
const timer = setTimeout(() => {
setBlinkClass(true);
}, 10);
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 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);
return (
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
<ViewParamsEditor viewOptions={backstageOptions} />
<div className='project-header'>
{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>
<ProgressBar
className='progress-container'
current={time.current}
duration={time.duration}
hidden={!showProgress}
/>
{backstageEvents.length === 0 && (
<div className='empty-container'>
<Empty text={getLocalizedString('common.no_data')} />
</div>
)}
<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>
{showSchedule && <BackstageSchedule selectedId={selectedId} />}
<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>
</div>
);
}
@@ -0,0 +1,58 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { CustomFields, OntimeEvent } from 'ontime-types';
import {
getTimeOption,
makeOptionsFromCustomFields,
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 getBackstageOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
return [
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
{
title: OptionTitle.DataSources,
collapsible: true,
options: [
{
id: 'secondary-src',
title: 'Event secondary text',
description: 'Select the data source for auxiliary text shown in now and next cards',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
],
},
scheduleOptions,
];
};
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;
}
@@ -0,0 +1,62 @@
import { MaybeNumber, OntimeEvent, Playback, TimerPhase } from 'ontime-types';
import { enDash } from '../../common/utils/styleUtils';
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') || 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,
};
}
@@ -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>
);
}
@@ -0,0 +1,91 @@
@use '../../../theme/viewerDefs' as *;
.schedule {
width: 100%;
position: relative;
list-style: none;
.entry {
position: absolute;
padding-bottom: 1em;
&--skip {
text-decoration: line-through;
text-decoration-color: var(--secondary-color-override, $viewer-secondary-color);
}
}
.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: 0.25rem;
}
&--delayed {
text-decoration: line-through;
text-decoration-color: $ontime-delay;
}
&--delay {
color: $ontime-delay-text;
}
&--ahead {
color: $green-500;
}
&--behind {
color: $orange-500;
}
}
.entry-title {
font-size: clamp(16px, 1.5vw, 24px);
font-size: $base-font-size;
line-height: 1.2em;
}
}
.schedule-nav {
display: flex;
.schedule-nav__item {
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;
transition-property: opacity;
transition-duration: 1s;
&--selected {
transition-property: opacity;
transition-duration: $viewer-transition-time;
opacity: 1;
}
&--indeterminate {
width: clamp(32px, 3vw, 48px);
}
}
}
@@ -0,0 +1,41 @@
import { cx } from '../../../common/utils/styleUtils';
import { getScheduledTimes } from './schedule.utils';
import { useSchedule } from './ScheduleContext';
import ScheduleItem from './ScheduleItem';
import './Schedule.scss';
interface ScheduleProps {
isProduction?: boolean;
className?: string;
}
export default function Schedule({ isProduction, className }: ScheduleProps) {
const { events, isBackstage, containerRef } = useSchedule();
if (events?.length < 1) {
return null;
}
return (
<ul className={cx(['schedule', className])} ref={containerRef}>
{events.map((event) => {
const { timeStart, timeEnd, delay } = getScheduledTimes(event, isProduction);
return (
<ScheduleItem
key={event.id}
timeStart={timeStart}
timeEnd={timeEnd}
title={event.title}
colour={isBackstage ? event.colour : undefined}
backstageEvent={!event.isPublic}
skip={event.skip}
delay={delay}
/>
);
})}
</ul>
);
}
@@ -0,0 +1,167 @@
import {
createContext,
PropsWithChildren,
RefObject,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
import { usePartialRundown } from '../../../common/hooks-query/useRundown';
import { useScheduleOptions } from './schedule.options';
interface ScheduleContextState {
events: OntimeEvent[];
selectedEventId: string | null;
numPages: number;
visiblePage: number;
isBackstage: boolean;
containerRef: RefObject<HTMLUListElement>;
}
const ScheduleContext = createContext<ScheduleContextState | undefined>(undefined);
interface ScheduleProviderProps {
selectedEventId: string | null;
isBackstage?: boolean;
}
export const ScheduleProvider = ({
children,
selectedEventId,
isBackstage = false,
}: PropsWithChildren<ScheduleProviderProps>) => {
const { cycleInterval, stopCycle } = useScheduleOptions();
const { data: events } = usePartialRundown((event: OntimeRundownEntry) => {
if (isBackstage) {
return isOntimeEvent(event);
}
return isOntimeEvent(event) && event.isPublic && !event.skip;
});
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);
// we want to show the event after the current
const viewEvents = events.toSpliced(0, selectedEventIndex + 1);
selectedEventIndex = 0;
return (
<ScheduleContext.Provider
value={{
events: viewEvents as OntimeEvent[],
selectedEventId,
numPages,
visiblePage,
isBackstage,
containerRef,
}}
>
{children}
</ScheduleContext.Provider>
);
};
export const useSchedule = () => {
const context = useContext(ScheduleContext);
if (!context) {
throw new Error('useSchedule() can only be used inside a ScheduleContext');
}
return context;
};
@@ -0,0 +1,138 @@
import { useRuntimeOffset } from '../../../common/hooks/useSocket';
import { cx } from '../../../common/utils/styleUtils';
import { formatTime } from '../../../common/utils/time';
import SuperscriptTime from '../../../features/viewers/common/superscript-time/SuperscriptTime';
import { useScheduleOptions } from './schedule.options';
import './Schedule.scss';
const formatOptions = {
format12: 'hh:mm a',
format24: 'HH:mm',
};
interface ScheduleItemProps {
timeStart: number;
timeEnd: number;
title: string;
backstageEvent: boolean;
colour?: string;
skip?: boolean;
delay: number;
}
export default function ScheduleItem(props: ScheduleItemProps) {
const { timeStart, timeEnd, title, backstageEvent, colour, skip, delay } = props;
const { showProjected } = useScheduleOptions();
if (showProjected) {
return (
<ProjectedScheduleItem
timeStart={timeStart}
timeEnd={timeEnd}
title={title}
colour={colour}
backstageEvent={backstageEvent}
skip={skip}
delay={delay}
/>
);
}
if (delay > 0) {
return (
<DelayedScheduleItem
timeStart={timeStart}
timeEnd={timeEnd}
title={title}
colour={colour}
backstageEvent={backstageEvent}
skip={skip}
delay={delay}
/>
);
}
const start = formatTime(timeStart, formatOptions);
const end = formatTime(timeEnd, formatOptions);
return (
<li className={cx(['entry', skip && 'entry--skip'])}>
<div className='entry-times'>
<span className='entry-colour' style={{ backgroundColor: colour }} />
<SuperscriptTime time={start} />
<SuperscriptTime time={end} />
{backstageEvent && '*'}
</div>
<div className='entry-title'>{title}</div>
</li>
);
}
function DelayedScheduleItem(props: ScheduleItemProps) {
const { timeStart, timeEnd, title, backstageEvent, colour, skip, delay } = props;
const start = formatTime(timeStart, formatOptions);
const end = formatTime(timeEnd, formatOptions);
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>
);
}
function ProjectedScheduleItem(props: ScheduleItemProps) {
const { timeStart, timeEnd, title, backstageEvent, colour, skip, delay } = props;
return (
<li className={cx(['entry', skip && 'entry--skip'])}>
<div className='entry-times'>
<span className='entry-colour' style={{ backgroundColor: colour }} />
<ProjectedTime time={timeStart} delay={delay} />
<ProjectedTime time={timeEnd} delay={delay} />
{backstageEvent && '*'}
</div>
<div className='entry-title'>{title}</div>
</li>
);
}
interface OffsetTimeProps {
time: number;
delay: number;
}
function ProjectedTime(props: OffsetTimeProps) {
const { time, delay } = props;
const { offset } = useRuntimeOffset();
const projectedOffset = offset - delay;
const projectedTime = formatTime(time - offset, formatOptions);
return (
<SuperscriptTime
className={cx([projectedOffset > 0 && 'entry-times--ahead', projectedOffset < 0 && 'entry-times--behind'])}
time={projectedTime}
/>
);
}
@@ -0,0 +1,50 @@
import { cx } from '../../../common/utils/styleUtils';
import { useSchedule } from './ScheduleContext';
import './Schedule.scss';
interface ScheduleNavProps {
className?: string;
}
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={cx(['schedule-nav', className])}>
{numPages > 1 &&
[...Array(numPages).keys()].map((i) => (
<div
key={i}
className={cx(['schedule-nav__item', i + 1 === visiblePage && 'schedule-nav__item--selected'])}
/>
))}
</div>
);
}
@@ -0,0 +1,57 @@
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,
},
{
id: 'showProjected',
title: 'Show projected time',
description: 'Whether scheduled times should account for runtime offset.',
type: 'boolean',
defaultValue: false,
},
],
};
type ScheduleOptions = {
cycleInterval: number;
stopCycle: boolean;
showProjected: boolean;
};
function getScheduleOptionsFromParams(searchParams: URLSearchParams): ScheduleOptions {
return {
cycleInterval: Number(searchParams.get('cycleInterval')) || 10,
stopCycle: isStringBoolean(searchParams.get('stopCycle')),
showProjected: isStringBoolean(searchParams.get('showProjected')),
};
}
/**
* Hook exposes the schedule component options
*/
export function useScheduleOptions() {
const [searchParams] = useSearchParams();
const options = useMemo(() => getScheduleOptionsFromParams(searchParams), [searchParams]);
return options;
}
@@ -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,
};
}
@@ -1,10 +1,10 @@
import { useCallback, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { IconButton, Modal, ModalContent, ModalOverlay, useDisclosure } from '@chakra-ui/react';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
import useViewEditor from '../../common/components/navigation-menu/useViewEditor';
import EmptyPage from '../../common/components/state/EmptyPage';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
@@ -25,7 +25,7 @@ export default function CuesheetPage() {
// TODO: can we use the normalised rundown for the table?
const { data: flatRundown } = useFlatRundown();
const { data: customFields } = useCustomFields();
const [searchParams, setSearchParams] = useSearchParams();
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable: true });
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
const { isOpen: isEventEditorOpen, onOpen: onEventEditorOpen, onClose: onEventEditorClose } = useDisclosure();
const [eventId, setEventId] = useState<string | null>(null);
@@ -34,12 +34,6 @@ export default function CuesheetPage() {
useWindowTitle('Cuesheet');
/** Handles showing the view params edit drawer */
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
/**
* Handles setting the edit modal target and visibility
*/
@@ -69,7 +63,7 @@ export default function CuesheetPage() {
</ModalContent>
</Modal>
<div className={styles.tableWrapper} data-testid='cuesheet'>
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
<NavigationMenu isOpen={isMenuOpen} onClose={onClose} />
<ViewParamsEditor viewOptions={cuesheetOptions} />
<CuesheetOverview>
<IconButton
@@ -78,6 +72,7 @@ export default function CuesheetPage() {
size='lg'
icon={<IoApps />}
onClick={onOpen}
isDisabled={isViewLocked}
/>
<IconButton
aria-label='Toggle settings'
@@ -85,6 +80,7 @@ export default function CuesheetPage() {
size='lg'
icon={<IoSettingsOutline />}
onClick={showEditFormDrawer}
isDisabled={isViewLocked}
/>
</CuesheetOverview>
<CuesheetProgress />
+203
View File
@@ -0,0 +1,203 @@
@use '../../theme/viewerDefs' as *;
.public-screen {
margin: 0;
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100vh;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color);
color: var(--color-override, $viewer-color);
gap: $view-element-gap;
padding: $view-outer-padding;
display: grid;
grid-template-columns: 1fr 40vw;
grid-template-rows: auto 12px 1fr auto;
grid-template-areas:
'header header'
'now schedule-nav'
'info schedule';
.empty-container {
position: absolute;
top: 0;
left: 0;
right: 0;
margin-top: 25vh;
}
/* =================== HEADER + EXTRAS ===================*/
.project-header {
grid-area: header;
font-size: $header-font-size;
font-weight: 600;
display: flex;
gap: 1rem;
}
.logo {
max-width: min(200px, 20vw);
max-height: min(100px, 20vh);
}
.title {
line-height: 1.1em;
}
.clock-container {
margin-left: auto;
font-weight: 600;
.label {
font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
.time {
font-size: $timer-value-size;
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
line-height: 0.95em;
}
}
/* =================== MAIN - NOW ===================*/
.card-container {
grid-area: now;
display: flex;
flex-direction: column;
gap: $view-element-gap;
}
.event {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
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;
}
.aux-timers {
&__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%;
padding-left: $schedule-left-spacing;
}
.schedule-nav-container {
grid-area: schedule-nav;
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: $base-font-size;
line-height: 1.2em;
white-space: pre-line;
overflow: hidden;
flex: 1;
}
.qr {
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;
}
}
}
+120
View File
@@ -0,0 +1,120 @@
import QRCode from 'react-qr-code';
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 { useTranslation } from '../../translation/TranslationProvider';
import { getIsPendingStart } from '../backstage/backstage.utils';
import PublicSchedule from '../common/schedule/PublicSchedule';
import { getPublicOptions, usePublicOptions } from './public.options';
import { getCardData, getFirstStartTime } from './public.utils';
import './Public.scss';
interface BackstageProps {
customFields: CustomFields;
events: OntimeEvent[];
general: ProjectData;
isMirrored: boolean;
publicEventNow: OntimeEvent | null;
publicEventNext: OntimeEvent | null;
time: ViewExtendedTimer;
publicSelectedId: string | null;
settings: Settings | undefined;
}
export default function Public(props: BackstageProps) {
const {
customFields,
events,
general,
isMirrored,
publicEventNow,
publicEventNext,
time,
publicSelectedId,
settings,
} = props;
const { getLocalizedString } = useTranslation();
const { secondarySource } = usePublicOptions();
const { height: screenHeight } = useViewportSize();
useWindowTitle('Public Schedule');
// gather card data
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 = showNow ? '' : getFirstStartTime(events[0]);
// gather presentation styles
const qrSize = Math.max(window.innerWidth / 15, 72);
const showSchedule = screenHeight > 700; // in vertical screens we may not have space
// gather option data
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const publicOptions = getPublicOptions(defaultFormat, customFields);
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' /> : <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>
{events.length === 0 && (
<div className='empty-container'>
<Empty text={getLocalizedString('countdown.waiting')} />
</div>
)}
<div className='card-container'>
{showNow && <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='aux-timers'>
<div className={cx(['aux-timers__label', isPendingStart && 'aux-timers--pending'])}>
{getLocalizedString('common.scheduled_start')}
</div>
<SuperscriptTime time={formatTime(scheduledStart)} className='aux-timers__value' />
</div>
</div>
</div>
)}
{showNext && <TitleCard className='event next' label='next' title={nextMain} secondary={nextSecondary} />}
</div>
{showSchedule && <PublicSchedule selectedId={publicSelectedId} />}
<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>
</div>
);
}
@@ -0,0 +1,58 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { CustomFields, OntimeEvent } from 'ontime-types';
import {
getTimeOption,
makeOptionsFromCustomFields,
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' });
return [
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
{
title: OptionTitle.DataSources,
collapsible: true,
options: [
{
id: 'secondary-src',
title: 'Event secondary text',
description: 'Select the data source for auxiliary text shown in now and next cards',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
],
},
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;
}
+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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "3.11.0-beta.1",
"version": "3.11.1-beta.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+2 -3
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "3.11.0-beta.1",
"version": "3.11.1-beta.1",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -19,7 +19,7 @@
"lowdb": "^7.0.1",
"multer": "^1.4.5-lts.1",
"ontime-utils": "workspace:*",
"osc-min": "^2.1.1",
"osc-min": "2.1.2",
"sanitize-filename": "^1.6.3",
"steno": "^4.0.2",
"ws": "^8.18.0",
@@ -30,7 +30,6 @@
"@types/express": "^4.17.17",
"@types/multer": "^1.4.11",
"@types/node": "catalog:",
"@types/node-osc": "^6.0.3",
"@types/websocket": "^1.0.5",
"@types/ws": "^8.5.10",
"@typescript-eslint/eslint-plugin": "catalog:",
+1 -5
View File
@@ -20,17 +20,13 @@ class OscServer implements IAdapter {
logger.info(LogOrigin.Rx, `OSC: Starting server on port ${port}`);
this.udpSocket = dgram.createSocket('udp4');
this.udpSocket.on('error', (error) => logger.error(LogOrigin.Rx, `OSC IN: ${error}`));
this.udpSocket.on('message', (buf: ArrayBuffer) => {
this.udpSocket.on('message', (buf) => {
// message should look like /ontime/{command}/{params?} {args} where
// ontime: fixed message for app
// command: command to be called
// params: used to create a nested object to patch with
// args: extra data, only used on some API entries
/**
* TODO: remove this type casting when mergend in deleration file
* https://github.com/DefinitelyTyped/DefinitelyTyped/pull/71659
*/
const msg = fromBuffer(buf);
if (msg.oscType === 'bundle') {
//TODO: manage bundles
@@ -33,11 +33,7 @@ function preparePayload(output: OSCOutput, state: RuntimeState): OscPacketInput
function emit(targetIP: string, targetPort: number, packet: OscPacketInput) {
logger.info(LogOrigin.Tx, `Sending OSC: ${targetIP}:${targetPort}`);
/**
* TODO: remove this type casting when change is merged
* https://github.com/DefinitelyTyped/DefinitelyTyped/pull/71659
*/
const buffer = oscPacketToBuffer(packet) as unknown as Uint8Array;
const buffer = oscPacketToBuffer(packet);
udpClient.send(buffer, 0, buffer.byteLength, targetPort, targetIP, (error) => {
if (error) {
logger.warning(LogOrigin.Tx, `Failed sending OSC: ${error}`);
+2 -2
View File
@@ -49,7 +49,7 @@ export async function patchPartialProjectFile(req: Request, res: Response<Databa
*/
export async function createProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
try {
const newFileName = await projectService.createProject(req.body.filename, {
const newFileName = await projectService.createProject(req.body.filename || 'untitled', {
project: {
title: req.body?.title ?? '',
description: req.body?.description ?? '',
@@ -75,7 +75,7 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
*/
export async function quickProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
try {
const filename = await projectService.createProject(req.body.project.title, req.body);
const filename = await projectService.createProject(req.body.project.title || 'untitled', req.body);
res.status(200).send({
filename,
});
+2 -2
View File
@@ -7,6 +7,7 @@ import { ensureJsonExtension } from '../../utils/fileManagement.js';
* @description Validates request for a new project.
*/
export const validateNewProject = [
body('filename').optional().isString().trim(),
body('title').optional().isString().trim(),
body('description').optional().isString().trim(),
body('publicUrl').optional().isString().trim(),
@@ -132,8 +133,7 @@ export const validateFilenameParam = [
.customSanitizer((input: string) => sanitize(input))
.withMessage('Failed to sanitize the filename')
.notEmpty()
.withMessage('Filename was empty or contained only invalid characters')
.customSanitizer((input: string) => ensureJsonExtension(input)),
.withMessage('Filename was empty or contained only invalid characters'),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
@@ -9,6 +9,7 @@ import {
appendToName,
dockerSafeRename,
ensureDirectory,
ensureJsonExtension,
generateUniqueFileName,
getFileNameFromPath,
removeFileExtension,
@@ -20,6 +21,7 @@ import { parseRundown } from '../../utils/parserFunctions.js';
import { demoDb } from '../../models/demoProject.js';
import { config } from '../../setup/config.js';
import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js';
import { safeMerge } from '../../classes/data-provider/DataProvider.utils.js';
import { initRundown } from '../rundown-service/RundownService.js';
import {
@@ -37,7 +39,6 @@ import {
moveCorruptFile,
parseJsonFile,
} from './projectServiceUtils.js';
import { safeMerge } from '../../classes/data-provider/DataProvider.utils.js';
// init dependencies
init();
@@ -257,7 +258,8 @@ export async function renameProjectFile(originalFile: string, newFilename: strin
export async function createProject(filename: string, initialData: Partial<DatabaseModel>) {
const data = safeMerge(dbModel, initialData);
const uniqueFileName = generateUniqueFileName(publicDir.projectsDir, filename);
const fileNameWithExtension = ensureJsonExtension(filename);
const uniqueFileName = generateUniqueFileName(publicDir.projectsDir, fileNameWithExtension);
const newFile = getPathToProject(uniqueFileName);
// change LowDB to point to new file
@@ -208,6 +208,15 @@ async function verifySheet(
});
return { worksheetOptions: spreadsheets.data.sheets.map((i) => i.properties.title) };
} catch (error) {
// attempt to catch errors caused by importing xlsx
if (
error.code === 400 &&
Array.isArray(error.errors) &&
error.errors[0].reason === 'failedPrecondition' &&
error.errors[0].message === 'This operation is not supported for this document'
) {
throw new Error('Cannot read the linked file as a Google Sheet. It may be an .xlsx file instead.');
}
const errorMessage = getErrorMessage(error);
throw new Error(`Failed to verify sheet: ${errorMessage}`);
}
@@ -1,7 +1,18 @@
import { PlayableEvent, Playback, TimerPhase } from 'ontime-types';
import { OntimeRundown, PlayableEvent, Playback, SupportedEvent, TimerPhase } from 'ontime-types';
import { deepmerge } from 'ontime-utils';
import { type RuntimeState, addTime, clear, getState, load, pause, roll, start, stop } from '../runtimeState.js';
import {
type RuntimeState,
addTime,
clear,
getState,
load,
loadBlock,
pause,
roll,
start,
stop,
} from '../runtimeState.js';
import { initRundown } from '../../services/rundown-service/RundownService.js';
const mockEvent = {
@@ -328,3 +339,125 @@ describe('roll mode', () => {
});
});
});
describe('loadBlock', () => {
test('from no-block to a block will clear startedAt', () => {
const rundown = [
{ id: '0', type: SupportedEvent.Event },
{ id: '1', type: SupportedEvent.Block },
{ id: '2', type: SupportedEvent.Event },
{ id: '3', type: SupportedEvent.Block },
{ id: '4', type: SupportedEvent.Event },
] as OntimeRundown;
const state = {
currentBlock: {
block: null,
startedAt: 123,
},
eventNow: rundown[2],
} as RuntimeState;
loadBlock(rundown, state);
expect(state).toMatchObject({
currentBlock: { block: rundown[1], startedAt: null },
eventNow: rundown[2],
});
});
test('from block to a different block will clear startedAt', () => {
const rundown = [
{ id: '0', type: SupportedEvent.Event },
{ id: '1', type: SupportedEvent.Block },
{ id: '2', type: SupportedEvent.Event },
{ id: '3', type: SupportedEvent.Block },
{ id: '4', type: SupportedEvent.Event },
] as OntimeRundown;
const state = {
currentBlock: {
block: rundown[1],
startedAt: 123,
},
eventNow: rundown[4],
} as RuntimeState;
loadBlock(rundown, state);
expect(state).toMatchObject({
currentBlock: { block: rundown[3], startedAt: null },
eventNow: rundown[4],
});
});
test('from block to a no-block will clear startedAt', () => {
const rundown = [
{ id: '0', type: SupportedEvent.Event },
{ id: '1', type: SupportedEvent.Block },
{ id: '2', type: SupportedEvent.Event },
{ id: '3', type: SupportedEvent.Block },
{ id: '4', type: SupportedEvent.Event },
] as OntimeRundown;
const state = {
currentBlock: {
block: rundown[1],
startedAt: 123,
},
eventNow: rundown[0],
} as RuntimeState;
loadBlock(rundown, state);
expect(state).toMatchObject({
currentBlock: { block: null, startedAt: null },
eventNow: rundown[0],
});
});
test('from block to same block will keep startedAt', () => {
const rundown = [
{ id: '0', type: SupportedEvent.Block },
{ id: '1', type: SupportedEvent.Event },
{ id: '2', type: SupportedEvent.Event },
] as OntimeRundown;
const state = {
currentBlock: {
block: rundown[0],
startedAt: 123,
},
eventNow: rundown[2],
} as RuntimeState;
loadBlock(rundown, state);
expect(state).toMatchObject({
currentBlock: { block: rundown[0], startedAt: 123 },
eventNow: rundown[2],
});
});
test('from no-block to no-block will keep startedAt', () => {
const rundown = [
{ id: '0', type: SupportedEvent.Event },
{ id: '1', type: SupportedEvent.Event },
] as OntimeRundown;
const state = {
currentBlock: {
block: null,
startedAt: 123,
},
eventNow: rundown[0],
} as RuntimeState;
loadBlock(rundown, state);
expect(state).toMatchObject({
currentBlock: { block: null, startedAt: 123 },
eventNow: rundown[0],
});
});
});
+35 -46
View File
@@ -8,6 +8,7 @@ import {
PlayableEvent,
Playback,
Runtime,
runtimeStorePlaceholder,
TimerPhase,
TimerState,
} from 'ontime-types';
@@ -32,29 +33,6 @@ import {
import { timerConfig } from '../config/config.js';
import { loadRoll, normaliseRollStart } from '../services/rollUtils.js';
const initialRuntime: Runtime = {
selectedEventIndex: null, // changes if rundown changes or we load a new event
numEvents: 0, // change initiated by user
offset: 0, // changes at runtime
plannedStart: 0, // only changes if event changes
plannedEnd: 0, // only changes if event changes, overflows over dayInMs
actualStart: null, // set once we start the timer
expectedEnd: null, // changes with runtime, based on offset, overflows over dayInMs
} as const;
const initialTimer: TimerState = {
addedTime: 0,
current: null, // changes on every update
duration: null, // only changes if event changes
elapsed: null, // changes on every update
expectedFinish: null, // change can only be initiated by user, can roll over midnight
finishedAt: null, // can change on update or user action
phase: TimerPhase.None, // can change on update or user action
playback: Playback.Stop, // change initiated by user
secondaryTimer: null, // change on every update
startedAt: null, // change can only be initiated by user
} as const;
export type RuntimeState = {
clock: number; // realtime clock
eventNow: PlayableEvent | null;
@@ -75,16 +53,13 @@ export type RuntimeState = {
const runtimeState: RuntimeState = {
clock: clock.timeNow(),
currentBlock: {
block: null,
startedAt: null,
},
currentBlock: { ...runtimeStorePlaceholder.currentBlock },
eventNow: null,
publicEventNow: null,
eventNext: null,
publicEventNext: null,
runtime: { ...initialRuntime },
timer: { ...initialTimer },
runtime: { ...runtimeStorePlaceholder.runtime },
timer: { ...runtimeStorePlaceholder.timer },
_timer: {
forceFinish: null,
totalDelay: 0,
@@ -124,7 +99,7 @@ export function clear() {
runtimeState.timer.playback = Playback.Stop;
runtimeState.clock = clock.timeNow();
runtimeState.timer = { ...initialTimer };
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
// when clearing, we maintain the total delay from the rundown
runtimeState._timer.forceFinish = null;
@@ -492,6 +467,7 @@ export type UpdateResult = {
export function update(): UpdateResult {
// 0. there are some things we always do
const previousClock = runtimeState.clock;
runtimeState.clock = clock.timeNow(); // we update the clock on every update call
// 1. is playback idle?
@@ -501,7 +477,8 @@ export function update(): UpdateResult {
// 2. are we waiting to roll?
if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) {
return updateIfWaitingToRoll();
const hasCrossedMidnight = previousClock > runtimeState.clock;
return updateIfWaitingToRoll(hasCrossedMidnight);
}
// 3. at this point we know that we are playing an event
@@ -543,16 +520,24 @@ export function update(): UpdateResult {
return { hasTimerFinished: false, hasSecondaryTimerFinished: false };
}
function updateIfWaitingToRoll() {
function updateIfWaitingToRoll(hasCrossedMidnight: boolean) {
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: {
if (runtimeState.eventNow === null || runtimeState._timer.secondaryTarget === null) {
throw new Error('runtimeState.updateIfWaitingToRoll: invalid state received');
}
}
//account for offset
// account for offset
const offsetClock = runtimeState.clock + runtimeState.runtime.offset;
runtimeState.timer.phase = TimerPhase.Pending;
if (hasCrossedMidnight) {
// if we crossed midnight, we need to update the target
// this is the same logic from the roll function
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock);
}
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
return { hasTimerFinished: false, hasSecondaryTimerFinished: runtimeState.timer.secondaryTimer <= 0 };
}
@@ -664,6 +649,11 @@ export function roll(rundown: OntimeRundown, offset = 0): { eventId: MaybeString
// there is something to run, load event
// update runtime
if (runtimeState.currentBlock.startedAt === null) {
runtimeState.currentBlock.startedAt = runtimeState.clock;
}
// event will finish on time
// account for event that finishes the day after
const endTime =
@@ -686,26 +676,25 @@ export function roll(rundown: OntimeRundown, offset = 0): { eventId: MaybeString
return { eventId: runtimeState.eventNow.id, didStart: true };
}
function loadBlock(rundown: OntimeRundown) {
if (runtimeState.eventNow === null) {
/**
* handle block loading, not for use outside of runtimeState
* @param rundown
*/
export function loadBlock(rundown: OntimeRundown, state = runtimeState) {
if (state.eventNow === null) {
// we need a loaded event to have a block
runtimeState.currentBlock.block = null;
runtimeState.currentBlock.startedAt = null;
state.currentBlock.block = null;
state.currentBlock.startedAt = null;
return;
}
const newCurrentBlock = getPreviousBlock(rundown, runtimeState.eventNow.id);
// test all block change posibiletys
const formNoBlockToBlock = runtimeState.currentBlock.block === null && newCurrentBlock !== null;
const formBlockToNoBlock = runtimeState.currentBlock.block !== null && newCurrentBlock === null;
const formBlockToNewBlock = runtimeState.currentBlock.block?.id !== newCurrentBlock?.id;
const newCurrentBlock = getPreviousBlock(rundown, state.eventNow.id);
// update time only if the block has changed
if (formNoBlockToBlock || formBlockToNoBlock || formBlockToNewBlock) {
runtimeState.currentBlock.startedAt = null;
if (state.currentBlock.block?.id !== newCurrentBlock?.id) {
state.currentBlock.startedAt = null;
}
// update the block anyway
runtimeState.currentBlock.block = newCurrentBlock === null ? null : { ...newCurrentBlock };
state.currentBlock.block = newCurrentBlock === null ? null : { ...newCurrentBlock };
}
+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;
@@ -1,80 +0,0 @@
import { stringToOSCArgs } from '../oscArgParser.js';
describe('test stringToOSCArgs()', () => {
it('all types', () => {
const test = 'test 1111 0.1111 TRUE FALSE';
const expected = [
{ type: 'string', value: 'test' },
{ type: 'integer', value: 1111 },
{ type: 'float', value: 0.1111 },
{ type: 'T', value: true },
{ type: 'F', value: false },
];
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
it('empty is nothing', () => {
const test = undefined;
const expected: any[] = [];
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
it('empty is nothing', () => {
const test = '';
const expected: any[] = [];
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
it('1 space is nothing', () => {
const test = ' ';
const expected: any[] = [];
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
it('keep other types in strings', () => {
const test = 'test "1111" "0.1111" "TRUE" "FALSE"';
const expected = [
{ type: 'string', value: 'test' },
{ type: 'string', value: '1111' },
{ type: 'string', value: '0.1111' },
{ type: 'string', value: 'TRUE' },
{ type: 'string', value: 'FALSE' },
];
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
it('keep spaces in quoted strings', () => {
const test = '"test space" 1111 0.1111 TRUE FALSE';
const expected = [
{ type: 'string', value: 'test space' },
{ type: 'integer', value: 1111 },
{ type: 'float', value: 0.1111 },
{ type: 'T', value: true },
{ type: 'F', value: false },
];
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
it('keep spaces escaped quotes', () => {
const test = '"test \\" space" 1111 0.1111 TRUE FALSE';
const expected = [
{ type: 'string', value: 'test " space' },
{ type: 'integer', value: 1111 },
{ type: 'float', value: 0.1111 },
{ type: 'T', value: true },
{ type: 'F', value: false },
];
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
it('2 spaces', () => {
const test = '1111 0.1111 TRUE FALSE';
const expected = [
{ type: 'integer', value: 1111 },
{ type: 'float', value: 0.1111 },
{ type: 'T', value: true },
{ type: 'F', value: false },
];
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
});
-41
View File
@@ -1,41 +0,0 @@
import type { Argument } from 'node-osc';
import { splitWhitespace } from 'ontime-utils';
export function stringToOSCArgs(argsString: string | undefined): Argument[] {
if (typeof argsString === 'undefined' || argsString === '') {
return new Array<Argument>();
}
const matches = splitWhitespace(argsString);
if (!matches) {
return new Array<Argument>();
}
const parsedArguments: Argument[] = matches.map((argString: string) => {
const argAsNum = Number(argString);
// NOTE: number like: 1 2.0 33333
if (!Number.isNaN(argAsNum)) {
return { type: argString.includes('.') ? 'float' : 'integer', value: argAsNum };
}
if (argString.startsWith('"') && argString.endsWith('"')) {
// NOTE: "quoted string"
return { type: 'string', value: argString.substring(1, argString.length - 1) };
}
if (argString === 'TRUE') {
// NOTE: Boolean true
return { type: 'T', value: true };
}
if (argString === 'FALSE') {
// NOTE: Boolean false
return { type: 'F', value: false };
}
// NOTE: string
return { type: 'string', value: argString };
});
return parsedArguments;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "3.11.0-beta.1",
"version": "3.11.1-beta.1",
"description": "Time keeping for live events",
"keywords": [
"ontime",
@@ -7,15 +7,15 @@ export const runtimeStorePlaceholder: RuntimeStore = {
clock: 0,
timer: {
addedTime: 0,
current: null,
duration: null,
elapsed: null,
expectedFinish: null,
finishedAt: null,
phase: TimerPhase.None,
playback: Playback.Stop,
secondaryTimer: null,
startedAt: null,
current: null, // changes on every update
duration: null, // only changes if event changes
elapsed: null, // changes on every update
expectedFinish: null, // change can only be initiated by user, can roll over midnight
finishedAt: null, // can change on update or user action
phase: TimerPhase.None, // can change on update or user action
playback: Playback.Stop, // change initiated by user
secondaryTimer: null, // change on every update
startedAt: null, // change can only be initiated by user
},
onAir: false,
message: {
@@ -29,13 +29,13 @@ export const runtimeStorePlaceholder: RuntimeStore = {
external: '',
},
runtime: {
selectedEventIndex: null,
numEvents: 0,
offset: 0,
plannedStart: 0,
plannedEnd: 0,
actualStart: null,
expectedEnd: null,
selectedEventIndex: null, // changes if rundown changes or we load a new event
numEvents: 0, // change initiated by user
offset: 0, // changes at runtime
plannedStart: 0, // only changes if event changes
plannedEnd: 0, // only changes if event changes, overflows over dayInMs
actualStart: null, // set once we start the timer
expectedEnd: null, // changes with runtime, based on offset, overflows over dayInMs
},
currentBlock: {
block: null,
+148 -59
View File
@@ -6,6 +6,9 @@ settings:
catalogs:
default:
'@types/node':
specifier: 20.17.16
version: 20.17.16
'@typescript-eslint/eslint-plugin':
specifier: 7.16.1
version: 7.16.1
@@ -15,6 +18,9 @@ catalogs:
eslint:
specifier: 8.56.0
version: 8.56.0
eslint-config-prettier:
specifier: 9.1.0
version: 9.1.0
eslint-plugin-prettier:
specifier: 5.1.3
version: 5.1.3
@@ -37,7 +43,7 @@ importers:
version: 1.49.1
'@types/node':
specifier: 'catalog:'
version: 20.14.10
version: 20.17.16
'@typescript-eslint/eslint-plugin':
specifier: 'catalog:'
version: 7.16.1(@typescript-eslint/parser@7.16.1(eslint@8.56.0)(typescript@5.5.3))(eslint@8.56.0)(typescript@5.5.3)
@@ -188,7 +194,7 @@ importers:
version: 7.16.1(eslint@8.56.0)(typescript@5.5.3)
'@vitejs/plugin-react':
specifier: ^4.2.1
version: 4.2.1(vite@5.2.11(@types/node@20.14.10)(sass@1.57.1))
version: 4.2.1(vite@5.2.11(@types/node@22.10.10)(sass@1.57.1))
eslint:
specifier: 'catalog:'
version: 8.56.0
@@ -230,19 +236,19 @@ importers:
version: 5.5.3
vite:
specifier: ^5.2.11
version: 5.2.11(@types/node@20.14.10)(sass@1.57.1)
version: 5.2.11(@types/node@22.10.10)(sass@1.57.1)
vite-plugin-compression2:
specifier: ^1.3.3
version: 1.3.3(rollup@4.17.2)(vite@5.2.11(@types/node@20.14.10)(sass@1.57.1))
version: 1.3.3(rollup@4.17.2)(vite@5.2.11(@types/node@22.10.10)(sass@1.57.1))
vite-plugin-svgr:
specifier: ^4.2.0
version: 4.2.0(rollup@4.17.2)(typescript@5.5.3)(vite@5.2.11(@types/node@20.14.10)(sass@1.57.1))
version: 4.2.0(rollup@4.17.2)(typescript@5.5.3)(vite@5.2.11(@types/node@22.10.10)(sass@1.57.1))
vite-tsconfig-paths:
specifier: ^4.3.1
version: 4.3.1(typescript@5.5.3)(vite@5.2.11(@types/node@20.14.10)(sass@1.57.1))
version: 4.3.1(typescript@5.5.3)(vite@5.2.11(@types/node@22.10.10)(sass@1.57.1))
vitest:
specifier: 'catalog:'
version: 2.1.6(@types/node@20.14.10)(happy-dom@16.7.2)(jsdom@21.1.0)(sass@1.57.1)
version: 2.1.6(@types/node@22.10.10)(happy-dom@16.7.2)(jsdom@21.1.0)(sass@1.57.1)
apps/electron:
devDependencies:
@@ -310,8 +316,8 @@ importers:
specifier: workspace:*
version: link:../../packages/utils
osc-min:
specifier: ^2.1.1
version: 2.1.1
specifier: 2.1.2
version: 2.1.2
sanitize-filename:
specifier: ^1.6.3
version: 1.6.3
@@ -336,10 +342,7 @@ importers:
version: 1.4.11
'@types/node':
specifier: 'catalog:'
version: 20.14.10
'@types/node-osc':
specifier: ^6.0.3
version: 6.0.3
version: 20.17.16
'@types/websocket':
specifier: ^1.0.5
version: 1.0.5
@@ -384,7 +387,7 @@ importers:
version: 5.5.3
vitest:
specifier: 'catalog:'
version: 2.1.6(@types/node@20.14.10)(happy-dom@16.7.2)(jsdom@21.1.0)(sass@1.57.1)
version: 2.1.6(@types/node@20.17.16)(happy-dom@16.7.2)(jsdom@21.1.0)(sass@1.57.1)
packages/types:
devDependencies:
@@ -439,7 +442,7 @@ importers:
version: 5.5.3
vitest:
specifier: 'catalog:'
version: 2.1.6(@types/node@20.14.10)(happy-dom@16.7.2)(jsdom@21.1.0)(sass@1.57.1)
version: 2.1.6(@types/node@22.10.10)(happy-dom@16.7.2)(jsdom@21.1.0)(sass@1.57.1)
packages:
@@ -2055,12 +2058,15 @@ packages:
'@types/multer@1.4.11':
resolution: {integrity: sha512-svK240gr6LVWvv3YGyhLlA+6LRRWA4mnGIU7RcNmgjBYFl6665wcXrRfxGp5tEPVHUNm5FMcmq7too9bxCwX/w==}
'@types/node-osc@6.0.3':
resolution: {integrity: sha512-f0JUTDVAlk/mV9RH6jE6g/8Y7l+Mvi3f1x7xv0RG3VI1PktaXEd60mR+4plpMS9VbRovFoPsS7qaaIDhFXdaEw==}
'@types/node@20.14.10':
resolution: {integrity: sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==}
'@types/node@20.17.16':
resolution: {integrity: sha512-vOTpLduLkZXePLxHiHsBLp98mHGnl8RptV4YAO3HfKO5UHjDvySGbxKtpYfy8Sx5+WKcgc45qNreJJRVM3L6mw==}
'@types/node@22.10.10':
resolution: {integrity: sha512-X47y/mPNzxviAGY5TcYPtYL8JsY3kAq2n8fMmKoRCxq/c4v4pyGNCzM2R6+M5/umG4ZfHuT+sgqDYqWc9rJ6ww==}
'@types/parse-json@4.0.0':
resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==}
@@ -3977,8 +3983,8 @@ packages:
resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==}
engines: {node: '>= 0.8.0'}
osc-min@2.1.1:
resolution: {integrity: sha512-3Ai9vP8AAh1sCh5Zm8BUybfulv5OvqpTVa2BcUoTrLeD1Ss3cohIQpF1f18gx0CTbi9Dv4SvtdMb7y4g++7lYA==}
osc-min@2.1.2:
resolution: {integrity: sha512-dEhg7wBJ+QTHqY4iwnctkaFWzXLq+yHA2olxYfnxBw1fMy/iWJ/b7ovfXfwc/yxLXGiUHQhTxlQSHzL8ueHz6Q==}
engines: {node: '>=0.10.0'}
p-cancelable@2.1.1:
@@ -4756,6 +4762,12 @@ packages:
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
undici-types@6.19.8:
resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==}
undici-types@6.20.0:
resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==}
universalify@0.1.2:
resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==}
engines: {node: '>= 4.0.0'}
@@ -6717,13 +6729,13 @@ snapshots:
'@types/body-parser@1.19.2':
dependencies:
'@types/connect': 3.4.35
'@types/node': 20.14.10
'@types/node': 20.17.16
'@types/cacheable-request@6.0.3':
dependencies:
'@types/http-cache-semantics': 4.0.4
'@types/keyv': 3.1.4
'@types/node': 20.14.10
'@types/node': 22.10.10
'@types/responselike': 1.0.3
'@types/color-convert@2.0.0':
@@ -6738,11 +6750,11 @@ snapshots:
'@types/connect@3.4.35':
dependencies:
'@types/node': 20.14.10
'@types/node': 20.17.16
'@types/cors@2.8.17':
dependencies:
'@types/node': 20.14.10
'@types/node': 20.17.16
'@types/debug@4.1.12':
dependencies:
@@ -6752,7 +6764,7 @@ snapshots:
'@types/express-serve-static-core@4.17.33':
dependencies:
'@types/node': 20.14.10
'@types/node': 20.17.16
'@types/qs': 6.9.7
'@types/range-parser': 1.2.4
@@ -6765,7 +6777,7 @@ snapshots:
'@types/fs-extra@9.0.13':
dependencies:
'@types/node': 20.14.10
'@types/node': 22.10.10
'@types/http-cache-semantics@4.0.4': {}
@@ -6773,7 +6785,7 @@ snapshots:
'@types/keyv@3.1.4':
dependencies:
'@types/node': 20.14.10
'@types/node': 22.10.10
'@types/lodash.mergewith@4.6.7':
dependencies:
@@ -6789,19 +6801,23 @@ snapshots:
dependencies:
'@types/express': 4.17.17
'@types/node-osc@6.0.3':
dependencies:
'@types/node': 20.14.10
'@types/node@20.14.10':
dependencies:
undici-types: 5.26.5
'@types/node@20.17.16':
dependencies:
undici-types: 6.19.8
'@types/node@22.10.10':
dependencies:
undici-types: 6.20.0
'@types/parse-json@4.0.0': {}
'@types/plist@3.0.5':
dependencies:
'@types/node': 20.14.10
'@types/node': 22.10.10
xmlbuilder: 15.1.1
optional: true
@@ -6823,7 +6839,7 @@ snapshots:
'@types/responselike@1.0.3':
dependencies:
'@types/node': 20.14.10
'@types/node': 22.10.10
'@types/scheduler@0.16.2': {}
@@ -6832,22 +6848,22 @@ snapshots:
'@types/serve-static@1.15.0':
dependencies:
'@types/mime': 3.0.1
'@types/node': 20.14.10
'@types/node': 20.17.16
'@types/verror@1.10.9':
optional: true
'@types/websocket@1.0.5':
dependencies:
'@types/node': 20.14.10
'@types/node': 20.17.16
'@types/ws@8.5.10':
dependencies:
'@types/node': 20.14.10
'@types/node': 20.17.16
'@types/yauzl@2.10.3':
dependencies:
'@types/node': 20.14.10
'@types/node': 22.10.10
optional: true
'@typescript-eslint/eslint-plugin@7.16.1(@typescript-eslint/parser@7.16.1(eslint@8.56.0)(typescript@5.5.3))(eslint@8.56.0)(typescript@5.5.3)':
@@ -6974,14 +6990,14 @@ snapshots:
'@ungap/structured-clone@1.2.0': {}
'@vitejs/plugin-react@4.2.1(vite@5.2.11(@types/node@20.14.10)(sass@1.57.1))':
'@vitejs/plugin-react@4.2.1(vite@5.2.11(@types/node@22.10.10)(sass@1.57.1))':
dependencies:
'@babel/core': 7.23.6
'@babel/plugin-transform-react-jsx-self': 7.23.3(@babel/core@7.23.6)
'@babel/plugin-transform-react-jsx-source': 7.23.3(@babel/core@7.23.6)
'@types/babel__core': 7.20.5
react-refresh: 0.14.0
vite: 5.2.11(@types/node@20.14.10)(sass@1.57.1)
vite: 5.2.11(@types/node@22.10.10)(sass@1.57.1)
transitivePeerDependencies:
- supports-color
@@ -6992,13 +7008,21 @@ snapshots:
chai: 5.1.2
tinyrainbow: 1.2.0
'@vitest/mocker@2.1.6(vite@5.2.11(@types/node@20.14.10)(sass@1.57.1))':
'@vitest/mocker@2.1.6(vite@5.2.11(@types/node@20.17.16)(sass@1.57.1))':
dependencies:
'@vitest/spy': 2.1.6
estree-walker: 3.0.3
magic-string: 0.30.14
optionalDependencies:
vite: 5.2.11(@types/node@20.14.10)(sass@1.57.1)
vite: 5.2.11(@types/node@20.17.16)(sass@1.57.1)
'@vitest/mocker@2.1.6(vite@5.2.11(@types/node@22.10.10)(sass@1.57.1))':
dependencies:
'@vitest/spy': 2.1.6
estree-walker: 3.0.3
magic-string: 0.30.14
optionalDependencies:
vite: 5.2.11(@types/node@22.10.10)(sass@1.57.1)
'@vitest/pretty-format@2.1.6':
dependencies:
@@ -9106,7 +9130,7 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
osc-min@2.1.1: {}
osc-min@2.1.2: {}
p-cancelable@2.1.1: {}
@@ -9896,6 +9920,10 @@ snapshots:
undici-types@5.26.5: {}
undici-types@6.19.8: {}
undici-types@6.20.0: {}
universalify@0.1.2: {}
universalify@0.2.0:
@@ -9968,13 +9996,13 @@ snapshots:
extsprintf: 1.4.1
optional: true
vite-node@2.1.6(@types/node@20.14.10)(sass@1.57.1):
vite-node@2.1.6(@types/node@20.17.16)(sass@1.57.1):
dependencies:
cac: 6.7.14
debug: 4.3.7
es-module-lexer: 1.5.4
pathe: 1.1.2
vite: 5.2.11(@types/node@20.14.10)(sass@1.57.1)
vite: 5.2.11(@types/node@20.17.16)(sass@1.57.1)
transitivePeerDependencies:
- '@types/node'
- less
@@ -9985,50 +10013,77 @@ snapshots:
- supports-color
- terser
vite-plugin-compression2@1.3.3(rollup@4.17.2)(vite@5.2.11(@types/node@20.14.10)(sass@1.57.1)):
vite-node@2.1.6(@types/node@22.10.10)(sass@1.57.1):
dependencies:
cac: 6.7.14
debug: 4.3.7
es-module-lexer: 1.5.4
pathe: 1.1.2
vite: 5.2.11(@types/node@22.10.10)(sass@1.57.1)
transitivePeerDependencies:
- '@types/node'
- less
- lightningcss
- sass
- stylus
- sugarss
- supports-color
- terser
vite-plugin-compression2@1.3.3(rollup@4.17.2)(vite@5.2.11(@types/node@22.10.10)(sass@1.57.1)):
dependencies:
'@rollup/pluginutils': 5.1.0(rollup@4.17.2)
tar-mini: 0.2.0
vite: 5.2.11(@types/node@20.14.10)(sass@1.57.1)
vite: 5.2.11(@types/node@22.10.10)(sass@1.57.1)
transitivePeerDependencies:
- rollup
vite-plugin-svgr@4.2.0(rollup@4.17.2)(typescript@5.5.3)(vite@5.2.11(@types/node@20.14.10)(sass@1.57.1)):
vite-plugin-svgr@4.2.0(rollup@4.17.2)(typescript@5.5.3)(vite@5.2.11(@types/node@22.10.10)(sass@1.57.1)):
dependencies:
'@rollup/pluginutils': 5.1.0(rollup@4.17.2)
'@svgr/core': 8.1.0(typescript@5.5.3)
'@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.5.3))
vite: 5.2.11(@types/node@20.14.10)(sass@1.57.1)
vite: 5.2.11(@types/node@22.10.10)(sass@1.57.1)
transitivePeerDependencies:
- rollup
- supports-color
- typescript
vite-tsconfig-paths@4.3.1(typescript@5.5.3)(vite@5.2.11(@types/node@20.14.10)(sass@1.57.1)):
vite-tsconfig-paths@4.3.1(typescript@5.5.3)(vite@5.2.11(@types/node@22.10.10)(sass@1.57.1)):
dependencies:
debug: 4.3.7
globrex: 0.1.2
tsconfck: 3.0.2(typescript@5.5.3)
optionalDependencies:
vite: 5.2.11(@types/node@20.14.10)(sass@1.57.1)
vite: 5.2.11(@types/node@22.10.10)(sass@1.57.1)
transitivePeerDependencies:
- supports-color
- typescript
vite@5.2.11(@types/node@20.14.10)(sass@1.57.1):
vite@5.2.11(@types/node@20.17.16)(sass@1.57.1):
dependencies:
esbuild: 0.20.2
postcss: 8.4.38
rollup: 4.17.2
optionalDependencies:
'@types/node': 20.14.10
'@types/node': 20.17.16
fsevents: 2.3.3
sass: 1.57.1
vitest@2.1.6(@types/node@20.14.10)(happy-dom@16.7.2)(jsdom@21.1.0)(sass@1.57.1):
vite@5.2.11(@types/node@22.10.10)(sass@1.57.1):
dependencies:
esbuild: 0.20.2
postcss: 8.4.38
rollup: 4.17.2
optionalDependencies:
'@types/node': 22.10.10
fsevents: 2.3.3
sass: 1.57.1
vitest@2.1.6(@types/node@20.17.16)(happy-dom@16.7.2)(jsdom@21.1.0)(sass@1.57.1):
dependencies:
'@vitest/expect': 2.1.6
'@vitest/mocker': 2.1.6(vite@5.2.11(@types/node@20.14.10)(sass@1.57.1))
'@vitest/mocker': 2.1.6(vite@5.2.11(@types/node@20.17.16)(sass@1.57.1))
'@vitest/pretty-format': 2.1.6
'@vitest/runner': 2.1.6
'@vitest/snapshot': 2.1.6
@@ -10044,11 +10099,47 @@ snapshots:
tinyexec: 0.3.1
tinypool: 1.0.2
tinyrainbow: 1.2.0
vite: 5.2.11(@types/node@20.14.10)(sass@1.57.1)
vite-node: 2.1.6(@types/node@20.14.10)(sass@1.57.1)
vite: 5.2.11(@types/node@20.17.16)(sass@1.57.1)
vite-node: 2.1.6(@types/node@20.17.16)(sass@1.57.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 20.14.10
'@types/node': 20.17.16
happy-dom: 16.7.2
jsdom: 21.1.0
transitivePeerDependencies:
- less
- lightningcss
- msw
- sass
- stylus
- sugarss
- supports-color
- terser
vitest@2.1.6(@types/node@22.10.10)(happy-dom@16.7.2)(jsdom@21.1.0)(sass@1.57.1):
dependencies:
'@vitest/expect': 2.1.6
'@vitest/mocker': 2.1.6(vite@5.2.11(@types/node@22.10.10)(sass@1.57.1))
'@vitest/pretty-format': 2.1.6
'@vitest/runner': 2.1.6
'@vitest/snapshot': 2.1.6
'@vitest/spy': 2.1.6
'@vitest/utils': 2.1.6
chai: 5.1.2
debug: 4.3.7
expect-type: 1.1.0
magic-string: 0.30.14
pathe: 1.1.2
std-env: 3.8.0
tinybench: 2.9.0
tinyexec: 0.3.1
tinypool: 1.0.2
tinyrainbow: 1.2.0
vite: 5.2.11(@types/node@22.10.10)(sass@1.57.1)
vite-node: 2.1.6(@types/node@22.10.10)(sass@1.57.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.10.10
happy-dom: 16.7.2
jsdom: 21.1.0
transitivePeerDependencies:
@@ -10145,8 +10236,6 @@ snapshots:
wrappy@1.0.2: {}
ws@8.13.0: {}
ws@8.18.0: {}
xlsx@0.18.5:
+1 -1
View File
@@ -6,7 +6,7 @@ catalog:
typescript: 5.5.3
"@typescript-eslint/eslint-plugin": 7.16.1
"@typescript-eslint/parser": 7.16.1
"@types/node": 20.14.10
"@types/node": 20.17.16
prettier: 3.3.1
eslint: 8.56.0
eslint-config-prettier: 9.1.0