From c070389937b23c124dd117d090522282562804aa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 12:06:25 +0000 Subject: [PATCH] Unify and refine viewer empty/loading/error states Every viewer page (Timer, Countdown, Backstage, Studio, Timeline, ProjectInfo, Operator) re-implemented the same data-loader flow by hand, each with a hard-coded, unlocalized error string and drifting empty-state styling. This centralizes that flow and elevates the shared presentation. - Add ViewDataBoundary: a single component that renders the shared loading, error and optional no-data states from a QueryStatus, so every view handles them the same obvious way. Refactor all seven view loaders through it. - Localize the error state via new common.fetch_error / common.fetch_error_hint keys (en + de/es/fr/it/pt), replacing the duplicated English literal with a two-line "Something went wrong / Please refresh the page" message. - Elevate the shared Empty component: legible viewer-secondary color (was 10% white), responsive title sizing, softened illustration, optional supporting subtitle line, and a reduced-motion-safe fade-in. - Unify loading: Cuesheet and the rundown table now use the animated Loader like every other view instead of an untranslated "Loading..." page. - Tidy up: EmptyTableBody uses Empty's text prop, drop Countdown's now-redundant size override, and remove a stray semicolon rendered in the ProjectInfo empty state. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Vq5XLTSQhU9ckZmCSW2SEr --- .../common/components/state/Empty.module.scss | 37 +++++++++++++-- .../src/common/components/state/Empty.tsx | 4 +- .../src/common/components/state/EmptyPage.tsx | 5 +- .../state/EmptyTableBody.module.scss | 5 -- .../components/state/EmptyTableBody.tsx | 3 +- .../client/src/features/operator/Operator.tsx | 17 +++---- .../rundown/rundown-table/RundownTable.tsx | 6 ++- apps/client/src/translation/languages/de.ts | 2 + apps/client/src/translation/languages/es.ts | 2 + apps/client/src/translation/languages/fr.ts | 2 + apps/client/src/translation/languages/it.ts | 2 + apps/client/src/translation/languages/pt.ts | 2 + apps/client/src/views/backstage/Backstage.tsx | 17 +++---- .../view-data-boundary/ViewDataBoundary.tsx | 47 +++++++++++++++++++ .../client/src/views/countdown/Countdown.scss | 11 ----- apps/client/src/views/countdown/Countdown.tsx | 25 ++++------ .../views/cuesheet/CuesheetTableWrapper.tsx | 6 ++- .../cuesheet/cuesheet-table/CuesheetTable.tsx | 4 +- .../src/views/project-info/ProjectInfo.tsx | 18 +++---- apps/client/src/views/studio/Studio.tsx | 17 +++---- .../src/views/timeline/TimelinePage.tsx | 16 +++---- apps/client/src/views/timer/Timer.tsx | 17 +++---- packages/types/src/translations/index.ts | 2 + 23 files changed, 156 insertions(+), 111 deletions(-) create mode 100644 apps/client/src/views/common/view-data-boundary/ViewDataBoundary.tsx diff --git a/apps/client/src/common/components/state/Empty.module.scss b/apps/client/src/common/components/state/Empty.module.scss index 345b3057f..3620ce78a 100644 --- a/apps/client/src/common/components/state/Empty.module.scss +++ b/apps/client/src/common/components/state/Empty.module.scss @@ -1,20 +1,49 @@ +@use '@/theme/viewerDefs' as *; + .emptyContainer { width: 100%; text-align: center; - color: $white-10; + color: $viewer-secondary-color; + + @media (prefers-reduced-motion: no-preference) { + animation: empty-fade-in $viewer-transition-time ease both; + } .empty { display: block; - width: min(100%, 24rem); + width: min(100%, 16rem); margin-inline: auto; - opacity: 0.8; + opacity: 0.5; } .text { display: block; margin-inline: auto; + margin-top: min(2vh, 16px); font-weight: 600; - font-size: 2em; + font-size: $title-font-size; + max-width: min(100%, 600px); + } + + .secondary { + display: block; + margin-inline: auto; + margin-top: 0.5rem; + font-weight: 400; + font-size: $base-font-size; + color: $viewer-label-color; max-width: min(100%, 600px); } } + +@keyframes empty-fade-in { + from { + opacity: 0; + transform: translateY(4px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/apps/client/src/common/components/state/Empty.tsx b/apps/client/src/common/components/state/Empty.tsx index 0e88e2868..907a1456e 100644 --- a/apps/client/src/common/components/state/Empty.tsx +++ b/apps/client/src/common/components/state/Empty.tsx @@ -7,15 +7,17 @@ import style from './Empty.module.scss'; interface EmptyProps { text?: string; + secondary?: string; injectedStyles?: CSSProperties; className?: string; } -export default function Empty({ text, className, injectedStyles }: EmptyProps) { +export default function Empty({ text, secondary, className, injectedStyles }: EmptyProps) { return (
{text && {text}} + {secondary && {secondary}}
); } diff --git a/apps/client/src/common/components/state/EmptyPage.tsx b/apps/client/src/common/components/state/EmptyPage.tsx index b589989e5..8287c9944 100644 --- a/apps/client/src/common/components/state/EmptyPage.tsx +++ b/apps/client/src/common/components/state/EmptyPage.tsx @@ -6,13 +6,14 @@ import style from './EmptyPage.module.scss'; interface EmptyPageProps { text?: string; + secondary?: string; injectedStyles?: CSSProperties; } -export default function EmptyPage({ text, injectedStyles }: EmptyPageProps) { +export default function EmptyPage({ text, secondary, injectedStyles }: EmptyPageProps) { return (
- +
); } diff --git a/apps/client/src/common/components/state/EmptyTableBody.module.scss b/apps/client/src/common/components/state/EmptyTableBody.module.scss index b2288ffef..969ed4b48 100644 --- a/apps/client/src/common/components/state/EmptyTableBody.module.scss +++ b/apps/client/src/common/components/state/EmptyTableBody.module.scss @@ -15,9 +15,4 @@ gap: 1rem; margin-top: 1em; } - - .text { - font-weight: 600; - font-size: 2em; - } } diff --git a/apps/client/src/common/components/state/EmptyTableBody.tsx b/apps/client/src/common/components/state/EmptyTableBody.tsx index 0edd2a22c..94cb7aa9b 100644 --- a/apps/client/src/common/components/state/EmptyTableBody.tsx +++ b/apps/client/src/common/components/state/EmptyTableBody.tsx @@ -18,8 +18,7 @@ export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) { - - {text} + {handleAddNew && (
@@ -137,7 +132,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod if (subscribedEvents.length === 0) { return (
- + @@ -154,7 +149,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod if (eventsToShow.length === 0) { return (
- +
); } diff --git a/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx b/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx index 595e221ba..8d71a47fc 100644 --- a/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx +++ b/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx @@ -2,11 +2,11 @@ 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'; import { AppMode } from '../../ontimeConfig'; +import Loader from '../common/loader/Loader'; import CuesheetDnd from './cuesheet-dnd/CuesheetDnd'; import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory'; import CuesheetTable from './cuesheet-table/CuesheetTable'; @@ -41,12 +41,14 @@ function CuesheetTableWrapper({ [customFields, cuesheetMode, preset], ); + // TODO: adopt the shared ViewDataBoundary (views/common/view-data-boundary) once this + // table exposes a single query status instead of the ad-hoc isLoading check const isLoading = !customFields || customFieldStatus === 'pending'; return ( {isLoading ? ( - + ) : ( ; + return ; } return ( diff --git a/apps/client/src/views/project-info/ProjectInfo.tsx b/apps/client/src/views/project-info/ProjectInfo.tsx index fed8b32c1..05defb146 100644 --- a/apps/client/src/views/project-info/ProjectInfo.tsx +++ b/apps/client/src/views/project-info/ProjectInfo.tsx @@ -6,7 +6,7 @@ import ViewLogo from '../../common/components/view-logo/ViewLogo'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { useTranslation } from '../../translation/TranslationProvider'; -import Loader from '../common/loader/Loader'; +import ViewDataBoundary from '../common/view-data-boundary/ViewDataBoundary'; import { ProjectInfoData, useProjectInfoData } from './useProjectInfoData'; import './ProjectInfo.scss'; @@ -16,15 +16,11 @@ export default function ProjectInfoLoader() { useWindowTitle('Project info'); - if (status === 'pending') { - return ; - } - - if (status === 'error') { - return ; - } - - return ; + return ( + + + + ); } function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) { @@ -41,7 +37,7 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) { return ( <> - ; + ); } diff --git a/apps/client/src/views/studio/Studio.tsx b/apps/client/src/views/studio/Studio.tsx index e363dc7b8..1c3b02930 100644 --- a/apps/client/src/views/studio/Studio.tsx +++ b/apps/client/src/views/studio/Studio.tsx @@ -1,13 +1,12 @@ import { OntimeView } from 'ontime-types'; import { useMemo } from 'react'; -import EmptyPage from '../../common/components/state/EmptyPage'; import ViewLogo from '../../common/components/view-logo/ViewLogo'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { cx } from '../../common/utils/styleUtils'; import { getDefaultFormat } from '../../common/utils/time'; -import Loader from '../common/loader/Loader'; +import ViewDataBoundary from '../common/view-data-boundary/ViewDataBoundary'; import { getStudioOptions, useStudioOptions } from './studio.options'; import StudioClock from './StudioClock'; import StudioTimers from './StudioTimers'; @@ -20,15 +19,11 @@ export default function StudioLoader() { useWindowTitle('Studio Clock'); - if (status === 'pending') { - return ; - } - - if (status === 'error') { - return ; - } - - return ; + return ( + + + + ); } function Studio({ customFields, projectData, isMirrored, settings, viewSettings }: StudioData) { diff --git a/apps/client/src/views/timeline/TimelinePage.tsx b/apps/client/src/views/timeline/TimelinePage.tsx index f505bf06a..fdebb3a2e 100644 --- a/apps/client/src/views/timeline/TimelinePage.tsx +++ b/apps/client/src/views/timeline/TimelinePage.tsx @@ -9,8 +9,8 @@ import { useSelectedEventId } from '../../common/hooks/useSocket'; import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { formatTime, getDefaultFormat } from '../../common/utils/time'; import { useTranslation } from '../../translation/TranslationProvider'; -import Loader from '../common/loader/Loader'; import SuperscriptTime from '../common/superscript-time/SuperscriptTime'; +import ViewDataBoundary from '../common/view-data-boundary/ViewDataBoundary'; import Timeline from './Timeline'; import { getTimelineOptions, useTimelineOptions } from './timeline.options'; import { getUpcomingEvents, useScopedRundown } from './timeline.utils'; @@ -24,15 +24,11 @@ export default function TimelinePageLoader() { useWindowTitle('Timeline'); - if (status === 'pending') { - return ; - } - - if (status === 'error') { - return ; - } - - return ; + return ( + + + + ); } function TimelinePage({ events, customFields, projectData, settings }: TimelineData) { diff --git a/apps/client/src/views/timer/Timer.tsx b/apps/client/src/views/timer/Timer.tsx index 3ae139d26..8dac69126 100644 --- a/apps/client/src/views/timer/Timer.tsx +++ b/apps/client/src/views/timer/Timer.tsx @@ -3,7 +3,6 @@ import { useMemo } from 'react'; import { FitText } from '../../common/components/fit-text/FitText'; import MultiPartProgressBar from '../../common/components/multi-part-progress-bar/MultiPartProgressBar'; -import EmptyPage from '../../common/components/state/EmptyPage'; import TitleCard from '../../common/components/title-card/TitleCard'; import ViewLogo from '../../common/components/view-logo/ViewLogo'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; @@ -13,8 +12,8 @@ import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { cx } from '../../common/utils/styleUtils'; import { formatTime, getDefaultFormat } from '../../common/utils/time'; import { useTranslation } from '../../translation/TranslationProvider'; -import Loader from '../common/loader/Loader'; import SuperscriptTime from '../common/superscript-time/SuperscriptTime'; +import ViewDataBoundary from '../common/view-data-boundary/ViewDataBoundary'; import { getFormattedTimer, getTimerByType } from '../common/viewUtils'; import { getTimerColour } from '../utils/presentation.utils'; import { getTimerOptions, useTimerOptions } from './timer.options'; @@ -38,15 +37,11 @@ export default function TimerLoader() { useWindowTitle('Timer'); - if (status === 'pending') { - return ; - } - - if (status === 'error') { - return ; - } - - return ; + return ( + + + + ); } function Timer({ customFields, projectData, isMirrored, settings, viewSettings, entries }: TimerData) { diff --git a/packages/types/src/translations/index.ts b/packages/types/src/translations/index.ts index 26e17381c..962ea4b0c 100644 --- a/packages/types/src/translations/index.ts +++ b/packages/types/src/translations/index.ts @@ -14,6 +14,8 @@ export const langEn = { 'common.started_at': 'Started At', 'common.time_now': 'Time now', 'common.no_data': 'No data', + 'common.fetch_error': 'Something went wrong', + 'common.fetch_error_hint': 'Please refresh the page', 'countdown.ended': 'Event ended at', 'countdown.running': 'Event running', 'countdown.group_running': 'Event in group running',