mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-03 06:28:01 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6dbe504b23 | |||
| 6d5c35eedc | |||
| d511946e71 | |||
| 3ddf61c651 | |||
| 50ae9640f3 | |||
| 494e2a9aef |
@@ -176,6 +176,13 @@ export async function postCloneEntry(
|
||||
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request events duration to fit inside the group target
|
||||
*/
|
||||
export async function requestFitGroupTarget(rundownId: RundownId, eventId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.post(`${rundownPath}/${rundownId}/${eventId}/fit-group-duration`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request for grouping a list of entries into a group
|
||||
*/
|
||||
|
||||
@@ -1,43 +1,20 @@
|
||||
@use '@/theme/viewerDefs' as *;
|
||||
|
||||
.emptyContainer {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
color: $white-10;
|
||||
|
||||
.empty {
|
||||
display: block;
|
||||
width: min(100%, 14rem);
|
||||
margin: 0 auto -1.5rem;
|
||||
opacity: 0.6;
|
||||
width: min(100%, 24rem);
|
||||
margin-inline: auto;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.text {
|
||||
display: block;
|
||||
margin-inline: auto;
|
||||
font-weight: 400;
|
||||
font-size: clamp(1rem, 1.55vw, 1.5rem);
|
||||
line-height: 1.35;
|
||||
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;
|
||||
font-weight: 600;
|
||||
font-size: 2em;
|
||||
max-width: min(100%, 600px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { IoWarningOutline } from 'react-icons/io5';
|
||||
|
||||
import EmptyImage from '../../../assets/images/empty.svg?react';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
@@ -10,18 +9,12 @@ interface EmptyProps {
|
||||
text?: string;
|
||||
injectedStyles?: CSSProperties;
|
||||
className?: string;
|
||||
variant?: 'error';
|
||||
}
|
||||
|
||||
export default function Empty({ text, className, injectedStyles, variant }: EmptyProps) {
|
||||
export default function Empty({ text, className, injectedStyles }: EmptyProps) {
|
||||
return (
|
||||
<div
|
||||
className={cx([style.emptyContainer, variant === 'error' && style.error, className])}
|
||||
style={injectedStyles}
|
||||
role={variant === 'error' ? 'alert' : undefined}
|
||||
>
|
||||
<div className={cx([style.emptyContainer, className])} style={injectedStyles}>
|
||||
<EmptyImage className={style.empty} />
|
||||
{variant === 'error' && <IoWarningOutline className={style.errorIcon} aria-hidden />}
|
||||
{text && <span className={style.text}>{text}</span>}
|
||||
</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 */
|
||||
overflow: hidden;
|
||||
width: 100%; /* restrict the page width to viewport */
|
||||
height: 100dvh;
|
||||
height: 100vh;
|
||||
|
||||
font-family: var(--font-family-override, $viewer-font-family);
|
||||
background: var(--background-color-override, $viewer-background-color);
|
||||
@@ -16,6 +16,5 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-block: 5rem;
|
||||
padding-top: 5rem;
|
||||
}
|
||||
|
||||
@@ -7,13 +7,12 @@ import style from './EmptyPage.module.scss';
|
||||
interface EmptyPageProps {
|
||||
text?: string;
|
||||
injectedStyles?: CSSProperties;
|
||||
variant?: 'error';
|
||||
}
|
||||
|
||||
export default function EmptyPage({ text, injectedStyles, variant }: EmptyPageProps) {
|
||||
export default function EmptyPage({ text, injectedStyles }: EmptyPageProps) {
|
||||
return (
|
||||
<div className={style.page}>
|
||||
<Empty text={text} injectedStyles={injectedStyles} variant={variant} />
|
||||
<Empty text={text} injectedStyles={injectedStyles} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,4 +15,9 @@
|
||||
gap: 1rem;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-weight: 600;
|
||||
font-size: 2em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
|
||||
<tbody className={style.emptyContainer}>
|
||||
<tr>
|
||||
<td colSpan={99} className={style.emptyCell}>
|
||||
<Empty text={text} injectedStyles={{ marginTop: '5vh' }} />
|
||||
<Empty injectedStyles={{ marginTop: '5vh' }} />
|
||||
<span className={style.text}>{text}</span>
|
||||
{handleAddNew && (
|
||||
<div className={style.inline}>
|
||||
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
|
||||
|
||||
@@ -8,12 +8,12 @@ import { getCustomFields } from '../api/customFields';
|
||||
const placeholder: CustomFields = {};
|
||||
|
||||
export default function useCustomFields() {
|
||||
const { data, status, isFetching, isError, isLoadingError, refetch } = useQuery({
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: CUSTOM_FIELDS,
|
||||
queryFn: ({ signal }) => getCustomFields({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
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';
|
||||
|
||||
export default function useProjectData() {
|
||||
const { data, status, isFetching, isError, isLoadingError, refetch } = useQuery({
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: PROJECT_DATA,
|
||||
queryFn: ({ signal }) => getProjectData({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
});
|
||||
|
||||
return { data: data ?? projectDataPlaceholder, status, isFetching, isError, isLoadingError, refetch };
|
||||
return { data: data ?? projectDataPlaceholder, status, isFetching, isError, refetch };
|
||||
}
|
||||
|
||||
export function useUpdateProjectData() {
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function useRundown() {
|
||||
data: { loaded: loadedRundownId },
|
||||
} = 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,
|
||||
queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
@@ -50,14 +50,14 @@ export default function useRundown() {
|
||||
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
|
||||
}, [loadedRundownId, queryClient]);
|
||||
|
||||
return { data: data ?? cachedRundownPlaceholder, status, isError, isLoadingError, refetch, isFetching };
|
||||
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
|
||||
}
|
||||
|
||||
export function useRundownWithMetadata() {
|
||||
const { data, status, isLoadingError } = useRundown();
|
||||
const { data, status } = useRundown();
|
||||
const selectedEventId = useSelectedEventId();
|
||||
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
|
||||
*/
|
||||
export function useFlatRundown() {
|
||||
const { data, status, isLoadingError } = useRundown();
|
||||
const { data, status } = useRundown();
|
||||
|
||||
const flatRundown = useMemo(() => {
|
||||
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);
|
||||
}, [data]);
|
||||
|
||||
return { data: flatRundown, rundownId: data.id, status, isLoadingError };
|
||||
return { data: flatRundown, rundownId: data.id, status };
|
||||
}
|
||||
|
||||
export function useFlatRundownWithMetadata() {
|
||||
const { data, status, isLoadingError } = useRundown();
|
||||
const { data, status } = useRundown();
|
||||
const selectedEventId = useSelectedEventId();
|
||||
|
||||
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) {
|
||||
const enabled = Boolean(rundownId);
|
||||
|
||||
const { data, status, isError, isLoadingError, refetch, isFetching } = useQuery<Rundown>({
|
||||
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
|
||||
queryKey: getRundownQueryKey(rundownId ?? ''),
|
||||
queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }),
|
||||
enabled,
|
||||
@@ -135,5 +135,5 @@ export function useRundownById(rundownId: string | null | undefined) {
|
||||
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;
|
||||
flatRundown: ExtendedEntry[];
|
||||
status: string;
|
||||
isLoadingError: boolean;
|
||||
selectedEventId: EntryId | null;
|
||||
};
|
||||
|
||||
@@ -36,7 +35,7 @@ function useRundownSource(rundownId: string | null, loadedRundownId: string | nu
|
||||
const isLoadedTarget = rundownId !== null && rundownId === loadedRundownId;
|
||||
const runtimeSelectedEventId = useSelectedEventId();
|
||||
const effectiveSelectedEventId = isLoadedTarget ? runtimeSelectedEventId : null;
|
||||
const { data: rundown, status, isLoadingError } = useRundownById(rundownId);
|
||||
const { data: rundown, status } = useRundownById(rundownId);
|
||||
const flatRundown = useMemo(
|
||||
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
|
||||
[effectiveSelectedEventId, rundown],
|
||||
@@ -48,9 +47,8 @@ function useRundownSource(rundownId: string | null, loadedRundownId: string | nu
|
||||
rundown,
|
||||
flatRundown,
|
||||
status,
|
||||
isLoadingError,
|
||||
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';
|
||||
|
||||
export default function useSettings() {
|
||||
const { data, status, isFetching, isError, isLoadingError, refetch } = useQuery({
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: APP_SETTINGS,
|
||||
queryFn: ({ signal }) => getSettings({ signal }),
|
||||
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';
|
||||
|
||||
export default function useViewSettings() {
|
||||
const { data, status, isLoadingError } = useQuery({
|
||||
const { data, status } = useQuery({
|
||||
queryKey: VIEW_SETTINGS,
|
||||
queryFn: ({ signal }) => getViewSettings({ signal }),
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
requestEventSwap,
|
||||
requestGroupEntries,
|
||||
requestUngroup,
|
||||
requestFitGroupTarget,
|
||||
} from '../api/rundown';
|
||||
import { logAxiosError } from '../api/utils';
|
||||
import { useEditorSettings } from '../stores/editorSettings';
|
||||
@@ -466,7 +467,22 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
||||
return previousEnd;
|
||||
}
|
||||
},
|
||||
[getCurrentRundownData, updateEntryMutation, queryClient],
|
||||
[getCurrentRundownData, updateEntryMutation, queryClient, resolveCurrentRundownQueryKey],
|
||||
);
|
||||
|
||||
/**
|
||||
* Updates time of existing event so it satisfies the group target duration
|
||||
* @param eventId {EntryId} - id of the event
|
||||
*/
|
||||
const matchGroupDuration = useCallback(
|
||||
async (eventId: EntryId) => {
|
||||
const rundownId = getCurrentRundownData()?.id;
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
await requestFitGroupTarget(rundownId, eventId);
|
||||
},
|
||||
[getCurrentRundownData],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -1009,6 +1025,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
||||
swapEvents,
|
||||
updateEntry,
|
||||
updateTimer,
|
||||
matchGroupDuration,
|
||||
}),
|
||||
[
|
||||
addEntry,
|
||||
@@ -1026,6 +1043,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
||||
swapEvents,
|
||||
updateEntry,
|
||||
updateTimer,
|
||||
matchGroupDuration,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import EmptyFill from '../../common/components/state/EmptyFill';
|
||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||
@@ -11,7 +10,6 @@ import { cx } from '../../common/utils/styleUtils';
|
||||
import { throttle } from '../../common/utils/throttle';
|
||||
import { getDefaultFormat } from '../../common/utils/time';
|
||||
import { isTouchDevice } from '../../externals';
|
||||
import { useTranslation } from '../../translation/TranslationProvider';
|
||||
import Loader from '../../views/common/loader/Loader';
|
||||
import CustomFieldEditModal from './custom-field-edit-modal/CustomFieldEditModal';
|
||||
import FollowButton from './follow-button/FollowButton';
|
||||
@@ -37,7 +35,7 @@ export default function OperatorLoader() {
|
||||
}
|
||||
|
||||
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} />;
|
||||
@@ -45,7 +43,6 @@ export default function OperatorLoader() {
|
||||
|
||||
function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) {
|
||||
const selectedEventId = useSelectedEventId();
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
|
||||
|
||||
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 canEdit = shouldEdit && subscribe.length;
|
||||
const hasEvents = rundown.order.length > 0;
|
||||
|
||||
return (
|
||||
<div className={style.operatorContainer} data-testid='operator-view'>
|
||||
@@ -131,121 +127,117 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasEvents ? (
|
||||
<EmptyFill text={getLocalizedString('common.no_data')} />
|
||||
) : (
|
||||
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
|
||||
{rundown.order.map((entryId) => {
|
||||
const entry = rundown.entries[entryId];
|
||||
if (isOntimeEvent(entry)) {
|
||||
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
|
||||
// hide past events (if setting) and skipped events
|
||||
if ((hidePast && isPast) || entry.skip) {
|
||||
return null;
|
||||
}
|
||||
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
|
||||
{rundown.order.map((entryId) => {
|
||||
const entry = rundown.entries[entryId];
|
||||
if (isOntimeEvent(entry)) {
|
||||
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
|
||||
// hide past events (if setting) and skipped events
|
||||
if ((hidePast && isPast) || entry.skip) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { mainField, secondaryField, subscribedData } = getEventData(
|
||||
entry,
|
||||
mainSource,
|
||||
secondarySource,
|
||||
subscribe,
|
||||
customFields,
|
||||
);
|
||||
const { mainField, secondaryField, subscribedData } = getEventData(
|
||||
entry,
|
||||
mainSource,
|
||||
secondarySource,
|
||||
subscribe,
|
||||
customFields,
|
||||
);
|
||||
|
||||
return (
|
||||
<OperatorEvent
|
||||
return (
|
||||
<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}
|
||||
id={entry.id}
|
||||
title={entry.title}
|
||||
colour={entry.colour}
|
||||
cue={entry.cue}
|
||||
main={mainField}
|
||||
secondary={secondaryField}
|
||||
timeStart={entry.timeStart}
|
||||
count={entry.entries.length}
|
||||
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 } = rundownMetadata[entry.id];
|
||||
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
|
||||
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
const { mainField, secondaryField, subscribedData } = getEventData(
|
||||
nestedEntry,
|
||||
mainSource,
|
||||
secondarySource,
|
||||
subscribe,
|
||||
customFields,
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment key={entry.id}>
|
||||
<OperatorGroup
|
||||
key={entry.id}
|
||||
title={entry.title}
|
||||
colour={entry.colour}
|
||||
count={entry.entries.length}
|
||||
duration={entry.duration}
|
||||
/>
|
||||
{entry.entries.map((nestedEntryId) => {
|
||||
const nestedEntry = rundown.entries[nestedEntryId];
|
||||
if (!isOntimeEvent(nestedEntry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
|
||||
|
||||
// hide past events (if setting) and skipped events
|
||||
if ((hidePast && isPast) || nestedEntry.skip) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { mainField, secondaryField, subscribedData } = getEventData(
|
||||
nestedEntry,
|
||||
mainSource,
|
||||
secondarySource,
|
||||
subscribe,
|
||||
customFields,
|
||||
);
|
||||
|
||||
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>
|
||||
)}
|
||||
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} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,18 +14,9 @@ export interface OperatorData {
|
||||
}
|
||||
|
||||
export function useOperatorData(): ViewData<OperatorData> {
|
||||
const {
|
||||
data: rundown,
|
||||
rundownMetadata,
|
||||
status: rundownStatus,
|
||||
isLoadingError: rundownIsLoadingError,
|
||||
} = useRundownWithMetadata();
|
||||
const {
|
||||
data: customFields,
|
||||
status: customFieldStatus,
|
||||
isLoadingError: customFieldIsLoadingError,
|
||||
} = useCustomFields();
|
||||
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
|
||||
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata();
|
||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||
const { data: settings, status: settingsStatus } = useSettings();
|
||||
|
||||
return {
|
||||
data: {
|
||||
@@ -34,10 +25,6 @@ export function useOperatorData(): ViewData<OperatorData> {
|
||||
customFields,
|
||||
settings,
|
||||
},
|
||||
status: aggregateQueryStatus([
|
||||
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
|
||||
{ status: customFieldStatus, isLoadingError: customFieldIsLoadingError },
|
||||
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
|
||||
]),
|
||||
status: aggregateQueryStatus([rundownStatus, customFieldStatus, settingsStatus]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
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 { useRundownEditor } from '../../common/hooks/useSocket';
|
||||
import { useTranslation } from '../../translation/TranslationProvider';
|
||||
import Rundown from './Rundown';
|
||||
|
||||
export default memo(RundownList);
|
||||
function RundownList() {
|
||||
const { data, status, isLoadingError, rundownMetadata } = useRundownWithMetadata();
|
||||
const { data, status, rundownMetadata } = useRundownWithMetadata();
|
||||
const featureData = useRundownEditor();
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
// avoid showing the editable empty state before we know whether the rundown is actually empty
|
||||
if (status === 'pending') {
|
||||
return <EmptyFill text='Loading…' />;
|
||||
}
|
||||
const isLoading = status !== 'success' || !data || !rundownMetadata;
|
||||
|
||||
if (isLoadingError) {
|
||||
return <EmptyFill text={getLocalizedString('common.no_data')} />;
|
||||
if (isLoading) {
|
||||
return <Empty text='Connecting to server' />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MaybeNumber } from 'ontime-types';
|
||||
import { IoLockClosed, IoLockOpenOutline } from 'react-icons/io5';
|
||||
import { TbTargetArrow, TbTarget } from 'react-icons/tb';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
||||
@@ -37,7 +37,7 @@ export default function TargetDurationInput({ duration, targetDuration, submitHa
|
||||
data-testid='lock__duration'
|
||||
render={<IconButton variant='subtle-white' className={isLocked ? style.active : style.inactive} />}
|
||||
>
|
||||
{isLocked ? <IoLockClosed /> : <IoLockOpenOutline />}
|
||||
{isLocked ? <TbTargetArrow /> : <TbTarget />}
|
||||
</Tooltip>
|
||||
</TimeInputGroup>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Day, EndAction, EntryId, Playback, TimeStrategy, TimerType } from 'ontime-types';
|
||||
import { Day, EndAction, EntryId, Maybe, OntimeGroup, Playback, TimeStrategy, TimerType } from 'ontime-types';
|
||||
import { isPlaybackActive } from 'ontime-utils';
|
||||
import { MouseEvent, useEffect, useRef } from 'react';
|
||||
import {
|
||||
@@ -13,9 +13,10 @@ import {
|
||||
IoTrash,
|
||||
IoUnlink,
|
||||
} from 'react-icons/io5';
|
||||
import { TbFlagFilled, TbListNumbers } from 'react-icons/tb';
|
||||
import { TbClockPin, TbFlagFilled, TbListNumbers } from 'react-icons/tb';
|
||||
|
||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||
import { useEntry } from '../../../common/hooks-query/useRundown';
|
||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
|
||||
@@ -102,7 +103,10 @@ export default function RundownEvent({
|
||||
const clearSelectedEventId = useEventIdSwapping((state) => state.clearSelectedEventId);
|
||||
const openRenumberDialog = useRenumberCuesDialogStore((state) => state.onOpen);
|
||||
|
||||
const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents } = useEntryActionsContext();
|
||||
const parentGroup = useEntry(parent) as Maybe<OntimeGroup>;
|
||||
|
||||
const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents, matchGroupDuration } =
|
||||
useEntryActionsContext();
|
||||
|
||||
const isSelected = useEventSelection((state) => state.selectedEvents.has(eventId));
|
||||
const unselect = useEventSelection((state) => state.unselect);
|
||||
@@ -172,6 +176,20 @@ export default function RundownEvent({
|
||||
updateEntry({ id: eventId, flag: !flag });
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Match Group Target Duration',
|
||||
description: 'Change event duration to fill the group target',
|
||||
icon: TbClockPin,
|
||||
onClick: () => {
|
||||
if (!parent) return;
|
||||
matchGroupDuration(eventId);
|
||||
},
|
||||
disabled:
|
||||
!parentGroup ||
|
||||
parentGroup.targetDuration === null ||
|
||||
parentGroup.duration === parentGroup.targetDuration,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'item',
|
||||
|
||||
@@ -90,7 +90,12 @@
|
||||
}
|
||||
|
||||
.lockIcon {
|
||||
opacity: 0.6;
|
||||
&.inactive {
|
||||
color: $muted-gray;
|
||||
}
|
||||
&.active {
|
||||
color: $active-indicator;
|
||||
}
|
||||
}
|
||||
|
||||
.over {
|
||||
|
||||
@@ -2,16 +2,16 @@ import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { EntryId, OntimeGroup } from 'ontime-types';
|
||||
import { MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||
import { MouseEvent, useRef } from 'react';
|
||||
import { MouseEvent, useCallback, useRef } from 'react';
|
||||
import {
|
||||
IoChevronDown,
|
||||
IoChevronUp,
|
||||
IoDuplicateOutline,
|
||||
IoFolderOpenOutline,
|
||||
IoLockClosed,
|
||||
IoReorderTwo,
|
||||
IoTrash,
|
||||
} from 'react-icons/io5';
|
||||
import { TbTargetArrow, TbClockPin } from 'react-icons/tb';
|
||||
|
||||
import IconButton from '../../../common/components/buttons/IconButton';
|
||||
import Tag from '../../../common/components/tag/Tag';
|
||||
@@ -40,12 +40,18 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
||||
'use memo';
|
||||
|
||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||
const { clone, ungroup, deleteEntry } = useEntryActionsContext();
|
||||
const { clone, ungroup, deleteEntry, updateEntry } = useEntryActionsContext();
|
||||
|
||||
const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection);
|
||||
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
||||
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
|
||||
|
||||
const isDurationMatching = data.targetDuration !== null && data.targetDuration === data.duration;
|
||||
|
||||
const matchDuration = useCallback(() => {
|
||||
updateEntry({ id: data.id, targetDuration: data.duration });
|
||||
}, [data.duration, data.id, updateEntry]);
|
||||
|
||||
const [onContextMenu] = useContextMenu<HTMLDivElement>(() => [
|
||||
{
|
||||
type: 'item',
|
||||
@@ -62,6 +68,15 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
||||
disabled: data.entries.length === 0,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Match Content Duration',
|
||||
icon: TbClockPin,
|
||||
onClick: matchDuration,
|
||||
disabled: isDurationMatching,
|
||||
description: "Change group target duration to match it's contents",
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Delete Group',
|
||||
@@ -186,7 +201,9 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
||||
<Tag className={style.offsetLabel}>{planOffset}</Tag>
|
||||
</span>
|
||||
)}
|
||||
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
|
||||
{data.targetDuration !== null && (
|
||||
<TbTargetArrow className={cx([style.lockIcon, isDurationMatching ? style.active : style.inactive])} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useEffect, useMemo } from 'react';
|
||||
|
||||
import EmptyPage from '../../../common/components/state/EmptyPage';
|
||||
import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
|
||||
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
||||
import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown';
|
||||
@@ -12,7 +13,7 @@ import { makeRundownColumns } from './makeRundownColumns';
|
||||
|
||||
export default memo(RundownTable);
|
||||
function RundownTable() {
|
||||
const { data: customFields } = useCustomFields();
|
||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
|
||||
const { editorMode } = useEditorFollowMode();
|
||||
const source = useLoadedRundownSource();
|
||||
@@ -31,10 +32,16 @@ function RundownTable() {
|
||||
|
||||
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
|
||||
|
||||
const isLoading = !customFields || customFieldStatus === 'pending';
|
||||
|
||||
return (
|
||||
<EntryActionsProvider actions={actions}>
|
||||
<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>
|
||||
</EntryActionsProvider>
|
||||
);
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function BackstageLoader() {
|
||||
}
|
||||
|
||||
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} />;
|
||||
|
||||
@@ -20,14 +20,10 @@ export function useBackstageData(): ViewData<BackstageData> {
|
||||
const isMirrored = useViewOptionsStore((state) => state.mirror);
|
||||
|
||||
// HTTP API data
|
||||
const { data: rundownData, status: rundownStatus, isLoadingError: rundownIsLoadingError } = useFlatRundown();
|
||||
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
|
||||
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
|
||||
const {
|
||||
data: customFields,
|
||||
status: customFieldsStatus,
|
||||
isLoadingError: customFieldsIsLoadingError,
|
||||
} = useCustomFields();
|
||||
const { data: rundownData, status: rundownStatus } = useFlatRundown();
|
||||
const { data: projectData, status: projectDataStatus } = useProjectData();
|
||||
const { data: settings, status: settingsStatus } = useSettings();
|
||||
const { data: customFields, status: customFieldsStatus } = useCustomFields();
|
||||
|
||||
return {
|
||||
data: {
|
||||
@@ -37,11 +33,6 @@ export function useBackstageData(): ViewData<BackstageData> {
|
||||
isMirrored,
|
||||
settings,
|
||||
},
|
||||
status: aggregateQueryStatus([
|
||||
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
|
||||
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
|
||||
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
|
||||
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
|
||||
]),
|
||||
status: aggregateQueryStatus([rundownStatus, projectDataStatus, settingsStatus, customFieldsStatus]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ $dot-spacing: 1.5rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background-color: var(--background-color-override, $viewer-background-color);
|
||||
height: 100dvh;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.ellipsis {
|
||||
@@ -21,26 +21,26 @@ $dot-spacing: 1.5rem;
|
||||
height: $dot-size;
|
||||
border-radius: 50%;
|
||||
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) {
|
||||
left: $dot-size;
|
||||
animation: lds-ellipsis1 1s infinite;
|
||||
animation: lds-ellipsis1 0.6s infinite;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
left: $dot-size;
|
||||
animation: lds-ellipsis2 1s infinite;
|
||||
animation: lds-ellipsis2 0.6s infinite;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
left: calc($dot-size + $dot-spacing);
|
||||
animation: lds-ellipsis2 1s infinite;
|
||||
animation: lds-ellipsis2 0.6s infinite;
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
left: calc($dot-size + 2 * $dot-spacing);
|
||||
animation: lds-ellipsis3 1s infinite;
|
||||
animation: lds-ellipsis3 0.6s infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,21 @@ $item-height: 3.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function CountdownLoader() {
|
||||
}
|
||||
|
||||
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} />;
|
||||
@@ -87,7 +87,7 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
|
||||
|
||||
{!hasEvents && (
|
||||
<div className='empty-state'>
|
||||
<Empty text={getLocalizedString('common.no_data')} />
|
||||
<Empty text={getLocalizedString('common.no_data')} className='empty-state__content' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -121,7 +121,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
|
||||
if (subscriptions.length === 0) {
|
||||
return (
|
||||
<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}>
|
||||
<IoAdd /> Add
|
||||
</Button>
|
||||
@@ -137,7 +137,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
|
||||
if (subscribedEvents.length === 0) {
|
||||
return (
|
||||
<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}>
|
||||
<IoAdd /> Add
|
||||
</Button>
|
||||
@@ -154,7 +154,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
|
||||
if (eventsToShow.length === 0) {
|
||||
return (
|
||||
<div className='empty-state'>
|
||||
<Empty text={getLocalizedString('countdown.all_have_finished')} />
|
||||
<Empty text={getLocalizedString('countdown.all_have_finished')} className='empty-state__content' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,18 +21,10 @@ export function useCountdownData(): ViewData<CountdownData> {
|
||||
const isMirrored = useViewOptionsStore((state) => state.mirror);
|
||||
|
||||
// HTTP API data
|
||||
const {
|
||||
data: rundownData,
|
||||
status: rundownStatus,
|
||||
isLoadingError: rundownIsLoadingError,
|
||||
} = 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();
|
||||
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
|
||||
const { data: projectData, status: projectDataStatus } = useProjectData();
|
||||
const { data: settings, status: settingsStatus } = useSettings();
|
||||
const { data: customFields, status: customFieldsStatus } = useCustomFields();
|
||||
|
||||
return {
|
||||
data: {
|
||||
@@ -42,11 +34,6 @@ export function useCountdownData(): ViewData<CountdownData> {
|
||||
isMirrored,
|
||||
settings,
|
||||
},
|
||||
status: aggregateQueryStatus([
|
||||
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
|
||||
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
|
||||
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
|
||||
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
|
||||
]),
|
||||
status: aggregateQueryStatus([rundownStatus, projectDataStatus, settingsStatus, customFieldsStatus]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { MaybeString, ProjectRundown } from 'ontime-types';
|
||||
import { memo, use, useMemo } from 'react';
|
||||
|
||||
import Select from '../../common/components/select/Select';
|
||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||
import { PresetContext } from '../../common/context/PresetContext';
|
||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||
import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
|
||||
@@ -33,34 +34,40 @@ function CuesheetTableWrapper({
|
||||
const preset = use(PresetContext);
|
||||
const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
|
||||
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
|
||||
const { data: customFields } = useCustomFields();
|
||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||
|
||||
const columns = useMemo(
|
||||
() => makeCuesheetColumns(customFields, cuesheetMode, preset),
|
||||
[customFields, cuesheetMode, preset],
|
||||
);
|
||||
|
||||
const isLoading = !customFields || customFieldStatus === 'pending';
|
||||
|
||||
return (
|
||||
<CuesheetDnd columns={columns}>
|
||||
<CuesheetTable
|
||||
columns={columns}
|
||||
source={source}
|
||||
cuesheetMode={cuesheetMode}
|
||||
tableRoot='cuesheet'
|
||||
setCuesheetMode={setCuesheetMode}
|
||||
isCurrentRundown={isCurrentRundown}
|
||||
insertElement={
|
||||
<>
|
||||
<RundownSelect
|
||||
cuesheetMode={cuesheetMode}
|
||||
selectedRundownId={selectedRundownId}
|
||||
loadedRundownId={loadedRundownId}
|
||||
setSelectedRundownId={setSelectedRundownId}
|
||||
projectRundowns={projectRundowns}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<EmptyPage text='Loading...' />
|
||||
) : (
|
||||
<CuesheetTable
|
||||
columns={columns}
|
||||
source={source}
|
||||
cuesheetMode={cuesheetMode}
|
||||
tableRoot='cuesheet'
|
||||
setCuesheetMode={setCuesheetMode}
|
||||
isCurrentRundown={isCurrentRundown}
|
||||
insertElement={
|
||||
<>
|
||||
<RundownSelect
|
||||
cuesheetMode={cuesheetMode}
|
||||
selectedRundownId={selectedRundownId}
|
||||
loadedRundownId={loadedRundownId}
|
||||
setSelectedRundownId={setSelectedRundownId}
|
||||
projectRundowns={projectRundowns}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CuesheetDnd>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,6 @@ $table-header-font-size: calc(1rem - 2px);
|
||||
|
||||
@include rows.cuesheet-row-columns($table-header-font-size);
|
||||
|
||||
.tableLoading {
|
||||
grid-area: table;
|
||||
}
|
||||
|
||||
.cuesheet {
|
||||
font-size: $table-font-size;
|
||||
font-weight: 400;
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
TableVirtuosoHandle,
|
||||
} 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 { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||
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 { useEventSelection } from '../../../features/rundown/useEventSelection';
|
||||
import { AppMode } from '../../../ontimeConfig';
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
import { usePersistedCuesheetOptions } from '../cuesheet.options';
|
||||
import { useCuesheetPermissions } from '../useTablePermissions';
|
||||
import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader';
|
||||
@@ -63,9 +62,8 @@ export default function CuesheetTable({
|
||||
isCurrentRundown,
|
||||
insertElement,
|
||||
}: CuesheetTableProps) {
|
||||
const { flatRundown, status, isLoadingError, selectedEventId } = source;
|
||||
const { flatRundown, status, selectedEventId } = source;
|
||||
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
|
||||
|
||||
const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions;
|
||||
@@ -230,13 +228,10 @@ export default function CuesheetTable({
|
||||
});
|
||||
}, [cuesheetMode, hideIndexColumn, table]);
|
||||
|
||||
// avoid showing the editable empty state before we know whether the rundown is actually empty
|
||||
if (status === 'pending') {
|
||||
return <EmptyFill text='Loading…' className={style.tableLoading} />;
|
||||
}
|
||||
const isLoading = !flatRundown || status === 'pending';
|
||||
|
||||
if (isLoadingError) {
|
||||
return <EmptyFill text={getLocalizedString('common.no_data')} className={style.tableLoading} />;
|
||||
if (isLoading) {
|
||||
return <EmptyPage text='Loading...' />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
@use '@/theme/viewerDefs' as *;
|
||||
|
||||
$content-width: min(100%, 1100px);
|
||||
|
||||
.project {
|
||||
margin: 0;
|
||||
box-sizing: border-box; /* reset */
|
||||
@@ -18,104 +16,56 @@ $content-width: min(100%, 1100px);
|
||||
display: flex;
|
||||
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 {
|
||||
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 {
|
||||
flex: 1;
|
||||
width: $content-width;
|
||||
max-height: 100%;
|
||||
margin-inline: auto;
|
||||
overflow-y: auto;
|
||||
|
||||
width: min(calc(100vw - 4rem), 960px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
gap: $view-element-gap;
|
||||
|
||||
padding-block: $view-element-gap;
|
||||
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 {
|
||||
font-size: $timer-label-size;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.info__value {
|
||||
white-space: break-spaces;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.info__custom {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.info__image-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex: 0 0 min(192px, 25%);
|
||||
width: 192px;
|
||||
height: 192px;
|
||||
}
|
||||
|
||||
.info__image {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.link.info__value {
|
||||
display: inline-flex;
|
||||
gap: 0.35em;
|
||||
display: flex;
|
||||
gap: $view-element-gap;
|
||||
align-items: center;
|
||||
color: $action-text-color;
|
||||
|
||||
@@ -128,13 +78,12 @@ $content-width: min(100%, 1100px);
|
||||
/* =================== MOBILE ===================*/
|
||||
@media screen and (max-width: 768px) {
|
||||
.project {
|
||||
.project-header {
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.logo img {
|
||||
height: min(50px, 10vh);
|
||||
}
|
||||
.info__image-container {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { OntimeView } from 'ontime-types';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { IoOpenOutline } from 'react-icons/io5';
|
||||
|
||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||
@@ -22,7 +21,7 @@ export default function ProjectInfoLoader() {
|
||||
}
|
||||
|
||||
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} />;
|
||||
@@ -42,49 +41,56 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
|
||||
return (
|
||||
<>
|
||||
<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 (
|
||||
<div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'>
|
||||
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
|
||||
{hasHeader && (
|
||||
<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>
|
||||
)}
|
||||
{projectData.logo && <ViewLogo name={projectData.logo} className='logo' />}
|
||||
<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 && (
|
||||
<div className='info__card'>
|
||||
<div>
|
||||
<div className='info__label'>{getLocalizedString('project.url')}</div>
|
||||
<a href={projectData.url} target='_blank' rel='noreferrer' className='info__value link'>
|
||||
{projectData.url}
|
||||
<IoOpenOutline style={{ fontSize: '1em' }} />
|
||||
{projectData.url} <IoOpenOutline style={{ fontSize: '1em' }} />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{projectData.custom.map((info, idx) => {
|
||||
const hasUrl = Boolean(info.url);
|
||||
return (
|
||||
// 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'>
|
||||
{info.title && <div className='info__label'>{info.title}</div>}
|
||||
{info.url ? (
|
||||
<div className='info__media'>
|
||||
<InfoImage src={info.url} />
|
||||
{info.value && <div className='info__value'>{info.value}</div>}
|
||||
<div key={`${info.title}-${idx}`} className='info__custom'>
|
||||
{hasUrl && (
|
||||
<div className='info__image-container'>
|
||||
<img className='info__image' src={info.url} loading='lazy' />
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
@@ -92,31 +98,3 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
|
||||
</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 { useViewOptionsStore } from '../../common/stores/viewOptions';
|
||||
import { ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils';
|
||||
import { ViewData } from '../utils/viewLoader.utils';
|
||||
|
||||
export interface ProjectInfoData {
|
||||
projectData: ProjectData;
|
||||
@@ -14,13 +14,13 @@ export function useProjectInfoData(): ViewData<ProjectInfoData> {
|
||||
const isMirrored = useViewOptionsStore((state) => state.mirror);
|
||||
|
||||
// HTTP API data
|
||||
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
|
||||
const { data: projectData, status: projectDataStatus } = useProjectData();
|
||||
|
||||
return {
|
||||
data: {
|
||||
projectData,
|
||||
isMirrored,
|
||||
},
|
||||
status: aggregateQueryStatus([{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError }]),
|
||||
status: projectDataStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function StudioLoader() {
|
||||
}
|
||||
|
||||
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} />;
|
||||
|
||||
@@ -20,18 +20,10 @@ export function useStudioData(): ViewData<StudioData> {
|
||||
const isMirrored = useViewOptionsStore((state) => state.mirror);
|
||||
|
||||
// HTTP API data
|
||||
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
|
||||
const {
|
||||
data: viewSettings,
|
||||
status: viewSettingsStatus,
|
||||
isLoadingError: viewSettingsIsLoadingError,
|
||||
} = useViewSettings();
|
||||
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
|
||||
const {
|
||||
data: customFields,
|
||||
status: customFieldsStatus,
|
||||
isLoadingError: customFieldsIsLoadingError,
|
||||
} = useCustomFields();
|
||||
const { data: projectData, status: projectDataStatus } = useProjectData();
|
||||
const { data: viewSettings, status: viewSettingsStatus } = useViewSettings();
|
||||
const { data: settings, status: settingsStatus } = useSettings();
|
||||
const { data: customFields, status: customFieldsStatus } = useCustomFields();
|
||||
|
||||
return {
|
||||
data: {
|
||||
@@ -41,11 +33,6 @@ export function useStudioData(): ViewData<StudioData> {
|
||||
settings,
|
||||
viewSettings,
|
||||
},
|
||||
status: aggregateQueryStatus([
|
||||
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
|
||||
{ status: viewSettingsStatus, isLoadingError: viewSettingsIsLoadingError },
|
||||
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
|
||||
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
|
||||
]),
|
||||
status: aggregateQueryStatus([projectDataStatus, viewSettingsStatus, settingsStatus, customFieldsStatus]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { OntimeView } from 'ontime-types';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import EmptyFill from '../../common/components/state/EmptyFill';
|
||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||
import ViewLogo from '../../common/components/view-logo/ViewLogo';
|
||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||
@@ -30,7 +29,7 @@ export default function TimelinePageLoader() {
|
||||
}
|
||||
|
||||
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} />;
|
||||
@@ -74,7 +73,7 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
) : (
|
||||
<EmptyFill text={getLocalizedString('common.no_data')} />
|
||||
<EmptyPage text={getLocalizedString('common.no_data')} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,18 +16,10 @@ export interface TimelineData {
|
||||
|
||||
export function useTimelineData(): ViewData<TimelineData> {
|
||||
// HTTP API data
|
||||
const {
|
||||
data: rundownData,
|
||||
status: rundownStatus,
|
||||
isLoadingError: rundownIsLoadingError,
|
||||
} = 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();
|
||||
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
|
||||
const { data: projectData, status: projectDataStatus } = useProjectData();
|
||||
const { data: settings, status: settingsStatus } = useSettings();
|
||||
const { data: customFields, status: customFieldsStatus } = useCustomFields();
|
||||
|
||||
return {
|
||||
data: {
|
||||
@@ -36,11 +28,6 @@ export function useTimelineData(): ViewData<TimelineData> {
|
||||
projectData,
|
||||
settings,
|
||||
},
|
||||
status: aggregateQueryStatus([
|
||||
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
|
||||
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
|
||||
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
|
||||
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
|
||||
]),
|
||||
status: aggregateQueryStatus([rundownStatus, projectDataStatus, settingsStatus, customFieldsStatus]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function TimerLoader() {
|
||||
}
|
||||
|
||||
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} />;
|
||||
|
||||
@@ -22,19 +22,11 @@ export function useTimerData(): ViewData<TimerData> {
|
||||
const isMirrored = useViewOptionsStore((state) => state.mirror);
|
||||
|
||||
// HTTP API data
|
||||
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
|
||||
const {
|
||||
data: viewSettings,
|
||||
status: viewSettingsStatus,
|
||||
isLoadingError: viewSettingsIsLoadingError,
|
||||
} = 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 { data: projectData, status: projectDataStatus } = useProjectData();
|
||||
const { data: viewSettings, status: viewSettingsStatus } = useViewSettings();
|
||||
const { data: settings, status: settingsStatus } = useSettings();
|
||||
const { data: customFields, status: customFieldsStatus } = useCustomFields();
|
||||
const { data: rundown, status: rundownStatus } = useRundown();
|
||||
const { entries } = rundown;
|
||||
|
||||
return {
|
||||
@@ -47,11 +39,11 @@ export function useTimerData(): ViewData<TimerData> {
|
||||
entries,
|
||||
},
|
||||
status: aggregateQueryStatus([
|
||||
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
|
||||
{ status: viewSettingsStatus, isLoadingError: viewSettingsIsLoadingError },
|
||||
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
|
||||
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
|
||||
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
|
||||
projectDataStatus,
|
||||
viewSettingsStatus,
|
||||
settingsStatus,
|
||||
customFieldsStatus,
|
||||
rundownStatus,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,27 +5,21 @@ export type ViewData<T> = {
|
||||
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
|
||||
* whether a view can render.
|
||||
* - 'pending' while any query hasn't settled yet (no result, first fetch in flight)
|
||||
* - 'error' once all queries have settled, if any of them never received data
|
||||
* - 'success' once all queries have settled and every one has data to show,
|
||||
* even if a query's last (background) fetch failed
|
||||
* Aggregates a loading status from multiple query statuses.
|
||||
* If all statuses are 'pending', returns 'pending'.
|
||||
* If all statuses are 'success', returns 'success'.
|
||||
* If any status is 'error', returns 'error'.
|
||||
*/
|
||||
export function aggregateQueryStatus(queries: AggregatableQuery[]): QueryStatus {
|
||||
const allSettled = queries.every((query) => query.status !== 'pending');
|
||||
if (!allSettled) {
|
||||
export function aggregateQueryStatus(statuses: QueryStatus[]): QueryStatus {
|
||||
if (statuses.every((status) => status === '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 'success';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
TimerType,
|
||||
Trigger,
|
||||
} from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, createEvent } from 'ontime-utils';
|
||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, createEvent } from 'ontime-utils';
|
||||
import { assertType } from 'vitest';
|
||||
|
||||
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js';
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
makeDeepClone,
|
||||
mergeRundownPreservingFields,
|
||||
isLoadedPlayable,
|
||||
eventDurationMatchGroupTarget,
|
||||
} from '../rundown.utils.js';
|
||||
|
||||
describe('test event validator', () => {
|
||||
@@ -610,3 +611,98 @@ describe('isLoadedPlayable()', () => {
|
||||
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('eventDurationMatchGroupTarget()', () => {
|
||||
it('returns unchanged duration when group already matches target', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: MILLIS_PER_HOUR,
|
||||
groupDuration: MILLIS_PER_HOUR,
|
||||
eventDuration: MILLIS_PER_MINUTE * 30,
|
||||
});
|
||||
expect(result).toStrictEqual(null);
|
||||
});
|
||||
|
||||
it('increases event duration when group is shorter than target', () => {
|
||||
// Group is 1h short of target, so event duration increases by 1h
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: MILLIS_PER_HOUR * 2, // 2h
|
||||
groupDuration: MILLIS_PER_HOUR, // 1h
|
||||
eventDuration: MILLIS_PER_MINUTE * 30, // 30m
|
||||
});
|
||||
expect(result).toStrictEqual(MILLIS_PER_HOUR + MILLIS_PER_MINUTE * 30); // 1h30m
|
||||
});
|
||||
|
||||
it('decreases event duration when group is longer than target', () => {
|
||||
// Group is 30m over target, so event duration decreases by 30m
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: MILLIS_PER_HOUR, // 1h
|
||||
groupDuration: MILLIS_PER_HOUR + MILLIS_PER_MINUTE * 30, // 1h30m
|
||||
eventDuration: MILLIS_PER_MINUTE * 30, // 30m
|
||||
});
|
||||
expect(result).toStrictEqual(0);
|
||||
});
|
||||
|
||||
it('handles zero target duration', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: 0,
|
||||
groupDuration: MILLIS_PER_HOUR,
|
||||
eventDuration: MILLIS_PER_HOUR,
|
||||
});
|
||||
expect(result).toStrictEqual(0);
|
||||
});
|
||||
|
||||
it('handles zero group duration', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: MILLIS_PER_HOUR,
|
||||
groupDuration: 0,
|
||||
eventDuration: MILLIS_PER_MINUTE * 30,
|
||||
});
|
||||
expect(result).toStrictEqual(MILLIS_PER_HOUR + MILLIS_PER_MINUTE * 30);
|
||||
});
|
||||
|
||||
it('handles zero event duration', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: MILLIS_PER_HOUR,
|
||||
groupDuration: MILLIS_PER_MINUTE * 30,
|
||||
eventDuration: 0,
|
||||
});
|
||||
expect(result).toStrictEqual(MILLIS_PER_HOUR - MILLIS_PER_MINUTE * 30);
|
||||
});
|
||||
|
||||
it('handles all zero values', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: 0,
|
||||
groupDuration: 0,
|
||||
eventDuration: 0,
|
||||
});
|
||||
expect(result).toStrictEqual(null);
|
||||
});
|
||||
|
||||
it('returns null when result would be negative', () => {
|
||||
// Group exceeds target by 1.5h, event shrinks by 1.5h (exceeds event duration)
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: MILLIS_PER_MINUTE * 30,
|
||||
groupDuration: MILLIS_PER_HOUR * 2,
|
||||
eventDuration: MILLIS_PER_HOUR,
|
||||
});
|
||||
expect(result).toStrictEqual(null);
|
||||
});
|
||||
|
||||
it('handles large durations', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: MILLIS_PER_HOUR * 24, // 24h
|
||||
groupDuration: MILLIS_PER_HOUR * 12, // 12h
|
||||
eventDuration: MILLIS_PER_HOUR, // 1h
|
||||
});
|
||||
expect(result).toStrictEqual(MILLIS_PER_HOUR * 13); // 13h
|
||||
});
|
||||
|
||||
it('returns null when targetDuration is null', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: null,
|
||||
groupDuration: MILLIS_PER_HOUR,
|
||||
eventDuration: MILLIS_PER_MINUTE * 30,
|
||||
});
|
||||
expect(result).toStrictEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
reorderEntry,
|
||||
swapEvents,
|
||||
ungroupEntries,
|
||||
entryFitGroupDuration,
|
||||
} from './rundown.service.js';
|
||||
import { normalisedToRundownArray } from './rundown.utils.js';
|
||||
import {
|
||||
@@ -337,6 +338,23 @@ router.post('/:rundownId/ungroup/:id', paramsWithId, async (req: Request, res: R
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Change a events duration to fit inside the group target
|
||||
*/
|
||||
router.post(
|
||||
'/:rundownId/:id/fit-group-duration',
|
||||
paramsWithId,
|
||||
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = await entryFitGroupDuration(req.params.rundownId, req.params.id);
|
||||
res.status(200).send(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Deletes a list of entries by their ID
|
||||
*/
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
hasChanges,
|
||||
mergeRundownPreservingFields,
|
||||
isLoadedPlayable,
|
||||
eventDurationMatchGroupTarget,
|
||||
} from './rundown.utils.js';
|
||||
import { assertInsertAnchorExists, assertInsertAnchorInOrder, assertSingleInsertAnchor } from './rundown.validation.js';
|
||||
|
||||
@@ -447,6 +448,69 @@ export async function cloneEntry(rundownId: string, entryId: EntryId, options: I
|
||||
return rundownResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change a events duration to fit inside the group target
|
||||
*/
|
||||
export async function entryFitGroupDuration(rundownId: string, entryId: EntryId): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
|
||||
|
||||
const entry = rundown.entries[entryId];
|
||||
|
||||
if (!entry) {
|
||||
throw new Error('Entry not found');
|
||||
}
|
||||
|
||||
if (!isOntimeEvent(entry)) {
|
||||
throw new Error('Entry must be an event');
|
||||
}
|
||||
|
||||
const { parent } = entry;
|
||||
if (!parent) {
|
||||
throw new Error('Entry must be in a group');
|
||||
}
|
||||
|
||||
const group = rundown.entries[parent];
|
||||
|
||||
if (!group) {
|
||||
throw new Error('Group not found');
|
||||
}
|
||||
|
||||
if (!isOntimeGroup(group)) {
|
||||
throw new Error('Group is not a group');
|
||||
}
|
||||
|
||||
const newDuration = eventDurationMatchGroupTarget({
|
||||
targetDuration: group.targetDuration,
|
||||
groupDuration: group.duration,
|
||||
eventDuration: entry.duration,
|
||||
});
|
||||
|
||||
if (newDuration === null) {
|
||||
throw new Error('Unable to fit a duration');
|
||||
}
|
||||
|
||||
const newEnd = entry.timeStart + newDuration;
|
||||
|
||||
rundownMutation.edit(rundown, {
|
||||
id: entryId,
|
||||
duration: newDuration,
|
||||
timeEnd: newEnd,
|
||||
timeStrategy: entry.timeStrategy,
|
||||
});
|
||||
const { rundown: rundownResult, rundownMetadata, revision } = await commit();
|
||||
|
||||
// schedule the side effects
|
||||
setImmediate(() => {
|
||||
// notify runtime that rundown has changed
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
// we need to notify the timer since we might be changing a running event
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { external: true, timer: true });
|
||||
});
|
||||
|
||||
return rundownResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups a list of entries into a new group
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
EntryCustomFields,
|
||||
EntryId,
|
||||
ImportedFields,
|
||||
Maybe,
|
||||
OntimeBaseEvent,
|
||||
OntimeDelay,
|
||||
OntimeEntry,
|
||||
@@ -601,3 +602,27 @@ export function getIntegerAndFraction(value: string): IncrementNumber {
|
||||
precision,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts an event's duration to fit inside the group target
|
||||
* @param targetDuration - The desired total duration for the group, or null
|
||||
* @param groupDuration - The current total duration of all events in the group
|
||||
* @param eventDuration - The current duration of the event being adjusted
|
||||
* @returns The adjusted event duration, or null if targetDuration is null or
|
||||
* the result would be negative
|
||||
*/
|
||||
export function eventDurationMatchGroupTarget({
|
||||
targetDuration,
|
||||
groupDuration,
|
||||
eventDuration,
|
||||
}: {
|
||||
targetDuration: Maybe<number>;
|
||||
groupDuration: number;
|
||||
eventDuration: number;
|
||||
}): Maybe<number> {
|
||||
if (targetDuration === null) return null;
|
||||
if (targetDuration === groupDuration) return null;
|
||||
const durationDiff = targetDuration - groupDuration;
|
||||
const newDuration = eventDuration + durationDiff;
|
||||
return newDuration < 0 ? null : newDuration;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user