refactor(views): consistent loading and empty states

This commit is contained in:
Carlos Valente
2026-07-25 12:06:25 +00:00
parent f7535651f6
commit 94d54529ee
23 changed files with 256 additions and 206 deletions
@@ -1,20 +1,43 @@
@use '@/theme/viewerDefs' as *;
.emptyContainer { .emptyContainer {
width: 100%; width: 100%;
text-align: center; text-align: center;
color: $white-10; color: var(--secondary-color-override, $viewer-secondary-color);
.empty { .empty {
display: block; display: block;
width: min(100%, 24rem); width: min(100%, 14rem);
margin-inline: auto; margin: 0 auto -1.5rem;
opacity: 0.8; opacity: 0.6;
} }
.text { .text {
display: block; display: block;
margin-inline: auto; margin-inline: auto;
font-weight: 600; font-weight: 400;
font-size: 2em; font-size: clamp(1rem, 1.55vw, 1.5rem);
max-width: min(100%, 600px); 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;
} }
} }
@@ -1,4 +1,5 @@
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';
@@ -9,12 +10,18 @@ interface EmptyProps {
text?: string; text?: string;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
className?: string; className?: string;
variant?: 'error';
} }
export default function Empty({ text, className, injectedStyles }: EmptyProps) { export default function Empty({ text, className, injectedStyles, variant }: EmptyProps) {
return ( return (
<div className={cx([style.emptyContainer, className])} style={injectedStyles}> <div
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>
); );
@@ -0,0 +1,11 @@
.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;
}
@@ -0,0 +1,19 @@
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: 100vh; height: 100dvh;
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,5 +16,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding-top: 5rem; justify-content: center;
padding-block: 5rem;
} }
@@ -7,12 +7,13 @@ 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 }: EmptyPageProps) { export default function EmptyPage({ text, injectedStyles, variant }: EmptyPageProps) {
return ( return (
<div className={style.page}> <div className={style.page}>
<Empty text={text} injectedStyles={injectedStyles} /> <Empty text={text} injectedStyles={injectedStyles} variant={variant} />
</div> </div>
); );
} }
@@ -15,9 +15,4 @@
gap: 1rem; gap: 1rem;
margin-top: 1em; margin-top: 1em;
} }
.text {
font-weight: 600;
font-size: 2em;
}
} }
@@ -18,8 +18,7 @@ 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 injectedStyles={{ marginTop: '5vh' }} /> <Empty text={text} 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'>
+110 -102
View File
@@ -1,6 +1,7 @@
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';
@@ -10,6 +11,7 @@ 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';
@@ -35,7 +37,7 @@ export default function OperatorLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />; return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
} }
return <Operator {...data} />; return <Operator {...data} />;
@@ -43,6 +45,7 @@ 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);
@@ -113,6 +116,7 @@ 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'>
@@ -127,117 +131,121 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
</div> </div>
)} )}
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}> {!hasEvents ? (
{rundown.order.map((entryId) => { <EmptyFill text={getLocalizedString('common.no_data')} />
const entry = rundown.entries[entryId]; ) : (
if (isOntimeEvent(entry)) { <div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId]; {rundown.order.map((entryId) => {
// hide past events (if setting) and skipped events const entry = rundown.entries[entryId];
if ((hidePast && isPast) || entry.skip) { if (isOntimeEvent(entry)) {
return null; 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( 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}
title={entry.title} id={entry.id}
colour={entry.colour} colour={entry.colour}
count={entry.entries.length} cue={entry.cue}
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;
}
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId]; if (isOntimeGroup(entry)) {
const { isPast } = rundownMetadata[entry.id];
// hide past events (if setting) and skipped events const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId]?.groupId === entry.id : false;
if ((hidePast && isPast) || nestedEntry.skip) {
return null;
}
const { mainField, secondaryField, subscribedData } = getEventData( if (hidePast && isPast && !isCurrentParent) {
nestedEntry, return null;
mainSource, }
secondarySource,
subscribe,
customFields,
);
return ( return (
<OperatorEvent <Fragment key={entry.id}>
key={nestedEntry.id} <OperatorGroup
id={nestedEntry.id} key={entry.id}
colour={nestedEntry.colour} title={entry.title}
cue={nestedEntry.cue} colour={entry.colour}
main={mainField} count={entry.entries.length}
secondary={secondaryField} duration={entry.duration}
timeStart={nestedEntry.timeStart} />
duration={nestedEntry.duration} {entry.entries.map((nestedEntryId) => {
delay={nestedEntry.delay} const nestedEntry = rundown.entries[nestedEntryId];
dayOffset={nestedEntry.dayOffset} if (!isOntimeEvent(nestedEntry)) {
isLinkedToLoaded={isLinkedToLoaded} return null;
isSelected={isLoaded} }
isPast={isPast}
groupColour={entry.colour} const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart} // hide past events (if setting) and skipped events
subscribed={subscribedData} if ((hidePast && isPast) || nestedEntry.skip) {
totalGap={totalGap} return null;
onLongPress={canEdit ? handleEdit : () => undefined} }
/>
); const { mainField, secondaryField, subscribedData } = getEventData(
})} nestedEntry,
</Fragment> mainSource,
); secondarySource,
} subscribe,
return null; customFields,
})} );
</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>
); );
@@ -1,19 +1,24 @@
import { memo } from 'react'; import { memo } from 'react';
import Empty from '../../common/components/state/Empty'; import EmptyFill from '../../common/components/state/EmptyFill';
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, rundownMetadata } = useRundownWithMetadata(); const { data, status, rundownMetadata } = useRundownWithMetadata();
const featureData = useRundownEditor(); const featureData = useRundownEditor();
const { getLocalizedString } = useTranslation();
const isLoading = status !== 'success' || !data || !rundownMetadata; // avoid showing the editable empty state before we know whether the rundown is actually empty
if (status === 'pending') {
return <EmptyFill text='Loading…' />;
}
if (isLoading) { if (status === 'error') {
return <Empty text='Connecting to server' />; return <EmptyFill text={getLocalizedString('common.no_data')} />;
} }
return ( return (
@@ -1,6 +1,5 @@
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';
@@ -13,7 +12,7 @@ import { makeRundownColumns } from './makeRundownColumns';
export default memo(RundownTable); export default memo(RundownTable);
function RundownTable() { function RundownTable() {
const { data: customFields, status: customFieldStatus } = useCustomFields(); const { data: customFields } = 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();
@@ -32,16 +31,10 @@ 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'>
{isLoading ? ( <CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
<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 text='There was an error fetching data, please refresh the page.' />; return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
} }
return <Backstage {...data} />; return <Backstage {...data} />;
@@ -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: 100vh; height: 100dvh;
} }
.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, 1, 1, 0); animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
&:nth-child(1) { &:nth-child(1) {
left: $dot-size; left: $dot-size;
animation: lds-ellipsis1 0.6s infinite; animation: lds-ellipsis1 1s infinite;
} }
&:nth-child(2) { &:nth-child(2) {
left: $dot-size; left: $dot-size;
animation: lds-ellipsis2 0.6s infinite; animation: lds-ellipsis2 1s infinite;
} }
&:nth-child(3) { &:nth-child(3) {
left: calc($dot-size + $dot-spacing); left: calc($dot-size + $dot-spacing);
animation: lds-ellipsis2 0.6s infinite; animation: lds-ellipsis2 1s infinite;
} }
&:nth-child(4) { &:nth-child(4) {
left: calc($dot-size + 2 * $dot-spacing); left: calc($dot-size + 2 * $dot-spacing);
animation: lds-ellipsis3 0.6s infinite; animation: lds-ellipsis3 1s infinite;
} }
} }
} }
+1 -15
View File
@@ -80,21 +80,7 @@ $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 text='There was an error fetching data, please refresh the page.' />; return <EmptyPage variant='error' 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')} className='empty-state__content' /> <Empty text={getLocalizedString('common.no_data')} />
</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')} className='empty-state__content' /> <Empty text={getLocalizedString('countdown.select_event')} />
<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')} className='empty-state__content' /> <Empty text={getLocalizedString('countdown.select_event')} />
<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')} className='empty-state__content' /> <Empty text={getLocalizedString('countdown.all_have_finished')} />
</div> </div>
); );
} }
@@ -2,7 +2,6 @@ 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';
@@ -34,40 +33,34 @@ 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, status: customFieldStatus } = useCustomFields(); const { data: customFields } = 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}>
{isLoading ? ( <CuesheetTable
<EmptyPage text='Loading...' /> columns={columns}
) : ( source={source}
<CuesheetTable cuesheetMode={cuesheetMode}
columns={columns} tableRoot='cuesheet'
source={source} setCuesheetMode={setCuesheetMode}
cuesheetMode={cuesheetMode} isCurrentRundown={isCurrentRundown}
tableRoot='cuesheet' insertElement={
setCuesheetMode={setCuesheetMode} <>
isCurrentRundown={isCurrentRundown} <RundownSelect
insertElement={ cuesheetMode={cuesheetMode}
<> selectedRundownId={selectedRundownId}
<RundownSelect loadedRundownId={loadedRundownId}
cuesheetMode={cuesheetMode} setSelectedRundownId={setSelectedRundownId}
selectedRundownId={selectedRundownId} projectRundowns={projectRundowns}
loadedRundownId={loadedRundownId} />
setSelectedRundownId={setSelectedRundownId} </>
projectRundowns={projectRundowns} }
/> />
</>
}
/>
)}
</CuesheetDnd> </CuesheetDnd>
); );
} }
@@ -5,6 +5,10 @@ $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 EmptyPage from '../../../common/components/state/EmptyPage'; import EmptyFill from '../../../common/components/state/EmptyFill';
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,6 +19,7 @@ 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';
@@ -64,6 +65,7 @@ export default function CuesheetTable({
}: CuesheetTableProps) { }: CuesheetTableProps) {
const { flatRundown, status, 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;
@@ -228,10 +230,13 @@ export default function CuesheetTable({
}); });
}, [cuesheetMode, hideIndexColumn, table]); }, [cuesheetMode, hideIndexColumn, table]);
const isLoading = !flatRundown || status === 'pending'; // 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} />;
}
if (isLoading) { if (status === 'error') {
return <EmptyPage text='Loading...' />; return <EmptyFill text={getLocalizedString('common.no_data')} className={style.tableLoading} />;
} }
return ( return (
@@ -100,13 +100,17 @@ $content-width: min(100%, 1100px);
} }
.info__image-container { .info__image-container {
flex: 0 0 min(128px, 25%); display: flex;
justify-content: center;
align-items: center;
flex: 0 0 min(192px, 25%);
} }
.info__image { .info__image {
display: block; display: block;
width: 100%; max-width: 100%;
height: auto; height: auto;
object-fit: contain;
} }
.link.info__value { .link.info__value {
@@ -22,7 +22,7 @@ export default function ProjectInfoLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />; return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
} }
return <ProjectInfo {...data} />; return <ProjectInfo {...data} />;
@@ -73,12 +73,11 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
</div> </div>
)} )}
{projectData.custom.map((info, idx) => { {projectData.custom.map((info, idx) => {
const hasImage = 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__card'>
{info.title && <div className='info__label'>{info.title}</div>} {info.title && <div className='info__label'>{info.title}</div>}
{hasImage ? ( {info.url ? (
<div className='info__media'> <div className='info__media'>
<InfoImage src={info.url} /> <InfoImage src={info.url} />
{info.value && <div className='info__value'>{info.value}</div>} {info.value && <div className='info__value'>{info.value}</div>}
@@ -108,10 +107,6 @@ function InfoCard({ label, children }: InfoCardProps) {
); );
} }
/**
* Renders an user provided image, collapsing itself if the image fails to load
* We remove the container along with the image to avoid leaving an empty gap in the card
*/
function InfoImage({ src }: { src: string }) { function InfoImage({ src }: { src: string }) {
const [hasError, setHasError] = useState(false); const [hasError, setHasError] = useState(false);
+1 -1
View File
@@ -25,7 +25,7 @@ export default function StudioLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />; return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
} }
return <Studio {...data} />; return <Studio {...data} />;
@@ -1,6 +1,7 @@
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';
@@ -29,7 +30,7 @@ export default function TimelinePageLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />; return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
} }
return <TimelinePage {...data} />; return <TimelinePage {...data} />;
@@ -73,7 +74,7 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
totalDuration={totalDuration} totalDuration={totalDuration}
/> />
) : ( ) : (
<EmptyPage text={getLocalizedString('common.no_data')} /> <EmptyFill text={getLocalizedString('common.no_data')} />
)} )}
</div> </div>
); );
+1 -1
View File
@@ -43,7 +43,7 @@ export default function TimerLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />; return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
} }
return <Timer {...data} />; return <Timer {...data} />;