Compare commits

..

41 Commits

Author SHA1 Message Date
cv 1b867b0dbc chore: update tests 2023-09-29 22:23:16 +02:00
cv 34b79e7699 chore: update tests 2023-09-29 22:21:15 +02:00
cv 4498a04a34 refactor: align import map to demo 2023-09-29 22:16:39 +02:00
cv d9df866308 refactor: enforce integers in numbers 2023-09-29 22:10:40 +02:00
cv 8146d88765 refactor: force refetch after mutation 2023-09-29 21:56:15 +02:00
cv d5cb2735ec Merge remote-tracking branch 'origin/master' into excel-import 2023-09-29 21:53:13 +02:00
cv 9b12afe2a6 refactor: align import map to demo 2023-09-29 21:40:22 +02:00
cv e10c0e8c97 refactor: typescript improvements 2023-09-29 21:39:50 +02:00
cv 4e9d9fc075 refactor: parse time fields 2023-09-29 21:12:40 +02:00
cv 45ece13d04 refactor: g-sheet time is object 2023-09-29 21:12:27 +02:00
cv e5e5798272 refactor: update type 2023-09-29 21:12:01 +02:00
cv cf7f6bdbf7 feat: resolve times on excel import, refs #508 2023-09-29 20:19:29 +02:00
cv a0d4f40bec refactor: small cleanups and type improvement 2023-09-28 08:15:54 +02:00
cv ba5ef6668d refactor: parse import fields 2023-09-28 08:15:24 +02:00
cv 9fc04f2fd1 chore: create feature endpoints 2023-09-27 15:09:48 +02:00
cv 13d72dd1a4 style: small presentation tweaks 2023-09-27 15:08:14 +02:00
cv 30905757f3 chore: increase size limits on uploads 2023-09-27 14:31:14 +02:00
cv e09a7d99f0 refactor: handle unexpected data types 2023-09-27 12:16:39 +02:00
cv 52ca04e063 chore: create import map utilities 2023-09-27 12:15:50 +02:00
cv b8188c7485 chore: add external deepmerge utility 2023-09-27 12:14:04 +02:00
cv 85172ae8d7 temp: handle inconsistent errors from server 2023-09-27 09:56:24 +02:00
cv cb871a8c26 refactor: simplify handling of notification 2023-09-27 09:55:18 +02:00
cv 88a853c158 refactor: convert to typescript 2023-09-17 20:55:23 +02:00
cv 99cd9bf0b7 refactor: simplify handling axios errors 2023-09-17 20:49:02 +02:00
cv 29fd145bd9 wip: create upload UI components 2023-09-17 20:48:27 +02:00
Carlos Valente e2aabe3646 chore: version bump (#528) 2023-09-17 15:04:55 +02:00
cv f6440a503f chore: upgrade deps 2023-09-17 13:16:46 +02:00
Carlos Valente 7545f6f7d0 refactor: clarify description import (#526) 2023-09-16 15:24:23 +02:00
cv aa0d4103b1 style: tweaks on components 2023-09-16 14:09:49 +02:00
cv 12b051dadb refactor: rename utilities file 2023-09-16 14:09:17 +02:00
Carlos Valente 958c68b6f6 style hover (#525)
* refactor: render if in view

* style: add hover indicator on row
2023-09-16 13:49:12 +02:00
Carlos Valente a0c5375376 refactor: project data (#523) 2023-09-15 17:33:30 +02:00
Carlos Valente 9708f0bfc6 refactor: pass events instead of derived data to titles (#493)
* refactor: pass events instead of derived data to titles
2023-09-14 22:30:23 +02:00
Carlos Valente 13eca98133 refactor: recompose and memoise (#522) 2023-09-14 21:06:48 +02:00
Carlos Valente 126e31403e style tweaks (#524)
* style: allow 2 rows in block title

* fix: accidental change to next and progress bar
2023-09-13 21:51:24 +02:00
Carlos Valente 53beea2768 refactor: prevent calling undefined (#521) 2023-09-13 21:24:49 +02:00
Carlos Valente 0bb09dd039 fix: sanitise cue import (#519)
* fix: sanitise cue import
2023-09-11 21:06:32 +02:00
Carlos Valente 93fb48ea1c feat: operator view (#440)
* feat: operator view

* chore: smoke test operator

* refactor: small improvements from deepsource

---------

Co-authored-by: arihanv <arihanvaranasi@gmail.com>
Co-authored-by: arihanv <63890951+arihanv@users.noreply.github.com>
2023-09-11 21:03:11 +02:00
Carlos Valente 1de3e01216 style: localise labels to user device (#509) 2023-09-03 09:13:56 +02:00
Carlos Valente 8d427a6bbe fix: allow spaces in title input (#504) 2023-09-01 23:26:59 +02:00
Carlos Valente 23dcca5527 fix: issue with resolving dev environment (#501)
* fix: issue with resolving dev environment

* fix: revert accidental commtit

* chore: version bump
2023-08-31 22:08:18 +02:00
160 changed files with 3668 additions and 1597 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "2.7.0",
"version": "2.9.0",
"private": true,
"dependencies": {
"@chakra-ui/react": "^2.7.0",
+4
View File
@@ -6,6 +6,7 @@ import withData from './features/viewers/ViewWrapper';
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
const Operator = lazy(() => import('./features/operator/Operator'));
const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
@@ -58,6 +59,9 @@ export default function AppRouter() {
{/*/!* Lower cannot have fallback *!/*/}
<Route path='/lower' element={<SLowerThird />} />
<Route path='/op' element={<Operator />} />
<Route path='/operator' element={<Operator />} />
{/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<Cuesheet />} />
+4 -3
View File
@@ -1,5 +1,5 @@
// REST stuff
export const EVENT_DATA = ['eventdata'];
export const PROJECT_DATA = ['project'];
export const ALIASES = ['aliases'];
export const USERFIELDS = ['userFields'];
export const RUNDOWN_TABLE_KEY = 'rundown';
@@ -12,13 +12,14 @@ export const RUNTIME = ['runtimeStore'];
const location = window.location;
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
export const isProduction = import.meta.env.MODE === 'production';
const STATIC_PORT = 4001;
export const serverPort = import.meta.env.DEV ? STATIC_PORT : location.port;
export const serverPort = isProduction ? location.port : STATIC_PORT;
export const serverURL = `${location.protocol}//${location.hostname}:${serverPort}`;
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
export const eventURL = `${serverURL}/eventdata`;
export const projectDataURL = `${serverURL}/project`;
export const rundownURL = `${serverURL}/events`;
export const ontimeURL = `${serverURL}/ontime`;
+30 -5
View File
@@ -2,18 +2,30 @@ import axios, { AxiosError } from 'axios';
import { LogLevel } from 'ontime-types';
import { generateId, millisToString } from 'ontime-utils';
import { ontimeQueryClient } from '../queryClient';
import { addLog } from '../stores/logger';
import { nowInMillis } from '../utils/time';
export function logAxiosError(prepend: string, error: unknown) {
let message;
export function maybeAxiosError(error: unknown) {
if (axios.isAxiosError(error)) {
const statusText = (error as AxiosError).response?.statusText ?? '';
const data = (error as AxiosError).response?.data ?? '';
message = `${prepend} ${statusText}: ${data}`;
let data = (error as AxiosError).response?.data ?? '';
if (typeof data === 'object') {
// TODO: use error instead, when migrated
if ('message' in data) {
data = JSON.stringify(data.message);
} else {
data = JSON.stringify(data);
}
}
return `${statusText}: ${data}`;
} else {
message = `${prepend}: ${error}`;
return error as string;
}
}
export function logAxiosError(prepend: string, error: unknown) {
const message = `${prepend}: ${maybeAxiosError(error)}`;
addLog({
id: generateId(),
@@ -23,3 +35,16 @@ export function logAxiosError(prepend: string, error: unknown) {
text: message,
});
}
export async function invalidateAllCaches() {
await ontimeQueryClient.invalidateQueries([
'project',
'aliases',
'userFields',
'rundown',
'appinfo',
'oscSettings',
'appSettings',
'viewSettings',
]);
}
@@ -1,21 +0,0 @@
import axios from 'axios';
import { EventData } from 'ontime-types';
import { eventURL } from './apiConstants';
/**
* @description HTTP request to fetch event data
* @return {Promise}
*/
export async function fetchEventData(): Promise<EventData> {
const res = await axios.get(eventURL);
return res.data;
}
/**
* @description HTTP request to mutate event data
* @return {Promise}
*/
export async function postEventData(data: EventData) {
return axios.post(eventURL, data);
}
+69 -8
View File
@@ -1,5 +1,16 @@
import axios from 'axios';
import { Alias, EventData, OSCSettings, OscSubscription, Settings, UserFields, ViewSettings } from 'ontime-types';
import axios, { AxiosResponse } from 'axios';
import {
Alias,
DatabaseModel,
OntimeRundown,
OSCSettings,
OscSubscription,
ProjectData,
Settings,
UserFields,
ViewSettings,
} from 'ontime-types';
import { ExcelImportMap } from 'ontime-utils';
import { apiRepoLatest } from '../../externals';
import { InfoType } from '../models/Info';
@@ -137,17 +148,26 @@ export const downloadRundown = async () => {
});
};
// TODO: should this be extracted to shared code?
export type ProjectFileImportOptions = {
onlyRundown: boolean;
};
/**
* @description HTTP request to upload events db
* @return {Promise}
*/
type UploadDataOptions = {
onlyRundown?: boolean;
};
export const uploadData = async (file: File, setProgress: (value: number) => void, options?: UploadDataOptions) => {
export const uploadProjectFile = async (
file: File,
setProgress: (value: number) => void,
options?: Partial<ProjectFileImportOptions>,
) => {
const formData = new FormData();
formData.append('userFile', file);
const onlyRundown = options?.onlyRundown || 'false';
const onlyRundown = Boolean(options?.onlyRundown);
console.log('debug here', onlyRundown, options);
await axios
.post(`${ontimeURL}/db?onlyRundown=${onlyRundown}`, formData, {
headers: {
@@ -161,6 +181,47 @@ export const uploadData = async (file: File, setProgress: (value: number) => voi
.then((response) => response.data.id);
};
/**
* @description Make patch changes to the objects in the db
* @return {Promise}
*/
export async function patchData(patchDb: Partial<DatabaseModel>) {
const response = await axios.patch(`${ontimeURL}/db`, patchDb);
return response;
}
type PostPreviewExcelResponse = {
rundown: OntimeRundown;
project: ProjectData;
userFields: UserFields;
};
/**
* @description Make patch changes to the objects in the db
* @return {Promise} - returns parsed rundown and userfields
*/
export async function postPreviewExcel(file: File, setProgress: (value: number) => void, options?: ExcelImportMap) {
const formData = new FormData();
formData.append('userFile', file);
formData.append('options', JSON.stringify(options));
const response: AxiosResponse<PostPreviewExcelResponse> = await axios.post(
`${ontimeURL}/preview-spreadsheet`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
const complete = progressEvent?.total ? Math.round((progressEvent.loaded * 100) / progressEvent.total) : 0;
setProgress(complete);
},
},
);
return response;
}
export type HasUpdate = {
url: string;
version: string;
@@ -178,6 +239,6 @@ export async function getLatestVersion(): Promise<HasUpdate> {
};
}
export async function postNew(initialData: Partial<EventData>) {
export async function postNew(initialData: Partial<ProjectData>) {
return axios.post(`${ontimeURL}/new`, initialData);
}
@@ -0,0 +1,21 @@
import axios from 'axios';
import { ProjectData } from 'ontime-types';
import { projectDataURL } from './apiConstants';
/**
* @description HTTP request to fetch project data
* @return {Promise}
*/
export async function getProjectData(): Promise<ProjectData> {
const res = await axios.get(projectDataURL);
return res.data;
}
/**
* @description HTTP request to mutate project data
* @return {Promise}
*/
export async function postProjectData(data: ProjectData) {
return axios.post(projectDataURL, data);
}
@@ -0,0 +1,9 @@
@use '../../../theme/v2Styles' as *;
.delaySymbol {
svg {
font-size: 1.5rem;
color: $ontime-delay;
margin: 0 auto;
}
}
@@ -0,0 +1,40 @@
import { Tooltip } from '@chakra-ui/react';
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { millisToDelayString } from '../../utils/dateConfig';
import style from './DelayIndicator.module.scss';
interface DelayIndicatorProps {
delayValue?: number;
}
export default function DelayIndicator(props: DelayIndicatorProps) {
const { delayValue } = props;
if (typeof delayValue === 'number') {
if (delayValue < 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronDown />
</span>
</Tooltip>
);
}
if (delayValue > 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronUp />
</span>
</Tooltip>
);
}
}
return null;
}
@@ -1,6 +1,6 @@
import { useCallback } from 'react';
import { TitleActions } from '../../../../features/event-editor/composite/EventEditorDataLeft';
import { EditorUpdateFields } from '../../../../features/event-editor/EventEditor';
import Swatch from './Swatch';
@@ -8,8 +8,8 @@ import style from './SwatchSelect.module.scss';
interface ColourInputProps {
value: string;
name: TitleActions;
handleChange: (newValue: TitleActions, name: string) => void;
name: EditorUpdateFields;
handleChange: (newValue: EditorUpdateFields, name: string) => void;
}
const colours = [
@@ -58,7 +58,7 @@ function NavigationMenu() {
const handleMirror = () => toggleMirror();
const showEditFormDrawer = () => {
searchParams.append('edit', 'true');
searchParams.set('edit', 'true');
setSearchParams(searchParams);
};
@@ -116,6 +116,10 @@ function NavigationMenu() {
Cuesheet
<IoArrowUp className={style.linkIcon} />
</Link>
<Link to='/op' className={style.link} tabIndex={0}>
Operator
<IoArrowUp className={style.linkIcon} />
</Link>
<hr className={style.separator} />
{navigatorConstants.map((route) => (
<Link
@@ -8,10 +8,12 @@ import { tooltipDelayFast } from '../../../ontimeConfig';
interface PlaybackIconProps {
state: Playback;
skipTooltip?: boolean;
className?: string;
}
export default function PlaybackIcon(props: PlaybackIconProps) {
const { state } = props;
const { state, skipTooltip, className } = props;
// if timer is Pause or Armed
let label = 'Timer Paused';
@@ -28,9 +30,13 @@ export default function PlaybackIcon(props: PlaybackIconProps) {
Icon = IoStop;
}
if (skipTooltip) {
return <Icon className={className} />;
}
return (
<Tooltip openDelay={tooltipDelayFast} label={label} shouldWrapChildren>
<Icon />
<Icon className={className} />
</Tooltip>
);
}
@@ -1,4 +1,5 @@
import { CSSProperties } from 'react';
import { ReactComponent as Emptyimage } from '@/assets/images/empty.svg';
import style from './Empty.module.scss';
@@ -2,7 +2,7 @@
@use '../../../theme/ontimeColours' as *;
.drawerContent {
background-color: $gray-1200;
background-color: $gray-1250;
}
.drawerHeader {
@@ -38,6 +38,12 @@
gap: $element-inner-spacing;
}
.noHover {
&:hover {
background-color: inherit;
}
}
.title {
font-size: $inner-section-text-size;
display: block;
@@ -1,3 +1,5 @@
import { UserFields } from 'ontime-types';
import { ParamField } from './types';
export const TIME_FORMAT_OPTION: ParamField = {
@@ -201,3 +203,61 @@ export const STUDIO_CLOCK_OPTIONS: ParamField[] = [
type: 'boolean',
},
];
export const getOperatorOptions = (userFields: UserFields): ParamField[] => {
return [
TIME_FORMAT_OPTION,
{
id: 'showseconds',
title: 'Show seconds',
description: 'Schedule shows hh:mm:ss',
type: 'boolean',
},
{
id: 'hidepast',
title: 'Hide Past Events',
description: 'Whether to events that have passed',
type: 'boolean',
},
{
id: 'main',
title: 'Main data field',
description: 'Field to be shown in the first line of text',
type: 'option',
values: {
title: 'Title',
subtitle: 'Subtitle',
presenter: 'Presenter',
},
},
{
id: 'secondary',
title: 'Secondary data field',
description: 'Field to be shown in the second line of text',
type: 'option',
values: {
title: 'Title',
subtitle: 'Subtitle',
presenter: 'Presenter',
},
},
{
id: 'subscribe',
title: 'Highlight Field',
description: 'Choose a field to highlight',
type: 'option',
values: {
user0: userFields.user0 || 'user0',
user1: userFields.user1 || 'user1',
user2: userFields.user2 || 'user2',
user3: userFields.user3 || 'user3',
user4: userFields.user4 || 'user4',
user5: userFields.user5 || 'user5',
user6: userFields.user6 || 'user6',
user7: userFields.user7 || 'user7',
user8: userFields.user8 || 'user8',
user9: userFields.user9 || 'user9',
},
},
];
};
@@ -0,0 +1,32 @@
// roughly from https://github.com/juliencrn/usehooks-ts/blob/master/packages/usehooks-ts/src/useMediaQuery/useMediaQuery.ts
import { useCallback, useEffect, useState } from 'react';
function getMatches(query: string): boolean {
return window.matchMedia(query).matches;
}
// TODO: debounce handleChange
export default function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(getMatches(query));
const handleChange = useCallback(() => {
setMatches(getMatches(query));
}, [query]);
useEffect(() => {
const matchMedia = window.matchMedia(query);
// Triggered at the first client-side load and if query changes
handleChange();
// Listen matchMedia
matchMedia.addEventListener('change', handleChange);
return () => {
matchMedia.removeEventListener('change', handleChange);
};
}, [handleChange, query]);
return matches;
}
@@ -1,15 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { EVENT_DATA } from '../api/apiConstants';
import { fetchEventData } from '../api/eventDataApi';
import { eventDataPlaceholder } from '../models/EventData';
import { PROJECT_DATA } from '../api/apiConstants';
import { getProjectData } from '../api/projectDataApi';
import { projectDataPlaceholder } from '../models/ProjectData';
export default function useEventData() {
export default function useProjectData() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: EVENT_DATA,
queryFn: fetchEventData,
placeholderData: eventDataPlaceholder,
queryKey: PROJECT_DATA,
queryFn: getProjectData,
placeholderData: projectDataPlaceholder,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -0,0 +1,63 @@
import { MutableRefObject, useCallback, useEffect } from 'react';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: MutableRefObject<ComponentRef>,
scrollRef: MutableRefObject<ScrollRef>,
topOffset: number,
) {
if (!componentRef.current || !scrollRef.current) {
return;
}
const componentRect = componentRef.current.getBoundingClientRect();
const scrollRect = scrollRef.current.getBoundingClientRect();
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - topOffset;
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
}
interface UseFollowComponentProps {
followRef: MutableRefObject<HTMLElement | null>;
scrollRef: MutableRefObject<HTMLElement | null>;
doFollow: boolean;
topOffset?: number;
setScrollFlag?: () => void;
}
export default function useFollowComponent(props: UseFollowComponentProps) {
const { followRef, scrollRef, doFollow, topOffset = 100, setScrollFlag } = props;
// when cursor moves, view should follow
useEffect(() => {
if (!doFollow) {
return;
}
if (followRef.current && scrollRef.current) {
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
setScrollFlag?.();
scrollToComponent(
followRef as MutableRefObject<HTMLElement>,
scrollRef as MutableRefObject<HTMLElement>,
topOffset,
);
});
}
// eslint-disable-next-line -- the prompt seems incorrect
}, [followRef?.current, scrollRef?.current]);
const scrollToRefComponent = useCallback(
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
if (componentRef.current && containerRef.current) {
// @ts-expect-error -- we know this are not null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
scrollToComponent(componentRef!, scrollRef!, offset);
}
},
[followRef, scrollRef, topOffset],
);
return scrollToRefComponent;
}
@@ -42,7 +42,7 @@ export default function useFullscreen() {
});
} else if (element.webkitRequestFullscreen) {
// iOS Safari fullscreen API is supported
element.webkitRequestFullscreen().catch(() => {
element.webkitRequestFullscreen?.().catch(() => {
/* nothing to do */
});
}
@@ -55,7 +55,7 @@ export default function useFullscreen() {
});
} else if ((document as WebkitDocument).webkitExitFullscreen) {
// iOS Safari fullscreen API is supported
(document as WebkitDocument).webkitExitFullscreen().catch(() => {
(document as WebkitDocument).webkitExitFullscreen?.().catch(() => {
/* nothing to do */
});
}
+12 -2
View File
@@ -13,6 +13,15 @@ export const useRundownEditor = () => {
return useRuntimeStore(featureSelector, deepCompare);
};
export const useOperator = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.playback,
selectedEventId: state.loaded.selectedEventId,
});
return useRuntimeStore(featureSelector, deepCompare);
};
export const useMessageControl = () => {
const featureSelector = (state: RuntimeStore) => ({
timerMessage: state.timerMessage,
@@ -70,7 +79,8 @@ export const setPlayback = {
export const useInfoPanel = () => {
const featureSelector = (state: RuntimeStore) => ({
titles: state.titles,
eventNow: state.eventNow,
eventNext: state.eventNext,
playback: state.playback,
selectedEventIndex: state.loaded.selectedEventIndex,
numEvents: state.loaded.numEvents,
@@ -85,7 +95,7 @@ export const useCuesheet = () => {
selectedEventId: state.loaded.selectedEventId,
selectedEventIndex: state.loaded.selectedEventIndex,
numEvents: state.loaded.numEvents,
titleNow: state.titles.titleNow,
titleNow: state.eventNow?.title || '',
});
return useRuntimeStore(featureSelector, deepCompare);
@@ -1,6 +1,6 @@
import { EventData } from 'ontime-types';
import { ProjectData } from 'ontime-types';
export const eventDataPlaceholder: EventData = {
export const projectDataPlaceholder: ProjectData = {
title: '',
description: '',
publicUrl: '',
+4 -20
View File
@@ -42,26 +42,10 @@ export const runtimeStorePlaceholder = {
nextEventId: null,
nextPublicEventId: null,
},
titles: {
titleNow: null,
subtitleNow: null,
presenterNow: null,
noteNow: null,
titleNext: null,
subtitleNext: null,
presenterNext: null,
noteNext: null,
},
titlesPublic: {
titleNow: null,
subtitleNow: null,
presenterNow: null,
noteNow: null,
titleNext: null,
subtitleNext: null,
presenterNext: null,
noteNext: null,
},
eventNow: null,
eventNext: null,
publicEventNow: null,
publicEventNext: null,
};
export const runtime = createStore<RuntimeStore>(() => ({
+12
View File
@@ -0,0 +1,12 @@
export function debounce(callback: () => void, wait: number) {
let timeout: NodeJS.Timeout | null;
return () => {
if (timeout) {
return;
}
timeout = setTimeout(() => {
timeout = null;
callback();
}, wait);
};
}
@@ -0,0 +1,6 @@
export function isMacOS() {
const userAgent = navigator.userAgent.toLowerCase();
return userAgent.includes('macintosh') || userAgent.includes('mac os');
}
export const deviceAlt = isMacOS() ? '⌥' : 'Alt';
+2 -14
View File
@@ -1,6 +1,6 @@
import { Log, RuntimeStore } from 'ontime-types';
import { RUNTIME, websocketUrl } from '../api/apiConstants';
import { isProduction, RUNTIME, websocketUrl } from '../api/apiConstants';
import { ontimeQueryClient } from '../queryClient';
import { socketClientName } from '../stores/connectionName';
import { addLog } from '../stores/logger';
@@ -64,7 +64,7 @@ export const connectSocket = (preferredClientName?: string) => {
}
case 'ontime': {
runtime.setState(payload as RuntimeStore);
if (import.meta.env.DEV) {
if (!isProduction) {
ontimeQueryClient.setQueryData(RUNTIME, data.payload);
}
break;
@@ -87,18 +87,6 @@ export const connectSocket = (preferredClientName?: string) => {
runtime.setState(state);
break;
}
case 'ontime-titles': {
const state = runtime.getState();
state.titles = payload;
runtime.setState(state);
break;
}
case 'ontime-titlesPublic': {
const state = runtime.getState();
state.titlesPublic = payload;
runtime.setState(state);
break;
}
case 'ontime-timerMessage': {
const state = runtime.getState();
state.timerMessage = payload;
+3 -3
View File
@@ -3,7 +3,7 @@ import Color from 'color';
type ColourCombination = {
backgroundColor: string;
color: string;
}
};
/**
* @description Selects text colour to maintain accessible contrast
@@ -19,11 +19,11 @@ export const getAccessibleColour = (bgColour: string): ColourCombination => {
console.log(`Unable to parse colour: ${bgColour}`);
}
}
return { backgroundColor: '#000', color: "#fffffa" };
return { backgroundColor: '#000', color: '#fffffa' };
};
/**
* @description Creates a list of classnames from array of css module conditions
* @param classNames - css modules objects
*/
export const cx = (classNames: any[]) => classNames.filter(Boolean).join(" ");
export const cx = (classNames: any[]) => classNames.filter(Boolean).join(' ');
+2 -1
View File
@@ -51,6 +51,7 @@ export const formatTime = (milliseconds: number | null, options?: FormatOptions,
return '...';
}
const timeFormat = resolver();
const { showSeconds = false, format: formatString = 'hh:mm a' } = options || {};
const fallback = options?.showSeconds ? 'hh:mm:ss a' : 'hh:mm a';
const { showSeconds = false, format: formatString = fallback } = options || {};
return timeFormat === '12' ? formatFromMillis(milliseconds, formatString) : millisToString(milliseconds, showSeconds);
};
@@ -61,6 +61,11 @@ $table-header-font-size: calc(1rem - 3px);
.eventRow {
vertical-align: top;
&:hover {
outline: 1px solid $blue-700;
outline-offset: -1px;
}
td {
background-color: $gray-1250;
border-radius: 2px;
@@ -113,14 +118,6 @@ $table-header-font-size: calc(1rem - 3px);
}
}
.delaySymbol {
svg {
font-size: 1.5rem;
color: $ontime-delay;
margin: 0 auto;
}
}
.delayedTime {
color: $ontime-delay-text;
font-size: calc(1rem - 2px);
+23 -150
View File
@@ -1,33 +1,20 @@
import { MutableRefObject, useEffect, useRef } from 'react';
import { Tooltip } from '@chakra-ui/react';
import {
closestCenter,
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { useRef } from 'react';
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useLocalStorage } from '../../common/hooks/useLocalStorage';
import { millisToDelayString } from '../../common/utils/dateConfig';
import { getAccessibleColour } from '../../common/utils/styleUtils';
import { tooltipDelayFast } from '../../ontimeConfig';
import BlockRow from './cuesheet-table-elements/BlockRow';
import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
import DelayRow from './cuesheet-table-elements/DelayRow';
import EventRow from './cuesheet-table-elements/EventRow';
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
import { useCuesheetSettings } from './store/CuesheetSettings';
import { SortableCell } from './tableElements/SortableCell';
import { initialColumnOrder } from './cuesheetCols';
import style from './Cuesheet.module.scss';
const pastOpacity = '0.2';
interface CuesheetProps {
data: OntimeRundown;
columns: ColumnDef<OntimeRundownEntry>[];
@@ -46,6 +33,8 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
const [columnSizing, setColumnSizing] = useLocalStorage('table-sizes', {});
const selectedRef = useRef<HTMLTableRowElement | null>(null);
const tableContainerRef = useRef<HTMLDivElement | null>(null);
useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected });
const table = useReactTable({
data,
@@ -63,78 +52,6 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
onColumnSizingChange: setColumnSizing,
getCoreRowModel: getCoreRowModel(),
});
const tableContainerRef = useRef<HTMLDivElement>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
delay: 100,
tolerance: 50,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 100,
tolerance: 50,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
// when selection moves, view should follow
useEffect(() => {
function scrollToComponent(
componentRef: MutableRefObject<HTMLTableRowElement>,
scrollRef: MutableRefObject<HTMLDivElement>,
) {
const componentRect = componentRef.current.getBoundingClientRect();
const scrollRect = scrollRef.current.getBoundingClientRect();
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - 100;
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
}
if (!followSelected) {
return;
}
if (selectedRef.current && tableContainerRef.current) {
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
scrollToComponent(
selectedRef as MutableRefObject<HTMLTableRowElement>,
tableContainerRef as MutableRefObject<HTMLDivElement>,
);
});
}
// eslint-disable-next-line -- the prompt seems incorrect, we need the refs
}, [selectedRef.current, tableContainerRef.current, followSelected]);
const handleOnDragEnd = (event: DragEndEvent) => {
const { delta, active, over } = event;
// cancel if delta y is greater than 200
if (delta.y > 200) return;
// cancel if we do not have an over id
if (over?.id == null) return;
// get index of from
const fromIndex = columnOrder.indexOf(active.id as string);
// get index of to
const toIndex = columnOrder.indexOf(over.id as string);
if (toIndex === -1) {
return;
}
const reorderedCols = [...columnOrder];
const reorderedItem = reorderedCols.splice(fromIndex, 1);
reorderedCols.splice(toIndex, 0, reorderedItem[0]);
saveColumnOrder(reorderedCols);
};
const resetColumnOrder = () => {
saveColumnOrder(initialColumnOrder);
@@ -148,6 +65,8 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
setColumnSizing({});
};
const headerGroups = table.getHeaderGroups;
let eventIndex = 0;
let isPast = Boolean(selectedId);
@@ -163,37 +82,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
)}
<div ref={tableContainerRef} className={style.cuesheetContainer}>
<table className={style.cuesheet}>
<thead className={style.tableHeader}>
{table.getHeaderGroups().map((headerGroup) => {
const key = headerGroup.id;
return (
<DndContext key={key} sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleOnDragEnd}>
<tr key={headerGroup.id}>
<th className={style.indexColumn}>
<Tooltip label='Event Order' openDelay={tooltipDelayFast}>
#
</Tooltip>
</th>
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
{headerGroup.headers.map((header) => {
const width = header.getSize();
return (
<SortableCell key={header.column.columnDef.id} header={header} style={{ width }}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</SortableCell>
);
})}
</SortableContext>
</tr>
</DndContext>
);
})}
</thead>
<CuesheetHeader headerGroups={headerGroups} />
<tbody>
{table.getRowModel().rows.map((row) => {
const key = row.original.id;
@@ -203,13 +92,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
}
if (isOntimeBlock(row.original)) {
const title = row.original.title;
return (
<tr key={key} className={style.blockRow}>
<td>{title}</td>
</tr>
);
return <BlockRow key={key} title={row.original.title} />;
}
if (isOntimeDelay(row.original)) {
const delayVal = row.original.duration;
@@ -218,12 +101,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
return null;
}
const delayTime = millisToDelayString(delayVal);
return (
<tr key={key} className={style.delayRow}>
<td>{delayTime}</td>
</tr>
);
return <DelayRow key={key} duration={delayVal} />;
}
if (isOntimeEvent(row.original)) {
eventIndex++;
@@ -236,25 +114,20 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
return null;
}
const bgFallback = 'transparent';
const bgColour = row.original.colour || bgFallback;
const textColour = bgColour === bgFallback ? undefined : getAccessibleColour(bgColour);
const isSkipped = row.original.skip;
let rowBgColour: string | undefined;
if (row.original.id === selectedId) {
rowBgColour = '#D20300'; // $red-700
if (isSelected) {
rowBgColour = 'var(--cuesheet-running-bg-override, #D20300)'; // $red-700
}
return (
<tr
<EventRow
key={key}
className={`${style.eventRow} ${isSkipped ? style.skip : ''}`}
style={{ opacity: `${isPast ? pastOpacity : '1'}` }}
ref={isSelected ? selectedRef : undefined}
eventIndex={eventIndex}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
skip={row.original.skip}
colour={row.original.colour}
>
<td className={style.indexColumn} style={{ backgroundColor: bgColour, color: textColour?.color }}>
{eventIndex}
</td>
{row.getVisibleCells().map((cell) => {
return (
<td key={cell.id} style={{ width: cell.column.getSize(), backgroundColor: rowBgColour }}>
@@ -262,7 +135,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
</td>
);
})}
</tr>
</EventRow>
);
}
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo } from 'react';
import { EventData, OntimeRundownEntry } from 'ontime-types';
import { OntimeRundownEntry, ProjectData } from 'ontime-types';
import Empty from '../../common/components/state/Empty';
import { useEventAction } from '../../common/hooks/useEventAction';
@@ -69,7 +69,7 @@ export default function CuesheetWrapper() {
);
const exportHandler = useCallback(
(headerData: EventData) => {
(headerData: ProjectData) => {
if (!headerData || !rundown || !userFields) {
return;
}
@@ -6,7 +6,11 @@ exports[`makeTable() > returns array of arrays with given fields 1`] = `
"Ontime · Schedule Template",
],
[
"Event Name",
"Project Title",
"",
],
[
"Project Description",
"",
],
[
@@ -26,7 +26,7 @@ describe('parseField()', () => {
});
it('returns an empty string on undefined fields', () => {
expect(parseField('presenter', undefined)).toBe('');
expect(parseField('presenter')).toBe('');
});
describe('simply returns any other value in any other field', () => {
@@ -0,0 +1,18 @@
import { memo } from 'react';
import style from '../Cuesheet.module.scss';
interface BlockRowProps {
title: string;
}
function BlockRow(props: BlockRowProps) {
const { title } = props;
return (
<tr className={style.blockRow}>
<td>{title}</td>
</tr>
);
}
export default memo(BlockRow);
@@ -0,0 +1,108 @@
import { memo } from 'react';
import { Tooltip } from '@chakra-ui/react';
import {
closestCenter,
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { flexRender, HeaderGroup } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
import { useLocalStorage } from '../../../common/hooks/useLocalStorage';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { initialColumnOrder } from '../cuesheetCols';
import { SortableCell } from './SortableCell';
import style from '../Cuesheet.module.scss';
interface CuesheetHeaderProps {
headerGroups: () => HeaderGroup<OntimeRundownEntry>[];
}
function CuesheetHeader(props: CuesheetHeaderProps) {
const { headerGroups } = props;
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>('table-order', initialColumnOrder);
const handleOnDragEnd = (event: DragEndEvent) => {
const { delta, active, over } = event;
// cancel if delta y is greater than 200
if (delta.y > 200) return;
// cancel if we do not have an over id
if (over?.id == null) return;
// get index of from
const fromIndex = columnOrder.indexOf(active.id as string);
// get index of to
const toIndex = columnOrder.indexOf(over.id as string);
if (toIndex === -1) {
return;
}
const reorderedCols = [...columnOrder];
const reorderedItem = reorderedCols.splice(fromIndex, 1);
reorderedCols.splice(toIndex, 0, reorderedItem[0]);
saveColumnOrder(reorderedCols);
};
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
delay: 100,
tolerance: 50,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 100,
tolerance: 50,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
return (
<thead className={style.tableHeader}>
{headerGroups().map((headerGroup) => {
const key = headerGroup.id;
return (
<DndContext key={key} sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleOnDragEnd}>
<tr key={headerGroup.id}>
<th className={style.indexColumn}>
<Tooltip label='Event Order' openDelay={tooltipDelayFast}>
#
</Tooltip>
</th>
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
{headerGroup.headers.map((header) => {
const width = header.getSize();
return (
<SortableCell key={header.column.columnDef.id} header={header} style={{ width }}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</SortableCell>
);
})}
</SortableContext>
</tr>
</DndContext>
);
})}
</thead>
);
}
export default memo(CuesheetHeader);
@@ -0,0 +1,6 @@
interface CuesheetRowProps {
row: OntimeRundownEntry;
isSelected: boolean;
}
function CuesheetRow() {}
@@ -0,0 +1,22 @@
import { memo } from 'react';
import { millisToDelayString } from '../../../common/utils/dateConfig';
import style from '../Cuesheet.module.scss';
interface DelayRowProps {
duration: number;
}
function DelayRow(props: DelayRowProps) {
const { duration } = props;
const delayTime = millisToDelayString(duration);
return (
<tr className={style.delayRow}>
<td>{delayTime}</td>
</tr>
);
}
export default memo(DelayRow);
@@ -0,0 +1,67 @@
import { memo, MutableRefObject, PropsWithChildren, useLayoutEffect, useRef, useState } from 'react';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import style from '../Cuesheet.module.scss';
const pastOpacity = '0.2';
interface EventRowProps {
eventIndex: number;
isPast?: boolean;
selectedRef?: MutableRefObject<HTMLTableRowElement | null>;
skip?: boolean;
colour?: string;
}
function EventRow(props: PropsWithChildren<EventRowProps>) {
const { children, eventIndex, isPast, selectedRef, skip, colour } = props;
const ownRef = useRef<HTMLTableRowElement>(null);
const [isVisible, setIsVisible] = useState(false);
const bgFallback = 'transparent';
const bgColour = colour || bgFallback;
const textColour = bgColour === bgFallback ? undefined : getAccessibleColour(bgColour);
useLayoutEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
}
},
{
root: null,
threshold: 0.01,
},
);
const handleRefCurrent = ownRef.current;
if (selectedRef) {
setIsVisible(true);
} else if (handleRefCurrent) {
observer.observe(handleRefCurrent);
}
return () => {
if (handleRefCurrent) {
observer.unobserve(handleRefCurrent);
}
};
}, [ownRef, selectedRef]);
return (
<tr
className={`${style.eventRow} ${skip ? style.skip : ''}`}
style={{ opacity: `${isPast ? pastOpacity : '1'}` }}
ref={selectedRef ?? ownRef}
>
<td className={style.indexColumn} style={{ backgroundColor: bgColour, color: textColour?.color }}>
{eventIndex}
</td>
{isVisible ? children : null}
</tr>
);
}
export default memo(EventRow);
@@ -101,13 +101,13 @@ $active-colour: $gray-500;
.actionIcon {
cursor: pointer;
&:hover {
color: $active-colour;
}
&.enabled {
color: $active-indicator;
}
&:hover {
color: $active-colour;
}
}
}
@@ -3,21 +3,20 @@ import { IoContract } from '@react-icons/all-files/io5/IoContract';
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { EventData, Playback } from 'ontime-types';
import { formatDisplay } from 'ontime-utils';
import { Playback, ProjectData } from 'ontime-types';
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
import useFullscreen from '../../../common/hooks/useFullscreen';
import { useTimer } from '../../../common/hooks/useSocket';
import useEventData from '../../../common/hooks-query/useEventData';
import { formatTime } from '../../../common/utils/time';
import useProjectData from '../../../common/hooks-query/useProjectData';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { useCuesheetSettings } from '../store/CuesheetSettings';
import PlaybackIcon from '../tableElements/PlaybackIcon';
import CuesheetTableHeaderTimers from './CuesheetTableHeaderTimers';
import style from './CuesheetTableHeader.module.scss';
interface CuesheetTableHeaderProps {
handleCSVExport: (headerData: EventData) => void;
handleCSVExport: (headerData: ProjectData) => void;
featureData: {
playback: Playback;
selectedEventIndex: number | null;
@@ -31,13 +30,12 @@ export default function CuesheetTableHeader({ handleCSVExport, featureData }: Cu
const showSettings = useCuesheetSettings((state) => state.showSettings);
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
const toggleFollow = useCuesheetSettings((state) => state.toggleFollow);
const timer = useTimer();
const { isFullScreen, toggleFullScreen } = useFullscreen();
const { data: event } = useEventData();
const { data: project } = useProjectData();
const exportCsv = () => {
if (event) {
handleCSVExport(event);
if (project) {
handleCSVExport(project);
}
};
@@ -47,32 +45,17 @@ export default function CuesheetTableHeader({ handleCSVExport, featureData }: Cu
featureData.numEvents ? featureData.numEvents : '-'
}`;
// prepare presentation variables
const isOvertime = (timer.current ?? 0) < 0;
const timerNow = timer.current == null ? '-' : `${isOvertime ? '-' : ''}${formatDisplay(timer.current)}`;
const timeNow = formatTime(timer.clock, {
showSeconds: true,
format: 'hh:mm:ss a',
});
return (
<div className={style.header}>
<div className={style.event}>
<div className={style.title}>{event?.title || '-'}</div>
<div className={style.title}>{project?.title || '-'}</div>
<div className={style.eventNow}>{featureData?.titleNow || '-'}</div>
</div>
<div className={style.playback}>
<div className={style.playbackLabel}>{selected}</div>
<PlaybackIcon state={featureData.playback} />
</div>
<div className={style.timer}>
<div className={style.timerLabel}>Running Timer</div>
<div className={style.value}>{timerNow}</div>
</div>
<div className={style.clock}>
<div className={style.clockLabel}>Time Now</div>
<div className={style.value}>{timeNow}</div>
</div>
<CuesheetTableHeaderTimers />
<div className={style.headerActions}>
<Tooltip openDelay={tooltipDelayFast} label='Toggle follow'>
<span onClick={() => toggleFollow()} className={`${style.actionIcon} ${followSelected ? style.enabled : ''}`}>
@@ -0,0 +1,31 @@
import { formatDisplay } from 'ontime-utils';
import { useTimer } from '../../../common/hooks/useSocket';
import { formatTime } from '../../../common/utils/time';
import style from './CuesheetTableHeader.module.scss';
export default function CuesheetTableHeaderTimers() {
const timer = useTimer();
// prepare presentation variables
const isOvertime = (timer.current ?? 0) < 0;
const timerNow = timer.current == null ? '-' : `${isOvertime ? '-' : ''}${formatDisplay(timer.current)}`;
const timeNow = formatTime(timer.clock, {
showSeconds: true,
format: 'hh:mm:ss a',
});
return (
<>
<div className={style.timer}>
<div className={style.timerLabel}>Running Timer</div>
<div className={style.value}>{timerNow}</div>
</div>
<div className={style.clock}>
<div className={style.clockLabel}>Time Now</div>
<div className={style.value}>{timeNow}</div>
</div>
</>
);
}
@@ -1,3 +1,4 @@
import { memo } from 'react';
import { Button, Checkbox, Switch } from '@chakra-ui/react';
import { Column } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
@@ -19,7 +20,7 @@ interface CuesheetTableSettingsProps {
handleClearToggles: () => void;
}
export default function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
const showPrevious = useCuesheetSettings((state) => state.showPrevious);
const togglePreviousVisibility = useCuesheetSettings((state) => state.togglePreviousVisibility);
@@ -81,3 +82,5 @@ export default function CuesheetTableSettings(props: CuesheetTableSettingsProps)
</div>
);
}
export default memo(CuesheetTableSettings);
@@ -1,17 +1,13 @@
import { useCallback } from 'react';
import { Tooltip } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp';
import { CellContext, ColumnDef } from '@tanstack/react-table';
import { OntimeEvent, OntimeRundownEntry, UserFields } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { millisToDelayString } from '../../common/utils/dateConfig';
import { tooltipDelayFast } from '../../ontimeConfig';
import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator';
import EditableCell from './cuesheet-table-elements/EditableCell';
import { useCuesheetSettings } from './store/CuesheetSettings';
import EditableCell from './tableElements/EditableCell';
import style from './Cuesheet.module.scss';
@@ -20,30 +16,6 @@ function makePublic(row: CellContext<OntimeRundownEntry, unknown>) {
return cellValue ? <IoCheckmark className={style.check} /> : '';
}
function DelayIndicator(props: { delayValue: number }) {
const { delayValue } = props;
if (delayValue < 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronDown />
</span>
</Tooltip>
);
}
if (delayValue > 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronUp />
</span>
</Tooltip>
);
}
return null;
}
function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEntry, unknown>) {
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
const cellValue = (getValue() as number | null) ?? 0;
@@ -1,5 +1,5 @@
import { stringify } from 'csv-stringify/browser/esm/sync';
import { EventData, OntimeEntryCommonKeys, OntimeRundown, UserFields } from 'ontime-types';
import { OntimeEntryCommonKeys, OntimeRundown, ProjectData, UserFields } from 'ontime-types';
import { millisToString } from 'ontime-utils';
/**
@@ -38,10 +38,11 @@ export const parseField = (field: keyof OntimeRundown, data: unknown): string =>
* @param {object} userFields
* @return {(string[])[]}
*/
export const makeTable = (headerData: EventData, rundown: OntimeRundown, userFields: UserFields): string[][] => {
export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, userFields: UserFields): string[][] => {
const data = [
['Ontime · Schedule Template'],
['Event Name', headerData?.title || ''],
['Project Title', headerData?.title || ''],
['Project Description', headerData?.description || ''],
['Public URL', headerData?.publicUrl || ''],
['Backstage URL', headerData?.backstageUrl || ''],
[],
@@ -144,7 +144,7 @@ $playback-width: 26rem;
.eventEditor {
border-radius: 8px 8px 0 0;
background-color: $bg-container-l2;
box-shadow: rgba(0, 0, 0, 0.35) 0 3px 6px 6px;
box-shadow: $large-bottom-drawer-shadow;
border-top: 1px solid $white-20;
position: absolute;
bottom: 0;
@@ -164,7 +164,7 @@ $playback-width: 26rem;
}
.header {
background-color: $gray-1200;
background-color: $gray-1250;
padding: 0.5rem;
border-left: 1px solid $white-10;
border-radius: 0 8px 0 0;
@@ -2,16 +2,15 @@ import { useCallback } from 'react';
import { Textarea } from '@chakra-ui/react';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { TitleActions } from './EventEditorDataLeft';
import { EditorUpdateFields } from '../EventEditor';
import style from '../EventEditor.module.scss';
interface CountedTextAreaProps {
field: TitleActions;
field: EditorUpdateFields;
label: string;
initialValue: string;
submitHandler: (field: TitleActions, value: string) => void;
submitHandler: (field: EditorUpdateFields, value: string) => void;
}
export default function CountedTextArea(props: CountedTextAreaProps) {
@@ -1,6 +1,5 @@
import { useCallback } from 'react';
import { Input, InputProps } from '@chakra-ui/react';
import { sanitiseCue } from 'ontime-utils';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { EditorUpdateFields } from '../EventEditor';
@@ -17,10 +16,7 @@ interface CountedTextInputProps extends InputProps {
export default function CountedTextInput(props: CountedTextInputProps) {
const { field, label, initialValue, submitHandler, maxLength } = props;
const submitCallback = useCallback(
(newValue: string) => submitHandler(field, sanitiseCue(newValue)),
[field, submitHandler],
);
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, {
submitOnEnter: true,
@@ -1,5 +1,6 @@
import { memo } from 'react';
import { Input } from '@chakra-ui/react';
import { sanitiseCue } from 'ontime-utils';
import { type EditorUpdateFields } from '../EventEditor';
@@ -19,6 +20,10 @@ interface EventEditorLeftProps {
const EventEditorDataLeft = (props: EventEditorLeftProps) => {
const { eventId, cue, title, presenter, subtitle, handleSubmit } = props;
const cueSubmitHandler = (_field: string, newValue: string) => {
handleSubmit('cue', sanitiseCue(newValue));
};
return (
<div className={style.left}>
<div className={style.splitTwo}>
@@ -37,7 +42,7 @@ const EventEditorDataLeft = (props: EventEditorLeftProps) => {
readOnly
/>
</div>
<CountedTextInput field='cue' label='Cue' initialValue={cue} submitHandler={handleSubmit} maxLength={10} />
<CountedTextInput field='cue' label='Cue' initialValue={cue} submitHandler={cueSubmitHandler} maxLength={10} />
</div>
<CountedTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} />
<CountedTextInput field='presenter' label='Presenter' initialValue={presenter} submitHandler={handleSubmit} />
+8 -8
View File
@@ -12,17 +12,17 @@ export default function Info() {
const showNif = useEditorSettings((state) => state.eventSettings.showNif);
const titlesNow = {
title: data.titles.titleNow || '',
subtitle: data.titles.subtitleNow || '',
presenter: data.titles.presenterNow || '',
note: data.titles.noteNow || '',
title: data.eventNow?.title || '',
subtitle: data.eventNow?.subtitle || '',
presenter: data.eventNow?.presenter || '',
note: data.eventNow?.note || '',
};
const titlesNext = {
title: data.titles.titleNext || '',
subtitle: data.titles.subtitleNext || '',
presenter: data.titles.presenterNext || '',
note: data.titles.noteNext || '',
title: data.eventNext?.title || '',
subtitle: data.eventNext?.subtitle || '',
presenter: data.eventNext?.presenter || '',
note: data.eventNext?.note || '',
};
const selected = !data.numEvents
@@ -1,9 +1,9 @@
import useEventData from '../../../common/hooks-query/useEventData';
import useProjectData from '../../../common/hooks-query/useProjectData';
import style from '../Info.module.scss';
export default function InfoHeader({ selected }: { selected: string }) {
const { data } = useEventData();
const { data } = useProjectData();
return (
<>
@@ -41,8 +41,8 @@ $el-padding-with-compensation: 24px; // 16 + 8
.title {
font-size: $inner-section-text-size;
color: $gray-500;
padding-left: 8px;
margin: 8px 0;
padding-left: 0.5rem;
margin: 0.5rem 0;
text-transform: uppercase;
}
@@ -107,6 +107,16 @@ $el-padding-with-compensation: 24px; // 16 + 8
color: $error-red;
}
.success {
@include subsection;
color: $action-blue;
}
.feedbackSection {
justify-content: flex-start;
}
.buttonSection {
margin-top: $section-spacing;
display: flex;
@@ -117,6 +127,10 @@ $el-padding-with-compensation: 24px; // 16 + 8
flex-grow: 1;
}
.vSpacer {
height: 2rem;
}
.shiftRight {
align-self: flex-end;
}
@@ -135,6 +149,12 @@ $el-padding-with-compensation: 24px; // 16 + 8
grid-template-columns: auto 1fr;
}
.twoEqualColumn {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.padBottom {
padding-bottom: $element-spacing;
}
@@ -16,12 +16,12 @@ import {
ModalOverlay,
Textarea,
} from '@chakra-ui/react';
import type { EventData } from 'ontime-types';
import type { ProjectData } from 'ontime-types';
import { EVENT_DATA, RUNDOWN_TABLE } from '../../../common/api/apiConstants';
import { PROJECT_DATA, RUNDOWN_TABLE } from '../../../common/api/apiConstants';
import { postNew } from '../../../common/api/ontimeApi';
import useEventData from '../../../common/hooks-query/useEventData';
import { eventDataPlaceholder } from '../../../common/models/EventData';
import useProjectData from '../../../common/hooks-query/useProjectData';
import { projectDataPlaceholder } from '../../../common/models/ProjectData';
import { ontimeQueryClient } from '../../../common/queryClient';
import styles from '../Modal.module.scss';
@@ -32,7 +32,7 @@ interface QuickStartProps {
}
export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
const { data, status } = useEventData();
const { data, status } = useProjectData();
const {
handleSubmit,
register,
@@ -49,10 +49,10 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
if (data) reset(data);
}, [data, reset]);
const onSubmit = async (data: Partial<EventData>) => {
const onSubmit = async (data: Partial<ProjectData>) => {
try {
await postNew(data);
await ontimeQueryClient.invalidateQueries(EVENT_DATA);
await ontimeQueryClient.invalidateQueries(PROJECT_DATA);
await ontimeQueryClient.invalidateQueries(RUNDOWN_TABLE);
onClose();
@@ -61,7 +61,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
}
};
const onReset = () => reset(eventDataPlaceholder);
const onReset = () => reset(projectDataPlaceholder);
const disableButtons = status !== 'success' || isSubmitting;
return (
@@ -86,13 +86,13 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
<div className={styles.column}>
<AlertTitle>Note</AlertTitle>
<AlertDescription>
On submit, application options will be kept but rundown and event data will be reset
On submit, application options will be kept but rundown and project data will be reset
</AlertDescription>
</div>
</Alert>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Event title
Project title
<Input
variant='ontime-filled-on-light'
size='sm'
@@ -104,7 +104,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Event description
Project description
<Input
variant='ontime-filled-on-light'
size='sm'
@@ -116,7 +116,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Public Info
Public info
<Textarea
variant='ontime-filled-on-light'
size='sm'
@@ -128,7 +128,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Public QR Code Url
Public QR code Url
<Input
variant='ontime-filled-on-light'
size='sm'
@@ -139,7 +139,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Backstage Info
Backstage info
<Textarea
variant='ontime-filled-on-light'
size='sm'
@@ -151,7 +151,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Backstage QR Code Url
Backstage QR code Url
<Input
variant='ontime-filled-on-light'
size='sm'
@@ -1,11 +1,11 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Input, Textarea } from '@chakra-ui/react';
import { EventData } from 'ontime-types';
import { ProjectData } from 'ontime-types';
import { logAxiosError } from '../../../common/api/apiUtils';
import { postEventData } from '../../../common/api/eventDataApi';
import useEventData from '../../../common/hooks-query/useEventData';
import { postProjectData } from '../../../common/api/projectDataApi';
import useProjectData from '../../../common/hooks-query/useProjectData';
import ModalLoader from '../modal-loader/ModalLoader';
import { inputProps } from '../modalHelper';
import ModalInput from '../ModalInput';
@@ -13,14 +13,14 @@ import OntimeModalFooter from '../OntimeModalFooter';
import style from './SettingsModal.module.scss';
export default function EventDataForm() {
const { data, status, isFetching, refetch } = useEventData();
export default function ProjectDataForm() {
const { data, status, isFetching, refetch } = useProjectData();
const {
handleSubmit,
register,
reset,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<EventData>({
} = useForm<ProjectData>({
defaultValues: data,
values: data,
resetOptions: {
@@ -34,11 +34,11 @@ export default function EventDataForm() {
}
}, [data, reset]);
const onSubmit = async (formData: EventData) => {
const onSubmit = async (formData: ProjectData) => {
try {
await postEventData(formData);
await postProjectData(formData);
} catch (error) {
logAxiosError('Error saving event settings', error);
logAxiosError('Error saving project data', error);
} finally {
await refetch();
}
@@ -55,10 +55,10 @@ export default function EventDataForm() {
}
return (
<form onSubmit={handleSubmit(onSubmit)} id='event-data' className={style.sectionContainer}>
<form onSubmit={handleSubmit(onSubmit)} id='project-data' className={style.sectionContainer}>
<ModalInput
field='title'
title='Event title'
title='Project title'
description='Shown in overview screens'
error={errors.title?.message}
>
@@ -73,7 +73,7 @@ export default function EventDataForm() {
</ModalInput>
<ModalInput
field='description'
title='Event description'
title='Project description'
description='Free field, shown in editor'
error={errors.description?.message}
>
@@ -87,7 +87,7 @@ export default function EventDataForm() {
/>
</ModalInput>
<div style={{ height: '16px' }} />
<ModalInput field='publicInfo' title='Public Info' description='Information shown in public screens'>
<ModalInput field='publicInfo' title='Public info' description='Information shown in public screens'>
<Textarea
{...inputProps}
variant='ontime-filled-on-light'
@@ -107,7 +107,7 @@ export default function EventDataForm() {
/>
</ModalInput>
<div style={{ height: '16px' }} />
<ModalInput field='backstageInfo' title='Backstage Info' description='Information shown in public screens'>
<ModalInput field='backstageInfo' title='Backstage info' description='Information shown in public screens'>
<Textarea
{...inputProps}
variant='ontime-filled-on-light'
@@ -128,7 +128,7 @@ export default function EventDataForm() {
/>
</ModalInput>
<OntimeModalFooter
formId='event-data'
formId='project-data'
handleRevert={onReset}
isDirty={isDirty}
isValid={isValid}
@@ -6,7 +6,7 @@ import AliasesForm from './AliasesForm';
import AppSettingsModal from './AppSettings';
import CuesheetSettingsForm from './CuesheetSettingsForm';
import EditorSettings from './EditorSettings';
import EventDataForm from './EventDataForm';
import ProjectDataForm from './ProjectDataForm';
import ViewSettingsForm from './ViewSettingsForm';
interface ModalManagerProps {
@@ -22,7 +22,7 @@ export default function SettingsModal(props: ModalManagerProps) {
<Tabs variant='ontime' size='sm' isLazy>
<TabList>
<Tab>App</Tab>
<Tab>Event Data</Tab>
<Tab>Project Data</Tab>
<Tab>Editor</Tab>
<Tab>Cuesheet</Tab>
<Tab>Views</Tab>
@@ -33,7 +33,7 @@ export default function SettingsModal(props: ModalManagerProps) {
<AppSettingsModal />
</TabPanel>
<TabPanel>
<EventDataForm />
<ProjectDataForm />
</TabPanel>
<TabPanel>
<EditorSettings />
@@ -0,0 +1,67 @@
import { ChangeEvent, useRef, useState } from 'react';
import { Input } from '@chakra-ui/react';
import UploadEntry from './upload-entry/UploadEntry';
import { useUploadModalContextStore } from './uploadModalContext';
import { validateFile } from './uploadUtils';
import style from './UploadModal.module.scss';
export default function UploadFile() {
const fileInputRef = useRef<HTMLInputElement>(null);
const { file, setFile, progress } = useUploadModalContextStore();
const [errors, setErrors] = useState<string | undefined>();
const success = false;
const clearFile = () => {
setFile(null);
setErrors('');
};
const handleFile = (event: ChangeEvent<HTMLInputElement>) => {
setErrors('');
const selectedFile = event?.target?.files?.[0];
if (!selectedFile) {
setFile(null);
return;
}
try {
validateFile(selectedFile);
setFile(selectedFile);
} catch (error) {
if (error instanceof Error) {
setErrors(error.message);
}
setFile(null);
}
};
const handleClick = () => {
fileInputRef.current?.click();
};
return (
<>
<Input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
onChange={handleFile}
accept='.json, .xlsx'
data-testid='file-input'
/>
{!file && (
<div className={style.uploadArea} onClick={handleClick}>
Click to upload Ontime project or xlsx file
</div>
)}
{(file || errors) && (
<UploadEntry file={file} errors={errors} progress={progress} success={success} handleClear={clearFile} />
)}
</>
);
}
@@ -5,79 +5,31 @@
.uploadBody {
display: flex;
flex-direction: column;
gap: 16px;
gap: 1rem;
}
.uploadArea {
margin: 0 auto;
width: 100%;
min-height: 200px;
border: 2px dashed $gray-50;
max-width: 550px;
min-height: 150px;
border: 2px dashed $gray-200;
border-radius: 3px;
display: grid;
place-content: center;
transition-property: background-color;
transition-duration: $transition-time-action;
font-size: calc(1rem - 1px);
&:hover {
border: 2px solid $blue-500;
background-color: $blue-50;
cursor: pointer;
}
&.comment {
color: gray;
}
}
.uploadedItem {
background-color: $gray-50;
padding: 8px;
display: grid;
grid-template-areas:
"icon title close"
"icon info ."
"progress progress progress";
grid-template-columns: auto 1fr auto;
column-gap: 16px;
border-radius: 3px;
.icon {
align-self: center;
grid-area: icon;
font-size: 32px;
color: $gray-700;
}
.fileTitle {
grid-area: title;
font-size: 14px;
color: $gray-1350;
}
.fileInfo {
grid-area: info;
font-size: 12px;
color: $gray-1100;
}
.fileProgress {
grid-area: progress;
}
.cancelUpload {
grid-area: close;
cursor: pointer;
}
&.error {
.icon {
color: $error-red;
}
}
&.success {
.icon {
color: $green-500;
}
color: $modal-note-color;
}
}
@@ -89,3 +41,9 @@
.pad {
margin: 8px;
}
.twoColumn {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
@@ -1,7 +1,6 @@
import { ChangeEvent, useCallback, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import {
Button,
Input,
Modal,
ModalBody,
ModalCloseButton,
@@ -9,22 +8,33 @@ import {
ModalFooter,
ModalHeader,
ModalOverlay,
Progress,
Switch,
} from '@chakra-ui/react';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import { IoDocumentTextOutline } from '@react-icons/all-files/io5/IoDocumentTextOutline';
import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline';
import { useQueryClient } from '@tanstack/react-query';
import { OntimeRundown, ProjectData, UserFields } from 'ontime-types';
import { defaultExcelImportMap, ExcelImportMap } from 'ontime-utils';
import { RUNDOWN_TABLE } from '../../../common/api/apiConstants';
import { uploadData } from '../../../common/api/ontimeApi';
import { useEmitLog } from '../../../common/stores/logger';
import ModalSplitInput from '../ModalSplitInput';
import { invalidateAllCaches, maybeAxiosError } from '../../../common/api/apiUtils';
import {
patchData,
postPreviewExcel,
ProjectFileImportOptions,
uploadProjectFile,
} from '../../../common/api/ontimeApi';
import { projectDataPlaceholder } from '../../../common/models/ProjectData';
import { userFieldsPlaceholder } from '../../../common/models/UserFields';
import { validateFile } from './utils';
import PreviewExcel from './preview/PreviewExcel';
import ExcelFileOptions from './upload-options/ExcelFileOptions';
import OntimeFileOptions from './upload-options/OntimeFileOptions';
import UploadStepTracker from './upload-step/UploadStep';
import UploadFile from './UploadFile';
import { useUploadModalContextStore } from './uploadModalContext';
import { isExcelFile, isOntimeFile } from './uploadUtils';
import style from './UploadModal.module.scss';
import { PROJECT_DATA, RUNDOWN_TABLE, USERFIELDS } from '../../../common/api/apiConstants';
export type UploadStep = 'upload' | 'review';
interface UploadModalProps {
onClose: () => void;
@@ -33,64 +43,130 @@ interface UploadModalProps {
export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
const queryClient = useQueryClient();
const { emitError } = useEmitLog();
const [errors, setErrors] = useState<string | undefined>();
const [isSubmitting, setSubmitting] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [progress, setProgress] = useState(0);
const overrideOptionRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [success, setSuccess] = useState(false);
const handleFile = useCallback((event: ChangeEvent<HTMLInputElement>) => {
const fileUploaded = event?.target?.files?.[0];
if (!fileUploaded) return;
const { file, setProgress, clear } = useUploadModalContextStore();
const validate = validateFile(fileUploaded);
setErrors(validate.errors?.[0]);
const [uploadStep, setUploadStep] = useState<UploadStep>('upload');
const [submitting, setSubmitting] = useState(false);
const [rundown, setRundown] = useState<OntimeRundown | null>(null);
const [userFields, setUserFields] = useState<UserFields | null>(null);
const [project, setProject] = useState<ProjectData | null>(null);
if (validate.isValid) {
setFile(fileUploaded);
} else {
setFile(null);
}
}, []);
const [errors, setErrors] = useState('');
const handleSubmit = useCallback(async () => {
setSubmitting(true);
const ontimeFileOptions = useRef<Partial<ProjectFileImportOptions>>({});
const excelFileOptions = useRef<ExcelImportMap>(defaultExcelImportMap);
/* if the modal re-opens, we want to restart all states */
useEffect(() => {
clear();
setUploadStep('upload');
setSubmitting(false);
setRundown(null);
setUserFields(null);
setProject(null);
setErrors('');
}, [clear, isOpen]);
/* uploads file to backend
* - in the case of excel, we get the preview
* - in the case of project file, this is end of line
**/
const handleUpload = async () => {
let doClose = false;
if (file) {
setSubmitting(true);
setErrors('');
try {
const options = {
onlyRundown: overrideOptionRef.current?.checked || false,
};
await uploadData(file, setProgress, options);
if (isOntimeFile(file)) {
// TODO: we would also like to have preview for ontime project files
const options = ontimeFileOptions.current;
await handleOntimeFile(file, options);
doClose = true;
} else if (isExcelFile(file)) {
const options = excelFileOptions.current;
await handleExcelFile(file, options);
await invalidateAllCaches();
}
} catch (error) {
emitError(`Failed uploading file: ${error}`);
const message = maybeAxiosError(error);
setErrors(`Failed uploading file ${message}`);
} finally {
await queryClient.invalidateQueries(RUNDOWN_TABLE);
setSuccess(true);
setSubmitting(false);
if (doClose) {
handleClose();
}
}
}
setSubmitting(false);
}, [emitError, file, queryClient]);
const handleClick = () => {
fileInputRef.current?.click();
};
const clearFile = () => {
setFile(null);
// when we upload excel, we populate state with preview data
async function handleExcelFile(file: File, options: ExcelImportMap) {
const response = await postPreviewExcel(file, setProgress, options);
if (response.status === 200) {
setRundown(response.data.rundown);
setUserFields(response.data.userFields);
setProject(response.data.project);
// in excel imports we have an extra review step
setUploadStep('review');
}
}
// when we upload project files, no extra operations are done
async function handleOntimeFile(file: File, options: Partial<ProjectFileImportOptions>) {
await uploadProjectFile(file, setProgress, options);
}
};
// before closing the modal, we clear data from mutations
const handleClose = () => {
clearFile();
setSuccess(false);
setErrors(undefined);
setProgress(0);
clear();
setRundown([]);
setUserFields(userFieldsPlaceholder);
setProject(projectDataPlaceholder);
onClose();
};
const disableSubmit = !file || isSubmitting;
const handleFinalise = async () => {
// this step is currently only used for excel files, after preview
if (isExcel && rundown && userFields && project) {
let doClose = false;
setSubmitting(true);
try {
await patchData({ rundown, userFields, project });
queryClient.setQueryData(RUNDOWN_TABLE, rundown);
queryClient.setQueryData(USERFIELDS, userFields);
queryClient.setQueryData(PROJECT_DATA, project);
await queryClient.invalidateQueries({
queryKey: [...RUNDOWN_TABLE, ...USERFIELDS, ...PROJECT_DATA],
});
doClose = true;
} catch (error) {
const message = maybeAxiosError(error);
setErrors(`Failed applying changes ${message}`);
} finally {
setSubmitting(false);
if (doClose) {
handleClose();
}
}
}
};
const undoReview = () => {
setUploadStep('upload');
setErrors('');
};
const isUpload = uploadStep === 'upload';
const isReview = uploadStep === 'review';
const isExcel = isExcelFile(file);
const isOntime = isOntimeFile(file);
const handleGoBack = isUpload ? undefined : undoReview;
const handleSubmit = isUpload ? handleUpload : handleFinalise;
const disableSubmit = (isUpload && !file) || (isReview && rundown === null);
const disableGoBack = isUpload;
const submitText = isUpload ? 'Upload' : 'Finish';
return (
<Modal
@@ -101,66 +177,51 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
size='xl'
scrollBehavior='inside'
preserveScrollBarGap
variant='ontime-small'
variant='ontime-upload'
>
<ModalOverlay />
<ModalContent>
<ModalHeader>File import</ModalHeader>
<ModalCloseButton />
<ModalBody className={style.uploadBody}>
<Input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
onChange={handleFile}
accept='.json, .xlsx'
data-testid='file-input'
/>
<div className={style.uploadArea} onClick={handleClick}>
Click to upload Ontime project file
</div>
{file && (
<div className={`${style.uploadedItem} ${success ? style.success : ''}`}>
<IoClose className={style.cancelUpload} onClick={clearFile} />
<IoDocumentTextOutline className={style.icon} />
<span className={style.fileTitle}>{file.name}</span>
<span className={style.fileInfo}>{`${(file.size / 1024).toFixed(2)}kb - ${file.type}`}</span>
<Progress variant='ontime-on-light' className={style.fileProgress} value={progress} />
</div>
{isExcel && <UploadStepTracker uploadStep={uploadStep} />}
{uploadStep === 'upload' ? (
<>
<UploadFile />
{isOntime && <OntimeFileOptions optionsRef={ontimeFileOptions} />}
{isExcel && <ExcelFileOptions optionsRef={excelFileOptions} />}
</>
) : (
<PreviewExcel
rundown={rundown ?? []}
project={project ?? projectDataPlaceholder}
userFields={userFields ?? userFieldsPlaceholder}
/>
)}
{errors && (
<div className={`${style.uploadedItem} ${style.error}`}>
<IoWarningOutline className={style.icon} />
<span className={style.fileTitle}>{errors}</span>
<span className={style.fileInfo}>Please try again</span>
<Progress className={style.fileProgress} value={progress} />
</div>
)}
<div className={style.uploadOptions}>
<span className={style.title}>Import options</span>
<ModalSplitInput
field=''
title='Only import rundown'
description='All other options, including application settings will be discarded'
>
<Switch variant='ontime-on-light' ref={overrideOptionRef} />
</ModalSplitInput>
</div>
</ModalBody>
<ModalFooter className={`${style.buttonSection} ${style.pad}`}>
<Button onClick={handleClose} isDisabled={isSubmitting} variant='ontime-ghost-on-light' size='sm'>
Cancel
</Button>
<Button
onClick={handleSubmit}
isLoading={isSubmitting}
isDisabled={disableSubmit}
variant='ontime-filled'
padding='0 2em'
size='sm'
>
Import
</Button>
<ModalFooter>
<div className={style.feedbackSection}>{errors && <div className={style.error}>{errors}</div>}</div>
<div className={`${style.buttonSection} ${style.pad}`}>
<Button
onClick={handleGoBack}
isDisabled={disableGoBack || submitting}
variant='ontime-ghost-on-light'
size='sm'
>
Go Back
</Button>
<Button
onClick={handleSubmit}
isLoading={submitting}
isDisabled={disableSubmit}
variant='ontime-filled'
padding='0 2em'
size='sm'
>
{submitText}
</Button>
</div>
</ModalFooter>
</ModalContent>
</Modal>
@@ -0,0 +1,22 @@
@use "../../../../theme/_ontimeColours" as *;
@mixin pad-item {
padding-left: 0.5rem;
padding-right: 1rem;
}
.previewTable {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: repeat(6, auto);
font-size: calc(1rem - 2px);
}
.field {
font-weight: 200;
@include pad-item;
}
.value {
@include pad-item;
}
@@ -0,0 +1,26 @@
import { OntimeRundown, ProjectData, UserFields } from 'ontime-types';
import PreviewProjectData from './PreviewProjectData';
import PreviewRundown from './PreviewRundown';
import style from '../../Modal.module.scss';
interface PreviewExcelProps {
rundown: OntimeRundown;
project: ProjectData;
userFields: UserFields;
}
export default function PreviewExcel(props: PreviewExcelProps) {
const { rundown, project, userFields } = props;
return (
<div className={`${style.column} ${style.noHover}`}>
<div className={style.title}>Review Project Data</div>
<PreviewProjectData project={project} />
<div className={style.vSpacer} />
<div className={style.title}>Review Rundown</div>
<PreviewRundown rundown={rundown} userFields={userFields} />
</div>
);
}
@@ -0,0 +1,26 @@
import { ProjectData } from 'ontime-types';
import style from './PreviewColumn.module.scss';
interface PreviewProjectDataProps {
project: ProjectData;
}
export default function PreviewProjectData({ project }: PreviewProjectDataProps) {
return (
<div className={style.previewTable}>
<span className={style.field}>Title</span>
<span className={style.value}>{project.title}</span>
<span className={style.field}>Description</span>
<span className={style.value}>{project.description}</span>
<span className={style.field}>Public URL</span>
<span className={style.value}>{project.publicUrl}</span>
<span className={style.field}>Public info</span>
<span className={style.value}>{project.publicInfo}</span>
<span className={style.field}>Backstage URL</span>
<span className={style.value}>{project.backstageUrl}</span>
<span className={style.field}>Backstage info</span>
<span className={style.value}>{project.backstageInfo}</span>
</div>
);
}
@@ -0,0 +1,125 @@
import { isOntimeEvent, OntimeRundown, UserFields } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import Tag from './Tag';
import style from './PreviewTable.module.scss';
interface PreviewRundownProps {
rundown: OntimeRundown;
userFields: UserFields;
}
function booleanToText(value?: boolean) {
return value ? 'Yes' : undefined;
}
export default function PreviewRundown({ rundown, userFields }: PreviewRundownProps) {
return (
<div className={style.container}>
<div className={style.scrollContainer}>
<table className={style.rundownPreview}>
<thead className={style.header}>
<tr>
<th>#</th>
<th>Type</th>
<th>Cue</th>
<th>Title</th>
<th>Subtitle</th>
<th>Presenter</th>
<th>Note</th>
<th>Time Start</th>
<th>Time End</th>
<th>Duration</th>
<th>Is Public</th>
<th>Skip</th>
<th>Colour</th>
<th>Timer Type</th>
<th>End Action</th>
<th>
user0 <Tag>{userFields.user0}</Tag>
</th>
<th>
user1 <Tag>{userFields.user1}</Tag>
</th>
<th>
user2 <Tag>{userFields.user2}</Tag>
</th>
<th>
user3 <Tag>{userFields.user3}</Tag>
</th>
<th>
user4 <Tag>{userFields.user4}</Tag>
</th>
<th>
user5 <Tag>{userFields.user5}</Tag>
</th>
<th>
user6 <Tag>{userFields.user6}</Tag>
</th>
<th>
user7 <Tag>{userFields.user7}</Tag>
</th>
<th>
user8 <Tag>{userFields.user8}</Tag>
</th>
<th>
user9 <Tag>{userFields.user9}</Tag>
</th>
</tr>
</thead>
<tbody className={style.body}>
{rundown.map((event, index) => {
const key = event.id;
if (isOntimeEvent(event)) {
const colour = event.colour ? getAccessibleColour(event.colour) : {};
const isPublic = booleanToText(event.isPublic);
const skip = booleanToText(event.skip);
return (
<tr key={key}>
<td className={style.center}>
<Tag>{index + 1}</Tag>
</td>
<td className={style.center}>
<Tag>Event</Tag>
</td>
<td className={style.nowrap}>{event.cue}</td>
<td>{event.title}</td>
<td>{event.subtitle}</td>
<td>{event.presenter}</td>
<td>{event.note}</td>
<td>{millisToString(event.timeStart)}</td>
<td>{millisToString(event.timeEnd)}</td>
<td>{millisToString(event.duration)}</td>
<td>{isPublic && <Tag>{isPublic}</Tag>}</td>
<td>{skip && <Tag>{skip}</Tag>}</td>
<td style={{ ...colour }}>{event.colour}</td>
<td>
<Tag>{event.timerType}</Tag>
</td>
<td>
<Tag>{event.endAction}</Tag>
</td>
<td>{event.user0}</td>
<td>{event.user1}</td>
<td>{event.user2}</td>
<td>{event.user3}</td>
<td>{event.user4}</td>
<td>{event.user5}</td>
<td>{event.user6}</td>
<td>{event.user7}</td>
<td>{event.user8}</td>
<td>{event.user9}</td>
</tr>
);
}
return null;
})}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,60 @@
@use "../../../../theme/_ontimeColours" as *;
.container {
max-width: 100%;
max-height: max(300px, 30vh);
overflow: scroll;
}
.scrollContainer {
overflow-x: scroll;
}
.rundownPreview {
font-size: calc(1rem - 2px);
border-collapse: separate;
}
.header,
.body {
th {
font-weight: 400;
height: unset;
line-height: calc(1rem - 2px);
white-space: nowrap;
padding-left: 0.25rem;
padding-right: 1rem;
}
}
.header {
th {
font-weight: 200;
text-align: left;
}
tr {
word-wrap: unset;
}
}
.body {
tr:nth-child(odd) {
background-color: $gray-50;
}
td {
text-align: left;
vertical-align: top;
padding: 0 0.5em;
}
.center {
text-align: center;
}
.nowrap {
white-space: nowrap;
}
}
@@ -0,0 +1,10 @@
@use "../../../../theme/_ontimeColours" as *;
.tag {
font-size: 10px;
background-color: $blue-500;
color: $pure-white;
border-radius: 2px;
padding: 0 0.25rem;
white-space: nowrap;
}
@@ -0,0 +1,7 @@
import { ReactNode } from 'react';
import style from './Tag.module.scss';
export default function Tag({ children }: { children: ReactNode }) {
return <span className={style.tag}>{children}</span>;
}
@@ -0,0 +1,59 @@
@use '../../../../theme/ontimeColours' as *;
@use '../../../../theme/v2Styles' as *;
.uploadedItem {
margin: 0 auto;
width: 100%;
max-width: 550px;
border: 1px solid $gray-200;
padding: 0.5rem;
display: grid;
grid-template-areas:
"icon title close"
"icon info ."
"progress progress progress";
grid-template-columns: auto 1fr auto;
column-gap: 1rem;
border-radius: 3px;
.icon {
align-self: center;
grid-area: icon;
font-size: 2rem;
color: $gray-700;
}
.fileTitle {
grid-area: title;
font-size: calc(1rem - 2px);
color: $ui-black;
}
.fileInfo {
grid-area: info;
font-size: calc(1rem - 4px);
color: $gray-1100;
}
.fileProgress {
grid-area: progress;
}
.cancelUpload {
grid-area: close;
cursor: pointer;
}
&.error {
.icon {
color: $error-red;
}
}
&.success {
.icon {
color: $green-500;
}
}
}
@@ -0,0 +1,53 @@
import { Progress } from '@chakra-ui/react';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import { IoDocumentTextOutline } from '@react-icons/all-files/io5/IoDocumentTextOutline';
import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline';
import { isExcelFile, isOntimeFile } from '../uploadUtils';
import style from './UploadEntry.module.scss';
interface UploadEntryProps {
file: File | null;
errors?: string;
progress: number;
success: boolean;
handleClear: () => void;
}
export default function UploadEntry(props: UploadEntryProps) {
const { file, errors, progress, success, handleClear } = props;
if (errors) {
return (
<div className={`${style.uploadedItem} ${style.error}`}>
<IoClose className={style.cancelUpload} onClick={handleClear} />
<IoWarningOutline className={style.icon} />
<span className={style.fileTitle}>{errors}</span>
<span className={style.fileInfo}>Please try again</span>
</div>
);
}
if (file) {
const fileSize = `${(file.size / 1024).toFixed(2)}kb`;
let fileType = '';
if (isOntimeFile(file)) {
fileType = 'Ontime Project File';
} else if (isExcelFile(file)) {
fileType = 'Excel Rundown';
}
return (
<div className={`${style.uploadedItem} ${success ? style.success : ''}`}>
<IoClose className={style.cancelUpload} onClick={handleClear} />
<IoDocumentTextOutline className={style.icon} />
<span className={style.fileTitle}>{file.name}</span>
<span className={style.fileInfo}>{`${fileSize} - ${fileType}`}</span>
<Progress variant='ontime-on-light' className={style.fileProgress} value={progress} />
</div>
);
}
return null;
}
@@ -0,0 +1,76 @@
import { MutableRefObject } from 'react';
import { ExcelImportMap } from 'ontime-utils';
import ImportMapTable, { type TableEntry } from './ImportMapTable';
import style from '../UploadModal.module.scss';
interface ExcelFileOptionsProps {
optionsRef: MutableRefObject<ExcelImportMap>;
}
export default function ExcelFileOptions(props: ExcelFileOptionsProps) {
const { optionsRef } = props;
const updateRef = <T extends keyof ExcelImportMap>(field: T, value: ExcelImportMap[T]) => {
// avoid unnecessary changes
if (optionsRef.current[field] !== value) {
optionsRef.current = { ...optionsRef.current, [field]: value };
}
};
const worksheet: TableEntry[] = [{ label: 'Worksheet', title: 'worksheet', value: optionsRef.current.worksheet }];
const timings: TableEntry[] = [
{ label: 'Start time', title: 'timeStart', value: optionsRef.current.timeStart },
{ label: 'End Time', title: 'timeEnd', value: optionsRef.current.timeEnd },
{ label: 'Duration', title: 'duration', value: optionsRef.current.duration },
];
const titles: TableEntry[] = [
{ label: 'Cue', title: 'cue', value: optionsRef.current.cue },
{ label: 'Colour', title: 'colour', value: optionsRef.current.colour },
{ label: 'Title', title: 'title', value: optionsRef.current.title },
{ label: 'Presenter', title: 'presenter', value: optionsRef.current.presenter },
{ label: 'Subtitle', title: 'subtitle', value: optionsRef.current.subtitle },
{ label: 'Note', title: 'note', value: optionsRef.current.note },
];
const options: TableEntry[] = [
{ label: 'Is Public', title: 'isPublic', value: optionsRef.current.isPublic },
{ label: 'Skip', title: 'skip', value: optionsRef.current.skip },
{ label: 'Timer Type', title: 'timerType', value: optionsRef.current.timerType },
{ label: 'End Action', title: 'endAction', value: optionsRef.current.endAction },
];
const userFields: TableEntry[] = [
{ label: 'User 0', title: 'user0', value: optionsRef.current.user0 },
{ label: 'User 1', title: 'user1', value: optionsRef.current.user1 },
{ label: 'User 2', title: 'user2', value: optionsRef.current.user2 },
{ label: 'User 3', title: 'user3', value: optionsRef.current.user3 },
{ label: 'User 4', title: 'user4', value: optionsRef.current.user4 },
{ label: 'User 5', title: 'user5', value: optionsRef.current.user5 },
{ label: 'User 6', title: 'user6', value: optionsRef.current.user6 },
{ label: 'User 7', title: 'user7', value: optionsRef.current.user7 },
{ label: 'User 8', title: 'user8', value: optionsRef.current.user8 },
{ label: 'User 9', title: 'user9', value: optionsRef.current.user9 },
];
return (
<div className={style.uploadOptions}>
<div className={style.twoEqualColumn}>
<ImportMapTable title='Import options' fields={worksheet} handleOnChange={updateRef} />
</div>
<div className={style.twoEqualColumn}>
<ImportMapTable title='Timings' fields={timings} handleOnChange={updateRef} />
<ImportMapTable title='Options' fields={options} handleOnChange={updateRef} />
</div>
<div className={style.twoEqualColumn}>
<ImportMapTable title='Titles' fields={titles} handleOnChange={updateRef} />
<ImportMapTable title='User Fields' fields={userFields} handleOnChange={updateRef} />
</div>
</div>
);
}
@@ -0,0 +1,33 @@
@use '../../../../theme/v2Styles' as *;
@use '../../../../theme/ontimeColours' as *;
.importTable {
margin: 0.5rem;
height: fit-content;
thead {
color: $gray-500;
text-transform: uppercase;
width: 10em;
}
tr:hover {
background-color: $gray-50;
}
tbody {
td {
max-width: fit-content;
}
}
}
.label {
display: inline-block;
min-width: 6em;
font-size: $inner-section-text-size;
}
.input {
width: 100%;
}
@@ -0,0 +1,50 @@
import { Input } from '@chakra-ui/react';
import { ExcelImportMap } from 'ontime-utils';
import style from './ImportMapTable.module.scss';
export type TableEntry = { label: string; title: keyof ExcelImportMap; value: string };
interface ImportMapTableProps {
title: string;
fields: TableEntry[];
handleOnChange: (field: keyof ExcelImportMap, value: string) => void;
}
export default function ImportMapTable(props: ImportMapTableProps) {
const { title, fields, handleOnChange } = props;
return (
<table className={style.importTable}>
<thead>
<tr>
<td colSpan={2}>{title}</td>
</tr>
</thead>
<tbody>
{fields.map((field) => {
return (
<tr key={field.title}>
<td className={style.label}>
<label htmlFor={field.title}>{field.title}</label>
</td>
<td className={style.input}>
<Input
id={field.title}
size='xs'
variant='ontime-filled-on-light'
maxLength={25}
defaultValue={field.value}
placeholder='Use default column name'
onBlur={(event) => {
handleOnChange(field.title, event.target.value);
}}
/>
</td>
</tr>
);
})}
</tbody>
</table>
);
}
@@ -0,0 +1,34 @@
import { MutableRefObject } from 'react';
import { Switch } from '@chakra-ui/react';
import { ProjectFileImportOptions } from '../../../../common/api/ontimeApi';
import ModalSplitInput from '../../ModalSplitInput';
import style from '../UploadModal.module.scss';
interface OntimeFileOptionsProps {
optionsRef: MutableRefObject<Partial<ProjectFileImportOptions>>;
}
export default function OntimeFileOptions(props: OntimeFileOptionsProps) {
const { optionsRef } = props;
const updateRef = <T extends keyof ProjectFileImportOptions>(field: T, value: ProjectFileImportOptions[T]) => {
optionsRef.current = { ...optionsRef.current, [field]: value };
};
return (
<div className={style.uploadOptions}>
<span className={style.title}>Import options</span>
<ModalSplitInput field='' title='Only import rundown' description='All other project options will be kept'>
<Switch
variant='ontime-on-light'
onChange={(e) => {
updateRef('onlyRundown', e.target.checked);
}}
defaultChecked={Boolean(optionsRef.current.onlyRundown)}
/>
</ModalSplitInput>
</div>
);
}
@@ -0,0 +1,40 @@
@use '../../../../theme/ontimeColours' as *;
@mixin row {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0 0.25rem;
}
.stepRow {
display: flex;
gap: 2rem;
align-items: center;
margin: 0 auto;
font-size: 1rem;
}
.idle {
@include row;
color: $blue-500;
}
.inactive {
@include row;
color: $gray-700;
}
.active {
@include row;
color: $blue-700;
}
.inactiveIcon {
color: $gray-700;
}
.activeIcon {
color: $blue-500;
}
@@ -0,0 +1,26 @@
import { IoCheckmarkCircle } from '@react-icons/all-files/io5/IoCheckmarkCircle';
import { IoChevronForward } from '@react-icons/all-files/io5/IoChevronForward';
import { IoEllipseOutline } from '@react-icons/all-files/io5/IoEllipseOutline';
import type { UploadStep } from '../UploadModal';
import style from './UploadStep.module.scss';
export default function UploadStepTracker({ uploadStep }: { uploadStep: UploadStep }) {
const isUpload = uploadStep === 'upload';
const isReview = uploadStep === 'review';
return (
<div className={style.stepRow}>
<div className={isUpload ? style.active : style.idle}>
<IoCheckmarkCircle />
Upload
</div>
<IoChevronForward className={isReview ? style.activeIcon : style.inactiveIcon} />
<div className={isReview ? style.active : style.inactive}>
{isReview ? <IoCheckmarkCircle /> : <IoEllipseOutline />}
Review
</div>
</div>
);
}
@@ -0,0 +1,21 @@
import { create } from 'zustand';
type UploadModalContext = {
file: File | null;
setFile: (file: File | null) => void;
progress: number;
setProgress: (progress: number) => void;
clear: () => void;
};
export const useUploadModalContextStore = create<UploadModalContext>((set) => ({
file: null,
setFile: (file: File | null) => set({ file }),
progress: 0,
setProgress: (progress: number) => set({ progress }),
clear: () => set({ file: null, progress: 0 }),
}));
@@ -0,0 +1,28 @@
export function validateFile(file: File) {
if (!file) {
throw new Error('No file to upload');
}
// Limit file size of a project file to around 1MB
if (file.name.endsWith('.json') && file.size > 1_000_000) {
throw new Error('File size limit (1MB) exceeded');
}
// Limit file size of an excel file to around 10MB
if (file.name.endsWith('.xlsx') && file.size > 10_000_000) {
throw new Error('File size limit (10MB) exceeded');
}
// Check file extension
if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.json')) {
throw new Error('Unhandled file type');
}
}
export function isExcelFile(file: File | null) {
return file?.name.endsWith('.xlsx');
}
export function isOntimeFile(file: File | null) {
return file?.name.endsWith('.json');
}
@@ -1,25 +0,0 @@
type ValidationStatus = {
errors: string[];
isValid: boolean;
};
export function validateFile(file: File): ValidationStatus {
const status: ValidationStatus = { errors: [], isValid: true };
if (!file) {
status.errors.push('No file to upload');
status.isValid = false;
}
// Limit file size to 1MB
if (file.size > 1000000) {
status.errors.push('File size limit (1MB) exceeded');
status.isValid = false;
}
// Check file extension
if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.json')) {
status.errors.push('Unhandled file type');
status.isValid = false;
}
return status;
}
@@ -0,0 +1,24 @@
@use '../../../src/theme/v2Styles' as *;
@use '../../../src/theme/ontimeColours' as *;
.operatorContainer {
width: 100vw;
height: 100vh;
color: $ui-white;
}
.operatorEvents {
width: 100vw;
height: 100vh;
padding-top: 0.25rem;
overflow: auto;
display: flex;
flex-direction: column;
gap: 2px;
}
.spacer {
min-height: 95vh;
}
@@ -0,0 +1,170 @@
import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { isOntimeEvent, OntimeEvent, SupportedEvent, UserFields } from 'ontime-types';
import { getFirstEvent, getLastEvent } from 'ontime-utils';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
import Empty from '../../common/components/state/Empty';
import { getOperatorOptions } from '../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useOperator } from '../../common/hooks/useSocket';
import useProjectData from '../../common/hooks-query/useProjectData';
import useRundown from '../../common/hooks-query/useRundown';
import useUserFields from '../../common/hooks-query/useUserFields';
import { isStringBoolean } from '../../common/utils/viewUtils';
import FollowButton from './follow-button/FollowButton';
import OperatorBlock from './operator-block/OperatorBlock';
import OperatorEvent from './operator-event/OperatorEvent';
import StatusBar from './status-bar/StatusBar';
import style from './Operator.module.scss';
const selectedOffset = 50;
type TitleFields = Pick<OntimeEvent, 'title' | 'subtitle' | 'presenter'>;
export default function Operator() {
const { data, status } = useRundown();
const { data: userFields, status: userFieldsStatus } = useUserFields();
const { data: projectData, status: projectDataStatus } = useProjectData();
const featureData = useOperator();
const [searchParams] = useSearchParams();
const isAutomatedScroll = useRef(false);
const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const scrollToComponent = useFollowComponent({
followRef: selectedRef,
scrollRef: scrollRef,
doFollow: !lockAutoScroll,
topOffset: selectedOffset,
setScrollFlag: () => (isAutomatedScroll.current = true),
});
// Set window title
useEffect(() => {
document.title = 'ontime - Operator';
}, []);
// reset scroll if nothing is selected
useEffect(() => {
if (!featureData?.selectedEventId) {
if (!lockAutoScroll) {
scrollRef.current?.scrollTo(0, 0);
}
}
}, [featureData?.selectedEventId, lockAutoScroll, scrollRef]);
const handleOffset = () => {
if (featureData.selectedEventId) {
scrollToComponent();
}
setLockAutoScroll(false);
};
const handleScroll = () => {
// prevent considering automated scrolls as user scrolls
if (isAutomatedScroll.current) {
isAutomatedScroll.current = false;
return;
}
if (selectedRef?.current && scrollRef?.current) {
const selectedRect = selectedRef.current.getBoundingClientRect();
const scrollerRect = scrollRef.current.getBoundingClientRect();
if (selectedRect && scrollerRect) {
const distanceFromTop = selectedRect.top - scrollerRect.top;
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > selectedOffset;
setLockAutoScroll(hasScrolledOutOfThreshold);
}
}
};
const missingData = !data || !userFields || !projectData;
const isLoading = status === 'loading' || userFieldsStatus === 'loading' || projectDataStatus === 'loading';
if (missingData || isLoading) {
return <Empty text='Loading...' />;
}
// get fields which the user subscribed to
const subscribe = searchParams.get('subscribe') as keyof UserFields | null;
const main = searchParams.get('main') as keyof TitleFields | null;
const secondary = searchParams.get('secondary') as keyof TitleFields | null;
const subscribedAlias = subscribe ? userFields[subscribe] : '';
const showSeconds = isStringBoolean(searchParams.get('showseconds'));
const operatorOptions = getOperatorOptions(userFields);
let isPast = Boolean(featureData.selectedEventId);
const hidePast = isStringBoolean(searchParams.get('hidepast'));
const firstEvent = getFirstEvent(data);
const lastEvent = getLastEvent(data);
return (
<div className={style.operatorContainer}>
<NavigationMenu />
<ViewParamsEditor paramFields={operatorOptions} />
<StatusBar
projectTitle={projectData.title}
playback={featureData.playback}
selectedEventId={featureData.selectedEventId}
firstStart={firstEvent?.timeStart}
firstId={firstEvent?.id}
lastEnd={lastEvent?.timeEnd}
lastId={lastEvent?.id}
/>
<div className={style.operatorEvents} onScroll={handleScroll} ref={scrollRef}>
{data.map((entry) => {
if (isOntimeEvent(entry)) {
const isSelected = featureData.selectedEventId === entry.id;
if (isSelected) {
isPast = false;
}
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || entry.skip) {
return null;
}
const mainField = main ? entry?.[main] || entry.title : entry.title;
const secondaryField = secondary ? entry?.[secondary] || entry.subtitle : entry.subtitle;
const subscribedData = (subscribe ? entry?.[subscribe] : undefined) || '';
return (
<OperatorEvent
key={entry.id}
colour={entry.colour}
cue={entry.cue}
main={mainField}
secondary={secondaryField}
timeStart={entry.timeStart}
timeEnd={entry.timeEnd}
duration={entry.duration}
delay={entry.delay}
isSelected={isSelected}
subscribed={subscribedData}
subscribedAlias={subscribedAlias}
showSeconds={showSeconds}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
/>
);
}
if (entry.type === SupportedEvent.Block) {
return <OperatorBlock key={entry.id} title={entry.title} />;
}
return null;
})}
<div className={style.spacer} />
</div>
<FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} />
</div>
);
}
@@ -0,0 +1,38 @@
@use '../../../../src/theme/ontimeColours' as *;
@use '../../../../src/theme/v2Styles' as *;
.followButton {
position: relative;
bottom: 12rem;
margin: 0 auto;
z-index: 1;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 1rem;
background-color: $blue-700;
color: #fff;
font-size: 1rem;
border-radius: 99px;
transition: bottom 1s;
&:active {
transition: background-color $transition-time-action;
background-color: $blue-900;
}
}
.hidden {
transition: bottom 1s;
bottom: -50px;
}
// tablet
@media (min-width: $min-tablet) {
.followButton {
font-size: 1.25rem;
gap: 1rem;
padding: 0.25rem 1rem;
}
}
@@ -0,0 +1,23 @@
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
import { cx } from '../../../common/utils/styleUtils';
import style from './FollowButton.module.scss';
interface FollowButtonProps {
isVisible: boolean;
onClickHandler: () => void;
}
export default function FollowButton(props: FollowButtonProps) {
const { isVisible, onClickHandler } = props;
const classes = cx([style.followButton, !isVisible && style.hidden]);
return (
<button className={classes} onClick={onClickHandler} type='button'>
<IoLocate />
Follow
</button>
);
}
@@ -0,0 +1,16 @@
@use '../../../../src/theme/ontimeColours' as *;
@use '../../../../src/theme/v2Styles' as *;
.block {
width: 100%;
padding: 0.25rem 0.5rem;
background-color: $gray-1350;
font-size: 1.25rem;
}
// tablet
@media (min-width: $min-tablet) {
.block {
padding: 0.25rem 1rem;
}
}
@@ -0,0 +1,13 @@
import { memo } from 'react';
import style from './OperatorBlock.module.scss';
interface OperatorBlockProps {
title: string;
}
function OperatorBlock({ title }: OperatorBlockProps) {
return <div className={style.block}>{title}</div>;
}
export default memo(OperatorBlock);
@@ -0,0 +1,111 @@
@use '../../../../src/theme/v2Styles' as *;
@use '../../../../src/theme/ontimeColours' as *;
@import '../Operator.module.scss';
@mixin clock-size {
font-size: calc(1rem - 2px);
@media (min-width: $min-tablet) {
font-size: 1rem;
}
}
.event {
opacity: 1;
border-top: 1px solid $white-1;
padding-right: 0.5rem;
color: $white-90;
background-color: $gray-1300;
display: grid;
align-items: center;
grid-template-columns: 1.25rem 1fr auto;
grid-template-rows: auto auto auto;
column-gap: 0.5rem;
grid-template-areas:
"binder main schedule"
"binder secondary running"
"binder fields fields";
&.subscribed {
background-color: $gray-1250;
}
&.running {
border-top: 1px solid $gray-1300;
background-color: var(--operator-running-bg-override, $red-700);
}
&.past {
border-top: 1px solid transparent;
opacity: 0.2;
}
}
.binder {
grid-area: binder;
color: $section-white;
height: 100%;
display: grid;
place-content: center;
position: relative;
background-color: $gray-1050; // to override inline
.cue {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
width: 6em;
rotate: -90deg;
letter-spacing: 0.5px;
}
}
.mainField {
grid-area: main;
font-size: 1.5rem;
letter-spacing: 0.5px;
color: $ui-white;
}
.secondaryField {
grid-area: secondary;
font-size: 1.25rem;
letter-spacing: 0.5px;
}
.schedule {
@include clock-size;
grid-area: schedule;
justify-self: end;
}
.running {
@include clock-size;
grid-area: running;
justify-self: end;
}
.fields {
grid-area: fields;
font-size: 1.25rem;
font-weight: 400;
color: $ui-black;
margin: 0.25rem 0;
.field {
font-weight: 600;
padding: 0 0.25rem;
background-color: var(--operator-highlight-override, $orange-600);
margin-right: 0.5rem;
}
.value {
color: $orange-500
}
}
.fields::after {
content: '\200b';
}
@@ -0,0 +1,92 @@
import { memo, RefObject } from 'react';
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
import { useTimer } from '../../../common/hooks/useSocket';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatTime } from '../../../common/utils/time';
import style from './OperatorEvent.module.scss';
interface OperatorEventProps {
colour: string;
cue: string;
main: string;
secondary: string;
timeStart: number;
timeEnd: number;
duration: number;
delay?: number;
isSelected: boolean;
subscribed?: string;
subscribedAlias: string;
showSeconds: boolean;
isPast: boolean;
selectedRef?: RefObject<HTMLDivElement>;
}
// extract this to contain re-renders
function RollingTime() {
const timer = useTimer();
return <>{formatTime(timer.current, { showSeconds: true, format: 'hh:mm:ss' })}</>;
}
function OperatorEvent(props: OperatorEventProps) {
const {
colour,
cue,
main,
secondary,
timeStart,
timeEnd,
duration,
delay,
isSelected,
subscribed,
subscribedAlias,
showSeconds,
isPast,
selectedRef,
} = props;
const start = formatTime(timeStart, { showSeconds });
const end = formatTime(timeEnd, { showSeconds });
const cueColours = colour && getAccessibleColour(colour);
const operatorClasses = cx([
style.event,
isSelected ? style.running : null,
subscribed ? style.subscribed : null,
isPast ? style.past : null,
]);
return (
<div className={operatorClasses} ref={selectedRef}>
<div className={style.binder} style={{ ...cueColours }}>
<span className={style.cue}>{cue}</span>
</div>
<span className={style.mainField}>{main}</span>
<span className={style.schedule}>
{start} - {end}
</span>
<span className={style.secondaryField}>{secondary}</span>
<span className={style.running}>
<DelayIndicator delayValue={delay} />
{isSelected ? <RollingTime /> : formatTime(duration, { showSeconds: true, format: 'hh:mm:ss' })}
</span>
<div className={style.fields}>
{subscribed && (
<>
<span className={style.field}>{subscribedAlias}</span>
<span className={style.value}>{subscribed}</span>
</>
)}
</div>
</div>
);
}
export default memo(OperatorEvent);
@@ -0,0 +1,105 @@
@use '../../../../src/theme/ontimeColours' as *;
@use '../../../../src/theme/v2Styles' as *;
@mixin column {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.statusBar {
position: sticky;
top: 0;
width: 100%;
background-color: $gray-1350;
z-index: 2;
padding: 0.5rem 1rem;
border-bottom: 1px solid $white-10;
box-shadow: $large-top-drawer-shadow;
display: grid;
grid-template-areas:
"playback timer1B timer2B timer3B";
grid-template-columns: 1fr auto auto auto;
column-gap: 1.5rem;
align-items: center;
}
.playbackIcon {
grid-area: playback;
font-size: 2rem;
color: $gray-700;
&.active {
color: $ui-white;
}
}
.timeNow {
grid-area: timer1B;
@include column;
}
.elapsedTime {
grid-area: timer2B;
@include column;
}
.runningTime {
grid-area: timer3B;
@include column;
}
.title {
grid-area: title;
font-size: 1.25rem;
padding-left: 0.25rem;
display: none;
line-height: 1.25em;
}
.startTime {
grid-area: timer2A;
@include column;
display: none;
}
.endTime {
grid-area: timer3A;
@include column;
display: none;
}
.label {
font-size: 0.75rem;
color: $gray-700;
}
.timer {
letter-spacing: 0.5px;
}
// tablet
@media (min-width: $min-tablet) {
.statusBar {
grid-template-areas:
"playback timer1B timer2A timer3A"
"title title timer2B timer3B";
row-gap: 0.25rem;
column-gap: 2rem;
}
.title {
display: block;
}
.startTime {
display: flex;
}
.endTime {
display: flex;
}
}
@@ -0,0 +1,94 @@
import { useMemo } from 'react';
import { Playback } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
import { useTimer } from '../../../common/hooks/useSocket';
import { cx } from '../../../common/utils/styleUtils';
import { formatTime } from '../../../common/utils/time';
import styles from './StatusBar.module.scss';
interface StatusBarProps {
projectTitle: string;
playback: Playback;
selectedEventId: string | null;
firstStart?: number;
firstId?: string;
lastEnd?: number;
lastId?: string;
}
export default function StatusBar(props: StatusBarProps) {
const { projectTitle, playback, selectedEventId, firstStart, firstId, lastEnd, lastId } = props;
const timer = useTimer();
const getTimeStart = () => {
if (firstStart === undefined) {
return '...';
}
if (selectedEventId) {
if (firstId === selectedEventId) {
return millisToString(timer.expectedFinish);
}
}
return millisToString(firstStart);
};
const getTimeEnd = () => {
if (lastEnd === undefined) {
return '...';
}
if (selectedEventId) {
if (lastId === selectedEventId) {
return millisToString(timer.expectedFinish);
}
}
return millisToString(lastEnd);
};
// use user defined format
const timeNow = formatTime(timer.clock, {
showSeconds: true,
});
const runningTime = millisToString(timer.current);
const elapsedTime = millisToString(timer.elapsed);
const PlaybackIconComponent = useMemo(() => {
const isPlaying = playback === Playback.Play || playback === Playback.Roll;
const classes = cx([styles.playbackIcon, isPlaying ? styles.active : null]);
return <PlaybackIcon state={playback} skipTooltip className={classes} />;
}, [playback]);
return (
<div className={styles.statusBar}>
{PlaybackIconComponent}
<div className={styles.timeNow}>
<span className={styles.label}>Time now</span>
<span className={styles.timer}>{timeNow}</span>
</div>
<div className={styles.elapsedTime}>
<span className={styles.label}>Elapsed time</span>
<span className={styles.timer}>{elapsedTime}</span>
</div>
<div className={styles.runningTime}>
<span className={styles.label}>Running timer</span>
<span className={styles.timer}>{runningTime}</span>
</div>
<span className={styles.title}>{projectTitle}</span>
<div className={styles.startTime}>
<span className={styles.label}>Scheduled start</span>
<span className={styles.timer}>{getTimeStart()}</span>
</div>
<div className={styles.endTime}>
<span className={styles.label}>Scheduled end</span>
<span className={styles.timer}>{getTimeEnd()}</span>
</div>
</div>
);
}
+3 -23
View File
@@ -1,10 +1,11 @@
import { Fragment, lazy, MutableRefObject, useCallback, useEffect, useRef, useState } from 'react';
import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react';
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { OntimeRundown, Playback, SupportedEvent } from 'ontime-types';
import { getFirst, getNext, getPrevious } from 'ontime-utils';
import { useEventAction } from '../../common/hooks/useEventAction';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
import { useEditorSettings } from '../../common/stores/editorSettings';
@@ -41,6 +42,7 @@ export default function Rundown(props: RundownProps) {
const moveCursorTo = useAppMode((state) => state.setCursor);
const cursorRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
useFollowComponent({ followRef: cursorRef, scrollRef: scrollRef, doFollow: true });
// DND KIT
const sensors = useSensors(useSensor(PointerSensor));
@@ -153,28 +155,6 @@ export default function Rundown(props: RundownProps) {
};
}, [handleKeyPress]);
// when cursor moves, view should follow
useEffect(() => {
function scrollToComponent(
componentRef: MutableRefObject<HTMLDivElement>,
scrollRef: MutableRefObject<HTMLDivElement>,
) {
const componentRect = componentRef.current.getBoundingClientRect();
const scrollRect = scrollRef.current.getBoundingClientRect();
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - 100;
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
}
if (cursorRef.current && scrollRef.current) {
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
scrollToComponent(cursorRef as MutableRefObject<HTMLDivElement>, scrollRef as MutableRefObject<HTMLDivElement>);
});
}
// eslint-disable-next-line -- the prompt seems incorrect
}, [cursorRef?.current, scrollRef]);
useEffect(() => {
// in run mode, we follow selection
if (!viewFollowsCursor || !featureData?.selectedEventId) {
@@ -7,7 +7,7 @@ $block-binder-width: 2rem;
$block-clearance: 0.5rem;
$block-border-radius: 0.5rem;
$block-text-color: $gray-50;
$block-bg: $gray-1200;
$block-bg: $gray-1250;
$block-bg2: $gray-1050; // for delay and blocks
$block-box-shadow: rgba(0, 0, 0, 0.5) 0 0 3px 2px;
$secondary-block-height: 2.5rem;
@@ -128,6 +128,9 @@ $skip-opacity: 0.1;
.eventTitle {
grid-area: title;
overflow: hidden;
max-height: calc(2.5em + 2px);
line-height: 1.25em;
}
.eventActions {
@@ -147,7 +147,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
<span>
<IoPeople
className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`}
data-isPublic={isPublic}
data-ispublic={isPublic}
/>
</span>
</Tooltip>
@@ -5,6 +5,7 @@ import { SupportedEvent } from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { useEditorSettings } from '../../../common/stores/editorSettings';
import { useEmitLog } from '../../../common/stores/logger';
import { deviceAlt } from '../../../common/utils/deviceUtils';
import { tooltipDelayMid } from '../../../ontimeConfig';
import style from './QuickAddBlock.module.scss';
@@ -82,7 +83,7 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
className={style.quickBtn}
data-testid='quick-add-event'
>
Event {showKbd && <span className={style.keyboard}>Alt + E</span>}
Event {showKbd && <span className={style.keyboard}>{`${deviceAlt} + E`}</span>}
</Button>
</Tooltip>
<Tooltip label='Add Delay' openDelay={tooltipDelayMid}>
@@ -94,7 +95,7 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
className={style.quickBtn}
data-testid='quick-add-delay'
>
Delay {showKbd && <span className={style.keyboard}>Alt + D</span>}
Delay {showKbd && <span className={style.keyboard}>{`${deviceAlt} + D`}</span>}
</Button>
</Tooltip>
<Tooltip label='Add Block' openDelay={tooltipDelayMid}>
@@ -106,7 +107,7 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
className={style.quickBtn}
data-testid='quick-add-block'
>
Block {showKbd && <span className={style.keyboard}>Alt + B</span>}
Block {showKbd && <span className={style.keyboard}>{`${deviceAlt} + B`}</span>}
</Button>
</Tooltip>
</div>
@@ -1,16 +1,13 @@
/* eslint-disable react/display-name */
import { ComponentType, useMemo } from 'react';
import { TitleBlock } from 'ontime-types';
import { useStore } from 'zustand';
import useEventData from '../../common/hooks-query/useEventData';
import useProjectData from '../../common/hooks-query/useProjectData';
import useRundown from '../../common/hooks-query/useRundown';
import useViewSettings from '../../common/hooks-query/useViewSettings';
import { runtime } from '../../common/stores/runtime';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
export type TitleManager = TitleBlock & { showNow: boolean; showNext: boolean };
const withData = <P extends object>(Component: ComponentType<P>) => {
return (props: Partial<P>) => {
// persisted app state
@@ -18,7 +15,7 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
// HTTP API data
const { data: rundownData } = useRundown();
const { data: eventData } = useEventData();
const { data: project } = useProjectData();
const { data: viewSettings } = useViewSettings();
const publicEvents = useMemo(() => {
@@ -30,44 +27,22 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
// websocket data
const data = useStore(runtime);
const { timer, titles, titlesPublic, publicMessage, timerMessage, lowerMessage, playback, onAir } = data;
const publicSelectedId = data.loaded.selectedPublicEventId;
const selectedId = data.loaded.selectedEventId;
const nextId = data.loaded.nextEventId;
/********************************************/
/*** + titleManager ***/
/*** WRAP INFORMATION RELATED TO TITLES ***/
/*** ---------------------------------- ***/
/********************************************/
// is there a now field?
let showNow = true;
if (!titles.titleNow && !titles.subtitleNow && !titles.presenterNow) showNow = false;
// is there a next field?
let showNext = true;
if (!titles.titleNext && !titles.subtitleNext && !titles.presenterNext) showNext = false;
const titleManager: TitleManager = { ...titles, showNow: showNow, showNext: showNext };
/********************************************/
/*** + publicTitleManager ***/
/*** WRAP INFORMATION RELATED TO TITLES ***/
/*** ---------------------------------- ***/
/********************************************/
// is there a now field?
let showPublicNow = true;
if (!titlesPublic.titleNow && !titlesPublic.subtitleNow && !titlesPublic.presenterNow) showPublicNow = false;
// is there a next field?
let showPublicNext = true;
if (!titlesPublic.titleNext && !titlesPublic.subtitleNext && !titlesPublic.presenterNext) showPublicNext = false;
const publicTitleManager: TitleManager = {
...titlesPublic,
showNow: showPublicNow,
showNext: showPublicNext,
};
const {
timer,
publicMessage,
timerMessage,
lowerMessage,
playback,
onAir,
eventNext,
publicEventNext,
publicEventNow,
eventNow,
loaded,
} = data;
const publicSelectedId = loaded.selectedPublicEventId;
const selectedId = loaded.selectedEventId;
const nextId = loaded.nextEventId;
/******************************************/
/*** + TimeManagerType ***/
@@ -75,9 +50,6 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
/*** -------------------------------- ***/
/******************************************/
// inject info:
// is timer finished
// get clock string
const TimeManagerType = {
...timer,
playback,
@@ -95,8 +67,10 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
pres={timerMessage}
publ={publicMessage}
lower={lowerMessage}
title={titleManager}
publicTitle={publicTitleManager}
eventNow={eventNow}
publicEventNow={publicEventNow}
eventNext={eventNext}
publicEventNext={publicEventNext}
time={TimeManagerType}
events={publicEvents}
backstageEvents={rundownData}
@@ -104,7 +78,7 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
publicSelectedId={publicSelectedId}
viewSettings={viewSettings}
nextId={nextId}
general={eventData}
general={project}
onAir={onAir}
/>
);
@@ -25,7 +25,7 @@
/* =================== HEADER + EXTRAS ===================*/
.event-header {
.project-header {
grid-area: header;
font-size: clamp(32px, 4.5vw, 64px);
font-weight: 600;
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import QRCode from 'react-qr-code';
import { AnimatePresence, motion } from 'framer-motion';
import { EventData, Message, OntimeEvent, SupportedEvent, ViewSettings } from 'ontime-types';
import { Message, OntimeEvent, ProjectData, SupportedEvent, ViewSettings } from 'ontime-types';
import { formatDisplay } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/apiConstants';
@@ -18,7 +18,6 @@ import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { formatTime } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import { titleVariants } from '../common/animation';
import { TitleManager } from '../ViewWrapper';
import './Backstage.scss';
@@ -30,16 +29,17 @@ const formatOptions = {
interface BackstageProps {
isMirrored: boolean;
publ: Message;
title: TitleManager;
eventNow: OntimeEvent | null;
eventNext: OntimeEvent | null;
time: TimeManagerType;
backstageEvents: OntimeEvent[];
selectedId: string | null;
general: EventData;
general: ProjectData;
viewSettings: ViewSettings;
}
export default function Backstage(props: BackstageProps) {
const { isMirrored, publ, title, time, backstageEvents, selectedId, general, viewSettings } = props;
const { isMirrored, publ, eventNow, eventNext, time, backstageEvents, selectedId, general, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation();
const [blinkClass, setBlinkClass] = useState(false);
@@ -93,7 +93,7 @@ export default function Backstage(props: BackstageProps) {
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
<NavigationMenu />
<ViewParamsEditor paramFields={[TIME_FORMAT_OPTION]} />
<div className='event-header'>
<div className='project-header'>
{general.title}
<div className='clock-container'>
<div className='label'>{getLocalizedString('common.time_now')}</div>
@@ -110,7 +110,7 @@ export default function Backstage(props: BackstageProps) {
<div className='now-container'>
<AnimatePresence>
{title.showNow && (
{eventNow && (
<motion.div
className={`event now ${blinkClass ? 'blink' : ''}`}
key='now'
@@ -121,9 +121,9 @@ export default function Backstage(props: BackstageProps) {
>
<TitleCard
label='now'
title={title.titleNow}
subtitle={title.subtitleNow}
presenter={title.presenterNow}
title={eventNow.title}
subtitle={eventNow.subtitle}
presenter={eventNow.presenter}
/>
<div className='timer-group'>
<div className='aux-timers'>
@@ -144,7 +144,7 @@ export default function Backstage(props: BackstageProps) {
</AnimatePresence>
<AnimatePresence>
{title.showNext && (
{eventNext && (
<motion.div
className='event next'
key='next'
@@ -155,9 +155,9 @@ export default function Backstage(props: BackstageProps) {
>
<TitleCard
label='next'
title={title.titleNext}
subtitle={title.subtitleNext}
presenter={title.presenterNext}
title={eventNext.title}
subtitle={eventNext.subtitle}
presenter={eventNext.presenter}
/>
</motion.div>
)}
@@ -5,7 +5,6 @@ import { Message } from 'ontime-types';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { LOWER_THIRDS_OPTIONS } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { TitleManager } from '../ViewWrapper';
import { LowerOptions } from './LowerWrapper';
@@ -13,12 +12,14 @@ import './LowerLines.scss';
interface LowerLinesProps {
lower: Message;
title: TitleManager;
heading: string;
subheading: string;
options: LowerOptions;
doShow: boolean;
}
export default function LowerLines(props: LowerLinesProps) {
const { lower, title, options } = props;
const { lower, heading, subheading, options, doShow } = props;
const [showLower, setShowLower] = useState(true);
// Unmount if fadeOut
@@ -36,8 +37,8 @@ export default function LowerLines(props: LowerLinesProps) {
}, [options.fadeOut, options.transitionIn]);
useEffect(() => {
setShowLower(title.showNow);
}, [title.showNow]);
setShowLower(doShow);
}, [doShow]);
// Format messages
const showLowerMessage = lower.text !== '' && lower.visible;
@@ -146,14 +147,14 @@ export default function LowerLines(props: LowerLinesProps) {
>
<motion.div className='title-container' variants={titleContainerVariants}>
<motion.div className='title' variants={titleVariants}>
{title.titleNow}
{heading}
</motion.div>
<div className='title-decor' />
</motion.div>
<motion.div className='subtitle-container' variants={subtitleContainerVariants}>
<div className='sub-decor' />
<motion.div className='subtitle' variants={subtitleVariants}>
{title.presenterNow}
{subheading}
</motion.div>
</motion.div>
</motion.div>
@@ -1,11 +1,10 @@
import { memo, useEffect, useState } from 'react';
import isEqual from 'react-fast-compare';
import { useSearchParams } from 'react-router-dom';
import { Message, ViewSettings } from 'ontime-types';
import { Message, OntimeEvent, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TitleManager } from '../ViewWrapper';
import LowerLines from './LowerLines';
@@ -17,33 +16,26 @@ export type LowerOptions = {
keyColour?: string;
fadeOut: number;
};
interface LowerProps {
title: TitleManager;
eventNow: OntimeEvent | null;
lower: Message;
viewSettings: ViewSettings;
}
// prevent triggering animation without a content change
const areEqual = (prevProps: LowerProps, nextProps: LowerProps) => {
return isEqual(prevProps.title, nextProps.title) && isEqual(prevProps.lower, nextProps.lower);
return isEqual(prevProps.eventNow?.title, nextProps.eventNow?.title) && isEqual(prevProps.lower, nextProps.lower);
};
const Lower = (props: LowerProps) => {
const { title, lower, viewSettings } = props;
const { eventNow, lower, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [searchParams] = useSearchParams();
const [titles, setTitles] = useState<TitleManager>({
titleNow: '',
titleNext: '',
subtitleNow: '',
subtitleNext: '',
presenterNow: '',
presenterNext: '',
noteNow: '',
noteNext: '',
showNow: false,
showNext: false,
});
const [heading, setHeading] = useState('');
const [subheading, setSubheading] = useState('');
const [showLower, setShowLower] = useState(false);
// Set window title
useEffect(() => {
@@ -54,28 +46,32 @@ const Lower = (props: LowerProps) => {
useEffect(() => {
// clear titles if necessary
// will trigger an animation out in the component
let timeout: NodeJS.Timeout | null = null;
if (
title?.titleNow !== titles?.titleNow ||
title?.subtitleNow !== titles?.subtitleNow ||
title?.presenterNow !== titles?.presenterNow
) {
setTitles((t) => ({ ...t, showNow: false }));
let timeout: NodeJS.Timeout;
const transitionTime = 2000;
const haveTitlesChanged = eventNow?.title !== heading || eventNow?.presenter !== subheading;
const areTitlesEmpty = !eventNow?.title && !eventNow?.presenter;
// we have new titles
if (haveTitlesChanged && !areTitlesEmpty) {
// show lower
setHeading(eventNow?.title ?? '');
setSubheading(eventNow?.presenter ?? '');
setShowLower(true);
// schedule transition out
const transitionTime = 5000;
timeout = setTimeout(() => {
setTitles(title);
setShowLower(false);
}, transitionTime);
}
return () => {
if (timeout != null) {
if (timeout) {
clearTimeout(timeout);
}
};
// eslint-disable-next-line -- we do this to keep animations
}, [title.titleNow, title.subtitleNow, title.presenterNow]);
}, [eventNow?.title, eventNow?.presenter]);
// defer rendering until we load stylesheets
if (!shouldRender) {
@@ -135,7 +131,7 @@ const Lower = (props: LowerProps) => {
}
}
return <LowerLines lower={lower} title={titles} options={options} />;
return <LowerLines lower={lower} heading={heading} subheading={subheading} options={options} doShow={showLower} />;
};
export default memo(Lower, areEqual);
@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { EventData, Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
import { Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
@@ -19,7 +19,6 @@ interface MinimalTimerProps {
pres: TimerMessage;
time: TimeManagerType;
viewSettings: ViewSettings;
general: EventData;
}
export default function MinimalTimer(props: MinimalTimerProps) {
@@ -24,7 +24,7 @@
/* =================== HEADER + EXTRAS ===================*/
.event-header {
.project-header {
grid-area: header;
font-size: clamp(32px, 4.5vw, 64px);
font-weight: 600;
@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import QRCode from 'react-qr-code';
import { AnimatePresence, motion } from 'framer-motion';
import { EventData, Message, OntimeEvent, ViewSettings } from 'ontime-types';
import { Message, OntimeEvent, ProjectData, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
@@ -16,7 +16,6 @@ import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { formatTime } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import { titleVariants } from '../common/animation';
import { TitleManager } from '../ViewWrapper';
import './Public.scss';
@@ -28,16 +27,18 @@ const formatOptions = {
interface BackstageProps {
isMirrored: boolean;
publ: Message;
publicTitle: TitleManager;
publicEventNow: OntimeEvent | null;
publicEventNext: OntimeEvent | null;
time: TimeManagerType;
events: OntimeEvent[];
publicSelectedId: string | null;
general: EventData;
general: ProjectData;
viewSettings: ViewSettings;
}
export default function Public(props: BackstageProps) {
const { isMirrored, publ, publicTitle, time, events, publicSelectedId, general, viewSettings } = props;
const { isMirrored, publ, publicEventNow, publicEventNext, time, events, publicSelectedId, general, viewSettings } =
props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation();
@@ -58,7 +59,7 @@ export default function Public(props: BackstageProps) {
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
<NavigationMenu />
<ViewParamsEditor paramFields={[TIME_FORMAT_OPTION]} />
<div className='event-header'>
<div className='project-header'>
{general.title}
<div className='clock-container'>
<div className='label'>{getLocalizedString('common.time_now')}</div>
@@ -68,7 +69,7 @@ export default function Public(props: BackstageProps) {
<div className='now-container'>
<AnimatePresence>
{publicTitle.showNow && (
{publicEventNow && (
<motion.div
className='event now'
key='now'
@@ -79,16 +80,16 @@ export default function Public(props: BackstageProps) {
>
<TitleCard
label='now'
title={publicTitle.titleNow}
subtitle={publicTitle.subtitleNow}
presenter={publicTitle.presenterNow}
title={publicEventNow.title}
subtitle={publicEventNow.subtitle}
presenter={publicEventNow.presenter}
/>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{publicTitle.showNext && (
{publicEventNext && (
<motion.div
className='event next'
key='next'
@@ -99,9 +100,9 @@ export default function Public(props: BackstageProps) {
>
<TitleCard
label='next'
title={publicTitle.titleNext}
subtitle={publicTitle.subtitleNext}
presenter={publicTitle.presenterNext}
title={publicEventNext.title}
subtitle={publicEventNext.subtitle}
presenter={publicEventNext.presenter}
/>
</motion.div>
)}

Some files were not shown because too many files have changed in this diff Show More