mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-19 06:04:05 +00:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9f0bdef4d | |||
| 8386105b75 | |||
| 459d584b35 | |||
| f98af929b3 | |||
| cbabec2b1d | |||
| 959a2b6d4a | |||
| 8ac5c0a9a2 | |||
| 649f0af2cf | |||
| fd9b944ecf | |||
| 0b36608134 | |||
| bac11adeb1 | |||
| 31799f8fab | |||
| af177060ff | |||
| d26a3cd393 | |||
| 38a3165a79 | |||
| 1715c19747 | |||
| 6be6e5fd8d | |||
| 44b0cfd928 | |||
| 3d50b8ce49 | |||
| c3d66f5bd1 | |||
| d08787a6ba | |||
| f0e6231338 | |||
| 430bfb38f2 | |||
| 60354f8ea3 | |||
| bc12531729 | |||
| 0b8a694e4f | |||
| 73ada99596 | |||
| f509655d31 | |||
| fb1bbd6d5c | |||
| 221ff7b3af |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "3.11.0-beta.1",
|
"version": "3.11.1-beta.1",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "3.11.0-beta.1",
|
"version": "3.11.1-beta.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-tim
|
|||||||
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
|
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
|
||||||
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
|
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 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 Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
||||||
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
|
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
|
||||||
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
|
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
|
||||||
@@ -44,6 +44,8 @@ const SPublic = withPreset(withData(Public));
|
|||||||
const SLowerThird = withPreset(withData(Lower));
|
const SLowerThird = withPreset(withData(Lower));
|
||||||
const SStudio = withPreset(withData(StudioClock));
|
const SStudio = withPreset(withData(StudioClock));
|
||||||
const STimeline = withPreset(withData(Timeline));
|
const STimeline = withPreset(withData(Timeline));
|
||||||
|
const PCuesheet = withPreset(Cuesheet);
|
||||||
|
const POperator = withPreset(Operator);
|
||||||
|
|
||||||
const EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper'));
|
const EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper'));
|
||||||
const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport'));
|
const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport'));
|
||||||
@@ -155,8 +157,8 @@ export default function AppRouter() {
|
|||||||
|
|
||||||
{/*/!* Protected Routes *!/*/}
|
{/*/!* Protected Routes *!/*/}
|
||||||
<Route path='/editor' element={<Editor />} />
|
<Route path='/editor' element={<Editor />} />
|
||||||
<Route path='/cuesheet' element={<Cuesheet />} />
|
<Route path='/cuesheet' element={<PCuesheet />} />
|
||||||
<Route path='/op' element={<Operator />} />
|
<Route path='/op' element={<POperator />} />
|
||||||
|
|
||||||
{/*/!* Protected Routes - Elements *!/*/}
|
{/*/!* Protected Routes - Elements *!/*/}
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
|||||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||||
|
|
||||||
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
|
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
|
||||||
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
|
||||||
import style from './NavigationMenu.module.scss';
|
import style from './NavigationMenu.module.scss';
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ export default function FloatingNavigation(props: FloatingNavigationProps) {
|
|||||||
const isButtonShown = useFadeOutOnInactivity();
|
const isButtonShown = useFadeOutOnInactivity();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${style.buttonContainer} ${!isButtonShown ? style.hidden : ''}`}>
|
<div className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}>
|
||||||
<button
|
<button
|
||||||
onClick={toggleMenu}
|
onClick={toggleMenu}
|
||||||
aria-label='toggle menu'
|
aria-label='toggle menu'
|
||||||
|
|||||||
@@ -7,24 +7,38 @@ $icon-color: $ui-white;
|
|||||||
$button-bg: $gray-1050;
|
$button-bg: $gray-1050;
|
||||||
$button-size: 3rem;
|
$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 {
|
.buttonContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
row-gap: 1rem;
|
row-gap: 1rem;
|
||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
|
|
||||||
transition-property: opacity;
|
|
||||||
transition-duration: 0.3s;
|
|
||||||
|
|
||||||
opacity: 1;
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 12;
|
z-index: 12;
|
||||||
|
|
||||||
&.hidden {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
@@ -38,16 +52,6 @@ $button-size: 3rem;
|
|||||||
border-radius: 3px;
|
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 {
|
.navButton {
|
||||||
@extend .button;
|
@extend .button;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
|
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
|
||||||
|
|
||||||
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
|
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
|
||||||
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
|
||||||
import style from './NavigationMenu.module.scss';
|
import style from './NavigationMenu.module.scss';
|
||||||
|
|
||||||
@@ -8,8 +9,8 @@ export default function ViewLockedIcon() {
|
|||||||
const isLockIconShown = useFadeOutOnInactivity();
|
const isLockIconShown = useFadeOutOnInactivity();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${style.buttonContainer} ${!isLockIconShown ? style.hidden : ''}`}>
|
<div className={cx([style.fadeable, style.lockIcon, !isLockIconShown && style.hidden])}>
|
||||||
<IoLockClosedOutline className={style.lockIcon} />
|
<IoLockClosedOutline />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,20 @@
|
|||||||
import { memo, useCallback } from 'react';
|
import { memo } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
|
||||||
import { useDisclosure } from '@chakra-ui/react';
|
import { useDisclosure } from '@chakra-ui/react';
|
||||||
|
|
||||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
|
||||||
|
|
||||||
import FloatingNavigation from './FloatingNavigation';
|
import FloatingNavigation from './FloatingNavigation';
|
||||||
import NavigationMenu from './NavigationMenu';
|
import NavigationMenu from './NavigationMenu';
|
||||||
|
import useViewEditor from './useViewEditor';
|
||||||
import ViewLockedIcon from './ViewLockedIcon';
|
import ViewLockedIcon from './ViewLockedIcon';
|
||||||
|
|
||||||
function ViewNavigationMenu() {
|
interface ViewNavigationMenuProps {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
isLockable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ViewNavigationMenu(props: ViewNavigationMenuProps) {
|
||||||
|
const { isLockable } = props;
|
||||||
|
|
||||||
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
|
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
|
||||||
|
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable });
|
||||||
const isViewLocked = isStringBoolean(searchParams.get('locked'));
|
|
||||||
|
|
||||||
const showEditFormDrawer = useCallback(() => {
|
|
||||||
searchParams.set('edit', 'true');
|
|
||||||
setSearchParams(searchParams);
|
|
||||||
}, [searchParams, setSearchParams]);
|
|
||||||
|
|
||||||
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
|
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 { CSSProperties } from 'react';
|
||||||
|
|
||||||
import EmptyImage from '../../../assets/images/empty.svg?react';
|
import EmptyImage from '../../../assets/images/empty.svg?react';
|
||||||
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
|
||||||
import style from './Empty.module.scss';
|
import style from './Empty.module.scss';
|
||||||
|
|
||||||
interface EmptyProps {
|
interface EmptyProps {
|
||||||
text?: string;
|
text?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Empty(props: EmptyProps) {
|
export default function Empty(props: EmptyProps) {
|
||||||
const { text, ...rest } = props;
|
const { text, className, ...rest } = props;
|
||||||
return (
|
return (
|
||||||
<div className={style.emptyContainer} {...rest}>
|
<div className={cx([style.emptyContainer, className])} {...rest}>
|
||||||
<EmptyImage className={style.empty} />
|
<EmptyImage className={style.empty} />
|
||||||
{text && <span className={style.text}>{text}</span>}
|
{text && <span className={style.text}>{text}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
@use '../../../theme/viewerDefs' as *;
|
@use '../../../theme/viewerDefs' as *;
|
||||||
|
|
||||||
$title-primary-size: clamp(1.5rem, 3vw, 3rem);
|
|
||||||
$title-secondary-size: clamp(1rem, 2vw, 2.25rem);
|
|
||||||
|
|
||||||
.title-card {
|
.title-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -10,21 +7,23 @@ $title-secondary-size: clamp(1rem, 2vw, 2.25rem);
|
|||||||
}
|
}
|
||||||
|
|
||||||
.title-card__title,
|
.title-card__title,
|
||||||
.title-card__secondary {
|
.title-card__placeholder {
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-card__title {
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: $title-primary-size;
|
font-size: $title-font-size;
|
||||||
color: var(--color-override, $viewer-color);
|
|
||||||
line-height: 1.2em;
|
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 {
|
.title-card__secondary {
|
||||||
font-size: $title-secondary-size;
|
font-size: $base-font-size;
|
||||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||||
line-height: 1.2em;
|
line-height: 1.2em;
|
||||||
}
|
}
|
||||||
@@ -35,7 +34,6 @@ $title-secondary-size: clamp(1rem, 2vw, 2.25rem);
|
|||||||
top: 0.5rem;
|
top: 0.5rem;
|
||||||
font-size: $timer-label-size;
|
font-size: $timer-label-size;
|
||||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||||
margin-left: auto;
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|
||||||
&--accent {
|
&--accent {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ForwardedRef, forwardRef } from 'react';
|
import { ForwardedRef, forwardRef } from 'react';
|
||||||
|
|
||||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||||
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
|
||||||
import './TitleCard.scss';
|
import './TitleCard.scss';
|
||||||
|
|
||||||
@@ -18,9 +19,9 @@ const TitleCard = forwardRef((props: TitleCardProps, ref: ForwardedRef<HTMLDivEl
|
|||||||
const accent = label === 'now';
|
const accent = label === 'now';
|
||||||
|
|
||||||
return (
|
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='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}`)}
|
{label && getLocalizedString(`common.${label}`)}
|
||||||
</span>
|
</span>
|
||||||
<div className='title-card__secondary'>{secondary}</div>
|
<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 { projectLogoPath } from '../../api/constants';
|
||||||
|
|
||||||
|
import './ViewLogo.scss';
|
||||||
|
|
||||||
interface ViewLogoProps {
|
interface ViewLogoProps {
|
||||||
name: string;
|
name: string;
|
||||||
className: string;
|
className: string;
|
||||||
@@ -7,5 +9,11 @@ interface ViewLogoProps {
|
|||||||
|
|
||||||
export default function ViewLogo(props: ViewLogoProps) {
|
export default function ViewLogo(props: ViewLogoProps) {
|
||||||
const { name, className } = props;
|
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}>
|
<MenuButton as={Button} variant='ontime-subtle-white' position='relative' width='fit-content' fontWeight={400}>
|
||||||
{paramField.title} <IoChevronDown style={{ display: 'inline' }} />
|
{paramField.title} <IoChevronDown style={{ display: 'inline' }} />
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
<MenuList>
|
<MenuList overflow='auto' maxHeight='200px'>
|
||||||
<MenuOptionGroup
|
<MenuOptionGroup
|
||||||
type='checkbox'
|
type='checkbox'
|
||||||
value={paramState}
|
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 { 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 { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||||
import { RUNDOWN } from '../api/constants';
|
import { RUNDOWN } from '../api/constants';
|
||||||
@@ -11,6 +11,9 @@ import useProjectData from './useProjectData';
|
|||||||
// revision is -1 so that the remote revision is higher
|
// revision is -1 so that the remote revision is higher
|
||||||
const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 };
|
const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalised rundown data
|
||||||
|
*/
|
||||||
export default function useRundown() {
|
export default function useRundown() {
|
||||||
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
|
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
|
||||||
queryKey: RUNDOWN,
|
queryKey: RUNDOWN,
|
||||||
@@ -24,6 +27,10 @@ export default function useRundown() {
|
|||||||
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
|
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides access to a flat rundown
|
||||||
|
* built from the order and rundown fields
|
||||||
|
*/
|
||||||
export function useFlatRundown() {
|
export function useFlatRundown() {
|
||||||
const { data, status } = useRundown();
|
const { data, status } = useRundown();
|
||||||
const { data: projectData } = useProjectData();
|
const { data: projectData } = useProjectData();
|
||||||
@@ -52,3 +59,15 @@ export function useFlatRundown() {
|
|||||||
|
|
||||||
return { data: flatRunDown, status };
|
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);
|
return useRuntimeStore(featureSelector);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useRuntimeOffset = () => {
|
||||||
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
|
offset: state.runtime.offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
return useRuntimeStore(featureSelector);
|
||||||
|
};
|
||||||
|
|
||||||
export const usePing = () => {
|
export const usePing = () => {
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
ping: state.ping,
|
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();
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
import isEqual from 'react-fast-compare';
|
import { Path, resolvePath } from 'react-router-dom';
|
||||||
import { Location, resolvePath } from 'react-router-dom';
|
|
||||||
import { URLPreset } from 'ontime-types';
|
import { URLPreset } from 'ontime-types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates a preset against defined parameters
|
* Validates a preset against defined parameters
|
||||||
* @param {string} preset
|
* Used in the context of form validation
|
||||||
* @returns {{message: string, isValid: boolean}}
|
|
||||||
*/
|
*/
|
||||||
export const validateUrlPresetPath = (preset: string): { message: string; isValid: boolean } => {
|
export function validateUrlPresetPath(preset: string): { message: string; isValid: boolean } {
|
||||||
if (preset === '' || preset == null) {
|
if (preset === '' || preset == null) {
|
||||||
return { isValid: false, message: 'Path cannot be empty' };
|
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' };
|
return { isValid: true, message: 'ok' };
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility removes trailing slash from a string
|
* Utility removes trailing slash from a string
|
||||||
@@ -36,48 +34,93 @@ function removeTrailingSlash(text: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the URL to send a preset to
|
* Checks whether the current location corresponds to a preset and returns the new path if necessary
|
||||||
* @param location
|
|
||||||
* @param data
|
|
||||||
* @param searchParams
|
|
||||||
*/
|
*/
|
||||||
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 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
|
// check if we have token or locked in the search params
|
||||||
const foundPreset = data.find((preset) => preset.alias === removeTrailingSlash(currentURL) && preset.enabled);
|
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) {
|
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');
|
const presetOnPage = searchParams.get('alias');
|
||||||
for (const d of data) {
|
if (!presetOnPage) {
|
||||||
if (presetOnPage) {
|
return null;
|
||||||
// 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 currentPath = `${location.pathname}${location.search}`.substring(1);
|
||||||
const newPath = resolvePath(d.pathAndParams);
|
|
||||||
const urlParams = new URLSearchParams(newPath.search);
|
for (const preset of urlPresets) {
|
||||||
urlParams.set('alias', d.alias);
|
// if the page has a known enabled alias, we check if we need to redirect
|
||||||
// we confirm either the url parameters does not match or the url path doesnt
|
if (preset.alias === presetOnPage && preset.enabled) {
|
||||||
if (!isEqual(urlParams, searchParams) || newPath.pathname !== location.pathname) {
|
const newPath = generatePathFromPreset(preset.pathAndParams, preset.alias, locked, token);
|
||||||
// we then redirect to the alias route, since the view listening to this alias has an outdated URL
|
if (!arePathsEquivalent(currentPath, newPath)) {
|
||||||
return `${newPath.pathname}?${urlParams}`;
|
// if current path is out of date
|
||||||
}
|
// return new path so we can redirect
|
||||||
|
return newPath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate URL from an preset
|
* Handles generating a path and search parameters from a preset
|
||||||
* @param presetData
|
|
||||||
*/
|
*/
|
||||||
export const generateUrlFromPreset = (presetData: URLPreset) => {
|
export function generatePathFromPreset(pathAndParams: string, alias: string, locked: string | null, token: string | null ): string {
|
||||||
const newPresetPath = resolvePath(presetData.pathAndParams);
|
const path = resolvePath(pathAndParams);
|
||||||
const urlParams = new URLSearchParams(newPresetPath.search);
|
const searchParams = new URLSearchParams(path.search);
|
||||||
urlParams.set('alias', presetData.alias);
|
|
||||||
|
|
||||||
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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* eslint-disable react/display-name */
|
/* eslint-disable react/display-name */
|
||||||
import { ComponentType, useEffect } from 'react';
|
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 useUrlPresets from '../common/hooks-query/useUrlPresets';
|
||||||
import { getRouteFromPreset } from '../common/utils/urlPresets';
|
import { getRouteFromPreset } from '../common/utils/urlPresets';
|
||||||
@@ -8,19 +8,19 @@ import { getRouteFromPreset } from '../common/utils/urlPresets';
|
|||||||
const withPreset = <P extends object>(Component: ComponentType<P>) => {
|
const withPreset = <P extends object>(Component: ComponentType<P>) => {
|
||||||
return (props: Partial<P>) => {
|
return (props: Partial<P>) => {
|
||||||
const { data } = useUrlPresets();
|
const { data } = useUrlPresets();
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
// navigate if is alias route
|
// navigate if is alias route
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
const url = getRouteFromPreset(location, data, searchParams);
|
const destination = getRouteFromPreset(location, data);
|
||||||
// navigate to this route if its not empty
|
|
||||||
if (url) {
|
// navigate to this destination if its not null
|
||||||
navigate(url);
|
if (destination) {
|
||||||
|
navigate(destination);
|
||||||
}
|
}
|
||||||
}, [data, searchParams, navigate, location]);
|
}, [data, navigate, location]);
|
||||||
|
|
||||||
return <Component {...(props as P)} />;
|
return <Component {...(props as P)} />;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ $inner-padding: 1rem;
|
|||||||
.section {
|
.section {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
max-width: 800px;
|
max-width: 850px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.indent {
|
.indent {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { preventEscape } from '../../../../common/utils/keyEvent';
|
|||||||
import { startsWithHttp } from '../../../../common/utils/regex';
|
import { startsWithHttp } from '../../../../common/utils/regex';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
|
import TemplateInput from './template-input/TemplateInput';
|
||||||
import { isAutomation, makeFieldList } from './automationUtils';
|
import { isAutomation, makeFieldList } from './automationUtils';
|
||||||
|
|
||||||
import style from './AutomationForm.module.scss';
|
import style from './AutomationForm.module.scss';
|
||||||
@@ -251,7 +252,7 @@ export default function AutomationForm(props: AutomationFormProps) {
|
|||||||
{...register(`filters.${index}.value`)}
|
{...register(`filters.${index}.value`)}
|
||||||
variant='ontime-filled'
|
variant='ontime-filled'
|
||||||
size='sm'
|
size='sm'
|
||||||
placeholder='<no value>'
|
placeholder='<empty / no value>'
|
||||||
autoComplete='off'
|
autoComplete='off'
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -357,7 +358,7 @@ export default function AutomationForm(props: AutomationFormProps) {
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Parameters
|
Parameters
|
||||||
<Input
|
<TemplateInput
|
||||||
{...register(`outputs.${index}.args`)}
|
{...register(`outputs.${index}.args`)}
|
||||||
variant='ontime-filled'
|
variant='ontime-filled'
|
||||||
size='sm'
|
size='sm'
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ export default function AutomationPanel({ location }: PanelBaseProps) {
|
|||||||
oscPortIn={data.oscPortIn}
|
oscPortIn={data.oscPortIn}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div ref={triggersRef}>
|
|
||||||
<TriggersList triggers={data.triggers} automations={data.automations} />
|
|
||||||
</div>
|
|
||||||
<div ref={automationsRef}>
|
<div ref={automationsRef}>
|
||||||
<AutomationsList automations={data.automations} />
|
<AutomationsList automations={data.automations} />
|
||||||
</div>
|
</div>
|
||||||
|
<div ref={triggersRef}>
|
||||||
|
<TriggersList triggers={data.triggers} automations={data.automations} />
|
||||||
|
</div>
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ const staticSelectProperties = [
|
|||||||
{ value: 'eventNow.colour', label: 'Colour' },
|
{ 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 = {
|
type SelectableField = {
|
||||||
value: string; // string encodes path in runtime state object
|
value: string; // string encodes path in runtime state object
|
||||||
label: string;
|
label: string;
|
||||||
@@ -48,6 +54,11 @@ export function makeFieldList(customFields: CustomFields): SelectableField[] {
|
|||||||
value: `eventNow.custom.${key}`,
|
value: `eventNow.custom.${key}`,
|
||||||
label: `Custom: ${label}`,
|
label: `Custom: ${label}`,
|
||||||
})),
|
})),
|
||||||
|
...staticNextSelectProperties,
|
||||||
|
...Object.entries(customFields).map(([key, { label }]) => ({
|
||||||
|
value: `eventNext.custom.${key}`,
|
||||||
|
label: `Next custom: ${label}`,
|
||||||
|
})),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+29
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+19
@@ -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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
+105
@@ -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 useSettings from '../../../../common/hooks-query/useSettings';
|
||||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||||
import { isOnlyNumbers } from '../../../../common/utils/regex';
|
import { isOnlyNumbers } from '../../../../common/utils/regex';
|
||||||
|
import { isOntimeCloud } from '../../../../externals';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import GeneralPinInput from './GeneralPinInput';
|
import GeneralPinInput from './GeneralPinInput';
|
||||||
@@ -25,6 +26,7 @@ export default function GeneralPanelForm() {
|
|||||||
setError,
|
setError,
|
||||||
formState: { isSubmitting, isDirty, isValid, errors },
|
formState: { isSubmitting, isDirty, isValid, errors },
|
||||||
} = useForm<Settings>({
|
} = useForm<Settings>({
|
||||||
|
mode: 'onChange',
|
||||||
defaultValues: data,
|
defaultValues: data,
|
||||||
values: data,
|
values: data,
|
||||||
resetOptions: {
|
resetOptions: {
|
||||||
@@ -94,7 +96,11 @@ export default function GeneralPanelForm() {
|
|||||||
<Panel.ListItem>
|
<Panel.ListItem>
|
||||||
<Panel.Field
|
<Panel.Field
|
||||||
title='Ontime server port'
|
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}
|
error={errors.serverPort?.message}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
@@ -104,6 +110,7 @@ export default function GeneralPanelForm() {
|
|||||||
variant='ontime-filled'
|
variant='ontime-filled'
|
||||||
maxLength={5}
|
maxLength={5}
|
||||||
width='75px'
|
width='75px'
|
||||||
|
isDisabled={isOntimeCloud}
|
||||||
{...register('serverPort', {
|
{...register('serverPort', {
|
||||||
required: { value: true, message: 'Required field' },
|
required: { value: true, message: 'Required field' },
|
||||||
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
|
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 { IoEyeOutline } from '@react-icons/all-files/io5/IoEyeOutline';
|
||||||
import { Settings } from 'ontime-types';
|
import { Settings } from 'ontime-types';
|
||||||
|
|
||||||
|
import { isAlphanumeric } from '../../../../common/utils/regex';
|
||||||
|
|
||||||
interface GeneralPinInputProps {
|
interface GeneralPinInputProps {
|
||||||
register: UseFormRegister<Settings>;
|
register: UseFormRegister<Settings>;
|
||||||
formName: keyof Settings;
|
formName: keyof Settings;
|
||||||
@@ -13,14 +15,18 @@ interface GeneralPinInputProps {
|
|||||||
export default function GeneralPinInput(props: PropsWithChildren<GeneralPinInputProps>) {
|
export default function GeneralPinInput(props: PropsWithChildren<GeneralPinInputProps>) {
|
||||||
const { register, formName, isDisabled } = props;
|
const { register, formName, isDisabled } = props;
|
||||||
const [isVisible, setVisible] = useState(false);
|
const [isVisible, setVisible] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InputGroup size='sm' width='100px'>
|
<InputGroup size='sm' width='100px'>
|
||||||
<Input
|
<Input
|
||||||
variant='ontime-filled'
|
variant='ontime-filled'
|
||||||
type={isVisible ? 'text' : 'password'}
|
type={isVisible ? 'text' : 'password'}
|
||||||
maxLength={4}
|
maxLength={4}
|
||||||
{...register(formName)}
|
{...register(formName, {
|
||||||
|
pattern: {
|
||||||
|
value: isAlphanumeric,
|
||||||
|
message: 'Only alphanumeric characters are allowed',
|
||||||
|
},
|
||||||
|
})}
|
||||||
placeholder='-'
|
placeholder='-'
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
|||||||
const handleConnect = async () => {
|
const handleConnect = async () => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
if (!sheetId) return;
|
if (!sheetId) return;
|
||||||
|
patchStepData({ worksheet: { available: false, error: '' } });
|
||||||
|
|
||||||
setLoading('connect');
|
setLoading('connect');
|
||||||
const result = await connect(file, sheetId);
|
const result = await connect(file, sheetId);
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ const staticOptions = [
|
|||||||
label: 'Automation',
|
label: 'Automation',
|
||||||
secondary: [
|
secondary: [
|
||||||
{ id: 'automation__settings', label: 'Automation settings' },
|
{ id: 'automation__settings', label: 'Automation settings' },
|
||||||
{ id: 'automation__triggers', label: 'Manage triggers' },
|
|
||||||
{ id: 'automation__automations', label: 'Manage automations' },
|
{ id: 'automation__automations', label: 'Manage automations' },
|
||||||
|
{ id: 'automation__triggers', label: 'Manage triggers' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
|||||||
import { IoClose } from '@react-icons/all-files/io5/IoClose';
|
import { IoClose } from '@react-icons/all-files/io5/IoClose';
|
||||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
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 { useElectronListener } from '../../common/hooks/useElectronEvent';
|
||||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||||
import AppSettings from '../app-settings/AppSettings';
|
import AppSettings from '../app-settings/AppSettings';
|
||||||
@@ -58,7 +58,7 @@ export default function Editor() {
|
|||||||
<div className={styles.mainContainer} data-testid='event-editor'>
|
<div className={styles.mainContainer} data-testid='event-editor'>
|
||||||
<WelcomePlacement />
|
<WelcomePlacement />
|
||||||
<Finder isOpen={isFinderOpen} onClose={onFinderClose} />
|
<Finder isOpen={isFinderOpen} onClose={onFinderClose} />
|
||||||
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
|
<NavigationMenu isOpen={isMenuOpen} onClose={onClose} />
|
||||||
<EditorOverview>
|
<EditorOverview>
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label='Toggle navigation'
|
aria-label='Toggle navigation'
|
||||||
|
|||||||
@@ -1,28 +1,12 @@
|
|||||||
import { useCallback } from 'react';
|
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
|
||||||
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 ProtectRoute from '../../common/components/protect-route/ProtectRoute';
|
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
|
||||||
|
|
||||||
import Operator from './Operator';
|
import Operator from './Operator';
|
||||||
|
|
||||||
export default function OperatorExport() {
|
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 (
|
return (
|
||||||
<ProtectRoute permission='operator'>
|
<ProtectRoute permission='operator'>
|
||||||
<FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
|
<ViewNavigationMenu isLockable />
|
||||||
<ProductionNavigationMenu isMenuOpen={isOpen} onMenuClose={onClose} />
|
|
||||||
<Operator />
|
<Operator />
|
||||||
</ProtectRoute>
|
</ProtectRoute>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
&.running {
|
&.running {
|
||||||
border-top: 1px solid $gray-1300;
|
border-top: 1px solid $gray-1300;
|
||||||
background-color: $active-red;
|
background-color: var(--operator-running-bg-override, $active-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.past {
|
&.past {
|
||||||
@@ -92,12 +92,14 @@
|
|||||||
|
|
||||||
.fields {
|
.fields {
|
||||||
grid-area: fields;
|
grid-area: fields;
|
||||||
font-size: 1.25rem;
|
font-size: var(--operator-customfield-font-size-override, 1.25rem);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: $ui-black;
|
color: $ui-black;
|
||||||
margin: 0.25rem 0;
|
margin: 0.25rem 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5em;
|
||||||
|
row-gap: 0.25em;
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -112,10 +114,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.value {
|
.value {
|
||||||
|
display: inline-flex;
|
||||||
padding-inline: 0.25rem;
|
padding-inline: 0.25rem;
|
||||||
background-color: $gray-1250;
|
background-color: $gray-1250;
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
margin-right: 1rem;
|
white-space: pre;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ViewNavigationMenu />
|
<ViewNavigationMenu isLockable />
|
||||||
<Component
|
<Component
|
||||||
{...props}
|
{...props}
|
||||||
auxTimer={auxtimer1}
|
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 {
|
sup.period {
|
||||||
top: -1em;
|
top: -1em;
|
||||||
font-size: 0.4em;
|
font-size: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subscript {
|
.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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
@@ -9,6 +9,12 @@ $viewer-transition-time: 0.5s;
|
|||||||
$viewer-font-family: 'Open Sans', 'Segoe UI', sans-serif; // --font-family-override
|
$viewer-font-family: 'Open Sans', 'Segoe UI', sans-serif; // --font-family-override
|
||||||
$viewer-opacity-disabled: 0.6;
|
$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
|
// General styling
|
||||||
$accent-color: $red-500; // --accent-color-override
|
$accent-color: $red-500; // --accent-color-override
|
||||||
$delay-color: $ontime-delay-text;
|
$delay-color: $ontime-delay-text;
|
||||||
@@ -23,16 +29,15 @@ $element-border-radius: 8px;
|
|||||||
|
|
||||||
// Generic element sizes
|
// Generic element sizes
|
||||||
$view-element-gap: min(2vh, 16px);
|
$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-outer-padding: min(2vh, 16px) clamp(16px, 2vw, 24px);
|
||||||
|
|
||||||
$view-card-padding: min(2vh, 8px) clamp(16px, 2vw, 24px);
|
$view-card-padding: min(2vh, 8px) clamp(16px, 2vw, 24px);
|
||||||
|
|
||||||
// Properties related to timer
|
// Properties related to timer
|
||||||
$timer-color: rgba(white, 80%); // --timer-color-override
|
$timer-color: rgba(white, 80%); // --timer-color-override
|
||||||
$timer-finished-color: $playback-negative;
|
$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
|
$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.stage_timer': 'Bühnen-Timer',
|
||||||
'common.started_at': 'Gestartet am',
|
'common.started_at': 'Gestartet am',
|
||||||
'common.time_now': 'Aktuelle Zeit',
|
'common.time_now': 'Aktuelle Zeit',
|
||||||
|
'common.no_data': 'Keine Daten',
|
||||||
'countdown.ended': 'Veranstaltung endete um',
|
'countdown.ended': 'Veranstaltung endete um',
|
||||||
'countdown.running': 'Veranstaltung läuft',
|
'countdown.running': 'Veranstaltung läuft',
|
||||||
'countdown.select_event': 'Wählen Sie eine Veranstaltung aus, um sie zu verfolgen',
|
'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.stage_timer': 'Stage Timer',
|
||||||
'common.started_at': 'Started At',
|
'common.started_at': 'Started At',
|
||||||
'common.time_now': 'Time now',
|
'common.time_now': 'Time now',
|
||||||
|
'common.no_data': 'No data',
|
||||||
'countdown.ended': 'Event ended at',
|
'countdown.ended': 'Event ended at',
|
||||||
'countdown.running': 'Event running',
|
'countdown.running': 'Event running',
|
||||||
'countdown.select_event': 'Select an event to follow',
|
'countdown.select_event': 'Select an event to follow',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const langEs: TranslationObject = {
|
|||||||
'common.stage_timer': 'Temporizador de presentador',
|
'common.stage_timer': 'Temporizador de presentador',
|
||||||
'common.started_at': 'Iniciado en',
|
'common.started_at': 'Iniciado en',
|
||||||
'common.time_now': 'Ahora',
|
'common.time_now': 'Ahora',
|
||||||
|
'common.no_data': 'Sin datos',
|
||||||
'countdown.ended': 'Evento finalizado a las',
|
'countdown.ended': 'Evento finalizado a las',
|
||||||
'countdown.running': 'Evento en curso',
|
'countdown.running': 'Evento en curso',
|
||||||
'countdown.select_event': 'Seleccionar un evento para seguir',
|
'countdown.select_event': 'Seleccionar un evento para seguir',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const langFr: TranslationObject = {
|
|||||||
'common.stage_timer': 'Minuteur de scène',
|
'common.stage_timer': 'Minuteur de scène',
|
||||||
'common.started_at': 'Commencé à',
|
'common.started_at': 'Commencé à',
|
||||||
'common.time_now': 'Heure',
|
'common.time_now': 'Heure',
|
||||||
|
'common.no_data': 'Aucune donnée',
|
||||||
'countdown.ended': 'Évènement terminé à',
|
'countdown.ended': 'Évènement terminé à',
|
||||||
'countdown.running': 'Évènement en cours',
|
'countdown.running': 'Évènement en cours',
|
||||||
'countdown.select_event': 'Sélectionnez un évènement à suivre',
|
'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.stage_timer': 'Színpadi időzítő',
|
||||||
'common.started_at': 'Kezdődött',
|
'common.started_at': 'Kezdődött',
|
||||||
'common.time_now': 'Jelenlegi idő',
|
'common.time_now': 'Jelenlegi idő',
|
||||||
|
'common.no_data': 'Nincsenek adatok',
|
||||||
'countdown.ended': 'Esemény véget ért',
|
'countdown.ended': 'Esemény véget ért',
|
||||||
'countdown.running': 'Esemény folyamatban',
|
'countdown.running': 'Esemény folyamatban',
|
||||||
'countdown.select_event': 'Válassza ki a követendő eseményt',
|
'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.stage_timer': 'Orologio Palco',
|
||||||
'common.started_at': 'Iniziato Alle',
|
'common.started_at': 'Iniziato Alle',
|
||||||
'common.time_now': 'Ora attuale',
|
'common.time_now': 'Ora attuale',
|
||||||
|
'common.no_data': 'Nessun dato disponibile',
|
||||||
'countdown.ended': 'Evento finito alle',
|
'countdown.ended': 'Evento finito alle',
|
||||||
'countdown.running': 'Evento in corso',
|
'countdown.running': 'Evento in corso',
|
||||||
'countdown.select_event': 'Seleziona un evento da seguire',
|
'countdown.select_event': 'Seleziona un evento da seguire',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const langNo: TranslationObject = {
|
|||||||
'common.stage_timer': 'Scenetimer',
|
'common.stage_timer': 'Scenetimer',
|
||||||
'common.started_at': 'Startet',
|
'common.started_at': 'Startet',
|
||||||
'common.time_now': 'Klokken nå',
|
'common.time_now': 'Klokken nå',
|
||||||
|
'common.no_data': 'Ingen data tilgjengelig',
|
||||||
'countdown.ended': 'Hendelse avsluttet',
|
'countdown.ended': 'Hendelse avsluttet',
|
||||||
'countdown.running': 'Hendelse pågår',
|
'countdown.running': 'Hendelse pågår',
|
||||||
'countdown.select_event': 'Velg en hendelse å følge',
|
'countdown.select_event': 'Velg en hendelse å følge',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const langPl: TranslationObject = {
|
|||||||
'common.stage_timer': 'Timer Scena',
|
'common.stage_timer': 'Timer Scena',
|
||||||
'common.started_at': 'Rozpoczęte o',
|
'common.started_at': 'Rozpoczęte o',
|
||||||
'common.time_now': 'Aktualny czas',
|
'common.time_now': 'Aktualny czas',
|
||||||
|
'common.no_data': 'Brak danych',
|
||||||
'countdown.ended': 'Zakończone o',
|
'countdown.ended': 'Zakończone o',
|
||||||
'countdown.running': 'Trwa',
|
'countdown.running': 'Trwa',
|
||||||
'countdown.select_event': 'Wybierz event który chcesz śledzić',
|
'countdown.select_event': 'Wybierz event który chcesz śledzić',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const langPt: TranslationObject = {
|
|||||||
'common.stage_timer': 'Temporizador do presentador',
|
'common.stage_timer': 'Temporizador do presentador',
|
||||||
'common.started_at': 'Iniciado em',
|
'common.started_at': 'Iniciado em',
|
||||||
'common.time_now': 'Hora atual',
|
'common.time_now': 'Hora atual',
|
||||||
|
'common.no_data': 'Sem dados',
|
||||||
'countdown.ended': 'Evento encerrado às',
|
'countdown.ended': 'Evento encerrado às',
|
||||||
'countdown.running': 'Evento em andamento',
|
'countdown.running': 'Evento em andamento',
|
||||||
'countdown.select_event': 'Selecione um evento para acompanhar',
|
'countdown.select_event': 'Selecione um evento para acompanhar',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const langSv: TranslationObject = {
|
|||||||
'common.stage_timer': 'Timer för scenen',
|
'common.stage_timer': 'Timer för scenen',
|
||||||
'common.started_at': 'Började vid',
|
'common.started_at': 'Började vid',
|
||||||
'common.time_now': 'Klockan nu',
|
'common.time_now': 'Klockan nu',
|
||||||
|
'common.no_data': 'Inga tillgängliga data',
|
||||||
'countdown.ended': 'Evenemanget avslutades vid',
|
'countdown.ended': 'Evenemanget avslutades vid',
|
||||||
'countdown.running': 'Evenemang pågår',
|
'countdown.running': 'Evenemang pågår',
|
||||||
'countdown.select_event': 'Välj ett evenemang att följa',
|
'countdown.select_event': 'Välj ett evenemang att följa',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const langZhCn: TranslationObject = {
|
|||||||
'common.stage_timer': '舞台计时器',
|
'common.stage_timer': '舞台计时器',
|
||||||
'common.started_at': '开始于',
|
'common.started_at': '开始于',
|
||||||
'common.time_now': '当前时间',
|
'common.time_now': '当前时间',
|
||||||
|
'common.no_data': '无数据',
|
||||||
'countdown.ended': '事件结束于',
|
'countdown.ended': '事件结束于',
|
||||||
'countdown.running': '事件进行中',
|
'countdown.running': '事件进行中',
|
||||||
'countdown.select_event': '选择要关注的事件',
|
'countdown.select_event': '选择要关注的事件',
|
||||||
|
|||||||
+113
-31
@@ -1,4 +1,4 @@
|
|||||||
@use '../../../theme/viewerDefs' as *;
|
@use '../../theme/viewerDefs' as *;
|
||||||
|
|
||||||
.backstage {
|
.backstage {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -10,23 +10,31 @@
|
|||||||
font-family: var(--font-family-override, $viewer-font-family);
|
font-family: var(--font-family-override, $viewer-font-family);
|
||||||
background: var(--background-color-override, $viewer-background-color);
|
background: var(--background-color-override, $viewer-background-color);
|
||||||
color: var(--color-override, $viewer-color);
|
color: var(--color-override, $viewer-color);
|
||||||
gap: min(2vh, 16px);
|
gap: $view-element-gap;
|
||||||
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
|
padding: $view-outer-padding;
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr 40vw;
|
grid-template-columns: 1fr 40vw;
|
||||||
grid-template-rows: auto 12px 1fr auto;
|
grid-template-rows: auto 12px 1fr auto;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
' header header header'
|
'header header'
|
||||||
' progress progress schedule-nav'
|
'progress schedule-nav'
|
||||||
' now now schedule'
|
'card schedule'
|
||||||
' info info schedule';
|
'info schedule';
|
||||||
|
|
||||||
|
.empty-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 25vh;
|
||||||
|
}
|
||||||
|
|
||||||
/* =================== HEADER + EXTRAS ===================*/
|
/* =================== HEADER + EXTRAS ===================*/
|
||||||
|
|
||||||
.project-header {
|
.project-header {
|
||||||
grid-area: header;
|
grid-area: header;
|
||||||
font-size: clamp(32px, 4.5vw, 64px);
|
font-size: $header-font-size;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -37,19 +45,22 @@
|
|||||||
max-height: min(100px, 20vh);
|
max-height: min(100px, 20vh);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
line-height: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
.clock-container {
|
.clock-container {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: clamp(16px, 1.5vw, 24px);
|
font-size: $timer-label-size;
|
||||||
font-weight: 600;
|
|
||||||
color: var(--label-color-override, $viewer-label-color);
|
color: var(--label-color-override, $viewer-label-color);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time {
|
.time {
|
||||||
font-size: clamp(32px, 3.5vw, 50px);
|
font-size: $timer-value-size;
|
||||||
font-weight: 600;
|
|
||||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
line-height: 0.95em;
|
line-height: 0.95em;
|
||||||
@@ -64,17 +75,27 @@
|
|||||||
margin: 0 auto -8px;
|
margin: 0 auto -8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.now-container {
|
.card-container {
|
||||||
grid-area: now;
|
grid-area: card;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.event {
|
||||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||||
padding: 16px 24px;
|
padding: $view-card-padding;
|
||||||
border-radius: 8px;
|
border-radius: $element-border-radius;
|
||||||
|
|
||||||
&.blink {
|
&.blink {
|
||||||
animation-name: blink;
|
animation-name: blink;
|
||||||
@@ -85,11 +106,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.timer-group {
|
.timer-group {
|
||||||
grid-area: timer;
|
|
||||||
border-top: 2px solid var(--background-color-override, $viewer-background-color);
|
border-top: 2px solid var(--background-color-override, $viewer-background-color);
|
||||||
margin-top: 2em;
|
margin-top: max(1vh, 16px);
|
||||||
padding-top: 1em;
|
padding-top: max(1vh, 16px);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
row-gap: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timer-gap {
|
.timer-gap {
|
||||||
@@ -98,58 +119,119 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.aux-timers {
|
.aux-timers {
|
||||||
font-size: max(1vw, 16px);
|
|
||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
|
font-size: $timer-label-size;
|
||||||
color: var(--label-color-override, $viewer-label-color);
|
color: var(--label-color-override, $viewer-label-color);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__value {
|
&__value {
|
||||||
|
font-size: $base-font-size;
|
||||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||||
font-size: clamp(24px, 2vw, 32px);
|
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
|
line-height: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--pending {
|
||||||
|
color: var(--timer-pending-color-override, $ontime-roll);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =================== MAIN - SCHEDULE ===================*/
|
/* =================== MAIN - SCHEDULE ===================*/
|
||||||
|
|
||||||
|
$schedule-left-spacing: clamp(16px, 4vw, 64px);
|
||||||
.schedule-container {
|
.schedule-container {
|
||||||
grid-area: schedule;
|
grid-area: schedule;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin-left: clamp(16px, 5vw, 64px);
|
padding-left: $schedule-left-spacing;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-nav-container {
|
.schedule-nav-container {
|
||||||
grid-area: schedule-nav;
|
grid-area: schedule-nav;
|
||||||
align-self: center;
|
padding-left: $schedule-left-spacing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =================== MAIN - INFO ===================*/
|
||||||
|
|
||||||
.info {
|
.info {
|
||||||
grid-area: info;
|
grid-area: info;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: max(1vw, 16px);
|
gap: max(1vw, 16px);
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
align-items: end;
|
||||||
|
|
||||||
&__message {
|
&__message {
|
||||||
font-size: clamp(16px, 1.5vw, 24px);
|
font-size: $base-font-size;
|
||||||
line-height: 1.3em;
|
line-height: 1.2em;
|
||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr {
|
.qr {
|
||||||
padding: 4px;
|
padding: 0.5rem;
|
||||||
background-color: white;
|
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 {
|
@keyframes blink {
|
||||||
0% {
|
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 { useCallback, useMemo, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
|
||||||
import { IconButton, Modal, ModalContent, ModalOverlay, useDisclosure } from '@chakra-ui/react';
|
import { IconButton, Modal, ModalContent, ModalOverlay, useDisclosure } from '@chakra-ui/react';
|
||||||
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
||||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
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 EmptyPage from '../../common/components/state/EmptyPage';
|
||||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||||
@@ -25,7 +25,7 @@ export default function CuesheetPage() {
|
|||||||
// TODO: can we use the normalised rundown for the table?
|
// TODO: can we use the normalised rundown for the table?
|
||||||
const { data: flatRundown } = useFlatRundown();
|
const { data: flatRundown } = useFlatRundown();
|
||||||
const { data: customFields } = useCustomFields();
|
const { data: customFields } = useCustomFields();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable: true });
|
||||||
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
|
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
|
||||||
const { isOpen: isEventEditorOpen, onOpen: onEventEditorOpen, onClose: onEventEditorClose } = useDisclosure();
|
const { isOpen: isEventEditorOpen, onOpen: onEventEditorOpen, onClose: onEventEditorClose } = useDisclosure();
|
||||||
const [eventId, setEventId] = useState<string | null>(null);
|
const [eventId, setEventId] = useState<string | null>(null);
|
||||||
@@ -34,12 +34,6 @@ export default function CuesheetPage() {
|
|||||||
|
|
||||||
useWindowTitle('Cuesheet');
|
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
|
* Handles setting the edit modal target and visibility
|
||||||
*/
|
*/
|
||||||
@@ -69,7 +63,7 @@ export default function CuesheetPage() {
|
|||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
||||||
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
|
<NavigationMenu isOpen={isMenuOpen} onClose={onClose} />
|
||||||
<ViewParamsEditor viewOptions={cuesheetOptions} />
|
<ViewParamsEditor viewOptions={cuesheetOptions} />
|
||||||
<CuesheetOverview>
|
<CuesheetOverview>
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -78,6 +72,7 @@ export default function CuesheetPage() {
|
|||||||
size='lg'
|
size='lg'
|
||||||
icon={<IoApps />}
|
icon={<IoApps />}
|
||||||
onClick={onOpen}
|
onClick={onOpen}
|
||||||
|
isDisabled={isViewLocked}
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label='Toggle settings'
|
aria-label='Toggle settings'
|
||||||
@@ -85,6 +80,7 @@ export default function CuesheetPage() {
|
|||||||
size='lg'
|
size='lg'
|
||||||
icon={<IoSettingsOutline />}
|
icon={<IoSettingsOutline />}
|
||||||
onClick={showEditFormDrawer}
|
onClick={showEditFormDrawer}
|
||||||
|
isDisabled={isViewLocked}
|
||||||
/>
|
/>
|
||||||
</CuesheetOverview>
|
</CuesheetOverview>
|
||||||
<CuesheetProgress />
|
<CuesheetProgress />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -198,8 +198,8 @@
|
|||||||
/* =================== LOGO ===================*/
|
/* =================== LOGO ===================*/
|
||||||
.logo {
|
.logo {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: min(2vh, 16px);
|
top: $view-block-padding;
|
||||||
left: min(2vw, 16px);
|
left: $view-inline-padding;
|
||||||
max-width: min(200px, 20vw);
|
max-width: min(200px, 20vw);
|
||||||
max-height: min(100px, 20vh);
|
max-height: min(100px, 20vh);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-electron",
|
"name": "ontime-electron",
|
||||||
"version": "3.11.0-beta.1",
|
"version": "3.11.1-beta.1",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "ontime-server",
|
"name": "ontime-server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"version": "3.11.0-beta.1",
|
"version": "3.11.1-beta.1",
|
||||||
"exports": "./src/index.js",
|
"exports": "./src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@googleapis/sheets": "^5.0.5",
|
"@googleapis/sheets": "^5.0.5",
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"lowdb": "^7.0.1",
|
"lowdb": "^7.0.1",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"ontime-utils": "workspace:*",
|
"ontime-utils": "workspace:*",
|
||||||
"osc-min": "^2.1.1",
|
"osc-min": "2.1.2",
|
||||||
"sanitize-filename": "^1.6.3",
|
"sanitize-filename": "^1.6.3",
|
||||||
"steno": "^4.0.2",
|
"steno": "^4.0.2",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
@@ -30,7 +30,6 @@
|
|||||||
"@types/express": "^4.17.17",
|
"@types/express": "^4.17.17",
|
||||||
"@types/multer": "^1.4.11",
|
"@types/multer": "^1.4.11",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/node-osc": "^6.0.3",
|
|
||||||
"@types/websocket": "^1.0.5",
|
"@types/websocket": "^1.0.5",
|
||||||
"@types/ws": "^8.5.10",
|
"@types/ws": "^8.5.10",
|
||||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||||
|
|||||||
@@ -20,17 +20,13 @@ class OscServer implements IAdapter {
|
|||||||
logger.info(LogOrigin.Rx, `OSC: Starting server on port ${port}`);
|
logger.info(LogOrigin.Rx, `OSC: Starting server on port ${port}`);
|
||||||
this.udpSocket = dgram.createSocket('udp4');
|
this.udpSocket = dgram.createSocket('udp4');
|
||||||
this.udpSocket.on('error', (error) => logger.error(LogOrigin.Rx, `OSC IN: ${error}`));
|
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
|
// message should look like /ontime/{command}/{params?} {args} where
|
||||||
// ontime: fixed message for app
|
// ontime: fixed message for app
|
||||||
// command: command to be called
|
// command: command to be called
|
||||||
// params: used to create a nested object to patch with
|
// params: used to create a nested object to patch with
|
||||||
// args: extra data, only used on some API entries
|
// 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);
|
const msg = fromBuffer(buf);
|
||||||
if (msg.oscType === 'bundle') {
|
if (msg.oscType === 'bundle') {
|
||||||
//TODO: manage bundles
|
//TODO: manage bundles
|
||||||
|
|||||||
@@ -33,11 +33,7 @@ function preparePayload(output: OSCOutput, state: RuntimeState): OscPacketInput
|
|||||||
function emit(targetIP: string, targetPort: number, packet: OscPacketInput) {
|
function emit(targetIP: string, targetPort: number, packet: OscPacketInput) {
|
||||||
logger.info(LogOrigin.Tx, `Sending OSC: ${targetIP}:${targetPort}`);
|
logger.info(LogOrigin.Tx, `Sending OSC: ${targetIP}:${targetPort}`);
|
||||||
|
|
||||||
/**
|
const buffer = oscPacketToBuffer(packet);
|
||||||
* TODO: remove this type casting when change is merged
|
|
||||||
* https://github.com/DefinitelyTyped/DefinitelyTyped/pull/71659
|
|
||||||
*/
|
|
||||||
const buffer = oscPacketToBuffer(packet) as unknown as Uint8Array;
|
|
||||||
udpClient.send(buffer, 0, buffer.byteLength, targetPort, targetIP, (error) => {
|
udpClient.send(buffer, 0, buffer.byteLength, targetPort, targetIP, (error) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
logger.warning(LogOrigin.Tx, `Failed sending OSC: ${error}`);
|
logger.warning(LogOrigin.Tx, `Failed sending OSC: ${error}`);
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export async function patchPartialProjectFile(req: Request, res: Response<Databa
|
|||||||
*/
|
*/
|
||||||
export async function createProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
|
export async function createProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
|
||||||
try {
|
try {
|
||||||
const newFileName = await projectService.createProject(req.body.filename, {
|
const newFileName = await projectService.createProject(req.body.filename || 'untitled', {
|
||||||
project: {
|
project: {
|
||||||
title: req.body?.title ?? '',
|
title: req.body?.title ?? '',
|
||||||
description: req.body?.description ?? '',
|
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>) {
|
export async function quickProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
|
||||||
try {
|
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({
|
res.status(200).send({
|
||||||
filename,
|
filename,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ensureJsonExtension } from '../../utils/fileManagement.js';
|
|||||||
* @description Validates request for a new project.
|
* @description Validates request for a new project.
|
||||||
*/
|
*/
|
||||||
export const validateNewProject = [
|
export const validateNewProject = [
|
||||||
|
body('filename').optional().isString().trim(),
|
||||||
body('title').optional().isString().trim(),
|
body('title').optional().isString().trim(),
|
||||||
body('description').optional().isString().trim(),
|
body('description').optional().isString().trim(),
|
||||||
body('publicUrl').optional().isString().trim(),
|
body('publicUrl').optional().isString().trim(),
|
||||||
@@ -132,8 +133,7 @@ export const validateFilenameParam = [
|
|||||||
.customSanitizer((input: string) => sanitize(input))
|
.customSanitizer((input: string) => sanitize(input))
|
||||||
.withMessage('Failed to sanitize the filename')
|
.withMessage('Failed to sanitize the filename')
|
||||||
.notEmpty()
|
.notEmpty()
|
||||||
.withMessage('Filename was empty or contained only invalid characters')
|
.withMessage('Filename was empty or contained only invalid characters'),
|
||||||
.customSanitizer((input: string) => ensureJsonExtension(input)),
|
|
||||||
|
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
appendToName,
|
appendToName,
|
||||||
dockerSafeRename,
|
dockerSafeRename,
|
||||||
ensureDirectory,
|
ensureDirectory,
|
||||||
|
ensureJsonExtension,
|
||||||
generateUniqueFileName,
|
generateUniqueFileName,
|
||||||
getFileNameFromPath,
|
getFileNameFromPath,
|
||||||
removeFileExtension,
|
removeFileExtension,
|
||||||
@@ -20,6 +21,7 @@ import { parseRundown } from '../../utils/parserFunctions.js';
|
|||||||
import { demoDb } from '../../models/demoProject.js';
|
import { demoDb } from '../../models/demoProject.js';
|
||||||
import { config } from '../../setup/config.js';
|
import { config } from '../../setup/config.js';
|
||||||
import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.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 { initRundown } from '../rundown-service/RundownService.js';
|
||||||
import {
|
import {
|
||||||
@@ -37,7 +39,6 @@ import {
|
|||||||
moveCorruptFile,
|
moveCorruptFile,
|
||||||
parseJsonFile,
|
parseJsonFile,
|
||||||
} from './projectServiceUtils.js';
|
} from './projectServiceUtils.js';
|
||||||
import { safeMerge } from '../../classes/data-provider/DataProvider.utils.js';
|
|
||||||
|
|
||||||
// init dependencies
|
// init dependencies
|
||||||
init();
|
init();
|
||||||
@@ -257,7 +258,8 @@ export async function renameProjectFile(originalFile: string, newFilename: strin
|
|||||||
export async function createProject(filename: string, initialData: Partial<DatabaseModel>) {
|
export async function createProject(filename: string, initialData: Partial<DatabaseModel>) {
|
||||||
const data = safeMerge(dbModel, initialData);
|
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);
|
const newFile = getPathToProject(uniqueFileName);
|
||||||
|
|
||||||
// change LowDB to point to new file
|
// change LowDB to point to new file
|
||||||
|
|||||||
@@ -208,6 +208,15 @@ async function verifySheet(
|
|||||||
});
|
});
|
||||||
return { worksheetOptions: spreadsheets.data.sheets.map((i) => i.properties.title) };
|
return { worksheetOptions: spreadsheets.data.sheets.map((i) => i.properties.title) };
|
||||||
} catch (error) {
|
} 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);
|
const errorMessage = getErrorMessage(error);
|
||||||
throw new Error(`Failed to verify sheet: ${errorMessage}`);
|
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 { 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';
|
import { initRundown } from '../../services/rundown-service/RundownService.js';
|
||||||
|
|
||||||
const mockEvent = {
|
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],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
PlayableEvent,
|
PlayableEvent,
|
||||||
Playback,
|
Playback,
|
||||||
Runtime,
|
Runtime,
|
||||||
|
runtimeStorePlaceholder,
|
||||||
TimerPhase,
|
TimerPhase,
|
||||||
TimerState,
|
TimerState,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
@@ -32,29 +33,6 @@ import {
|
|||||||
import { timerConfig } from '../config/config.js';
|
import { timerConfig } from '../config/config.js';
|
||||||
import { loadRoll, normaliseRollStart } from '../services/rollUtils.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 = {
|
export type RuntimeState = {
|
||||||
clock: number; // realtime clock
|
clock: number; // realtime clock
|
||||||
eventNow: PlayableEvent | null;
|
eventNow: PlayableEvent | null;
|
||||||
@@ -75,16 +53,13 @@ export type RuntimeState = {
|
|||||||
|
|
||||||
const runtimeState: RuntimeState = {
|
const runtimeState: RuntimeState = {
|
||||||
clock: clock.timeNow(),
|
clock: clock.timeNow(),
|
||||||
currentBlock: {
|
currentBlock: { ...runtimeStorePlaceholder.currentBlock },
|
||||||
block: null,
|
|
||||||
startedAt: null,
|
|
||||||
},
|
|
||||||
eventNow: null,
|
eventNow: null,
|
||||||
publicEventNow: null,
|
publicEventNow: null,
|
||||||
eventNext: null,
|
eventNext: null,
|
||||||
publicEventNext: null,
|
publicEventNext: null,
|
||||||
runtime: { ...initialRuntime },
|
runtime: { ...runtimeStorePlaceholder.runtime },
|
||||||
timer: { ...initialTimer },
|
timer: { ...runtimeStorePlaceholder.timer },
|
||||||
_timer: {
|
_timer: {
|
||||||
forceFinish: null,
|
forceFinish: null,
|
||||||
totalDelay: 0,
|
totalDelay: 0,
|
||||||
@@ -124,7 +99,7 @@ export function clear() {
|
|||||||
|
|
||||||
runtimeState.timer.playback = Playback.Stop;
|
runtimeState.timer.playback = Playback.Stop;
|
||||||
runtimeState.clock = clock.timeNow();
|
runtimeState.clock = clock.timeNow();
|
||||||
runtimeState.timer = { ...initialTimer };
|
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
|
||||||
|
|
||||||
// when clearing, we maintain the total delay from the rundown
|
// when clearing, we maintain the total delay from the rundown
|
||||||
runtimeState._timer.forceFinish = null;
|
runtimeState._timer.forceFinish = null;
|
||||||
@@ -492,6 +467,7 @@ export type UpdateResult = {
|
|||||||
|
|
||||||
export function update(): UpdateResult {
|
export function update(): UpdateResult {
|
||||||
// 0. there are some things we always do
|
// 0. there are some things we always do
|
||||||
|
const previousClock = runtimeState.clock;
|
||||||
runtimeState.clock = clock.timeNow(); // we update the clock on every update call
|
runtimeState.clock = clock.timeNow(); // we update the clock on every update call
|
||||||
|
|
||||||
// 1. is playback idle?
|
// 1. is playback idle?
|
||||||
@@ -501,7 +477,8 @@ export function update(): UpdateResult {
|
|||||||
|
|
||||||
// 2. are we waiting to roll?
|
// 2. are we waiting to roll?
|
||||||
if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) {
|
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
|
// 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 };
|
return { hasTimerFinished: false, hasSecondaryTimerFinished: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateIfWaitingToRoll() {
|
function updateIfWaitingToRoll(hasCrossedMidnight: boolean) {
|
||||||
// eslint-disable-next-line no-unused-labels -- dev code path
|
// eslint-disable-next-line no-unused-labels -- dev code path
|
||||||
DEV: {
|
DEV: {
|
||||||
if (runtimeState.eventNow === null || runtimeState._timer.secondaryTarget === null) {
|
if (runtimeState.eventNow === null || runtimeState._timer.secondaryTarget === null) {
|
||||||
throw new Error('runtimeState.updateIfWaitingToRoll: invalid state received');
|
throw new Error('runtimeState.updateIfWaitingToRoll: invalid state received');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
//account for offset
|
|
||||||
|
// account for offset
|
||||||
const offsetClock = runtimeState.clock + runtimeState.runtime.offset;
|
const offsetClock = runtimeState.clock + runtimeState.runtime.offset;
|
||||||
runtimeState.timer.phase = TimerPhase.Pending;
|
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;
|
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
|
||||||
return { hasTimerFinished: false, hasSecondaryTimerFinished: runtimeState.timer.secondaryTimer <= 0 };
|
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
|
// there is something to run, load event
|
||||||
|
|
||||||
|
// update runtime
|
||||||
|
if (runtimeState.currentBlock.startedAt === null) {
|
||||||
|
runtimeState.currentBlock.startedAt = runtimeState.clock;
|
||||||
|
}
|
||||||
|
|
||||||
// event will finish on time
|
// event will finish on time
|
||||||
// account for event that finishes the day after
|
// account for event that finishes the day after
|
||||||
const endTime =
|
const endTime =
|
||||||
@@ -686,26 +676,25 @@ export function roll(rundown: OntimeRundown, offset = 0): { eventId: MaybeString
|
|||||||
return { eventId: runtimeState.eventNow.id, didStart: true };
|
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
|
// we need a loaded event to have a block
|
||||||
runtimeState.currentBlock.block = null;
|
state.currentBlock.block = null;
|
||||||
runtimeState.currentBlock.startedAt = null;
|
state.currentBlock.startedAt = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newCurrentBlock = getPreviousBlock(rundown, runtimeState.eventNow.id);
|
const newCurrentBlock = getPreviousBlock(rundown, state.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;
|
|
||||||
|
|
||||||
// update time only if the block has changed
|
// update time only if the block has changed
|
||||||
if (formNoBlockToBlock || formBlockToNoBlock || formBlockToNewBlock) {
|
if (state.currentBlock.block?.id !== newCurrentBlock?.id) {
|
||||||
runtimeState.currentBlock.startedAt = null;
|
state.currentBlock.startedAt = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// update the block anyway
|
// update the block anyway
|
||||||
runtimeState.currentBlock.block = newCurrentBlock === null ? null : { ...newCurrentBlock };
|
state.currentBlock.block = newCurrentBlock === null ? null : { ...newCurrentBlock };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
--timer-warning-color-override: #ffbc56;
|
--timer-warning-color-override: #ffbc56;
|
||||||
--timer-danger-color-override: #e69000;
|
--timer-danger-color-override: #e69000;
|
||||||
--timer-overtime-color-override: #fa5656;
|
--timer-overtime-color-override: #fa5656;
|
||||||
|
--timer-pending-color-override: #578AF4;
|
||||||
|
|
||||||
/** Background for card elements on background */
|
/** Background for card elements on background */
|
||||||
--card-background-color-override: #fff;
|
--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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime",
|
"name": "ontime",
|
||||||
"version": "3.11.0-beta.1",
|
"version": "3.11.1-beta.1",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ontime",
|
"ontime",
|
||||||
|
|||||||
@@ -7,15 +7,15 @@ export const runtimeStorePlaceholder: RuntimeStore = {
|
|||||||
clock: 0,
|
clock: 0,
|
||||||
timer: {
|
timer: {
|
||||||
addedTime: 0,
|
addedTime: 0,
|
||||||
current: null,
|
current: null, // changes on every update
|
||||||
duration: null,
|
duration: null, // only changes if event changes
|
||||||
elapsed: null,
|
elapsed: null, // changes on every update
|
||||||
expectedFinish: null,
|
expectedFinish: null, // change can only be initiated by user, can roll over midnight
|
||||||
finishedAt: null,
|
finishedAt: null, // can change on update or user action
|
||||||
phase: TimerPhase.None,
|
phase: TimerPhase.None, // can change on update or user action
|
||||||
playback: Playback.Stop,
|
playback: Playback.Stop, // change initiated by user
|
||||||
secondaryTimer: null,
|
secondaryTimer: null, // change on every update
|
||||||
startedAt: null,
|
startedAt: null, // change can only be initiated by user
|
||||||
},
|
},
|
||||||
onAir: false,
|
onAir: false,
|
||||||
message: {
|
message: {
|
||||||
@@ -29,13 +29,13 @@ export const runtimeStorePlaceholder: RuntimeStore = {
|
|||||||
external: '',
|
external: '',
|
||||||
},
|
},
|
||||||
runtime: {
|
runtime: {
|
||||||
selectedEventIndex: null,
|
selectedEventIndex: null, // changes if rundown changes or we load a new event
|
||||||
numEvents: 0,
|
numEvents: 0, // change initiated by user
|
||||||
offset: 0,
|
offset: 0, // changes at runtime
|
||||||
plannedStart: 0,
|
plannedStart: 0, // only changes if event changes
|
||||||
plannedEnd: 0,
|
plannedEnd: 0, // only changes if event changes, overflows over dayInMs
|
||||||
actualStart: null,
|
actualStart: null, // set once we start the timer
|
||||||
expectedEnd: null,
|
expectedEnd: null, // changes with runtime, based on offset, overflows over dayInMs
|
||||||
},
|
},
|
||||||
currentBlock: {
|
currentBlock: {
|
||||||
block: null,
|
block: null,
|
||||||
|
|||||||
Generated
+148
-59
@@ -6,6 +6,9 @@ settings:
|
|||||||
|
|
||||||
catalogs:
|
catalogs:
|
||||||
default:
|
default:
|
||||||
|
'@types/node':
|
||||||
|
specifier: 20.17.16
|
||||||
|
version: 20.17.16
|
||||||
'@typescript-eslint/eslint-plugin':
|
'@typescript-eslint/eslint-plugin':
|
||||||
specifier: 7.16.1
|
specifier: 7.16.1
|
||||||
version: 7.16.1
|
version: 7.16.1
|
||||||
@@ -15,6 +18,9 @@ catalogs:
|
|||||||
eslint:
|
eslint:
|
||||||
specifier: 8.56.0
|
specifier: 8.56.0
|
||||||
version: 8.56.0
|
version: 8.56.0
|
||||||
|
eslint-config-prettier:
|
||||||
|
specifier: 9.1.0
|
||||||
|
version: 9.1.0
|
||||||
eslint-plugin-prettier:
|
eslint-plugin-prettier:
|
||||||
specifier: 5.1.3
|
specifier: 5.1.3
|
||||||
version: 5.1.3
|
version: 5.1.3
|
||||||
@@ -37,7 +43,7 @@ importers:
|
|||||||
version: 1.49.1
|
version: 1.49.1
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 20.14.10
|
version: 20.17.16
|
||||||
'@typescript-eslint/eslint-plugin':
|
'@typescript-eslint/eslint-plugin':
|
||||||
specifier: 'catalog:'
|
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)
|
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)
|
version: 7.16.1(eslint@8.56.0)(typescript@5.5.3)
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^4.2.1
|
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:
|
eslint:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 8.56.0
|
version: 8.56.0
|
||||||
@@ -230,19 +236,19 @@ importers:
|
|||||||
version: 5.5.3
|
version: 5.5.3
|
||||||
vite:
|
vite:
|
||||||
specifier: ^5.2.11
|
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:
|
vite-plugin-compression2:
|
||||||
specifier: ^1.3.3
|
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:
|
vite-plugin-svgr:
|
||||||
specifier: ^4.2.0
|
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:
|
vite-tsconfig-paths:
|
||||||
specifier: ^4.3.1
|
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:
|
vitest:
|
||||||
specifier: 'catalog:'
|
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:
|
apps/electron:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
@@ -310,8 +316,8 @@ importers:
|
|||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/utils
|
version: link:../../packages/utils
|
||||||
osc-min:
|
osc-min:
|
||||||
specifier: ^2.1.1
|
specifier: 2.1.2
|
||||||
version: 2.1.1
|
version: 2.1.2
|
||||||
sanitize-filename:
|
sanitize-filename:
|
||||||
specifier: ^1.6.3
|
specifier: ^1.6.3
|
||||||
version: 1.6.3
|
version: 1.6.3
|
||||||
@@ -336,10 +342,7 @@ importers:
|
|||||||
version: 1.4.11
|
version: 1.4.11
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 20.14.10
|
version: 20.17.16
|
||||||
'@types/node-osc':
|
|
||||||
specifier: ^6.0.3
|
|
||||||
version: 6.0.3
|
|
||||||
'@types/websocket':
|
'@types/websocket':
|
||||||
specifier: ^1.0.5
|
specifier: ^1.0.5
|
||||||
version: 1.0.5
|
version: 1.0.5
|
||||||
@@ -384,7 +387,7 @@ importers:
|
|||||||
version: 5.5.3
|
version: 5.5.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: 'catalog:'
|
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:
|
packages/types:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
@@ -439,7 +442,7 @@ importers:
|
|||||||
version: 5.5.3
|
version: 5.5.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: 'catalog:'
|
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:
|
packages:
|
||||||
|
|
||||||
@@ -2055,12 +2058,15 @@ packages:
|
|||||||
'@types/multer@1.4.11':
|
'@types/multer@1.4.11':
|
||||||
resolution: {integrity: sha512-svK240gr6LVWvv3YGyhLlA+6LRRWA4mnGIU7RcNmgjBYFl6665wcXrRfxGp5tEPVHUNm5FMcmq7too9bxCwX/w==}
|
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':
|
'@types/node@20.14.10':
|
||||||
resolution: {integrity: sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==}
|
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':
|
'@types/parse-json@4.0.0':
|
||||||
resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==}
|
resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==}
|
||||||
|
|
||||||
@@ -3977,8 +3983,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==}
|
resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
osc-min@2.1.1:
|
osc-min@2.1.2:
|
||||||
resolution: {integrity: sha512-3Ai9vP8AAh1sCh5Zm8BUybfulv5OvqpTVa2BcUoTrLeD1Ss3cohIQpF1f18gx0CTbi9Dv4SvtdMb7y4g++7lYA==}
|
resolution: {integrity: sha512-dEhg7wBJ+QTHqY4iwnctkaFWzXLq+yHA2olxYfnxBw1fMy/iWJ/b7ovfXfwc/yxLXGiUHQhTxlQSHzL8ueHz6Q==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
p-cancelable@2.1.1:
|
p-cancelable@2.1.1:
|
||||||
@@ -4756,6 +4762,12 @@ packages:
|
|||||||
undici-types@5.26.5:
|
undici-types@5.26.5:
|
||||||
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
|
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:
|
universalify@0.1.2:
|
||||||
resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==}
|
resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==}
|
||||||
engines: {node: '>= 4.0.0'}
|
engines: {node: '>= 4.0.0'}
|
||||||
@@ -6717,13 +6729,13 @@ snapshots:
|
|||||||
'@types/body-parser@1.19.2':
|
'@types/body-parser@1.19.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/connect': 3.4.35
|
'@types/connect': 3.4.35
|
||||||
'@types/node': 20.14.10
|
'@types/node': 20.17.16
|
||||||
|
|
||||||
'@types/cacheable-request@6.0.3':
|
'@types/cacheable-request@6.0.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/http-cache-semantics': 4.0.4
|
'@types/http-cache-semantics': 4.0.4
|
||||||
'@types/keyv': 3.1.4
|
'@types/keyv': 3.1.4
|
||||||
'@types/node': 20.14.10
|
'@types/node': 22.10.10
|
||||||
'@types/responselike': 1.0.3
|
'@types/responselike': 1.0.3
|
||||||
|
|
||||||
'@types/color-convert@2.0.0':
|
'@types/color-convert@2.0.0':
|
||||||
@@ -6738,11 +6750,11 @@ snapshots:
|
|||||||
|
|
||||||
'@types/connect@3.4.35':
|
'@types/connect@3.4.35':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.14.10
|
'@types/node': 20.17.16
|
||||||
|
|
||||||
'@types/cors@2.8.17':
|
'@types/cors@2.8.17':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.14.10
|
'@types/node': 20.17.16
|
||||||
|
|
||||||
'@types/debug@4.1.12':
|
'@types/debug@4.1.12':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -6752,7 +6764,7 @@ snapshots:
|
|||||||
|
|
||||||
'@types/express-serve-static-core@4.17.33':
|
'@types/express-serve-static-core@4.17.33':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.14.10
|
'@types/node': 20.17.16
|
||||||
'@types/qs': 6.9.7
|
'@types/qs': 6.9.7
|
||||||
'@types/range-parser': 1.2.4
|
'@types/range-parser': 1.2.4
|
||||||
|
|
||||||
@@ -6765,7 +6777,7 @@ snapshots:
|
|||||||
|
|
||||||
'@types/fs-extra@9.0.13':
|
'@types/fs-extra@9.0.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.14.10
|
'@types/node': 22.10.10
|
||||||
|
|
||||||
'@types/http-cache-semantics@4.0.4': {}
|
'@types/http-cache-semantics@4.0.4': {}
|
||||||
|
|
||||||
@@ -6773,7 +6785,7 @@ snapshots:
|
|||||||
|
|
||||||
'@types/keyv@3.1.4':
|
'@types/keyv@3.1.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.14.10
|
'@types/node': 22.10.10
|
||||||
|
|
||||||
'@types/lodash.mergewith@4.6.7':
|
'@types/lodash.mergewith@4.6.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -6789,19 +6801,23 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/express': 4.17.17
|
'@types/express': 4.17.17
|
||||||
|
|
||||||
'@types/node-osc@6.0.3':
|
|
||||||
dependencies:
|
|
||||||
'@types/node': 20.14.10
|
|
||||||
|
|
||||||
'@types/node@20.14.10':
|
'@types/node@20.14.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 5.26.5
|
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/parse-json@4.0.0': {}
|
||||||
|
|
||||||
'@types/plist@3.0.5':
|
'@types/plist@3.0.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.14.10
|
'@types/node': 22.10.10
|
||||||
xmlbuilder: 15.1.1
|
xmlbuilder: 15.1.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -6823,7 +6839,7 @@ snapshots:
|
|||||||
|
|
||||||
'@types/responselike@1.0.3':
|
'@types/responselike@1.0.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.14.10
|
'@types/node': 22.10.10
|
||||||
|
|
||||||
'@types/scheduler@0.16.2': {}
|
'@types/scheduler@0.16.2': {}
|
||||||
|
|
||||||
@@ -6832,22 +6848,22 @@ snapshots:
|
|||||||
'@types/serve-static@1.15.0':
|
'@types/serve-static@1.15.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/mime': 3.0.1
|
'@types/mime': 3.0.1
|
||||||
'@types/node': 20.14.10
|
'@types/node': 20.17.16
|
||||||
|
|
||||||
'@types/verror@1.10.9':
|
'@types/verror@1.10.9':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@types/websocket@1.0.5':
|
'@types/websocket@1.0.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.14.10
|
'@types/node': 20.17.16
|
||||||
|
|
||||||
'@types/ws@8.5.10':
|
'@types/ws@8.5.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.14.10
|
'@types/node': 20.17.16
|
||||||
|
|
||||||
'@types/yauzl@2.10.3':
|
'@types/yauzl@2.10.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.14.10
|
'@types/node': 22.10.10
|
||||||
optional: true
|
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)':
|
'@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': {}
|
'@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:
|
dependencies:
|
||||||
'@babel/core': 7.23.6
|
'@babel/core': 7.23.6
|
||||||
'@babel/plugin-transform-react-jsx-self': 7.23.3(@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)
|
'@babel/plugin-transform-react-jsx-source': 7.23.3(@babel/core@7.23.6)
|
||||||
'@types/babel__core': 7.20.5
|
'@types/babel__core': 7.20.5
|
||||||
react-refresh: 0.14.0
|
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:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -6992,13 +7008,21 @@ snapshots:
|
|||||||
chai: 5.1.2
|
chai: 5.1.2
|
||||||
tinyrainbow: 1.2.0
|
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:
|
dependencies:
|
||||||
'@vitest/spy': 2.1.6
|
'@vitest/spy': 2.1.6
|
||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
magic-string: 0.30.14
|
magic-string: 0.30.14
|
||||||
optionalDependencies:
|
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':
|
'@vitest/pretty-format@2.1.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -9106,7 +9130,7 @@ snapshots:
|
|||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
type-check: 0.4.0
|
type-check: 0.4.0
|
||||||
|
|
||||||
osc-min@2.1.1: {}
|
osc-min@2.1.2: {}
|
||||||
|
|
||||||
p-cancelable@2.1.1: {}
|
p-cancelable@2.1.1: {}
|
||||||
|
|
||||||
@@ -9896,6 +9920,10 @@ snapshots:
|
|||||||
|
|
||||||
undici-types@5.26.5: {}
|
undici-types@5.26.5: {}
|
||||||
|
|
||||||
|
undici-types@6.19.8: {}
|
||||||
|
|
||||||
|
undici-types@6.20.0: {}
|
||||||
|
|
||||||
universalify@0.1.2: {}
|
universalify@0.1.2: {}
|
||||||
|
|
||||||
universalify@0.2.0:
|
universalify@0.2.0:
|
||||||
@@ -9968,13 +9996,13 @@ snapshots:
|
|||||||
extsprintf: 1.4.1
|
extsprintf: 1.4.1
|
||||||
optional: true
|
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:
|
dependencies:
|
||||||
cac: 6.7.14
|
cac: 6.7.14
|
||||||
debug: 4.3.7
|
debug: 4.3.7
|
||||||
es-module-lexer: 1.5.4
|
es-module-lexer: 1.5.4
|
||||||
pathe: 1.1.2
|
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:
|
transitivePeerDependencies:
|
||||||
- '@types/node'
|
- '@types/node'
|
||||||
- less
|
- less
|
||||||
@@ -9985,50 +10013,77 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- terser
|
- 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:
|
dependencies:
|
||||||
'@rollup/pluginutils': 5.1.0(rollup@4.17.2)
|
'@rollup/pluginutils': 5.1.0(rollup@4.17.2)
|
||||||
tar-mini: 0.2.0
|
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:
|
transitivePeerDependencies:
|
||||||
- rollup
|
- 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:
|
dependencies:
|
||||||
'@rollup/pluginutils': 5.1.0(rollup@4.17.2)
|
'@rollup/pluginutils': 5.1.0(rollup@4.17.2)
|
||||||
'@svgr/core': 8.1.0(typescript@5.5.3)
|
'@svgr/core': 8.1.0(typescript@5.5.3)
|
||||||
'@svgr/plugin-jsx': 8.1.0(@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:
|
transitivePeerDependencies:
|
||||||
- rollup
|
- rollup
|
||||||
- supports-color
|
- supports-color
|
||||||
- typescript
|
- 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:
|
dependencies:
|
||||||
debug: 4.3.7
|
debug: 4.3.7
|
||||||
globrex: 0.1.2
|
globrex: 0.1.2
|
||||||
tsconfck: 3.0.2(typescript@5.5.3)
|
tsconfck: 3.0.2(typescript@5.5.3)
|
||||||
optionalDependencies:
|
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:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
- typescript
|
- 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:
|
dependencies:
|
||||||
esbuild: 0.20.2
|
esbuild: 0.20.2
|
||||||
postcss: 8.4.38
|
postcss: 8.4.38
|
||||||
rollup: 4.17.2
|
rollup: 4.17.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 20.14.10
|
'@types/node': 20.17.16
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
sass: 1.57.1
|
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:
|
dependencies:
|
||||||
'@vitest/expect': 2.1.6
|
'@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/pretty-format': 2.1.6
|
||||||
'@vitest/runner': 2.1.6
|
'@vitest/runner': 2.1.6
|
||||||
'@vitest/snapshot': 2.1.6
|
'@vitest/snapshot': 2.1.6
|
||||||
@@ -10044,11 +10099,47 @@ snapshots:
|
|||||||
tinyexec: 0.3.1
|
tinyexec: 0.3.1
|
||||||
tinypool: 1.0.2
|
tinypool: 1.0.2
|
||||||
tinyrainbow: 1.2.0
|
tinyrainbow: 1.2.0
|
||||||
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)
|
||||||
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)
|
||||||
why-is-node-running: 2.3.0
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
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
|
happy-dom: 16.7.2
|
||||||
jsdom: 21.1.0
|
jsdom: 21.1.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -10145,8 +10236,6 @@ snapshots:
|
|||||||
|
|
||||||
wrappy@1.0.2: {}
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
ws@8.13.0: {}
|
|
||||||
|
|
||||||
ws@8.18.0: {}
|
ws@8.18.0: {}
|
||||||
|
|
||||||
xlsx@0.18.5:
|
xlsx@0.18.5:
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ catalog:
|
|||||||
typescript: 5.5.3
|
typescript: 5.5.3
|
||||||
"@typescript-eslint/eslint-plugin": 7.16.1
|
"@typescript-eslint/eslint-plugin": 7.16.1
|
||||||
"@typescript-eslint/parser": 7.16.1
|
"@typescript-eslint/parser": 7.16.1
|
||||||
"@types/node": 20.14.10
|
"@types/node": 20.17.16
|
||||||
prettier: 3.3.1
|
prettier: 3.3.1
|
||||||
eslint: 8.56.0
|
eslint: 8.56.0
|
||||||
eslint-config-prettier: 9.1.0
|
eslint-config-prettier: 9.1.0
|
||||||
|
|||||||
Reference in New Issue
Block a user