Compare commits

..

1 Commits

Author SHA1 Message Date
alex-arc 6d7cd416dc fix: count to end ccrossing midnight 2026-07-25 17:47:13 +02:00
39 changed files with 436 additions and 533 deletions
@@ -1,43 +1,20 @@
@use '@/theme/viewerDefs' as *;
.emptyContainer { .emptyContainer {
width: 100%; width: 100%;
text-align: center; text-align: center;
color: var(--secondary-color-override, $viewer-secondary-color); color: $white-10;
.empty { .empty {
display: block; display: block;
width: min(100%, 14rem); width: min(100%, 24rem);
margin: 0 auto -1.5rem; margin-inline: auto;
opacity: 0.6; opacity: 0.8;
} }
.text { .text {
display: block; display: block;
margin-inline: auto; margin-inline: auto;
font-weight: 400; font-weight: 600;
font-size: clamp(1rem, 1.55vw, 1.5rem); font-size: 2em;
line-height: 1.35; max-width: min(100%, 600px);
max-width: min(100%, 40rem);
}
&.error {
color: $error-red;
.empty {
opacity: 0.35;
filter: grayscale(1);
}
.text {
margin-top: 0.5rem;
}
}
.errorIcon {
display: block;
width: 1.5rem;
height: 1.5rem;
margin: -0.125rem auto 0;
} }
} }
@@ -1,5 +1,4 @@
import { CSSProperties } from 'react'; import { CSSProperties } from 'react';
import { IoWarningOutline } from 'react-icons/io5';
import EmptyImage from '../../../assets/images/empty.svg?react'; import EmptyImage from '../../../assets/images/empty.svg?react';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -10,18 +9,12 @@ interface EmptyProps {
text?: string; text?: string;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
className?: string; className?: string;
variant?: 'error';
} }
export default function Empty({ text, className, injectedStyles, variant }: EmptyProps) { export default function Empty({ text, className, injectedStyles }: EmptyProps) {
return ( return (
<div <div className={cx([style.emptyContainer, className])} style={injectedStyles}>
className={cx([style.emptyContainer, variant === 'error' && style.error, className])}
style={injectedStyles}
role={variant === 'error' ? 'alert' : undefined}
>
<EmptyImage className={style.empty} /> <EmptyImage className={style.empty} />
{variant === 'error' && <IoWarningOutline className={style.errorIcon} aria-hidden />}
{text && <span className={style.text}>{text}</span>} {text && <span className={style.text}>{text}</span>}
</div> </div>
); );
@@ -1,11 +0,0 @@
.fill {
box-sizing: border-box;
width: 100%;
height: 100%;
min-height: 0;
flex: 1;
display: grid;
place-items: start center;
padding: clamp(4rem, 20dvh, 12rem) 1.5rem 1rem;
}
@@ -1,19 +0,0 @@
import { cx } from '../../utils/styleUtils';
import Empty from './Empty';
import style from './EmptyFill.module.scss';
interface EmptyFillProps {
text?: string;
/** placed on the fill wrapper — e.g. to assign a grid-area in a grid parent */
className?: string;
}
/** Container-filling empty/loading state for panels and grid/flex cells. */
export default function EmptyFill({ text, className }: EmptyFillProps) {
return (
<div className={cx([style.fill, className])}>
<Empty text={text} />
</div>
);
}
@@ -6,7 +6,7 @@
box-sizing: border-box; /* reset */ box-sizing: border-box; /* reset */
overflow: hidden; overflow: hidden;
width: 100%; /* restrict the page width to viewport */ width: 100%; /* restrict the page width to viewport */
height: 100dvh; height: 100vh;
font-family: var(--font-family-override, $viewer-font-family); font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color); background: var(--background-color-override, $viewer-background-color);
@@ -16,6 +16,5 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; padding-top: 5rem;
padding-block: 5rem;
} }
@@ -7,13 +7,12 @@ import style from './EmptyPage.module.scss';
interface EmptyPageProps { interface EmptyPageProps {
text?: string; text?: string;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
variant?: 'error';
} }
export default function EmptyPage({ text, injectedStyles, variant }: EmptyPageProps) { export default function EmptyPage({ text, injectedStyles }: EmptyPageProps) {
return ( return (
<div className={style.page}> <div className={style.page}>
<Empty text={text} injectedStyles={injectedStyles} variant={variant} /> <Empty text={text} injectedStyles={injectedStyles} />
</div> </div>
); );
} }
@@ -15,4 +15,9 @@
gap: 1rem; gap: 1rem;
margin-top: 1em; margin-top: 1em;
} }
.text {
font-weight: 600;
font-size: 2em;
}
} }
@@ -18,7 +18,8 @@ export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
<tbody className={style.emptyContainer}> <tbody className={style.emptyContainer}>
<tr> <tr>
<td colSpan={99} className={style.emptyCell}> <td colSpan={99} className={style.emptyCell}>
<Empty text={text} injectedStyles={{ marginTop: '5vh' }} /> <Empty injectedStyles={{ marginTop: '5vh' }} />
<span className={style.text}>{text}</span>
{handleAddNew && ( {handleAddNew && (
<div className={style.inline}> <div className={style.inline}>
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'> <Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
@@ -8,12 +8,12 @@ import { getCustomFields } from '../api/customFields';
const placeholder: CustomFields = {}; const placeholder: CustomFields = {};
export default function useCustomFields() { export default function useCustomFields() {
const { data, status, isFetching, isError, isLoadingError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: CUSTOM_FIELDS, queryKey: CUSTOM_FIELDS,
queryFn: ({ signal }) => getCustomFields({ signal }), queryFn: ({ signal }) => getCustomFields({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
}); });
return { data: data ?? placeholder, status, isFetching, isError, isLoadingError, refetch }; return { data: data ?? placeholder, status, isFetching, isError, refetch };
} }
@@ -6,14 +6,14 @@ import { getProjectData, postProjectData } from '../api/project';
import { projectDataPlaceholder } from '../models/ProjectData'; import { projectDataPlaceholder } from '../models/ProjectData';
export default function useProjectData() { export default function useProjectData() {
const { data, status, isFetching, isError, isLoadingError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: PROJECT_DATA, queryKey: PROJECT_DATA,
queryFn: ({ signal }) => getProjectData({ signal }), queryFn: ({ signal }) => getProjectData({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
}); });
return { data: data ?? projectDataPlaceholder, status, isFetching, isError, isLoadingError, refetch }; return { data: data ?? projectDataPlaceholder, status, isFetching, isError, refetch };
} }
export function useUpdateProjectData() { export function useUpdateProjectData() {
@@ -32,7 +32,7 @@ export default function useRundown() {
data: { loaded: loadedRundownId }, data: { loaded: loadedRundownId },
} = useProjectRundowns(); } = useProjectRundowns();
const { data, status, isError, isLoadingError, refetch, isFetching } = useQuery<Rundown>({ const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY, queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY,
queryFn: ({ signal }) => fetchCurrentRundown({ signal }), queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
@@ -50,14 +50,14 @@ export default function useRundown() {
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true }); queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}, [loadedRundownId, queryClient]); }, [loadedRundownId, queryClient]);
return { data: data ?? cachedRundownPlaceholder, status, isError, isLoadingError, refetch, isFetching }; return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
} }
export function useRundownWithMetadata() { export function useRundownWithMetadata() {
const { data, status, isLoadingError } = useRundown(); const { data, status } = useRundown();
const selectedEventId = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]); const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data, status, isLoadingError, rundownMetadata }; return { data, status, rundownMetadata };
} }
/** /**
@@ -65,7 +65,7 @@ export function useRundownWithMetadata() {
* built from the order and rundown fields * built from the order and rundown fields
*/ */
export function useFlatRundown() { export function useFlatRundown() {
const { data, status, isLoadingError } = useRundown(); const { data, status } = useRundown();
const flatRundown = useMemo(() => { const flatRundown = useMemo(() => {
if (data.revision === -1) { if (data.revision === -1) {
@@ -74,15 +74,15 @@ export function useFlatRundown() {
return data.flatOrder.map((id) => data.entries[id]).filter((entry): entry is OntimeEntry => entry !== undefined); return data.flatOrder.map((id) => data.entries[id]).filter((entry): entry is OntimeEntry => entry !== undefined);
}, [data]); }, [data]);
return { data: flatRundown, rundownId: data.id, status, isLoadingError }; return { data: flatRundown, rundownId: data.id, status };
} }
export function useFlatRundownWithMetadata() { export function useFlatRundownWithMetadata() {
const { data, status, isLoadingError } = useRundown(); const { data, status } = useRundown();
const selectedEventId = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]); const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data: rundownWithMetadata, status, isLoadingError }; return { data: rundownWithMetadata, status };
} }
/** /**
@@ -127,7 +127,7 @@ export function useRundownAuxData() {
export function useRundownById(rundownId: string | null | undefined) { export function useRundownById(rundownId: string | null | undefined) {
const enabled = Boolean(rundownId); const enabled = Boolean(rundownId);
const { data, status, isError, isLoadingError, refetch, isFetching } = useQuery<Rundown>({ const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: getRundownQueryKey(rundownId ?? ''), queryKey: getRundownQueryKey(rundownId ?? ''),
queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }), queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }),
enabled, enabled,
@@ -135,5 +135,5 @@ export function useRundownById(rundownId: string | null | undefined) {
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
}); });
return { data: data ?? cachedRundownPlaceholder, status, isError, isLoadingError, refetch, isFetching }; return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
} }
@@ -11,7 +11,6 @@ export type RundownSource = {
rundown: Rundown; rundown: Rundown;
flatRundown: ExtendedEntry[]; flatRundown: ExtendedEntry[];
status: string; status: string;
isLoadingError: boolean;
selectedEventId: EntryId | null; selectedEventId: EntryId | null;
}; };
@@ -36,7 +35,7 @@ function useRundownSource(rundownId: string | null, loadedRundownId: string | nu
const isLoadedTarget = rundownId !== null && rundownId === loadedRundownId; const isLoadedTarget = rundownId !== null && rundownId === loadedRundownId;
const runtimeSelectedEventId = useSelectedEventId(); const runtimeSelectedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedTarget ? runtimeSelectedEventId : null; const effectiveSelectedEventId = isLoadedTarget ? runtimeSelectedEventId : null;
const { data: rundown, status, isLoadingError } = useRundownById(rundownId); const { data: rundown, status } = useRundownById(rundownId);
const flatRundown = useMemo( const flatRundown = useMemo(
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId), () => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
[effectiveSelectedEventId, rundown], [effectiveSelectedEventId, rundown],
@@ -48,9 +47,8 @@ function useRundownSource(rundownId: string | null, loadedRundownId: string | nu
rundown, rundown,
flatRundown, flatRundown,
status, status,
isLoadingError,
selectedEventId: effectiveSelectedEventId, selectedEventId: effectiveSelectedEventId,
}), }),
[effectiveSelectedEventId, flatRundown, isLoadingError, rundown, rundownId, status], [effectiveSelectedEventId, flatRundown, rundown, rundownId, status],
); );
} }
@@ -6,7 +6,7 @@ import { getSettings } from '../api/settings';
import { ontimePlaceholderSettings } from '../models/OntimeSettings'; import { ontimePlaceholderSettings } from '../models/OntimeSettings';
export default function useSettings() { export default function useSettings() {
const { data, status, isFetching, isError, isLoadingError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: APP_SETTINGS, queryKey: APP_SETTINGS,
queryFn: ({ signal }) => getSettings({ signal }), queryFn: ({ signal }) => getSettings({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
@@ -22,5 +22,5 @@ export default function useSettings() {
}, },
}); });
return { data: data ?? ontimePlaceholderSettings, status, isFetching, isError, isLoadingError, refetch }; return { data: data ?? ontimePlaceholderSettings, status, isFetching, isError, refetch };
} }
@@ -7,7 +7,7 @@ import { VIEW_SETTINGS } from '../api/constants';
import { viewsSettingsPlaceholder } from '../models/ViewSettings.type'; import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
export default function useViewSettings() { export default function useViewSettings() {
const { data, status, isLoadingError } = useQuery({ const { data, status } = useQuery({
queryKey: VIEW_SETTINGS, queryKey: VIEW_SETTINGS,
queryFn: ({ signal }) => getViewSettings({ signal }), queryFn: ({ signal }) => getViewSettings({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
@@ -24,5 +24,5 @@ export default function useViewSettings() {
}, },
}); });
return { data: data ?? viewsSettingsPlaceholder, status, isLoadingError, mutateAsync }; return { data: data ?? viewsSettingsPlaceholder, status, mutateAsync };
} }
+102 -110
View File
@@ -1,7 +1,6 @@
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types'; import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage'; import EmptyPage from '../../common/components/state/EmptyPage';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import useFollowComponent from '../../common/hooks/useFollowComponent'; import useFollowComponent from '../../common/hooks/useFollowComponent';
@@ -11,7 +10,6 @@ import { cx } from '../../common/utils/styleUtils';
import { throttle } from '../../common/utils/throttle'; import { throttle } from '../../common/utils/throttle';
import { getDefaultFormat } from '../../common/utils/time'; import { getDefaultFormat } from '../../common/utils/time';
import { isTouchDevice } from '../../externals'; import { isTouchDevice } from '../../externals';
import { useTranslation } from '../../translation/TranslationProvider';
import Loader from '../../views/common/loader/Loader'; import Loader from '../../views/common/loader/Loader';
import CustomFieldEditModal from './custom-field-edit-modal/CustomFieldEditModal'; import CustomFieldEditModal from './custom-field-edit-modal/CustomFieldEditModal';
import FollowButton from './follow-button/FollowButton'; import FollowButton from './follow-button/FollowButton';
@@ -37,7 +35,7 @@ export default function OperatorLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />; return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
} }
return <Operator {...data} />; return <Operator {...data} />;
@@ -45,7 +43,6 @@ export default function OperatorLoader() {
function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) { function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) {
const selectedEventId = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const { getLocalizedString } = useTranslation();
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions(); const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
const [showEditPrompt, setShowEditPrompt] = useState(false); const [showEditPrompt, setShowEditPrompt] = useState(false);
@@ -116,7 +113,6 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
const operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]); const operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]);
const canEdit = shouldEdit && subscribe.length; const canEdit = shouldEdit && subscribe.length;
const hasEvents = rundown.order.length > 0;
return ( return (
<div className={style.operatorContainer} data-testid='operator-view'> <div className={style.operatorContainer} data-testid='operator-view'>
@@ -131,121 +127,117 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
</div> </div>
)} )}
{!hasEvents ? ( <div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
<EmptyFill text={getLocalizedString('common.no_data')} /> {rundown.order.map((entryId) => {
) : ( const entry = rundown.entries[entryId];
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}> if (isOntimeEvent(entry)) {
{rundown.order.map((entryId) => { const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
const entry = rundown.entries[entryId]; // hide past events (if setting) and skipped events
if (isOntimeEvent(entry)) { if ((hidePast && isPast) || entry.skip) {
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId]; return null;
// hide past events (if setting) and skipped events }
if ((hidePast && isPast) || entry.skip) {
return null;
}
const { mainField, secondaryField, subscribedData } = getEventData( const { mainField, secondaryField, subscribedData } = getEventData(
entry, entry,
mainSource, mainSource,
secondarySource, secondarySource,
subscribe, subscribe,
customFields, customFields,
); );
return ( return (
<OperatorEvent <OperatorEvent
key={entry.id}
id={entry.id}
colour={entry.colour}
cue={entry.cue}
main={mainField}
secondary={secondaryField}
timeStart={entry.timeStart}
duration={entry.duration}
delay={entry.delay}
dayOffset={entry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
/>
);
}
if (isOntimeGroup(entry)) {
const { isPast } = rundownMetadata[entry.id];
const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId]?.groupId === entry.id : false;
if (hidePast && isPast && !isCurrentParent) {
return null;
}
return (
<Fragment key={entry.id}>
<OperatorGroup
key={entry.id} key={entry.id}
id={entry.id} title={entry.title}
colour={entry.colour} colour={entry.colour}
cue={entry.cue} count={entry.entries.length}
main={mainField}
secondary={secondaryField}
timeStart={entry.timeStart}
duration={entry.duration} duration={entry.duration}
delay={entry.delay}
dayOffset={entry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
/> />
); {entry.entries.map((nestedEntryId) => {
} const nestedEntry = rundown.entries[nestedEntryId];
if (!isOntimeEvent(nestedEntry)) {
return null;
}
if (isOntimeGroup(entry)) { const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
const { isPast } = rundownMetadata[entry.id];
const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId]?.groupId === entry.id : false; // hide past events (if setting) and skipped events
if ((hidePast && isPast) || nestedEntry.skip) {
return null;
}
if (hidePast && isPast && !isCurrentParent) { const { mainField, secondaryField, subscribedData } = getEventData(
return null; nestedEntry,
} mainSource,
secondarySource,
subscribe,
customFields,
);
return ( return (
<Fragment key={entry.id}> <OperatorEvent
<OperatorGroup key={nestedEntry.id}
key={entry.id} id={nestedEntry.id}
title={entry.title} colour={nestedEntry.colour}
colour={entry.colour} cue={nestedEntry.cue}
count={entry.entries.length} main={mainField}
duration={entry.duration} secondary={secondaryField}
/> timeStart={nestedEntry.timeStart}
{entry.entries.map((nestedEntryId) => { duration={nestedEntry.duration}
const nestedEntry = rundown.entries[nestedEntryId]; delay={nestedEntry.delay}
if (!isOntimeEvent(nestedEntry)) { dayOffset={nestedEntry.dayOffset}
return null; isLinkedToLoaded={isLinkedToLoaded}
} isSelected={isLoaded}
isPast={isPast}
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId]; groupColour={entry.colour}
selectedRef={isLoaded ? selectedRef : undefined}
// hide past events (if setting) and skipped events showStart={showStart}
if ((hidePast && isPast) || nestedEntry.skip) { subscribed={subscribedData}
return null; totalGap={totalGap}
} onLongPress={canEdit ? handleEdit : () => undefined}
/>
const { mainField, secondaryField, subscribedData } = getEventData( );
nestedEntry, })}
mainSource, </Fragment>
secondarySource, );
subscribe, }
customFields, return null;
); })}
</div>
return (
<OperatorEvent
key={nestedEntry.id}
id={nestedEntry.id}
colour={nestedEntry.colour}
cue={nestedEntry.cue}
main={mainField}
secondary={secondaryField}
timeStart={nestedEntry.timeStart}
duration={nestedEntry.duration}
delay={nestedEntry.delay}
dayOffset={nestedEntry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
groupColour={entry.colour}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
/>
);
})}
</Fragment>
);
}
return null;
})}
</div>
)}
<FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} /> <FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} />
</div> </div>
); );
@@ -14,18 +14,9 @@ export interface OperatorData {
} }
export function useOperatorData(): ViewData<OperatorData> { export function useOperatorData(): ViewData<OperatorData> {
const { const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata();
data: rundown, const { data: customFields, status: customFieldStatus } = useCustomFields();
rundownMetadata, const { data: settings, status: settingsStatus } = useSettings();
status: rundownStatus,
isLoadingError: rundownIsLoadingError,
} = useRundownWithMetadata();
const {
data: customFields,
status: customFieldStatus,
isLoadingError: customFieldIsLoadingError,
} = useCustomFields();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
return { return {
data: { data: {
@@ -34,10 +25,6 @@ export function useOperatorData(): ViewData<OperatorData> {
customFields, customFields,
settings, settings,
}, },
status: aggregateQueryStatus([ status: aggregateQueryStatus([rundownStatus, customFieldStatus, settingsStatus]),
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
{ status: customFieldStatus, isLoadingError: customFieldIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
]),
}; };
} }
@@ -1,24 +1,19 @@
import { memo } from 'react'; import { memo } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill'; import Empty from '../../common/components/state/Empty';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown'; import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import { useRundownEditor } from '../../common/hooks/useSocket'; import { useRundownEditor } from '../../common/hooks/useSocket';
import { useTranslation } from '../../translation/TranslationProvider';
import Rundown from './Rundown'; import Rundown from './Rundown';
export default memo(RundownList); export default memo(RundownList);
function RundownList() { function RundownList() {
const { data, status, isLoadingError, rundownMetadata } = useRundownWithMetadata(); const { data, status, rundownMetadata } = useRundownWithMetadata();
const featureData = useRundownEditor(); const featureData = useRundownEditor();
const { getLocalizedString } = useTranslation();
// avoid showing the editable empty state before we know whether the rundown is actually empty const isLoading = status !== 'success' || !data || !rundownMetadata;
if (status === 'pending') {
return <EmptyFill text='Loading…' />;
}
if (isLoadingError) { if (isLoading) {
return <EmptyFill text={getLocalizedString('common.no_data')} />; return <Empty text='Connecting to server' />;
} }
return ( return (
@@ -1,5 +1,6 @@
import { memo, useEffect, useMemo } from 'react'; import { memo, useEffect, useMemo } from 'react';
import EmptyPage from '../../../common/components/state/EmptyPage';
import { EntryActionsProvider } from '../../../common/context/EntryActionsContext'; import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown'; import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown';
@@ -12,7 +13,7 @@ import { makeRundownColumns } from './makeRundownColumns';
export default memo(RundownTable); export default memo(RundownTable);
function RundownTable() { function RundownTable() {
const { data: customFields } = useCustomFields(); const { data: customFields, status: customFieldStatus } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions); const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const { editorMode } = useEditorFollowMode(); const { editorMode } = useEditorFollowMode();
const source = useLoadedRundownSource(); const source = useLoadedRundownSource();
@@ -31,10 +32,16 @@ function RundownTable() {
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]); const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
const isLoading = !customFields || customFieldStatus === 'pending';
return ( return (
<EntryActionsProvider actions={actions}> <EntryActionsProvider actions={actions}>
<CuesheetDnd columns={columns} tableRoot='editor'> <CuesheetDnd columns={columns} tableRoot='editor'>
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' /> {isLoading ? (
<EmptyPage text='Loading...' />
) : (
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
)}
</CuesheetDnd> </CuesheetDnd>
</EntryActionsProvider> </EntryActionsProvider>
); );
@@ -35,7 +35,7 @@ export default function BackstageLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />; return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
} }
return <Backstage {...data} />; return <Backstage {...data} />;
@@ -20,14 +20,10 @@ export function useBackstageData(): ViewData<BackstageData> {
const isMirrored = useViewOptionsStore((state) => state.mirror); const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data // HTTP API data
const { data: rundownData, status: rundownStatus, isLoadingError: rundownIsLoadingError } = useFlatRundown(); const { data: rundownData, status: rundownStatus } = useFlatRundown();
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData(); const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings(); const { data: settings, status: settingsStatus } = useSettings();
const { const { data: customFields, status: customFieldsStatus } = useCustomFields();
data: customFields,
status: customFieldsStatus,
isLoadingError: customFieldsIsLoadingError,
} = useCustomFields();
return { return {
data: { data: {
@@ -37,11 +33,6 @@ export function useBackstageData(): ViewData<BackstageData> {
isMirrored, isMirrored,
settings, settings,
}, },
status: aggregateQueryStatus([ status: aggregateQueryStatus([rundownStatus, projectDataStatus, settingsStatus, customFieldsStatus]),
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
]),
}; };
} }
@@ -7,7 +7,7 @@ $dot-spacing: 1.5rem;
display: grid; display: grid;
place-items: center; place-items: center;
background-color: var(--background-color-override, $viewer-background-color); background-color: var(--background-color-override, $viewer-background-color);
height: 100dvh; height: 100vh;
} }
.ellipsis { .ellipsis {
@@ -21,26 +21,26 @@ $dot-spacing: 1.5rem;
height: $dot-size; height: $dot-size;
border-radius: 50%; border-radius: 50%;
background-color: var(--accent-color-override, $ontime-color); background-color: var(--accent-color-override, $ontime-color);
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); animation-timing-function: cubic-bezier(0, 1, 1, 0);
&:nth-child(1) { &:nth-child(1) {
left: $dot-size; left: $dot-size;
animation: lds-ellipsis1 1s infinite; animation: lds-ellipsis1 0.6s infinite;
} }
&:nth-child(2) { &:nth-child(2) {
left: $dot-size; left: $dot-size;
animation: lds-ellipsis2 1s infinite; animation: lds-ellipsis2 0.6s infinite;
} }
&:nth-child(3) { &:nth-child(3) {
left: calc($dot-size + $dot-spacing); left: calc($dot-size + $dot-spacing);
animation: lds-ellipsis2 1s infinite; animation: lds-ellipsis2 0.6s infinite;
} }
&:nth-child(4) { &:nth-child(4) {
left: calc($dot-size + 2 * $dot-spacing); left: calc($dot-size + 2 * $dot-spacing);
animation: lds-ellipsis3 1s infinite; animation: lds-ellipsis3 0.6s infinite;
} }
} }
} }
+15 -1
View File
@@ -80,7 +80,21 @@ $item-height: 3.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 1.5rem;
button {
margin-top: 1.75rem;
}
}
.empty-state__content {
max-width: none;
span {
max-width: none;
white-space: nowrap;
font-size: clamp(1.5rem, 4vw, 2.25rem);
line-height: 1.1;
}
} }
.list-container { .list-container {
@@ -43,7 +43,7 @@ export default function CountdownLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />; return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
} }
return <Countdown {...data} />; return <Countdown {...data} />;
@@ -87,7 +87,7 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
{!hasEvents && ( {!hasEvents && (
<div className='empty-state'> <div className='empty-state'>
<Empty text={getLocalizedString('common.no_data')} /> <Empty text={getLocalizedString('common.no_data')} className='empty-state__content' />
</div> </div>
)} )}
@@ -121,7 +121,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (subscriptions.length === 0) { if (subscriptions.length === 0) {
return ( return (
<div className='empty-state'> <div className='empty-state'>
<Empty text={getLocalizedString('countdown.select_event')} /> <Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' />
<Button variant='primary' size='xlarge' onClick={goToEditMode}> <Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add <IoAdd /> Add
</Button> </Button>
@@ -137,7 +137,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (subscribedEvents.length === 0) { if (subscribedEvents.length === 0) {
return ( return (
<div className='empty-state'> <div className='empty-state'>
<Empty text={getLocalizedString('countdown.select_event')} /> <Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' />
<Button variant='primary' size='xlarge' onClick={goToEditMode}> <Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add <IoAdd /> Add
</Button> </Button>
@@ -154,7 +154,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (eventsToShow.length === 0) { if (eventsToShow.length === 0) {
return ( return (
<div className='empty-state'> <div className='empty-state'>
<Empty text={getLocalizedString('countdown.all_have_finished')} /> <Empty text={getLocalizedString('countdown.all_have_finished')} className='empty-state__content' />
</div> </div>
); );
} }
@@ -21,18 +21,10 @@ export function useCountdownData(): ViewData<CountdownData> {
const isMirrored = useViewOptionsStore((state) => state.mirror); const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data // HTTP API data
const { const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
data: rundownData, const { data: projectData, status: projectDataStatus } = useProjectData();
status: rundownStatus, const { data: settings, status: settingsStatus } = useSettings();
isLoadingError: rundownIsLoadingError, const { data: customFields, status: customFieldsStatus } = useCustomFields();
} = useFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
const {
data: customFields,
status: customFieldsStatus,
isLoadingError: customFieldsIsLoadingError,
} = useCustomFields();
return { return {
data: { data: {
@@ -42,11 +34,6 @@ export function useCountdownData(): ViewData<CountdownData> {
isMirrored, isMirrored,
settings, settings,
}, },
status: aggregateQueryStatus([ status: aggregateQueryStatus([rundownStatus, projectDataStatus, settingsStatus, customFieldsStatus]),
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
]),
}; };
} }
@@ -2,6 +2,7 @@ import { MaybeString, ProjectRundown } from 'ontime-types';
import { memo, use, useMemo } from 'react'; import { memo, use, useMemo } from 'react';
import Select from '../../common/components/select/Select'; import Select from '../../common/components/select/Select';
import EmptyPage from '../../common/components/state/EmptyPage';
import { PresetContext } from '../../common/context/PresetContext'; import { PresetContext } from '../../common/context/PresetContext';
import useCustomFields from '../../common/hooks-query/useCustomFields'; import useCustomFields from '../../common/hooks-query/useCustomFields';
import type { RundownSource } from '../../common/hooks-query/useScopedRundown'; import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
@@ -33,34 +34,40 @@ function CuesheetTableWrapper({
const preset = use(PresetContext); const preset = use(PresetContext);
const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId; const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown }); const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
const { data: customFields } = useCustomFields(); const { data: customFields, status: customFieldStatus } = useCustomFields();
const columns = useMemo( const columns = useMemo(
() => makeCuesheetColumns(customFields, cuesheetMode, preset), () => makeCuesheetColumns(customFields, cuesheetMode, preset),
[customFields, cuesheetMode, preset], [customFields, cuesheetMode, preset],
); );
const isLoading = !customFields || customFieldStatus === 'pending';
return ( return (
<CuesheetDnd columns={columns}> <CuesheetDnd columns={columns}>
<CuesheetTable {isLoading ? (
columns={columns} <EmptyPage text='Loading...' />
source={source} ) : (
cuesheetMode={cuesheetMode} <CuesheetTable
tableRoot='cuesheet' columns={columns}
setCuesheetMode={setCuesheetMode} source={source}
isCurrentRundown={isCurrentRundown} cuesheetMode={cuesheetMode}
insertElement={ tableRoot='cuesheet'
<> setCuesheetMode={setCuesheetMode}
<RundownSelect isCurrentRundown={isCurrentRundown}
cuesheetMode={cuesheetMode} insertElement={
selectedRundownId={selectedRundownId} <>
loadedRundownId={loadedRundownId} <RundownSelect
setSelectedRundownId={setSelectedRundownId} cuesheetMode={cuesheetMode}
projectRundowns={projectRundowns} selectedRundownId={selectedRundownId}
/> loadedRundownId={loadedRundownId}
</> setSelectedRundownId={setSelectedRundownId}
} projectRundowns={projectRundowns}
/> />
</>
}
/>
)}
</CuesheetDnd> </CuesheetDnd>
); );
} }
@@ -5,10 +5,6 @@ $table-header-font-size: calc(1rem - 2px);
@include rows.cuesheet-row-columns($table-header-font-size); @include rows.cuesheet-row-columns($table-header-font-size);
.tableLoading {
grid-area: table;
}
.cuesheet { .cuesheet {
font-size: $table-font-size; font-size: $table-font-size;
font-weight: 400; font-weight: 400;
@@ -11,7 +11,7 @@ import {
TableVirtuosoHandle, TableVirtuosoHandle,
} from 'react-virtuoso'; } from 'react-virtuoso';
import EmptyFill from '../../../common/components/state/EmptyFill'; import EmptyPage from '../../../common/components/state/EmptyPage';
import EmptyTableBody from '../../../common/components/state/EmptyTableBody'; import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import type { RundownSource } from '../../../common/hooks-query/useScopedRundown'; import type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
@@ -19,7 +19,6 @@ import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options'; import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
import { useEventSelection } from '../../../features/rundown/useEventSelection'; import { useEventSelection } from '../../../features/rundown/useEventSelection';
import { AppMode } from '../../../ontimeConfig'; import { AppMode } from '../../../ontimeConfig';
import { useTranslation } from '../../../translation/TranslationProvider';
import { usePersistedCuesheetOptions } from '../cuesheet.options'; import { usePersistedCuesheetOptions } from '../cuesheet.options';
import { useCuesheetPermissions } from '../useTablePermissions'; import { useCuesheetPermissions } from '../useTablePermissions';
import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader'; import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader';
@@ -63,9 +62,8 @@ export default function CuesheetTable({
isCurrentRundown, isCurrentRundown,
insertElement, insertElement,
}: CuesheetTableProps) { }: CuesheetTableProps) {
const { flatRundown, status, isLoadingError, selectedEventId } = source; const { flatRundown, status, selectedEventId } = source;
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext(); const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
const { getLocalizedString } = useTranslation();
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit; const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions; const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions;
@@ -230,13 +228,10 @@ export default function CuesheetTable({
}); });
}, [cuesheetMode, hideIndexColumn, table]); }, [cuesheetMode, hideIndexColumn, table]);
// avoid showing the editable empty state before we know whether the rundown is actually empty const isLoading = !flatRundown || status === 'pending';
if (status === 'pending') {
return <EmptyFill text='Loading…' className={style.tableLoading} />;
}
if (isLoadingError) { if (isLoading) {
return <EmptyFill text={getLocalizedString('common.no_data')} className={style.tableLoading} />; return <EmptyPage text='Loading...' />;
} }
return ( return (
@@ -1,7 +1,5 @@
@use '@/theme/viewerDefs' as *; @use '@/theme/viewerDefs' as *;
$content-width: min(100%, 1100px);
.project { .project {
margin: 0; margin: 0;
box-sizing: border-box; /* reset */ box-sizing: border-box; /* reset */
@@ -18,104 +16,56 @@ $content-width: min(100%, 1100px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
/* =================== HEADER ===================*/
.project-header {
width: $content-width;
margin-inline: auto;
display: flex;
align-items: center;
gap: clamp(12px, 2vw, 24px);
padding-bottom: $view-element-gap;
border-bottom: 1px solid $white-10;
}
.logo { .logo {
max-width: min(200px, 30vw); max-width: min(200px, 30vw);
} }
.title {
font-size: $header-font-size;
font-weight: 600;
line-height: 1.1em;
}
.description {
font-size: $base-font-size;
color: var(--secondary-color-override, $viewer-secondary-color);
}
/* =================== CONTENT ===================*/
.info { .info {
flex: 1; flex: 1;
width: $content-width; max-height: 100%;
margin-inline: auto; margin-inline: auto;
overflow-y: auto; overflow-y: auto;
width: min(calc(100vw - 4rem), 960px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: start;
gap: $view-element-gap; gap: $view-element-gap;
padding-block: $view-element-gap;
padding-bottom: 10vh; padding-bottom: 10vh;
} }
.info__card {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
border-radius: $element-border-radius;
padding: $view-block-padding $view-inline-padding;
display: flex;
flex-direction: column;
gap: 0.35em;
}
.info__media {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: $view-element-gap;
}
.info__media .info__value {
flex: 1;
min-width: 0;
}
.info__label { .info__label {
font-size: $timer-label-size;
font-weight: 600; font-weight: 600;
letter-spacing: 0.05em;
color: var(--label-color-override, $viewer-label-color); color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase; text-transform: uppercase;
} }
.info__value { .info__value {
white-space: break-spaces; white-space: break-spaces;
line-height: 1.35; }
overflow-wrap: anywhere;
.info__custom {
display: flex;
gap: 1rem;
} }
.info__image-container { .info__image-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
flex: 0 0 min(192px, 25%); width: 192px;
height: 192px;
} }
.info__image { .info__image {
display: block;
max-width: 100%; max-width: 100%;
height: auto; max-height: 100%;
object-fit: contain; object-fit: contain;
} }
.link.info__value { .link.info__value {
display: inline-flex; display: flex;
gap: 0.35em; gap: $view-element-gap;
align-items: center; align-items: center;
color: $action-text-color; color: $action-text-color;
@@ -128,13 +78,12 @@ $content-width: min(100%, 1100px);
/* =================== MOBILE ===================*/ /* =================== MOBILE ===================*/
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
.project { .project {
.project-header {
flex-direction: column;
align-items: start;
gap: 0.5rem;
}
.logo img { .logo img {
height: min(50px, 10vh); height: min(50px, 10vh);
} }
.info__image-container {
width: 96px;
height: 96px;
}
} }
} }
@@ -1,5 +1,4 @@
import { OntimeView } from 'ontime-types'; import { OntimeView } from 'ontime-types';
import { type ReactNode, useState } from 'react';
import { IoOpenOutline } from 'react-icons/io5'; import { IoOpenOutline } from 'react-icons/io5';
import EmptyPage from '../../common/components/state/EmptyPage'; import EmptyPage from '../../common/components/state/EmptyPage';
@@ -22,7 +21,7 @@ export default function ProjectInfoLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />; return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
} }
return <ProjectInfo {...data} />; return <ProjectInfo {...data} />;
@@ -42,49 +41,56 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
return ( return (
<> <>
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} /> <ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
<EmptyPage text={getLocalizedString('common.no_data')} /> <EmptyPage text={getLocalizedString('common.no_data')} />;
</> </>
); );
} }
const hasHeader = Boolean(projectData.logo || projectData.title || projectData.description);
return ( return (
<div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'> <div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'>
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} /> <ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
{hasHeader && ( {projectData.logo && <ViewLogo name={projectData.logo} className='logo' />}
<div className='project-header'>
{projectData.logo && <ViewLogo name={projectData.logo} className='logo' />}
<div className='project-header__text'>
{projectData.title && <div className='title'>{projectData.title}</div>}
{projectData.description && <div className='description'>{projectData.description}</div>}
</div>
</div>
)}
<div className='info'> <div className='info'>
{projectData.info && <InfoCard label={getLocalizedString('project.info')}>{projectData.info}</InfoCard>} {projectData.title && (
<div>
<div className='info__label'>{getLocalizedString('project.title')}</div>
<div className='info__value'>{projectData.title}</div>
</div>
)}
{projectData.description && (
<div>
<div className='info__label'>{getLocalizedString('project.description')}</div>
<div className='info__value'>{projectData.description}</div>
</div>
)}
{projectData.info && (
<div>
<div className='info__label'>{getLocalizedString('project.info')}</div>
<div className='info__value'>{projectData.info}</div>
</div>
)}
{projectData.url && ( {projectData.url && (
<div className='info__card'> <div>
<div className='info__label'>{getLocalizedString('project.url')}</div> <div className='info__label'>{getLocalizedString('project.url')}</div>
<a href={projectData.url} target='_blank' rel='noreferrer' className='info__value link'> <a href={projectData.url} target='_blank' rel='noreferrer' className='info__value link'>
{projectData.url} {projectData.url} <IoOpenOutline style={{ fontSize: '1em' }} />
<IoOpenOutline style={{ fontSize: '1em' }} />
</a> </a>
</div> </div>
)} )}
{projectData.custom.map((info, idx) => { {projectData.custom.map((info, idx) => {
const hasUrl = Boolean(info.url);
return ( return (
// oxlint-disable-next-line react/no-array-index-key - we only have the index to go of here // oxlint-disable-next-line react/no-array-index-key - we only have the index to go of here
<div key={`${info.title}-${idx}`} className='info__card'> <div key={`${info.title}-${idx}`} className='info__custom'>
{info.title && <div className='info__label'>{info.title}</div>} {hasUrl && (
{info.url ? ( <div className='info__image-container'>
<div className='info__media'> <img className='info__image' src={info.url} loading='lazy' />
<InfoImage src={info.url} />
{info.value && <div className='info__value'>{info.value}</div>}
</div> </div>
) : (
info.value && <div className='info__value'>{info.value}</div>
)} )}
<div>
<div className='info__label'>{info.title}</div>
<div className='info__value'>{info.value}</div>
</div>
</div> </div>
); );
})} })}
@@ -92,31 +98,3 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
</div> </div>
); );
} }
interface InfoCardProps {
label: string;
children: ReactNode;
}
function InfoCard({ label, children }: InfoCardProps) {
return (
<div className='info__card'>
<div className='info__label'>{label}</div>
<div className='info__value'>{children}</div>
</div>
);
}
function InfoImage({ src }: { src: string }) {
const [hasError, setHasError] = useState(false);
if (hasError) {
return null;
}
return (
<div className='info__image-container'>
<img className='info__image' src={src} loading='lazy' alt='' onError={() => setHasError(true)} />
</div>
);
}
@@ -2,7 +2,7 @@ import { ProjectData } from 'ontime-types';
import useProjectData from '../../common/hooks-query/useProjectData'; import useProjectData from '../../common/hooks-query/useProjectData';
import { useViewOptionsStore } from '../../common/stores/viewOptions'; import { useViewOptionsStore } from '../../common/stores/viewOptions';
import { ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils'; import { ViewData } from '../utils/viewLoader.utils';
export interface ProjectInfoData { export interface ProjectInfoData {
projectData: ProjectData; projectData: ProjectData;
@@ -14,13 +14,13 @@ export function useProjectInfoData(): ViewData<ProjectInfoData> {
const isMirrored = useViewOptionsStore((state) => state.mirror); const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data // HTTP API data
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData(); const { data: projectData, status: projectDataStatus } = useProjectData();
return { return {
data: { data: {
projectData, projectData,
isMirrored, isMirrored,
}, },
status: aggregateQueryStatus([{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError }]), status: projectDataStatus,
}; };
} }
+1 -1
View File
@@ -25,7 +25,7 @@ export default function StudioLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />; return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
} }
return <Studio {...data} />; return <Studio {...data} />;
+5 -18
View File
@@ -20,18 +20,10 @@ export function useStudioData(): ViewData<StudioData> {
const isMirrored = useViewOptionsStore((state) => state.mirror); const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data // HTTP API data
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData(); const { data: projectData, status: projectDataStatus } = useProjectData();
const { const { data: viewSettings, status: viewSettingsStatus } = useViewSettings();
data: viewSettings, const { data: settings, status: settingsStatus } = useSettings();
status: viewSettingsStatus, const { data: customFields, status: customFieldsStatus } = useCustomFields();
isLoadingError: viewSettingsIsLoadingError,
} = useViewSettings();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
const {
data: customFields,
status: customFieldsStatus,
isLoadingError: customFieldsIsLoadingError,
} = useCustomFields();
return { return {
data: { data: {
@@ -41,11 +33,6 @@ export function useStudioData(): ViewData<StudioData> {
settings, settings,
viewSettings, viewSettings,
}, },
status: aggregateQueryStatus([ status: aggregateQueryStatus([projectDataStatus, viewSettingsStatus, settingsStatus, customFieldsStatus]),
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
{ status: viewSettingsStatus, isLoadingError: viewSettingsIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
]),
}; };
} }
@@ -1,7 +1,6 @@
import { OntimeView } from 'ontime-types'; import { OntimeView } from 'ontime-types';
import { useMemo } from 'react'; import { useMemo } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage'; import EmptyPage from '../../common/components/state/EmptyPage';
import ViewLogo from '../../common/components/view-logo/ViewLogo'; import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
@@ -30,7 +29,7 @@ export default function TimelinePageLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />; return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
} }
return <TimelinePage {...data} />; return <TimelinePage {...data} />;
@@ -74,7 +73,7 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
totalDuration={totalDuration} totalDuration={totalDuration}
/> />
) : ( ) : (
<EmptyFill text={getLocalizedString('common.no_data')} /> <EmptyPage text={getLocalizedString('common.no_data')} />
)} )}
</div> </div>
); );
@@ -16,18 +16,10 @@ export interface TimelineData {
export function useTimelineData(): ViewData<TimelineData> { export function useTimelineData(): ViewData<TimelineData> {
// HTTP API data // HTTP API data
const { const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
data: rundownData, const { data: projectData, status: projectDataStatus } = useProjectData();
status: rundownStatus, const { data: settings, status: settingsStatus } = useSettings();
isLoadingError: rundownIsLoadingError, const { data: customFields, status: customFieldsStatus } = useCustomFields();
} = useFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
const {
data: customFields,
status: customFieldsStatus,
isLoadingError: customFieldsIsLoadingError,
} = useCustomFields();
return { return {
data: { data: {
@@ -36,11 +28,6 @@ export function useTimelineData(): ViewData<TimelineData> {
projectData, projectData,
settings, settings,
}, },
status: aggregateQueryStatus([ status: aggregateQueryStatus([rundownStatus, projectDataStatus, settingsStatus, customFieldsStatus]),
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
]),
}; };
} }
+1 -1
View File
@@ -43,7 +43,7 @@ export default function TimerLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />; return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
} }
return <Timer {...data} />; return <Timer {...data} />;
+10 -18
View File
@@ -22,19 +22,11 @@ export function useTimerData(): ViewData<TimerData> {
const isMirrored = useViewOptionsStore((state) => state.mirror); const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data // HTTP API data
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData(); const { data: projectData, status: projectDataStatus } = useProjectData();
const { const { data: viewSettings, status: viewSettingsStatus } = useViewSettings();
data: viewSettings, const { data: settings, status: settingsStatus } = useSettings();
status: viewSettingsStatus, const { data: customFields, status: customFieldsStatus } = useCustomFields();
isLoadingError: viewSettingsIsLoadingError, const { data: rundown, status: rundownStatus } = useRundown();
} = useViewSettings();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
const {
data: customFields,
status: customFieldsStatus,
isLoadingError: customFieldsIsLoadingError,
} = useCustomFields();
const { data: rundown, status: rundownStatus, isLoadingError: rundownIsLoadingError } = useRundown();
const { entries } = rundown; const { entries } = rundown;
return { return {
@@ -47,11 +39,11 @@ export function useTimerData(): ViewData<TimerData> {
entries, entries,
}, },
status: aggregateQueryStatus([ status: aggregateQueryStatus([
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError }, projectDataStatus,
{ status: viewSettingsStatus, isLoadingError: viewSettingsIsLoadingError }, viewSettingsStatus,
{ status: settingsStatus, isLoadingError: settingsIsLoadingError }, settingsStatus,
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError }, customFieldsStatus,
{ status: rundownStatus, isLoadingError: rundownIsLoadingError }, rundownStatus,
]), ]),
}; };
} }
+11 -17
View File
@@ -5,27 +5,21 @@ export type ViewData<T> = {
status: QueryStatus; status: QueryStatus;
}; };
type AggregatableQuery = {
status: QueryStatus;
/** true only when the query has never received data, ie useQuery's isLoadingError */
isLoadingError: boolean;
};
/** /**
* Aggregates a loading status from multiple queries, for the purpose of deciding * Aggregates a loading status from multiple query statuses.
* whether a view can render. * If all statuses are 'pending', returns 'pending'.
* - 'pending' while any query hasn't settled yet (no result, first fetch in flight) * If all statuses are 'success', returns 'success'.
* - 'error' once all queries have settled, if any of them never received data * If any status is 'error', returns 'error'.
* - 'success' once all queries have settled and every one has data to show,
* even if a query's last (background) fetch failed
*/ */
export function aggregateQueryStatus(queries: AggregatableQuery[]): QueryStatus { export function aggregateQueryStatus(statuses: QueryStatus[]): QueryStatus {
const allSettled = queries.every((query) => query.status !== 'pending'); if (statuses.every((status) => status === 'pending')) {
if (!allSettled) {
return 'pending'; return 'pending';
} }
if (queries.some((query) => query.isLoadingError)) { if (statuses.every((status) => status === 'success')) {
return 'success';
}
if (statuses.some((status) => status === 'error')) {
return 'error'; return 'error';
} }
return 'success'; return 'pending';
} }
@@ -225,7 +225,7 @@ describe('getExpectedFinish()', () => {
expect(calculatedFinish).toBe(10); expect(calculatedFinish).toBe(10);
}); });
describe('on timers of type time-to-end', () => { describe('on timers of type time-to-end', () => {
it('finish time is as schedule + added time', () => { it('finish time is the fixed end, ignoring added time', () => {
const state = { const state = {
eventNow: { eventNow: {
timeEnd: 30, timeEnd: 30,
@@ -245,6 +245,29 @@ describe('getExpectedFinish()', () => {
const calculatedFinish = getExpectedFinish(state); const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(40); expect(calculatedFinish).toBe(40);
}); });
it('finish time is the fixed end, including paused time', () => {
const state = {
eventNow: {
timeEnd: 30,
countToEnd: true,
},
clock: 25,
timer: {
addedTime: 0,
duration: dayInMs,
startedAt: 10,
},
_timer: {
pausedAt: 20, // paused 5 ago
hasFinished: false,
},
} as RuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(35);
});
it('handles events that finish the day after', () => { it('handles events that finish the day after', () => {
const state = { const state = {
eventNow: { eventNow: {
@@ -404,6 +427,9 @@ describe('getCurrent()', () => {
it('current time is the time to end even if it hasnt started, this is weird, but by design', () => { it('current time is the time to end even if it hasnt started, this is weird, but by design', () => {
const state = { const state = {
eventNow: { eventNow: {
timeStart: 0,
duration: 100,
dayOffset: 0,
timeEnd: 100, timeEnd: 100,
countToEnd: true, countToEnd: true,
}, },
@@ -429,6 +455,9 @@ describe('getCurrent()', () => {
it('current time is the time to end', () => { it('current time is the time to end', () => {
const state = { const state = {
eventNow: { eventNow: {
timeStart: 0,
duration: 100,
dayOffset: 0,
timeEnd: 100, timeEnd: 100,
countToEnd: true, countToEnd: true,
}, },
@@ -451,10 +480,13 @@ describe('getCurrent()', () => {
expect(current).toBe(70); expect(current).toBe(70);
}); });
it('current time is the time to end + added time', () => { it('current time is the time to end, ignoring added time', () => {
const state = { const state = {
eventNow: { eventNow: {
timeStart: 0,
duration: 100,
timeEnd: 100, timeEnd: 100,
dayOffset: 0,
countToEnd: true, countToEnd: true,
}, },
clock: 30, clock: 30,
@@ -480,8 +512,10 @@ describe('getCurrent()', () => {
const state = { const state = {
eventNow: { eventNow: {
timeStart: 79200000, // 22:00:00 timeStart: 79200000, // 22:00:00
duration: 2 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE,
timeEnd: 600000, // 00:10:00 timeEnd: 600000, // 00:10:00
countToEnd: true, countToEnd: true,
dayOffset: 0,
}, },
clock: 79500000, // 22:05:00 clock: 79500000, // 22:05:00
timer: { timer: {
@@ -492,6 +526,7 @@ describe('getCurrent()', () => {
rundown: { rundown: {
actualStart: 79200000, actualStart: 79200000,
plannedEnd: 600000, plannedEnd: 600000,
currentDay: 0,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
@@ -503,6 +538,36 @@ describe('getCurrent()', () => {
expect(current).toBe(dayInMs - 79500000 + 600000); expect(current).toBe(dayInMs - 79500000 + 600000);
}); });
it('handles overnight count-to-end after midnight', () => {
const state = {
eventNow: {
timeStart: 23 * MILLIS_PER_HOUR, // 23:00:00
duration: 2 * MILLIS_PER_HOUR, // 2h
timeEnd: 1 * MILLIS_PER_HOUR, // 01:00:00
countToEnd: true,
dayOffset: 0,
},
clock: 30 * MILLIS_PER_MINUTE, // 00:30:00 on day 1
timer: {
addedTime: 0,
duration: Infinity, // not relevant,
startedAt: 23 * MILLIS_PER_HOUR,
},
rundown: {
actualStart: 23 * MILLIS_PER_HOUR,
plannedEnd: 1 * MILLIS_PER_HOUR,
currentDay: 1,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as RuntimeState;
const current = getCurrent(state);
expect(current).toBe(30 * MILLIS_PER_MINUTE);
});
it('handles events that were started late', () => { it('handles events that were started late', () => {
const state = { const state = {
clock: 82000000, // 22:46:40 <--- starting 16 min after the scheduled end clock: 82000000, // 22:46:40 <--- starting 16 min after the scheduled end
@@ -511,6 +576,7 @@ describe('getCurrent()', () => {
timeEnd: 81000000, // 22:30:00 timeEnd: 81000000, // 22:30:00
duration: 3600000, // 01:00:00 duration: 3600000, // 01:00:00
countToEnd: true, countToEnd: true,
dayOffset: 0,
}, },
timer: { timer: {
addedTime: 0, addedTime: 0,
@@ -1073,7 +1139,7 @@ describe('getRuntimeOffset()', () => {
expect(absolute).toBe(0); expect(absolute).toBe(0);
}); });
it('with time-to-end, offset is the overtime', () => { it('with time-to-end, offset combines overtime and added time', () => {
const state = { const state = {
clock: 82000000, // 22:46:40 clock: 82000000, // 22:46:40
eventNow: { eventNow: {
@@ -1126,7 +1192,45 @@ describe('getRuntimeOffset()', () => {
} as RuntimeState; } as RuntimeState;
const { absolute } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(400000 - 200000); // <--- offset is always the overtime + added time // overtime (400000) plus the operator's added time (-200000)
expect(absolute).toBe(200000);
});
it('with time-to-end', () => {
const state = {
clock: 80000000, // 22:13:20 - before the scheduled end, not in overtime
eventNow: {
id: 'd6a2ce',
timeStart: 77400000, // 21:30:00
timeEnd: 81000000, // 22:30:00
duration: 3600000, // 01:00:00
timeStrategy: TimeStrategy.LockEnd,
countToEnd: true,
dayOffset: 0,
delay: 0,
},
rundown: {
plannedStart: 77400000, // 21:30:00
plannedEnd: 81000000, // 22:30:00
actualStart: 78000000, // 21:40:00
currentDay: 0,
},
offset: {
absolute: 0,
},
timer: {
addedTime: 300000, // operator added 5 minutes
current: 1000000, // still counting down, no overtime
duration: 3600000,
startedAt: 78000000,
},
_startDayOffset: 0,
_timer: { pausedAt: null },
} as RuntimeState;
// the end is anchored, so the added 5 minutes shows up as offset
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(300000);
}); });
it('handles time-to-end started after the end time', () => { it('handles time-to-end started after the end time', () => {
+12 -12
View File
@@ -62,34 +62,34 @@ export function getExpectedFinish(state: RuntimeState): MaybeNumber {
*/ */
export function getCurrent(state: RuntimeState): number { export function getCurrent(state: RuntimeState): number {
const { timer, eventNow } = state;
// eslint-disable-next-line no-unused-labels -- dev code path // eslint-disable-next-line no-unused-labels -- dev code path
DEV: { DEV: {
if (state.eventNow === null || state.timer.duration === null) { if (eventNow === null || timer.duration === null) {
throw new Error('timerUtils.getCurrent: invalid state received'); throw new Error('timerUtils.getCurrent: invalid state received');
} }
} }
const { startedAt, duration, addedTime } = state.timer;
const { countToEnd, timeStart, timeEnd } = state.eventNow;
const { pausedAt } = state._timer; const { pausedAt } = state._timer;
const { clock } = state; const { clock } = state;
if (countToEnd) { if (eventNow.countToEnd) {
const isEventOverMidnight = timeStart > timeEnd; const clockDayCorrected = clock + (state.rundown.currentDay ?? 0) * dayInMs;
const correctDay = isEventOverMidnight ? dayInMs : 0; const eventDayOffset = eventNow.dayOffset * dayInMs;
return correctDay - clock + timeEnd + addedTime; return eventDayOffset + eventNow.timeStart + eventNow.duration + timer.addedTime - clockDayCorrected;
} }
if (startedAt === null) { if (timer.startedAt === null) {
return duration; return timer.duration;
} }
if (pausedAt != null) { if (pausedAt != null) {
return startedAt + duration + addedTime - pausedAt; return timer.startedAt + timer.duration + timer.addedTime - pausedAt;
} }
const hasPassedMidnight = startedAt > clock; const hasPassedMidnight = timer.startedAt > clock;
const correctDay = hasPassedMidnight ? dayInMs : 0; const correctDay = hasPassedMidnight ? dayInMs : 0;
return startedAt + duration + addedTime - clock - correctDay; return timer.startedAt + timer.duration + timer.addedTime - clock - correctDay;
} }
/** /**