mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-21 06:59:09 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e2baf1fa1 | |||
| 46e4ac94f1 | |||
| b5e1bccc70 | |||
| c840add9c5 |
@@ -15,6 +15,7 @@ export const RUNDOWN = ['rundown'];
|
||||
export const CURRENT_RUNDOWN_QUERY_KEY = ['rundown', 'current'];
|
||||
export const getRundownQueryKey = (rundownId: string) => ['rundown', rundownId];
|
||||
export const RUNTIME = ['runtimeStore'];
|
||||
export const SESSION_STATS = ['sessionStats'];
|
||||
export const URL_PRESETS = ['urlpresets'];
|
||||
export const VIEW_SETTINGS = ['viewSettings'];
|
||||
export const CSS_OVERRIDE = ['cssOverride'];
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import axios from 'axios';
|
||||
import { GetInfo, LinkOptions } from 'ontime-types';
|
||||
import { GetInfo, LinkOptions, SessionStats } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
|
||||
const sessionPath = `${apiEntryUrl}/session`;
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve statistics of the running session
|
||||
*/
|
||||
export async function getSessionStats(options?: RequestOptions): Promise<SessionStats> {
|
||||
const res = await axios.get(sessionPath, { signal: options?.signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve application info
|
||||
*/
|
||||
|
||||
@@ -13,6 +13,9 @@ import Select from '../select/Select';
|
||||
|
||||
import style from './RedirectClientModal.module.scss';
|
||||
|
||||
/** the first navigator view, used as the initial value of the view select */
|
||||
const defaultView = `/${navigatorConstants[0].url}`;
|
||||
|
||||
interface RedirectClientModalProps {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -25,7 +28,7 @@ interface RedirectClientModalProps {
|
||||
export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onClose }: RedirectClientModalProps) {
|
||||
const { data } = useUrlPresets();
|
||||
const [path, setPath] = useState(currentPath);
|
||||
const [selected, setSelected] = useState('/');
|
||||
const [selected, setSelected] = useState(defaultView);
|
||||
|
||||
const { setRedirect } = setClientRemote;
|
||||
|
||||
@@ -97,19 +100,18 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
||||
<Select
|
||||
fluid
|
||||
options={viewOptions}
|
||||
defaultValue={viewOptions[0].value}
|
||||
defaultValue={defaultView}
|
||||
onValueChange={(value) => {
|
||||
if (value === null) return;
|
||||
setSelected(value);
|
||||
}}
|
||||
disabled={enabledPresets.length === 0}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
variant='primary'
|
||||
aria-label='Redirect to preset'
|
||||
className={style.redirect}
|
||||
disabled={enabledPresets.length === 0 || selected === '/'}
|
||||
disabled={selected === currentPath}
|
||||
onClick={() => handleRedirect(selected)}
|
||||
>
|
||||
Redirect <IoArrowForward />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
|
||||
import Button from '../../../common/components/buttons/Button';
|
||||
import { setClientRemote } from '../../hooks/useSocket';
|
||||
import { isKeyEnter } from '../../utils/keyEvent';
|
||||
import Dialog from '../dialog/Dialog';
|
||||
import Input from '../input/input/Input';
|
||||
|
||||
@@ -33,7 +34,18 @@ export function RenameClientModal({ id, name: currentName = '', isOpen, onClose
|
||||
showCloseButton
|
||||
onClose={onClose}
|
||||
bodyElements={
|
||||
<Input height='large' placeholder='New name' value={name} onChange={(event) => setName(event.target.value)} />
|
||||
<Input
|
||||
height='large'
|
||||
placeholder='New name'
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (isKeyEnter(event) && canSubmit) {
|
||||
handleRename();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
footerElements={
|
||||
<>
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { SessionStats } from 'ontime-types';
|
||||
|
||||
import { queryRefetchInterval } from '../../ontimeConfig';
|
||||
import { SESSION_STATS } from '../api/constants';
|
||||
import { getSessionStats } from '../api/session';
|
||||
|
||||
export default function useSessionStats() {
|
||||
const { data, status, isError, refetch } = useQuery<SessionStats>({
|
||||
queryKey: SESSION_STATS,
|
||||
queryFn: ({ signal }) => getSessionStats({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
refetchInterval: queryRefetchInterval,
|
||||
});
|
||||
|
||||
return { data, status, isError, refetch };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -15,11 +15,14 @@ const logger = createStore<LogStore>(() => ({
|
||||
logs: [],
|
||||
}));
|
||||
|
||||
/** the log is kept in memory, we cap it to avoid it growing through a long show */
|
||||
const maxLogEntries = 500;
|
||||
|
||||
export const useLogData = () => useStore(logger);
|
||||
|
||||
export const addLog = (log: Log) =>
|
||||
logger.setState((state) => ({
|
||||
logs: [log, ...state.logs],
|
||||
logs: [log, ...state.logs].slice(0, maxLogEntries),
|
||||
}));
|
||||
|
||||
export const clearLogs = () => logger.setState({ logs: [] });
|
||||
|
||||
@@ -117,6 +117,10 @@ $inner-padding: 1rem;
|
||||
td[data-warn='true'] {
|
||||
background-color: $orange-1300;
|
||||
}
|
||||
|
||||
tr[data-highlight='true'] {
|
||||
background-color: $blue-1100;
|
||||
}
|
||||
}
|
||||
|
||||
.listGroup {
|
||||
|
||||
@@ -19,10 +19,12 @@ export default function InfoNif() {
|
||||
// interfaces outside localhost wont have access
|
||||
if (nif.name === 'localhost' && !isLocalhost) return null;
|
||||
const address = linkToOtherHost(nif.address);
|
||||
// we show the address as it would be typed in a browser, including the port
|
||||
const label = new URL(address).host;
|
||||
|
||||
return (
|
||||
<CopyTag key={nif.name} copyValue={address} onClick={() => handleClick(address)}>
|
||||
{`${nif.name} - ${nif.address}`} <IoArrowUp className={style.goIcon} />
|
||||
<CopyTag key={`${nif.name}-${nif.address}`} copyValue={address} onClick={() => handleClick(address)}>
|
||||
{`${nif.name} - ${label}`} <IoArrowUp className={style.goIcon} />
|
||||
</CopyTag>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -19,10 +19,13 @@ export default function LogExport() {
|
||||
<Panel.SubHeader>
|
||||
Event log
|
||||
<Button onClick={extract}>
|
||||
Extract <IoArrowUp className={style.iconRotate} />
|
||||
Open in new window <IoArrowUp className={style.iconRotate} />
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Paragraph>
|
||||
Activity in this session. The log is not saved with the project and is cleared when Ontime restarts.
|
||||
</Panel.Paragraph>
|
||||
<Log />
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
|
||||
@@ -1,26 +1,38 @@
|
||||
import { MessageTag } from 'ontime-types';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { usePing } from '../../../../common/hooks/useSocket';
|
||||
import { sendSocket } from '../../../../common/utils/socket';
|
||||
import { isDocker } from '../../../../externals';
|
||||
import { isOntimeCloud } from '../../../../externals';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import ClientControlPanel from './client-control/ClientControlPanel';
|
||||
import InfoNif from './NetworkInterfaces';
|
||||
import LogExport from './NetworkLogExport';
|
||||
import NetworkStatus from './NetworkStatus';
|
||||
|
||||
export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
||||
const statusRef = useScrollIntoView<HTMLDivElement>('status', location);
|
||||
const interfacesRef = useScrollIntoView<HTMLDivElement>('interfaces', location);
|
||||
const clientsRef = useScrollIntoView<HTMLDivElement>('clients', location);
|
||||
const logRef = useScrollIntoView<HTMLDivElement>('log', location);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Network</Panel.Header>
|
||||
{isDocker && (
|
||||
<Panel.Section>
|
||||
<OntimeCloudStats />
|
||||
</Panel.Section>
|
||||
<div ref={statusRef}>
|
||||
<NetworkStatus />
|
||||
</div>
|
||||
{!isOntimeCloud && (
|
||||
<div ref={interfacesRef}>
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>Network interfaces</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Paragraph>
|
||||
Addresses which can be used to reach Ontime from other devices in the network. <br />
|
||||
Click an address to open it in a new window, or use the icon to copy it to the clipboard.
|
||||
</Panel.Paragraph>
|
||||
<InfoNif />
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
</div>
|
||||
)}
|
||||
<div ref={logRef}>
|
||||
<LogExport />
|
||||
@@ -31,29 +43,3 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function OntimeCloudStats() {
|
||||
const ping = usePing();
|
||||
|
||||
/**
|
||||
* Send immediate ping request, and keep sending on an interval
|
||||
*/
|
||||
useEffect(() => {
|
||||
sendSocket(MessageTag.Ping, new Date());
|
||||
|
||||
const doPing = setInterval(() => {
|
||||
sendSocket(MessageTag.Ping, new Date());
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
clearInterval(doPing);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Panel.SubHeader>
|
||||
Ontime cloud
|
||||
<Panel.Description>Current ping: {ping}ms</Panel.Description>
|
||||
</Panel.SubHeader>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { MaybeString, MessageTag } from 'ontime-types';
|
||||
import { MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import useSessionStats from '../../../../common/hooks-query/useSessionStats';
|
||||
import { usePing } from '../../../../common/hooks/useSocket';
|
||||
import { useClientStore } from '../../../../common/stores/clientStore';
|
||||
import { formatDuration } from '../../../../common/utils/time';
|
||||
import { sendSocket } from '../../../../common/utils/socket';
|
||||
import { isDocker } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
/** ping values above this are flagged to the user (ms) */
|
||||
const slowPingThreshold = 100;
|
||||
|
||||
/** how often we ask the server for a ping (ms) */
|
||||
const pingInterval = 5000;
|
||||
|
||||
/**
|
||||
* Presents a date as the time elapsed since it happened
|
||||
*/
|
||||
function timeSince(date: MaybeString): string {
|
||||
if (date === null) {
|
||||
return 'Never';
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - new Date(date).getTime();
|
||||
if (elapsed < MILLIS_PER_MINUTE) {
|
||||
return 'Just now';
|
||||
}
|
||||
return `${formatDuration(elapsed)} ago`;
|
||||
}
|
||||
|
||||
export default function NetworkStatus() {
|
||||
const { data, status, isError } = useSessionStats();
|
||||
// the client list is kept up to date over websocket, unlike the polled session stats
|
||||
const clients = useClientStore((store) => store.clients);
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>Server status</Panel.SubHeader>
|
||||
<Panel.Loader isLoading={status === 'pending'} />
|
||||
{isError && <Panel.Error>Failed to load session data</Panel.Error>}
|
||||
<Panel.Divider />
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Connected clients' description='Clients currently connected to this server' />
|
||||
<Tag>{Object.keys(clients).length}</Tag>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Server running for' description='Time since the Ontime server was started' />
|
||||
<Tag>{data ? formatDuration(Date.now() - new Date(data.startedAt).getTime()) : '...'}</Tag>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Last client connection' description='When a client last connected to the server' />
|
||||
<Tag>{data ? timeSince(data.lastConnection) : '...'}</Tag>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Last integration request'
|
||||
description='When the server last received a request from an integration'
|
||||
/>
|
||||
<Tag>{data ? timeSince(data.lastRequest) : '...'}</Tag>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Server time zone' description='Time zone used by the machine running Ontime' />
|
||||
<Tag>{data ? data.timezone : '...'}</Tag>
|
||||
</Panel.ListItem>
|
||||
{isDocker && <CloudPing />}
|
||||
</Panel.ListGroup>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ontime cloud users are not in the same network as the server
|
||||
* we show the round trip time to help them qualify the connection
|
||||
*/
|
||||
function CloudPing() {
|
||||
const ping = usePing();
|
||||
|
||||
/**
|
||||
* Send immediate ping request, and keep sending on an interval
|
||||
*/
|
||||
useEffect(() => {
|
||||
sendSocket(MessageTag.Ping, new Date());
|
||||
|
||||
const doPing = setInterval(() => {
|
||||
sendSocket(MessageTag.Ping, new Date());
|
||||
}, pingInterval);
|
||||
|
||||
return () => {
|
||||
clearInterval(doPing);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Ontime cloud latency'
|
||||
description='Time for a message to travel to the server and back. Lower is better'
|
||||
/>
|
||||
<Tag variant={ping > slowPingThreshold ? 'warning' : 'default'}>{ping}ms</Tag>
|
||||
</Panel.ListItem>
|
||||
);
|
||||
}
|
||||
+7
-10
@@ -1,11 +1,3 @@
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.halfWidth {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.halfWidthNoWrap {
|
||||
width: 50%;
|
||||
white-space: nowrap;
|
||||
@@ -16,6 +8,11 @@
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.self {
|
||||
background-color: $blue-1100;
|
||||
.muted {
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
// align section notes with the table contents
|
||||
.note {
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
+65
-11
@@ -5,14 +5,42 @@ import { useState } from 'react';
|
||||
import Button from '../../../../../common/components/buttons/Button';
|
||||
import { RedirectClientModal } from '../../../../../common/components/client-modal/RedirectClientModal';
|
||||
import { RenameClientModal } from '../../../../../common/components/client-modal/RenameClientModal';
|
||||
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
|
||||
import Tag from '../../../../../common/components/tag/Tag';
|
||||
import Tooltip from '../../../../../common/components/tooltip/Tooltip';
|
||||
import { setClientRemote } from '../../../../../common/hooks/useSocket';
|
||||
import { useClientStore } from '../../../../../common/stores/clientStore';
|
||||
import { cx } from '../../../../../common/utils/styleUtils';
|
||||
import { openLink } from '../../../../../common/utils/linkUtils';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './ClientControlPanel.module.scss';
|
||||
|
||||
/**
|
||||
* Shows the path a client is showing
|
||||
* The full URL is available for copying or opening in a new window
|
||||
*/
|
||||
function ClientPath({ origin, path }: { origin: string; path: string }) {
|
||||
// clients report their path on connection, we may not have it yet
|
||||
if (!path) {
|
||||
return <span className={style.muted}>unknown</span>;
|
||||
}
|
||||
|
||||
// origin is empty for clients which have not reported their location
|
||||
if (!origin) {
|
||||
return <span className={style.copiable}>{path}</span>;
|
||||
}
|
||||
|
||||
const fullUrl = `${origin}${path}`;
|
||||
|
||||
return (
|
||||
<Tooltip text={fullUrl} render={<span />}>
|
||||
<CopyTag copyValue={fullUrl} size='small' onClick={() => openLink(fullUrl)}>
|
||||
{path}
|
||||
</CopyTag>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClientList() {
|
||||
const id = useClientStore((store) => store.id);
|
||||
const clients = useClientStore((store) => store.clients);
|
||||
@@ -32,8 +60,19 @@ export default function ClientList() {
|
||||
redirectHandler.open();
|
||||
};
|
||||
|
||||
const ontimeClients = Object.entries(clients).filter(([_, { type }]) => type === 'ontime');
|
||||
const otherClients = Object.entries(clients).filter(([_, { type }]) => type !== 'ontime');
|
||||
/**
|
||||
* Clients are given by the server in connection order, which means rows move
|
||||
* as clients come and go. We keep a stable order: self first, then by name
|
||||
*/
|
||||
const sortClients = (clientEntries: [string, Client][]) =>
|
||||
clientEntries.sort(([keyA, clientA], [keyB, clientB]) => {
|
||||
if (keyA === id) return -1;
|
||||
if (keyB === id) return 1;
|
||||
return clientA.name.localeCompare(clientB.name);
|
||||
});
|
||||
|
||||
const ontimeClients = sortClients(Object.entries(clients).filter(([_, { type }]) => type === 'ontime'));
|
||||
const otherClients = sortClients(Object.entries(clients).filter(([_, { type }]) => type !== 'ontime'));
|
||||
|
||||
const targetClient: Client | undefined = clients[targetId];
|
||||
|
||||
@@ -59,29 +98,37 @@ export default function ClientList() {
|
||||
)}
|
||||
<Panel.Section>
|
||||
<Panel.Title>Ontime Clients ({ontimeClients.length})</Panel.Title>
|
||||
<div className={style.note}>
|
||||
<Panel.Description>
|
||||
Ontime views connected to this server. Identify shows a marker in the client screen until you turn it off,
|
||||
redirect sends the client to a different view.
|
||||
</Panel.Description>
|
||||
</div>
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td style={{ width: '20%' }}>Client Name</td>
|
||||
<td>Path</td>
|
||||
<td />
|
||||
<th style={{ width: '20%' }}>Client Name</th>
|
||||
<th>Path</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ontimeClients.length === 0 && <Panel.TableEmpty label='No Ontime clients connected' />}
|
||||
{ontimeClients.map(([key, client]) => {
|
||||
const { identify, name, path } = client;
|
||||
const isCurrent = id === key;
|
||||
return (
|
||||
<tr key={key} className={cx([isCurrent && style.self])}>
|
||||
<tr key={key} data-highlight={isCurrent}>
|
||||
<Panel.InlineElements relation='inner' as='td'>
|
||||
{isCurrent && <Tag>SELF</Tag>}
|
||||
{name}
|
||||
</Panel.InlineElements>
|
||||
<td className={style.copiable}>{path}</td>
|
||||
<td>
|
||||
<ClientPath origin={client.origin} path={path} />
|
||||
</td>
|
||||
<Panel.InlineElements relation='inner' as='td'>
|
||||
<Button
|
||||
size='small'
|
||||
className={`${identify ? style.blink : ''}`}
|
||||
disabled={isCurrent}
|
||||
variant={identify ? 'primary' : 'subtle'}
|
||||
data-testid={isCurrent ? '' : 'not-self-identify'}
|
||||
@@ -117,14 +164,21 @@ export default function ClientList() {
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Panel.Title>Other Clients ({otherClients.length})</Panel.Title>
|
||||
<div className={style.note}>
|
||||
<Panel.Description>
|
||||
Connections which are not Ontime views, such as integrations and custom clients. These cannot be controlled
|
||||
from Ontime.
|
||||
</Panel.Description>
|
||||
</div>
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td className={style.halfWidthNoWrap}>Client Name</td>
|
||||
<td className={style.halfWidthNoWrap}>Client type</td>
|
||||
<th className={style.halfWidthNoWrap}>Client Name</th>
|
||||
<th className={style.halfWidthNoWrap}>Client type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{otherClients.length === 0 && <Panel.TableEmpty label='No other clients connected' />}
|
||||
{otherClients.map(([key, client]) => {
|
||||
const { name, type } = client;
|
||||
|
||||
|
||||
@@ -67,6 +67,14 @@ const staticOptions = [
|
||||
id: 'network',
|
||||
label: 'Network',
|
||||
secondary: [
|
||||
{
|
||||
id: 'network__status',
|
||||
label: 'Server status',
|
||||
},
|
||||
{
|
||||
id: 'network__interfaces',
|
||||
label: 'Network interfaces',
|
||||
},
|
||||
{
|
||||
id: 'network__log',
|
||||
label: 'Event log',
|
||||
|
||||
@@ -51,3 +51,13 @@ $info-hover: $section-white;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: $info-gray;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: $info-gray;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
@@ -1,115 +1,90 @@
|
||||
import { LogOrigin } from 'ontime-types';
|
||||
import { LogLevel, LogOrigin } from 'ontime-types';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { IoClose } from 'react-icons/io5';
|
||||
|
||||
import Button from '../../common/components/buttons/Button';
|
||||
import Input from '../../common/components/input/input/Input';
|
||||
import { clearLogs, useLogData } from '../../common/stores/logger';
|
||||
import * as Panel from '../app-settings/panel-utils/PanelUtils';
|
||||
|
||||
import style from './Log.module.scss';
|
||||
|
||||
const allOrigins = [
|
||||
LogOrigin.User,
|
||||
LogOrigin.Client,
|
||||
LogOrigin.Server,
|
||||
LogOrigin.Playback,
|
||||
LogOrigin.Rx,
|
||||
LogOrigin.Tx,
|
||||
];
|
||||
|
||||
export default function Log() {
|
||||
const { logs: logData } = useLogData();
|
||||
|
||||
const [showClient, setShowClient] = useState(true);
|
||||
const [showServer, setShowServer] = useState(true);
|
||||
const [showRx, setShowRx] = useState(true);
|
||||
const [showTx, setShowTx] = useState(true);
|
||||
const [showPlayback, setShowPlayback] = useState(true);
|
||||
const [showUser, setShowUser] = useState(true);
|
||||
// log entries are not guaranteed to have an origin from the enum
|
||||
const [origins, setOrigins] = useState<Set<string>>(() => new Set<string>(allOrigins));
|
||||
const [onlyProblems, setOnlyProblems] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const matchers: LogOrigin[] = [];
|
||||
if (showUser) {
|
||||
matchers.push(LogOrigin.User);
|
||||
}
|
||||
if (showClient) {
|
||||
matchers.push(LogOrigin.Client);
|
||||
}
|
||||
if (showServer) {
|
||||
matchers.push(LogOrigin.Server);
|
||||
}
|
||||
if (showRx) {
|
||||
matchers.push(LogOrigin.Rx);
|
||||
}
|
||||
if (showTx) {
|
||||
matchers.push(LogOrigin.Tx);
|
||||
}
|
||||
if (showPlayback) {
|
||||
matchers.push(LogOrigin.Playback);
|
||||
}
|
||||
|
||||
const filteredData = logData.filter((entry) => matchers.some((match) => entry.origin === match));
|
||||
|
||||
const disableOthers = useCallback((toEnable: LogOrigin) => {
|
||||
setShowUser(toEnable === LogOrigin.User);
|
||||
setShowClient(toEnable === LogOrigin.Client);
|
||||
setShowServer(toEnable === LogOrigin.Server);
|
||||
setShowRx(toEnable === LogOrigin.Rx);
|
||||
setShowTx(toEnable === LogOrigin.Tx);
|
||||
setShowPlayback(toEnable === LogOrigin.Playback);
|
||||
const toggleOrigin = useCallback((origin: LogOrigin) => {
|
||||
setOrigins((previous) => {
|
||||
const newOrigins = new Set(previous);
|
||||
if (newOrigins.has(origin)) {
|
||||
newOrigins.delete(origin);
|
||||
} else {
|
||||
newOrigins.add(origin);
|
||||
}
|
||||
return newOrigins;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/** middle click on an origin shows only that origin */
|
||||
const showOnlyOrigin = useCallback((origin: LogOrigin) => {
|
||||
setOrigins(new Set([origin]));
|
||||
}, []);
|
||||
|
||||
const searchTerm = search.toLowerCase();
|
||||
const filteredData = logData.filter((entry) => {
|
||||
if (!origins.has(entry.origin)) return false;
|
||||
if (onlyProblems && entry.level === LogLevel.Info) return false;
|
||||
if (searchTerm && !entry.text.toLowerCase().includes(searchTerm)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const isFiltered = filteredData.length !== logData.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel.InlineElements className={style.buttonBar}>
|
||||
{allOrigins.map((origin) => (
|
||||
<Button
|
||||
key={origin}
|
||||
variant={origins.has(origin) ? 'primary' : 'subtle'}
|
||||
size='small'
|
||||
onClick={() => toggleOrigin(origin)}
|
||||
onAuxClick={() => showOnlyOrigin(origin)}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
{origin}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
variant={showUser ? 'primary' : 'subtle'}
|
||||
variant={onlyProblems ? 'primary' : 'subtle'}
|
||||
size='small'
|
||||
onClick={() => setShowUser((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.User)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onClick={() => setOnlyProblems((previous) => !previous)}
|
||||
>
|
||||
{LogOrigin.User}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showClient ? 'primary' : 'subtle'}
|
||||
size='small'
|
||||
onClick={() => setShowClient((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Client)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{LogOrigin.Client}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showServer ? 'primary' : 'subtle'}
|
||||
size='small'
|
||||
onClick={() => setShowServer((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Server)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{LogOrigin.Server}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showPlayback ? 'primary' : 'subtle'}
|
||||
size='small'
|
||||
onClick={() => setShowPlayback((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Playback)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{LogOrigin.Playback}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showRx ? 'primary' : 'subtle'}
|
||||
size='small'
|
||||
onClick={() => setShowRx((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Rx)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{LogOrigin.Rx}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showTx ? 'primary' : 'subtle'}
|
||||
size='small'
|
||||
onClick={() => setShowTx((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Tx)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{LogOrigin.Tx}
|
||||
Issues only
|
||||
</Button>
|
||||
<Button variant='subtle-destructive' size='small' onClick={clearLogs} className={style.apart}>
|
||||
<IoClose /> Clear
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
<Panel.InlineElements className={style.buttonBar}>
|
||||
<Input fluid placeholder='Filter messages' value={search} onChange={(event) => setSearch(event.target.value)} />
|
||||
<span className={style.count}>
|
||||
{isFiltered ? `${filteredData.length} of ${logData.length}` : logData.length} entries
|
||||
</span>
|
||||
</Panel.InlineElements>
|
||||
<ul className={style.log}>
|
||||
{filteredData.map((logEntry) => (
|
||||
<li key={logEntry.id} className={`${style.logEntry} ${style[logEntry.level]} `}>
|
||||
@@ -118,6 +93,9 @@ export default function Log() {
|
||||
<span className={style.msg}>{logEntry.text}</span>
|
||||
</li>
|
||||
))}
|
||||
{filteredData.length === 0 && (
|
||||
<li className={style.empty}>{logData.length === 0 ? 'No activity yet' : 'No entries match the filters'}</li>
|
||||
)}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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,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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user