mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-16 21:03:29 +00:00
refactor(views): consistent loading and empty states
This commit is contained in:
@@ -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'>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
|||||||
Reference in New Issue
Block a user