Compare commits

..

3 Commits

Author SHA1 Message Date
Claude 8ce4627121 chore: upgrade react-router to 8.3.0
Patch/minor bump within the existing ^8 range. Includes the RFC 3986
path-segment encoding change, optional-segment param extraction fix and
route ranking fix for dynamic params with static suffixes.

Lockfile also picks up incidental transitive drift (acorn, fs-extra,
jsonfile, @jridgewell/trace-mapping) from re-resolution.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1tN6RYPVBX7FxxbnuqxC2
2026-08-02 07:59:12 +00:00
Carlos Valente 94d54529ee refactor(views): consistent loading and empty states 2026-08-01 16:25:14 +02:00
Carlos Valente f7535651f6 refactor(info): improve UI consistency and polish 2026-08-01 14:41:39 +02:00
66 changed files with 958 additions and 892 deletions
+1 -1
View File
@@ -30,7 +30,7 @@
"react-fast-compare": "^3.2.2", "react-fast-compare": "^3.2.2",
"react-hook-form": "^7.80.0", "react-hook-form": "^7.80.0",
"react-icons": "5.6.0", "react-icons": "5.6.0",
"react-router": "^8.0.1", "react-router": "^8.3.0",
"react-virtuoso": "^4.18.7", "react-virtuoso": "^4.18.7",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
@@ -1,20 +1,43 @@
@use '@/theme/viewerDefs' as *;
.emptyContainer { .emptyContainer {
width: 100%; width: 100%;
text-align: center; text-align: center;
color: $white-10; color: var(--secondary-color-override, $viewer-secondary-color);
.empty { .empty {
display: block; display: block;
width: min(100%, 24rem); width: min(100%, 14rem);
margin-inline: auto; margin: 0 auto -1.5rem;
opacity: 0.8; opacity: 0.6;
} }
.text { .text {
display: block; display: block;
margin-inline: auto; margin-inline: auto;
font-weight: 600; font-weight: 400;
font-size: 2em; font-size: clamp(1rem, 1.55vw, 1.5rem);
max-width: min(100%, 600px); line-height: 1.35;
max-width: min(100%, 40rem);
}
&.error {
color: $error-red;
.empty {
opacity: 0.35;
filter: grayscale(1);
}
.text {
margin-top: 0.5rem;
}
}
.errorIcon {
display: block;
width: 1.5rem;
height: 1.5rem;
margin: -0.125rem auto 0;
} }
} }
@@ -1,4 +1,5 @@
import { CSSProperties } from 'react'; import { CSSProperties } from 'react';
import { IoWarningOutline } from 'react-icons/io5';
import EmptyImage from '../../../assets/images/empty.svg?react'; import EmptyImage from '../../../assets/images/empty.svg?react';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -9,12 +10,18 @@ interface EmptyProps {
text?: string; text?: string;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
className?: string; className?: string;
variant?: 'error';
} }
export default function Empty({ text, className, injectedStyles }: EmptyProps) { export default function Empty({ text, className, injectedStyles, variant }: EmptyProps) {
return ( return (
<div className={cx([style.emptyContainer, className])} style={injectedStyles}> <div
className={cx([style.emptyContainer, variant === 'error' && style.error, className])}
style={injectedStyles}
role={variant === 'error' ? 'alert' : undefined}
>
<EmptyImage className={style.empty} /> <EmptyImage className={style.empty} />
{variant === 'error' && <IoWarningOutline className={style.errorIcon} aria-hidden />}
{text && <span className={style.text}>{text}</span>} {text && <span className={style.text}>{text}</span>}
</div> </div>
); );
@@ -0,0 +1,11 @@
.fill {
box-sizing: border-box;
width: 100%;
height: 100%;
min-height: 0;
flex: 1;
display: grid;
place-items: start center;
padding: clamp(4rem, 20dvh, 12rem) 1.5rem 1rem;
}
@@ -0,0 +1,19 @@
import { cx } from '../../utils/styleUtils';
import Empty from './Empty';
import style from './EmptyFill.module.scss';
interface EmptyFillProps {
text?: string;
/** placed on the fill wrapper — e.g. to assign a grid-area in a grid parent */
className?: string;
}
/** Container-filling empty/loading state for panels and grid/flex cells. */
export default function EmptyFill({ text, className }: EmptyFillProps) {
return (
<div className={cx([style.fill, className])}>
<Empty text={text} />
</div>
);
}
@@ -6,7 +6,7 @@
box-sizing: border-box; /* reset */ box-sizing: border-box; /* reset */
overflow: hidden; overflow: hidden;
width: 100%; /* restrict the page width to viewport */ width: 100%; /* restrict the page width to viewport */
height: 100vh; height: 100dvh;
font-family: var(--font-family-override, $viewer-font-family); font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color); background: var(--background-color-override, $viewer-background-color);
@@ -16,5 +16,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding-top: 5rem; justify-content: center;
padding-block: 5rem;
} }
@@ -7,12 +7,13 @@ import style from './EmptyPage.module.scss';
interface EmptyPageProps { interface EmptyPageProps {
text?: string; text?: string;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
variant?: 'error';
} }
export default function EmptyPage({ text, injectedStyles }: EmptyPageProps) { export default function EmptyPage({ text, injectedStyles, variant }: EmptyPageProps) {
return ( return (
<div className={style.page}> <div className={style.page}>
<Empty text={text} injectedStyles={injectedStyles} /> <Empty text={text} injectedStyles={injectedStyles} variant={variant} />
</div> </div>
); );
} }
@@ -15,9 +15,4 @@
gap: 1rem; gap: 1rem;
margin-top: 1em; margin-top: 1em;
} }
.text {
font-weight: 600;
font-size: 2em;
}
} }
@@ -18,8 +18,7 @@ export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
<tbody className={style.emptyContainer}> <tbody className={style.emptyContainer}>
<tr> <tr>
<td colSpan={99} className={style.emptyCell}> <td colSpan={99} className={style.emptyCell}>
<Empty injectedStyles={{ marginTop: '5vh' }} /> <Empty text={text} injectedStyles={{ marginTop: '5vh' }} />
<span className={style.text}>{text}</span>
{handleAddNew && ( {handleAddNew && (
<div className={style.inline}> <div className={style.inline}>
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'> <Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
@@ -1,15 +1,15 @@
import { PropsWithChildren, createContext, useContext } from 'react'; import { PropsWithChildren, createContext, useContext } from 'react';
import { useEntryActions, useScopedEntryActions } from '../hooks/useEntryAction'; import { useEntryActions } from '../hooks/useEntryAction';
import { useRundownSelectionContext } from './RundownSelectionContext';
type EntryActionsContextValue = ReturnType<typeof useEntryActions>; type EntryActionsContextValue = ReturnType<typeof useEntryActions>;
const EntryActionsContext = createContext<EntryActionsContextValue | null>(null); const EntryActionsContext = createContext<EntryActionsContextValue | null>(null);
export function EntryActionsProvider({ children }: PropsWithChildren) { interface EntryActionsProviderProps extends PropsWithChildren {
const { effectiveRundownId } = useRundownSelectionContext(); actions: EntryActionsContextValue;
const actions = useScopedEntryActions(effectiveRundownId); }
export function EntryActionsProvider({ children, actions }: EntryActionsProviderProps) {
return <EntryActionsContext.Provider value={actions}>{children}</EntryActionsContext.Provider>; return <EntryActionsContext.Provider value={actions}>{children}</EntryActionsContext.Provider>;
} }
@@ -1,120 +0,0 @@
import { Maybe, ProjectRundown } from 'ontime-types';
import { PropsWithChildren, createContext, startTransition, useCallback, useContext, useEffect, useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { useNavigate } from 'react-router';
import { useProjectRundowns } from '../hooks-query/useProjectRundowns';
export type RundownScopeValue = {
loadedRundownId: string;
selectedRundownId: Maybe<string>;
isLoadedRundown: boolean;
effectiveRundownId: string;
selectRundownId: (val: Maybe<string>) => void;
rundowns: ProjectRundown[];
};
const RundownScopeContext = createContext<RundownScopeValue | null>(null);
export function RundownSelectionContextProvider({ children }: PropsWithChildren) {
'use memo';
const { data } = useProjectRundowns();
const { loaded, rundowns } = data;
const [selectedRundownId, setSelectedRundownId] = useSelectRundownFromParams();
const selectRundownId = useCallback(
(rundownId: Maybe<string>) => {
startTransition(() => {
if (rundowns.find((entry) => entry.id === rundownId)) setSelectedRundownId(rundownId);
else setSelectedRundownId(null);
});
},
[rundowns, setSelectedRundownId],
);
const effectiveRundownId = selectedRundownId ? selectedRundownId : loaded;
const isLoadedRundown = effectiveRundownId === loaded;
useEffect(() => {
if (!rundowns.find((entry) => entry.id === effectiveRundownId)) setSelectedRundownId(null);
}, [rundowns, effectiveRundownId, setSelectedRundownId]);
const value = useMemo(
(): RundownScopeValue => ({
loadedRundownId: loaded,
isLoadedRundown,
selectedRundownId,
effectiveRundownId,
selectRundownId,
rundowns,
}),
[loaded, isLoadedRundown, selectedRundownId, effectiveRundownId, selectRundownId, rundowns],
);
return <RundownScopeContext.Provider value={value}>{children}</RundownScopeContext.Provider>;
}
export function useRundownSelectionContext() {
const context = useContext(RundownScopeContext);
if (!context) {
throw new Error('useRundownScopeSelection requires a RundownSelectionContextProvider');
}
return context;
}
const rundownParam = 'rundownId';
export function useSelectRundownFromParams(): [Maybe<string>, (id: Maybe<string>) => void] {
'use memo';
const [searchParams, setSearchParams] = useSearchParams();
const selectedRundownId = searchParams.get(rundownParam);
const setSelectedRundownId = useCallback(
(id: Maybe<string>) => {
if (id === null) {
setSearchParams((searchParams) => {
searchParams.delete(rundownParam);
return searchParams;
});
} else {
setSearchParams((searchParams) => {
searchParams.set(rundownParam, id);
return searchParams;
});
}
},
[setSearchParams],
);
return [selectedRundownId, setSelectedRundownId];
}
/**
*
* mutates the provided `searchParams`
*/
export function setSelectRundownInParams(id: Maybe<string>, searchParams: URLSearchParams): void {
if (id === null) {
searchParams.delete(rundownParam);
} else {
searchParams.set(rundownParam, id);
}
}
export function useDirectLinkToBackgroundEdit() {
const navigate = useNavigate();
const [search] = useSearchParams();
return useCallback(
async (rundownId: string) => {
setSelectRundownInParams(rundownId, search);
navigate({
pathname: '/cuesheet',
search: search.toString(),
});
},
[navigate, search],
);
}
@@ -1,101 +0,0 @@
import { isOntimeEvent, OntimeEvent } from 'ontime-types';
import { useMemo } from 'react';
import { useRundownSelectionContext } from '../context/RundownSelectionContext';
import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { useFlatRundown, useRundown } from './useRundown';
export function useContextRundownEditModal() {
'use memo';
const { effectiveRundownId } = useRundownSelectionContext();
const { data: rundown } = useRundown(effectiveRundownId);
return { rundown };
}
export function useContextRundownCueRenumberModal() {
'use memo';
const { effectiveRundownId } = useRundownSelectionContext();
const { data } = useRundown(effectiveRundownId);
const { flatOrder } = data;
return { flatOrder };
}
export function useContextRundownList() {
'use memo';
const { effectiveRundownId, isLoadedRundown } = useRundownSelectionContext();
const loadedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedRundown ? loadedEventId : null;
const { data: rundown, status } = useRundown(effectiveRundownId);
const rundownMetadata = useMemo(
() => getRundownMetadata(rundown, effectiveSelectedEventId),
[effectiveSelectedEventId, rundown],
);
return useMemo(
() => ({
rundown,
rundownMetadata,
status,
isLoadedRundown,
}),
[rundown, rundownMetadata, status, isLoadedRundown],
);
}
export function useContextRundownTitleList() {
'use memo';
const { effectiveRundownId, isLoadedRundown } = useRundownSelectionContext();
const loadedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedRundown ? loadedEventId : null;
const { data: rundown, status } = useRundown(effectiveRundownId);
const flatRundown = useMemo(() => {
const flatData = getFlatRundownMetadata(rundown, effectiveSelectedEventId);
return flatData.filter(isOntimeEvent) as ExtendedEntry<OntimeEvent>[];
}, [effectiveSelectedEventId, rundown]);
return useMemo(
() => ({
flatRundown,
status,
isLoadedRundown,
}),
[status, isLoadedRundown, flatRundown],
);
}
export function useContextRundownTable() {
'use memo';
const { effectiveRundownId, isLoadedRundown } = useRundownSelectionContext();
const loadedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedRundown ? loadedEventId : null;
const { data: rundown, status } = useRundown(effectiveRundownId);
const flatRundown = useMemo(
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
[effectiveSelectedEventId, rundown],
);
return useMemo(
() => ({
flatRundown,
status,
loadedEventId,
}),
[flatRundown, status, loadedEventId],
);
}
export function useContextRundownFinder() {
'use memo';
const { effectiveRundownId } = useRundownSelectionContext();
const { data: rundown, status } = useFlatRundown(effectiveRundownId);
return useMemo(
() => ({
rundown,
rundownId: effectiveRundownId,
status,
}),
[rundown, status, effectiveRundownId],
);
}
@@ -1,18 +1,24 @@
import { useSuspenseQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { ProjectFile, ProjectFileList } from 'ontime-types'; import { ProjectFile, ProjectFileList, ProjectFileListResponse } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_LIST } from '../api/constants'; import { PROJECT_LIST } from '../api/constants';
import { getProjects } from '../api/db'; import { getProjects } from '../api/db';
export function useProjectList() { const placeholderProjectList: ProjectFileListResponse = {
const { data, status, refetch } = useSuspenseQuery({ files: [],
lastLoadedProject: '',
};
function useProjectList() {
const { data, status, refetch } = useQuery({
queryKey: PROJECT_LIST, queryKey: PROJECT_LIST,
queryFn: ({ signal }) => getProjects({ signal }), queryFn: ({ signal }) => getProjects({ signal }),
staleTime: MILLIS_PER_HOUR, placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
}); });
return { data, status, refetch }; return { data: data ?? placeholderProjectList, status, refetch };
} }
export type ProjectSortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'modified-asc' | 'modified-desc'; export type ProjectSortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'modified-asc' | 'modified-desc';
@@ -1,7 +1,7 @@
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ProjectRundownsList } from 'ontime-types'; import { ProjectRundownsList } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_RUNDOWNS } from '../api/constants'; import { PROJECT_RUNDOWNS } from '../api/constants';
import { import {
createRundown, createRundown,
@@ -11,21 +11,24 @@ import {
loadRundown, loadRundown,
renameRundown, renameRundown,
} from '../api/rundown'; } from '../api/rundown';
import { ontimeQueryClient } from '../queryClient';
//TODO: make suspends so we don't have to deal with no value all over
/** /**
* Project rundowns * Project rundowns
*/ */
export function useProjectRundowns() { export function useProjectRundowns() {
const { data, status, isError, refetch, isFetching } = useSuspenseQuery<ProjectRundownsList>({ const { data, status, isError, refetch, isFetching } = useQuery<ProjectRundownsList>({
queryKey: PROJECT_RUNDOWNS, queryKey: PROJECT_RUNDOWNS,
queryFn: ({ signal }) => fetchProjectRundownList({ signal }), queryFn: ({ signal }) => fetchProjectRundownList({ signal }),
staleTime: MILLIS_PER_HOUR, placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
}); });
return { data, status, isError, refetch, isFetching }; return { data: data ?? { loaded: '', rundowns: [] }, status, isError, refetch, isFetching };
} }
export function useMutateProjectRundowns() { export function useMutateProjectRundowns() {
const ontimeQueryClient = useQueryClient();
const { mutateAsync: create } = useMutation({ const { mutateAsync: create } = useMutation({
mutationFn: createRundown, mutationFn: createRundown,
onMutate: () => { onMutate: () => {
@@ -1,10 +1,10 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { EntryId, Maybe, OntimeEntry, Rundown } from 'ontime-types'; import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
import { useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { getRundownQueryKey } from '../api/constants'; import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants';
import { fetchRundown } from '../api/rundown'; import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket'; import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata'; import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns'; import { useProjectRundowns } from './useProjectRundowns';
@@ -20,36 +20,43 @@ const cachedRundownPlaceholder: Rundown = {
}; };
/** /**
* Provides access to a specific rundown by ID. * Normalised rundown data for the currently loaded rundown.
* When rundownId is not provided the loaded rundown is provided *
* Bootstraps via the `/current` alias so the first paint is a single round-trip,
* independent of the project rundown list. Once the loaded id is known, the
* query key swaps to the id-keyed cache that is shared with `useRundownById`.
*/ */
export function useRundown(rundownId: Maybe<string>) { export default function useRundown() {
'use memo'; const queryClient = useQueryClient();
const { const {
data: { loaded: loadedRundownId }, data: { loaded: loadedRundownId },
} = useProjectRundowns(); } = useProjectRundowns();
const effectiveRundownId = rundownId !== null ? rundownId : loadedRundownId;
const isLoadedRundown = effectiveRundownId === loadedRundownId;
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({ const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: getRundownQueryKey(effectiveRundownId), queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY,
queryFn: ({ signal }) => fetchRundown(effectiveRundownId, { signal }), queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
}); });
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching, isLoadedRundown }; // Seed the id-keyed cache when fetching via the bootstrap alias
useEffect(() => {
if (!data || loadedRundownId) return;
queryClient.setQueryData(getRundownQueryKey(data.id), data);
}, [data, loadedRundownId, queryClient]);
// Once we have the ID, drop the temporary current cache
useEffect(() => {
if (!loadedRundownId) return;
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}, [loadedRundownId, queryClient]);
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
} }
export function useRundownWithMetadata(rundownId: Maybe<string>) { export function useRundownWithMetadata() {
'use memo'; const { data, status } = useRundown();
const { data, status, isLoadedRundown } = useRundown(rundownId);
const selectedEventId = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedRundown ? selectedEventId : null; const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
const rundownMetadata = getRundownMetadata(data, effectiveSelectedEventId);
return { data, status, rundownMetadata }; return { data, status, rundownMetadata };
} }
@@ -57,8 +64,8 @@ export function useRundownWithMetadata(rundownId: Maybe<string>) {
* Provides access to a flat rundown * Provides access to a flat rundown
* built from the order and rundown fields * built from the order and rundown fields
*/ */
export function useFlatRundown(rundownId: Maybe<string>) { export function useFlatRundown() {
const { data, status } = useRundown(rundownId); const { data, status } = useRundown();
const flatRundown = useMemo(() => { const flatRundown = useMemo(() => {
if (data.revision === -1) { if (data.revision === -1) {
@@ -70,13 +77,11 @@ export function useFlatRundown(rundownId: Maybe<string>) {
return { data: flatRundown, rundownId: data.id, status }; return { data: flatRundown, rundownId: data.id, status };
} }
export function useFlatRundownWithMetadata(rundownId: Maybe<string>) { export function useFlatRundownWithMetadata() {
'use memo'; const { data, status } = useRundown();
const { data, status, isLoadedRundown } = useRundown(rundownId);
const selectedEventId = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedRundown ? selectedEventId : null;
const rundownWithMetadata = getFlatRundownMetadata(data, effectiveSelectedEventId); const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data: rundownWithMetadata, status }; return { data: rundownWithMetadata, status };
} }
@@ -87,8 +92,8 @@ export function useFlatRundownWithMetadata(rundownId: Maybe<string>) {
* re-filtering on every render. * re-filtering on every render.
* *
*/ */
export function usePartialRundown(rundownId: Maybe<string>, cb: (event: ExtendedEntry<OntimeEntry>) => boolean) { export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useFlatRundownWithMetadata(rundownId); const { data, status } = useFlatRundownWithMetadata();
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
return data.filter(cb); return data.filter(cb);
}, [data, cb]); }, [data, cb]);
@@ -98,20 +103,37 @@ export function usePartialRundown(rundownId: Maybe<string>, cb: (event: Extended
/** /**
* Hook to get a specific entry by ID from the rundown * Hook to get a specific entry by ID from the rundown
* @deprecated
*/ */
export function useEntry(rundownId: Maybe<string>, entryId: EntryId | null): OntimeEntry | null { export function useEntry(entryId: EntryId | null): OntimeEntry | null {
const { data: rundown } = useRundown(rundownId); const { data: rundown } = useRundown();
if (entryId === null) return null; if (entryId === null) return null;
return rundown.entries[entryId] ?? null; return rundown.entries[entryId] ?? null;
} }
export function useRundownAuxData(rundownId: Maybe<string>) { export function useRundownAuxData() {
const { data, status } = useRundown(rundownId); const { data, status } = useRundown();
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
const { title, id } = data; const { title, id } = data;
return { title, id }; return { title, id };
}, [data]); }, [data]);
return { data: filteredData, status }; return { data: filteredData, status };
} }
/**
* Provides access to a specific rundown by ID.
* When rundownId is null/undefined the query is disabled and returns the placeholder.
*/
export function useRundownById(rundownId: string | null | undefined) {
const enabled = Boolean(rundownId);
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: getRundownQueryKey(rundownId ?? ''),
queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }),
enabled,
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
@@ -0,0 +1,54 @@
import { EntryId, Rundown } from 'ontime-types';
import { useMemo } from 'react';
import { useSelectedEventId } from '../hooks/useSocket';
import { getFlatRundownMetadata, type ExtendedEntry } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns';
import { useRundownById } from './useRundown';
export type RundownSource = {
rundownId: string | null;
rundown: Rundown;
flatRundown: ExtendedEntry[];
status: string;
selectedEventId: EntryId | null;
};
/**
* Explicitly scoped rundown data for views that may operate on a non-loaded rundown.
*/
export function useScopedRundown(rundownId: string | null): RundownSource {
const { data: projectRundowns } = useProjectRundowns();
return useRundownSource(rundownId, projectRundowns.loaded || null);
}
/**
* Loaded-rundown source for views that must follow the active runtime rundown.
*/
export function useLoadedRundownSource(): RundownSource {
const { data: projectRundowns } = useProjectRundowns();
const loadedRundownId = projectRundowns.loaded || null;
return useRundownSource(loadedRundownId, loadedRundownId);
}
function useRundownSource(rundownId: string | null, loadedRundownId: string | null): RundownSource {
const isLoadedTarget = rundownId !== null && rundownId === loadedRundownId;
const runtimeSelectedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedTarget ? runtimeSelectedEventId : null;
const { data: rundown, status } = useRundownById(rundownId);
const flatRundown = useMemo(
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
[effectiveSelectedEventId, rundown],
);
return useMemo(
() => ({
rundownId,
rundown,
flatRundown,
status,
selectedEventId: effectiveSelectedEventId,
}),
[effectiveSelectedEventId, flatRundown, rundown, rundownId, status],
);
}
@@ -90,7 +90,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 0, eventIndex: 0,
isPast: true, isPast: true,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -108,7 +107,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 1, // UI indexes are 1 based eventIndex: 1, // UI indexes are 1 based
isPast: true, isPast: true,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -126,7 +124,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 1, eventIndex: 1,
isPast: true, isPast: true,
isNextDay: false, isNextDay: false,
isParentToLoaded: true,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -143,7 +140,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 2, eventIndex: 2,
isPast: true, isPast: true,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -160,7 +156,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 2, eventIndex: 2,
isPast: true, isPast: true,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -177,7 +172,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 3, eventIndex: 3,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: true, isLinkedToLoaded: true,
isLoaded: true, isLoaded: true,
@@ -194,7 +188,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 4, eventIndex: 4,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: true, isLinkedToLoaded: true,
isLoaded: false, isLoaded: false,
@@ -211,7 +204,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 5, eventIndex: 5,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 7, totalGap: 7,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -264,7 +256,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 0, eventIndex: 0,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -282,7 +273,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 1, eventIndex: 1,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -300,7 +290,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 2, eventIndex: 2,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -27,7 +27,6 @@ export type RundownMetadata = {
groupColour: string | undefined; groupColour: string | undefined;
groupEntries: number | undefined; groupEntries: number | undefined;
isFirstAfterGroup: boolean; isFirstAfterGroup: boolean;
isParentToLoaded: boolean; // if the group contains the loaded event
}; };
export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T & RundownMetadata; export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T & RundownMetadata;
@@ -95,7 +94,6 @@ export function initRundownMetadata(selectedEventId: MaybeString) {
groupColour: undefined, groupColour: undefined,
groupEntries: undefined, groupEntries: undefined,
isFirstAfterGroup: false, isFirstAfterGroup: false,
isParentToLoaded: false,
}; };
function process(entry: OntimeEntry): Readonly<RundownMetadata> { function process(entry: OntimeEntry): Readonly<RundownMetadata> {
@@ -119,7 +117,6 @@ function processEntry(
// initialise data to be overridden below // initialise data to be overridden below
processedData.isNextDay = false; processedData.isNextDay = false;
processedData.isLoaded = false; processedData.isLoaded = false;
processedData.isParentToLoaded = false;
processedData.previousEntryId = processedData.thisId; // thisId comes from the previous iteration processedData.previousEntryId = processedData.thisId; // thisId comes from the previous iteration
processedData.thisId = entry.id; // we reassign thisId processedData.thisId = entry.id; // we reassign thisId
@@ -135,7 +132,6 @@ function processEntry(
processedData.groupId = entry.id; processedData.groupId = entry.id;
processedData.groupColour = entry.colour; processedData.groupColour = entry.colour;
processedData.groupEntries = entry.entries.length; processedData.groupEntries = entry.entries.length;
processedData.isParentToLoaded = selectedEventId ? entry.entries.includes(selectedEventId) : false;
} else { } else {
// for delays and groups, we insert the group metadata // for delays and groups, we insert the group metadata
if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent !== processedData.groupId) { if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent !== processedData.groupId) {
+8 -4
View File
@@ -14,6 +14,7 @@ import {
APP_SETTINGS, APP_SETTINGS,
CLIENT_LIST, CLIENT_LIST,
CSS_OVERRIDE, CSS_OVERRIDE,
CURRENT_RUNDOWN_QUERY_KEY,
CUSTOM_FIELDS, CUSTOM_FIELDS,
PROJECT_DATA, PROJECT_DATA,
REPORT, REPORT,
@@ -24,7 +25,6 @@ import {
VIEW_SETTINGS, VIEW_SETTINGS,
getRundownQueryKey, getRundownQueryKey,
PROJECT_RUNDOWNS, PROJECT_RUNDOWNS,
PROJECT_LIST,
} from '../api/constants'; } from '../api/constants';
import { invalidateAllCaches } from '../api/utils'; import { invalidateAllCaches } from '../api/utils';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
@@ -216,9 +216,6 @@ export const connectSocket = () => {
case RefetchKey.ProjectRundowns: case RefetchKey.ProjectRundowns:
ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_RUNDOWNS }); ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_RUNDOWNS });
break; break;
case RefetchKey.ProjectFiles:
ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_LIST });
break;
default: { default: {
target satisfies never; target satisfies never;
break; break;
@@ -241,6 +238,7 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s
if (!rundownId) { if (!rundownId) {
// we omit rundownId to signify invalidate all rundowns // we omit rundownId to signify invalidate all rundowns
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN }); ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
return; return;
} }
@@ -254,6 +252,12 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s
} }
ontimeQueryClient.invalidateQueries({ queryKey, exact: true }); ontimeQueryClient.invalidateQueries({ queryKey, exact: true });
// keep current alias in sync with the ID-based cache
const loadedRundownId = ontimeQueryClient.getQueryData<{ loaded: string }>(PROJECT_RUNDOWNS)?.loaded;
if (!loadedRundownId || loadedRundownId === rundownId) {
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}
} }
export function sendSocket<T extends MessageTag | ApiActionTag>( export function sendSocket<T extends MessageTag | ApiActionTag>(
@@ -5,7 +5,7 @@ import { deleteAllReport } from '../../../../common/api/report';
import { createBlob, downloadBlob } from '../../../../common/api/utils'; import { createBlob, downloadBlob } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import useReport from '../../../../common/hooks-query/useReport'; import useReport from '../../../../common/hooks-query/useReport';
import { useRundown } from '../../../../common/hooks-query/useRundown'; import useRundown from '../../../../common/hooks-query/useRundown';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import { formatTime } from '../../../../common/utils/time'; import { formatTime } from '../../../../common/utils/time';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -15,7 +15,7 @@ import style from './ReportSettings.module.scss';
export default function ReportSettings() { export default function ReportSettings() {
const { data: reportData } = useReport(); const { data: reportData } = useReport();
const { data } = useRundown(null); const { data } = useRundown();
const clearReport = async () => await deleteAllReport(); const clearReport = async () => await deleteAllReport();
const downloadCSV = (combinedReport: CombinedReport[]) => { const downloadCSV = (combinedReport: CombinedReport[]) => {
@@ -1,5 +1,5 @@
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { Suspense, useState } from 'react'; import { useState } from 'react';
import { import {
IoAdd, IoAdd,
IoDocumentOutline, IoDocumentOutline,
@@ -18,9 +18,9 @@ import IconButton from '../../../../common/components/buttons/IconButton';
import Dialog from '../../../../common/components/dialog/Dialog'; import Dialog from '../../../../common/components/dialog/Dialog';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu'; import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import { useDirectLinkToBackgroundEdit } from '../../../../common/context/RundownSelectionContext';
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns'; import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import { useDirectLinkToBackgroundEdit } from '../../../../views/cuesheet/useCuesheetRundownSelection';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import RundownRenameForm from './composite/RundownRenameForm'; import RundownRenameForm from './composite/RundownRenameForm';
import { ManageRundownForm } from './ManageRundownForm'; import { ManageRundownForm } from './ManageRundownForm';
@@ -28,20 +28,6 @@ import { ManageRundownForm } from './ManageRundownForm';
import style from './ManagePanel.module.scss'; import style from './ManagePanel.module.scss';
export default function ManageRundowns() { export default function ManageRundowns() {
return (
<Suspense
fallback={
<div className={style.empty}>
<Panel.Loader isLoading />
</div>
}
>
<ManageRundownsSuspense />
</Suspense>
);
}
function ManageRundownsSuspense() {
const { data } = useProjectRundowns(); const { data } = useProjectRundowns();
const { duplicate, remove, load, rename } = useMutateProjectRundowns(); const { duplicate, remove, load, rename } = useMutateProjectRundowns();
const [isOpenDelete, deleteHandlers] = useDisclosure(); const [isOpenDelete, deleteHandlers] = useDisclosure();
@@ -24,7 +24,7 @@ import Button from '../../../../../common/components/buttons/Button';
import Info from '../../../../../common/components/info/Info'; import Info from '../../../../../common/components/info/Info';
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink'; import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
import Modal from '../../../../../common/components/modal/Modal'; import Modal from '../../../../../common/components/modal/Modal';
import { useRundown } from '../../../../../common/hooks-query/useRundown'; import useRundown from '../../../../../common/hooks-query/useRundown';
import { removeFileExtension, validateExcelImport } from '../../../../../common/utils/uploadUtils'; import { removeFileExtension, validateExcelImport } from '../../../../../common/utils/uploadUtils';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import GSheetSetup from './GSheetSetup'; import GSheetSetup from './GSheetSetup';
@@ -59,7 +59,7 @@ export default function SourcesPanel() {
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null); const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
const [completedRundownTitle, setCompletedRundownTitle] = useState(''); const [completedRundownTitle, setCompletedRundownTitle] = useState('');
const { data: currentRundown } = useRundown(null); const { data: currentRundown } = useRundown();
const { applyImport } = useSpreadsheetImport(); const { applyImport } = useSpreadsheetImport();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -1,4 +1,4 @@
import { Suspense, useState } from 'react'; import { useState } from 'react';
import { IoArrowDown, IoArrowUp } from 'react-icons/io5'; import { IoArrowDown, IoArrowUp } from 'react-icons/io5';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
@@ -11,25 +11,11 @@ import style from './ProjectPanel.module.scss';
type SortParameter = 'alphabetical' | 'modified'; type SortParameter = 'alphabetical' | 'modified';
export default function ProjectList() { export default function ProjectList() {
return (
<Suspense
fallback={
<div className={style.empty}>
<Panel.Loader isLoading />
</div>
}
>
<ProjectListSuspend />
</Suspense>
);
}
function ProjectListSuspend() {
const [editingMode, setEditingMode] = useState<EditMode | null>(null); const [editingMode, setEditingMode] = useState<EditMode | null>(null);
const [editingFilename, setEditingFilename] = useState<string | null>(null); const [editingFilename, setEditingFilename] = useState<string | null>(null);
const [sortMode, setSortMode] = useState<ProjectSortMode>('modified-desc'); const [sortMode, setSortMode] = useState<ProjectSortMode>('modified-desc');
const { data, refetch } = useOrderedProjectList(sortMode); const { data, refetch, status } = useOrderedProjectList(sortMode);
const handleToggleEditMode = (editMode: EditMode, filename: string | null) => { const handleToggleEditMode = (editMode: EditMode, filename: string | null) => {
setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode)); setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode));
@@ -52,6 +38,14 @@ function ProjectListSuspend() {
}); });
}; };
if (status === 'pending') {
return (
<div className={style.empty}>
<Panel.Loader isLoading />
</div>
);
}
const numProjects = data.reorderedProjectFiles.length; const numProjects = data.reorderedProjectFiles.length;
return ( return (
+110 -102
View File
@@ -1,6 +1,7 @@
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types'; import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage'; import EmptyPage from '../../common/components/state/EmptyPage';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import useFollowComponent from '../../common/hooks/useFollowComponent'; import useFollowComponent from '../../common/hooks/useFollowComponent';
@@ -10,6 +11,7 @@ import { cx } from '../../common/utils/styleUtils';
import { throttle } from '../../common/utils/throttle'; import { throttle } from '../../common/utils/throttle';
import { getDefaultFormat } from '../../common/utils/time'; import { getDefaultFormat } from '../../common/utils/time';
import { isTouchDevice } from '../../externals'; import { isTouchDevice } from '../../externals';
import { useTranslation } from '../../translation/TranslationProvider';
import Loader from '../../views/common/loader/Loader'; import Loader from '../../views/common/loader/Loader';
import CustomFieldEditModal from './custom-field-edit-modal/CustomFieldEditModal'; import CustomFieldEditModal from './custom-field-edit-modal/CustomFieldEditModal';
import FollowButton from './follow-button/FollowButton'; import FollowButton from './follow-button/FollowButton';
@@ -35,7 +37,7 @@ export default function OperatorLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />; return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
} }
return <Operator {...data} />; return <Operator {...data} />;
@@ -43,6 +45,7 @@ export default function OperatorLoader() {
function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) { function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) {
const selectedEventId = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const { getLocalizedString } = useTranslation();
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions(); const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
const [showEditPrompt, setShowEditPrompt] = useState(false); const [showEditPrompt, setShowEditPrompt] = useState(false);
@@ -113,6 +116,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
const operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]); const operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]);
const canEdit = shouldEdit && subscribe.length; const canEdit = shouldEdit && subscribe.length;
const hasEvents = rundown.order.length > 0;
return ( return (
<div className={style.operatorContainer} data-testid='operator-view'> <div className={style.operatorContainer} data-testid='operator-view'>
@@ -127,117 +131,121 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
</div> </div>
)} )}
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}> {!hasEvents ? (
{rundown.order.map((entryId) => { <EmptyFill text={getLocalizedString('common.no_data')} />
const entry = rundown.entries[entryId]; ) : (
if (isOntimeEvent(entry)) { <div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId]; {rundown.order.map((entryId) => {
// hide past events (if setting) and skipped events const entry = rundown.entries[entryId];
if ((hidePast && isPast) || entry.skip) { if (isOntimeEvent(entry)) {
return null; const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
} // hide past events (if setting) and skipped events
if ((hidePast && isPast) || entry.skip) {
return null;
}
const { mainField, secondaryField, subscribedData } = getEventData( const { mainField, secondaryField, subscribedData } = getEventData(
entry, entry,
mainSource, mainSource,
secondarySource, secondarySource,
subscribe, subscribe,
customFields, customFields,
); );
return ( return (
<OperatorEvent <OperatorEvent
key={entry.id}
id={entry.id}
colour={entry.colour}
cue={entry.cue}
main={mainField}
secondary={secondaryField}
timeStart={entry.timeStart}
duration={entry.duration}
delay={entry.delay}
dayOffset={entry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
/>
);
}
if (isOntimeGroup(entry)) {
const { isPast } = rundownMetadata[entry.id];
const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId]?.groupId === entry.id : false;
if (hidePast && isPast && !isCurrentParent) {
return null;
}
return (
<Fragment key={entry.id}>
<OperatorGroup
key={entry.id} key={entry.id}
title={entry.title} id={entry.id}
colour={entry.colour} colour={entry.colour}
count={entry.entries.length} cue={entry.cue}
main={mainField}
secondary={secondaryField}
timeStart={entry.timeStart}
duration={entry.duration} duration={entry.duration}
delay={entry.delay}
dayOffset={entry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
/> />
{entry.entries.map((nestedEntryId) => { );
const nestedEntry = rundown.entries[nestedEntryId]; }
if (!isOntimeEvent(nestedEntry)) {
return null;
}
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId]; if (isOntimeGroup(entry)) {
const { isPast } = rundownMetadata[entry.id];
// hide past events (if setting) and skipped events const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId]?.groupId === entry.id : false;
if ((hidePast && isPast) || nestedEntry.skip) {
return null;
}
const { mainField, secondaryField, subscribedData } = getEventData( if (hidePast && isPast && !isCurrentParent) {
nestedEntry, return null;
mainSource, }
secondarySource,
subscribe,
customFields,
);
return ( return (
<OperatorEvent <Fragment key={entry.id}>
key={nestedEntry.id} <OperatorGroup
id={nestedEntry.id} key={entry.id}
colour={nestedEntry.colour} title={entry.title}
cue={nestedEntry.cue} colour={entry.colour}
main={mainField} count={entry.entries.length}
secondary={secondaryField} duration={entry.duration}
timeStart={nestedEntry.timeStart} />
duration={nestedEntry.duration} {entry.entries.map((nestedEntryId) => {
delay={nestedEntry.delay} const nestedEntry = rundown.entries[nestedEntryId];
dayOffset={nestedEntry.dayOffset} if (!isOntimeEvent(nestedEntry)) {
isLinkedToLoaded={isLinkedToLoaded} return null;
isSelected={isLoaded} }
isPast={isPast}
groupColour={entry.colour} const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart} // hide past events (if setting) and skipped events
subscribed={subscribedData} if ((hidePast && isPast) || nestedEntry.skip) {
totalGap={totalGap} return null;
onLongPress={canEdit ? handleEdit : () => undefined} }
/>
); const { mainField, secondaryField, subscribedData } = getEventData(
})} nestedEntry,
</Fragment> mainSource,
); secondarySource,
} subscribe,
return null; customFields,
})} );
</div>
return (
<OperatorEvent
key={nestedEntry.id}
id={nestedEntry.id}
colour={nestedEntry.colour}
cue={nestedEntry.cue}
main={mainField}
secondary={secondaryField}
timeStart={nestedEntry.timeStart}
duration={nestedEntry.duration}
delay={nestedEntry.delay}
dayOffset={nestedEntry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
groupColour={entry.colour}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
/>
);
})}
</Fragment>
);
}
return null;
})}
</div>
)}
<FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} /> <FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} />
</div> </div>
); );
@@ -14,7 +14,7 @@ export interface OperatorData {
} }
export function useOperatorData(): ViewData<OperatorData> { export function useOperatorData(): ViewData<OperatorData> {
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata(null); const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata();
const { data: customFields, status: customFieldStatus } = useCustomFields(); const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: settings, status: settingsStatus } = useSettings(); const { data: settings, status: settingsStatus } = useSettings();
@@ -1,10 +1,9 @@
import { ErrorBoundary } from '@sentry/react'; import { ErrorBoundary } from '@sentry/react';
import { PropsWithChildren, ReactNode, Suspense } from 'react'; import { PropsWithChildren, ReactNode } from 'react';
import ScrollArea from '../../common/components/scroll-area/ScrollArea'; import ScrollArea from '../../common/components/scroll-area/ScrollArea';
import { useIsOnline } from '../../common/hooks/useSocket'; import { useIsOnline } from '../../common/hooks/useSocket';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import Loader from '../../views/common/loader/Loader';
import style from './Overview.module.scss'; import style from './Overview.module.scss';
@@ -16,37 +15,18 @@ export function OverviewWrapper({ navElements, children }: PropsWithChildren<Ove
const isOnline = useIsOnline(); const isOnline = useIsOnline();
return ( return (
<Suspense fallback={<OverviewFallback navElements={navElements} />}> <div className={cx([style.overview, !isOnline && style.isOffline])}>
<div className={cx([style.overview, !isOnline && style.isOffline])}> <ErrorBoundary>
<ErrorBoundary> <div className={style.nav}>{navElements}</div>
<div className={style.nav}>{navElements}</div> <ScrollArea
<ScrollArea className={style.infoScroll}
className={style.infoScroll} contentClassName={style.info}
contentClassName={style.info} contentStyle={{ minWidth: '100%' }}
contentStyle={{ minWidth: '100%' }} orientation='horizontal'
orientation='horizontal' >
> {children}
{children} </ScrollArea>
</ScrollArea> </ErrorBoundary>
</ErrorBoundary>
</div>
</Suspense>
);
}
function OverviewFallback({ navElements }: OverviewWrapperProps) {
return (
<div className={style.overview}>
<div className={style.nav}>{navElements}</div>
<ScrollArea
className={style.infoScroll}
contentClassName={style.info}
contentStyle={{ minWidth: '100%' }}
orientation='horizontal'
>
{/* TODO: this could be alined in a nicer way */}
<Loader />
</ScrollArea>
</div> </div>
); );
} }
@@ -210,7 +210,7 @@ export function MetadataTimes() {
function GroupTimes() { function GroupTimes() {
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback, phase } = useGroupTimerOverView(); const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback, phase } = useGroupTimerOverView();
const currentGroupId = useCurrentGroupId(); const currentGroupId = useCurrentGroupId();
const group = useEntry(null, currentGroupId) as OntimeGroup | null; const group = useEntry(currentGroupId) as OntimeGroup | null;
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback); const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
@@ -266,7 +266,7 @@ function GroupTimes() {
function FlagTimes() { function FlagTimes() {
const { clock, mode, actualStart, plannedStart, playback, currentDay, phase } = useFlagTimerOverView(); const { clock, mode, actualStart, plannedStart, playback, currentDay, phase } = useFlagTimerOverView();
const { id, expectedStart } = useNextFlag(); const { id, expectedStart } = useNextFlag();
const entry = useEntry(null, id) as OntimeEvent | null; const entry = useEntry(id) as OntimeEvent | null;
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback); const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
@@ -6,7 +6,7 @@ import style from './TitleOverview.module.scss';
export default function TitleOverview() { export default function TitleOverview() {
'use memo'; 'use memo';
const { data: projectData } = useProjectData(); const { data: projectData } = useProjectData();
const { data: rundownData } = useRundownAuxData(null); const { data: rundownData } = useRundownAuxData();
if (!projectData.title && !rundownData.title) { if (!projectData.title && !rundownData.title) {
return null; return null;
@@ -6,7 +6,8 @@ import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu'; import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../common/components/protect-route/ProtectRoute'; import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import { EntryActionsProvider } from '../../common/context/EntryActionsContext'; import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
import { RundownSelectionContextProvider } from '../../common/context/RundownSelectionContext'; import { useLoadedRundownSource } from '../../common/hooks-query/useScopedRundown';
import { useEntryActions } from '../../common/hooks/useEntryAction';
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice'; import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
import { handleLinks } from '../../common/utils/linkUtils'; import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
@@ -38,29 +39,28 @@ function RundownExport() {
defaultValue: RundownViewMode.List, defaultValue: RundownViewMode.List,
}); });
const isSmallDevice = useIsSmallDevice(); const isSmallDevice = useIsSmallDevice();
const entryActions = useEntryActions();
if (isSmallDevice && isExtracted) { if (isSmallDevice && isExtracted) {
return ( return (
<RundownSelectionContextProvider> <EntryActionsProvider actions={entryActions}>
<EntryActionsProvider> <ProtectRoute permission='editor'>
<ProtectRoute permission='editor'> <div
<div className={cx([style.rundownExport, style.extracted])}
className={cx([style.rundownExport, style.extracted])} data-target='small-device'
data-target='small-device' data-testid='panel-rundown'
data-testid='panel-rundown' >
> <FinderPlacement />
<FinderPlacement /> <ViewNavigationMenu suppressSettings />
<ViewNavigationMenu suppressSettings /> <div className={style.rundown}>
<div className={style.rundown}> <ErrorBoundary>
<ErrorBoundary> <RundownRoot isSmallDevice isExtracted viewMode={viewMode} setViewMode={setViewMode} />
<RundownRoot isSmallDevice isExtracted viewMode={viewMode} setViewMode={setViewMode} /> <RundownContextMenu />
<RundownContextMenu /> </ErrorBoundary>
</ErrorBoundary>
</div>
</div> </div>
</ProtectRoute> </div>
</EntryActionsProvider> </ProtectRoute>
</RundownSelectionContextProvider> </EntryActionsProvider>
); );
} }
@@ -70,32 +70,30 @@ function RundownExport() {
viewMode === RundownViewMode.Table; viewMode === RundownViewMode.Table;
return ( return (
<RundownSelectionContextProvider> <EntryActionsProvider actions={entryActions}>
<EntryActionsProvider> <ProtectRoute permission='editor'>
<ProtectRoute permission='editor'> <div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'> <FinderPlacement />
<FinderPlacement /> {isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />} <div className={style.rundown}>
<div className={style.rundown}> <Editor.Panel className={style.list}>
<Editor.Panel className={style.list}> <ErrorBoundary>
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownRoot isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary>
</Editor.Panel>
{!hideSideBar && (
<div className={style.side}>
<ErrorBoundary> <ErrorBoundary>
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />} <RundownEntryEditor />
<RundownRoot isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary> </ErrorBoundary>
</Editor.Panel> </div>
{!hideSideBar && ( )}
<div className={style.side}>
<ErrorBoundary>
<RundownEntryEditor />
</ErrorBoundary>
</div>
)}
</div>
</div> </div>
</ProtectRoute> </div>
</EntryActionsProvider> </ProtectRoute>
</RundownSelectionContextProvider> </EntryActionsProvider>
); );
} }
@@ -107,16 +105,17 @@ interface RundownRootProps {
} }
function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) { function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) {
const source = useLoadedRundownSource();
return ( return (
<div className={style.rundownRoot}> <div className={style.rundownRoot}>
{isSmallDevice ? ( {isSmallDevice ? (
<RundownHeaderMobile viewMode={viewMode} setViewMode={setViewMode} /> <RundownHeaderMobile viewMode={viewMode} setViewMode={setViewMode} />
) : ( ) : (
// TODO: add data-background-rundown={!isLoadedRundown} styling
<RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} /> <RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
)} )}
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />} {viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
{viewMode === RundownViewMode.Table && <EntryEditModal />} {viewMode === RundownViewMode.Table && <EntryEditModal rundown={source.rundown} />}
<RenumberCuesDialog /> <RenumberCuesDialog />
</div> </div>
); );
@@ -1,36 +1,34 @@
import { Playback } from 'ontime-types';
import { memo } from 'react'; import { memo } from 'react';
import Empty from '../../common/components/state/Empty'; import EmptyFill from '../../common/components/state/EmptyFill';
import { useContextRundownList } from '../../common/hooks-query/useContextRundown'; import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import { useRundownEditor } from '../../common/hooks/useSocket'; import { useRundownEditor } from '../../common/hooks/useSocket';
import { useTranslation } from '../../translation/TranslationProvider';
import Rundown from './Rundown'; import Rundown from './Rundown';
const backgroundFeatureData = {
playback: Playback.Stop,
selectedEventId: null,
nextEventId: null,
};
export default memo(RundownList); export default memo(RundownList);
function RundownList() { function RundownList() {
const { rundown, status, rundownMetadata, isLoadedRundown } = useContextRundownList(); const { data, status, rundownMetadata } = useRundownWithMetadata();
const featureData = useRundownEditor(); const featureData = useRundownEditor();
const { getLocalizedString } = useTranslation();
const isLoading = status !== 'success' || !rundown || !rundownMetadata; // avoid showing the editable empty state before we know whether the rundown is actually empty
if (status === 'pending') {
return <EmptyFill text='Loading…' />;
}
if (isLoading) { if (status === 'error') {
return <Empty text='Connecting to server' />; return <EmptyFill text={getLocalizedString('common.no_data')} />;
} }
return ( return (
<Rundown <Rundown
order={rundown.order} order={data.order}
flatOrder={rundown.flatOrder} flatOrder={data.flatOrder}
entries={rundown.entries} entries={data.entries}
id={rundown.id} id={data.id}
rundownMetadata={rundownMetadata} rundownMetadata={rundownMetadata}
featureData={isLoadedRundown ? featureData : backgroundFeatureData} featureData={featureData}
/> />
); );
} }
@@ -1,3 +0,0 @@
.rundownSelect {
min-width: min(20rem, calc(100vw - 6rem));
}
@@ -1,42 +0,0 @@
import Select from '../../../common/components/select/Select';
import { useRundownSelectionContext } from '../../../common/context/RundownSelectionContext';
import { AppMode } from '../../../ontimeConfig';
import styles from './RundownSelect.module.scss';
const FOLLOW = '___null___';
interface RundownSelectProps {
appMode: AppMode;
}
export function RundownSelect({ appMode }: RundownSelectProps) {
'use memo';
const { selectRundownId, rundowns, loadedRundownId, selectedRundownId } = useRundownSelectionContext();
const options = rundowns.map(({ id, title }) => ({
value: id,
label: loadedRundownId === id ? `${title} (loaded)` : title,
}));
// add a follow option
options.unshift({
value: FOLLOW,
label: 'Follow loaded',
});
return (
<div className={styles.rundownSelect}>
<Select
value={selectedRundownId ?? FOLLOW}
options={options}
onValueChange={(value) => {
if (value === FOLLOW) selectRundownId(null);
else selectRundownId(value);
}}
disabled={appMode === AppMode.Run}
fluid
/>
</div>
);
}
@@ -1,7 +1,7 @@
import { OntimeEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; import { OntimeEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useRundown } from '../../../common/hooks-query/useRundown'; import useRundown from '../../../common/hooks-query/useRundown';
import { useEventSelection } from '../useEventSelection'; import { useEventSelection } from '../useEventSelection';
import EventEditorFooter from './composite/EventEditorFooter'; import EventEditorFooter from './composite/EventEditorFooter';
import EventEditor from './EventEditor'; import EventEditor from './EventEditor';
@@ -13,7 +13,7 @@ import style from './EntryEditor.module.scss';
export default function RundownEntryEditor() { export default function RundownEntryEditor() {
const selectedEvents = useEventSelection((state) => state.selectedEvents); const selectedEvents = useEventSelection((state) => state.selectedEvents);
const { data } = useRundown(null); const { data } = useRundown();
const entry = useMemo<OntimeEntry | null>(() => { const entry = useMemo<OntimeEntry | null>(() => {
if (data.order.length === 0) { if (data.order.length === 0) {
@@ -7,7 +7,7 @@ import Button from '../../../common/components/buttons/Button';
import Dialog from '../../../common/components/dialog/Dialog'; import Dialog from '../../../common/components/dialog/Dialog';
import Input from '../../../common/components/input/input/Input'; import Input from '../../../common/components/input/input/Input';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextRundownCueRenumberModal } from '../../../common/hooks-query/useContextRundown'; import useRundown from '../../../common/hooks-query/useRundown';
import { orderEntries } from '../rundown.utils'; import { orderEntries } from '../rundown.utils';
import { useEventSelection } from '../useEventSelection'; import { useEventSelection } from '../useEventSelection';
@@ -17,7 +17,8 @@ type RenumberCueData = Pick<RenumberCues, 'increment' | 'prefix' | 'start'>;
export default function RenumberCuesDialog() { export default function RenumberCuesDialog() {
'use memo'; 'use memo';
const { flatOrder } = useContextRundownCueRenumberModal(); const { data } = useRundown();
const { flatOrder } = data;
const { onClose, isOpen } = useRenumberCuesDialogStore(); const { onClose, isOpen } = useRenumberCuesDialogStore();
const { renumberCues } = useEntryActionsContext(); const { renumberCues } = useEntryActionsContext();
const selectedEvents = useEventSelection((state) => state.selectedEvents); const selectedEvents = useEventSelection((state) => state.selectedEvents);
@@ -8,7 +8,6 @@ import Tooltip from '../../../common/components/tooltip/Tooltip';
import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket'; import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket';
import { AppMode } from '../../../ontimeConfig'; import { AppMode } from '../../../ontimeConfig';
import { EditorLayoutMode, useEditorLayout } from '../../../views/editor/useEditorLayout'; import { EditorLayoutMode, useEditorLayout } from '../../../views/editor/useEditorLayout';
import { RundownSelect } from '../common/RundownSelect';
import { RundownViewMode } from '../rundown.options'; import { RundownViewMode } from '../rundown.options';
import { useEditorFollowMode } from '../useEditorFollowMode'; import { useEditorFollowMode } from '../useEditorFollowMode';
import RundownMenu from './RundownMenu'; import RundownMenu from './RundownMenu';
@@ -25,7 +24,6 @@ interface HeaderControlsConfig {
showRunEditToggle: boolean; showRunEditToggle: boolean;
showOffsetToggle: boolean; showOffsetToggle: boolean;
showOverflowMenu: boolean; showOverflowMenu: boolean;
showRundownSelect: boolean;
} }
export const HEADER_CONTROLS_CONFIG: Record<EditorLayoutMode, HeaderControlsConfig> = { export const HEADER_CONTROLS_CONFIG: Record<EditorLayoutMode, HeaderControlsConfig> = {
@@ -33,19 +31,16 @@ export const HEADER_CONTROLS_CONFIG: Record<EditorLayoutMode, HeaderControlsConf
showRunEditToggle: true, showRunEditToggle: true,
showOffsetToggle: true, showOffsetToggle: true,
showOverflowMenu: true, showOverflowMenu: true,
showRundownSelect: false,
}, },
[EditorLayoutMode.PLANNING]: { [EditorLayoutMode.PLANNING]: {
showRunEditToggle: false, showRunEditToggle: false,
showOffsetToggle: false, showOffsetToggle: false,
showOverflowMenu: true, showOverflowMenu: true,
showRundownSelect: true,
}, },
[EditorLayoutMode.TRACKING]: { [EditorLayoutMode.TRACKING]: {
showRunEditToggle: false, showRunEditToggle: false,
showOffsetToggle: true, showOffsetToggle: true,
showOverflowMenu: false, showOverflowMenu: false,
showRundownSelect: false,
}, },
} as const; } as const;
@@ -55,8 +50,7 @@ function RundownHeader({ isExtracted, viewMode, setViewMode }: RundownHeaderProp
const offsetMode = useOffsetMode(); const offsetMode = useOffsetMode();
const { layoutMode } = useEditorLayout(); const { layoutMode } = useEditorLayout();
const { showRunEditToggle, showOffsetToggle, showOverflowMenu, showRundownSelect } = const { showRunEditToggle, showOffsetToggle, showOverflowMenu } = HEADER_CONTROLS_CONFIG[layoutMode];
HEADER_CONTROLS_CONFIG[layoutMode];
const toggleAppMode = (mode: AppMode[]) => { const toggleAppMode = (mode: AppMode[]) => {
// we need to stop user from deselecting a mode // we need to stop user from deselecting a mode
@@ -128,8 +122,6 @@ function RundownHeader({ isExtracted, viewMode, setViewMode }: RundownHeaderProp
</ToggleGroup> </ToggleGroup>
)} )}
{showRundownSelect && <RundownSelect appMode={editorMode} />}
{showOverflowMenu && <RundownMenu allowNavigation={!isExtracted} />} {showOverflowMenu && <RundownMenu allowNavigation={!isExtracted} />}
</Toolbar.Root> </Toolbar.Root>
); );
@@ -1,7 +1,9 @@
import { memo, useEffect, useMemo } from 'react'; import { memo, useEffect, useMemo } from 'react';
import EmptyPage from '../../../common/components/state/EmptyPage'; import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd'; import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd';
import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable'; import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable';
import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions'; import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions';
@@ -10,9 +12,11 @@ import { makeRundownColumns } from './makeRundownColumns';
export default memo(RundownTable); export default memo(RundownTable);
function RundownTable() { function RundownTable() {
const { data: customFields, status: customFieldStatus } = useCustomFields(); const { data: customFields } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions); const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const { editorMode } = useEditorFollowMode(); const { editorMode } = useEditorFollowMode();
const source = useLoadedRundownSource();
const actions = useEntryActions();
// Editor always has full permissions // Editor always has full permissions
useEffect(() => { useEffect(() => {
@@ -27,15 +31,11 @@ function RundownTable() {
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]); const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
const isLoading = !customFields || customFieldStatus === 'pending';
return ( return (
<CuesheetDnd columns={columns} tableRoot='editor'> <EntryActionsProvider actions={actions}>
{isLoading ? ( <CuesheetDnd columns={columns} tableRoot='editor'>
<EmptyPage text='Loading...' /> <CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
) : ( </CuesheetDnd>
<CuesheetTable columns={columns} cuesheetMode={editorMode} tableRoot='editor' /> </EntryActionsProvider>
)}
</CuesheetDnd>
); );
} }
@@ -35,7 +35,7 @@ export default function BackstageLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />; return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
} }
return <Backstage {...data} />; return <Backstage {...data} />;
@@ -20,7 +20,7 @@ export function useBackstageData(): ViewData<BackstageData> {
const isMirrored = useViewOptionsStore((state) => state.mirror); const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data // HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundown(null); const { data: rundownData, status: rundownStatus } = useFlatRundown();
const { data: projectData, status: projectDataStatus } = useProjectData(); const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings(); const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields(); const { data: customFields, status: customFieldsStatus } = useCustomFields();
@@ -7,7 +7,7 @@ $dot-spacing: 1.5rem;
display: grid; display: grid;
place-items: center; place-items: center;
background-color: var(--background-color-override, $viewer-background-color); background-color: var(--background-color-override, $viewer-background-color);
height: 100vh; height: 100dvh;
} }
.ellipsis { .ellipsis {
@@ -21,26 +21,26 @@ $dot-spacing: 1.5rem;
height: $dot-size; height: $dot-size;
border-radius: 50%; border-radius: 50%;
background-color: var(--accent-color-override, $ontime-color); background-color: var(--accent-color-override, $ontime-color);
animation-timing-function: cubic-bezier(0, 1, 1, 0); animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
&:nth-child(1) { &:nth-child(1) {
left: $dot-size; left: $dot-size;
animation: lds-ellipsis1 0.6s infinite; animation: lds-ellipsis1 1s infinite;
} }
&:nth-child(2) { &:nth-child(2) {
left: $dot-size; left: $dot-size;
animation: lds-ellipsis2 0.6s infinite; animation: lds-ellipsis2 1s infinite;
} }
&:nth-child(3) { &:nth-child(3) {
left: calc($dot-size + $dot-spacing); left: calc($dot-size + $dot-spacing);
animation: lds-ellipsis2 0.6s infinite; animation: lds-ellipsis2 1s infinite;
} }
&:nth-child(4) { &:nth-child(4) {
left: calc($dot-size + 2 * $dot-spacing); left: calc($dot-size + 2 * $dot-spacing);
animation: lds-ellipsis3 0.6s infinite; animation: lds-ellipsis3 1s infinite;
} }
} }
} }
@@ -45,7 +45,7 @@ export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildre
[filter], [filter],
); );
const { data: events } = usePartialRundown(null, filterCallback); const { data: events } = usePartialRundown(filterCallback);
const [firstIndex, setFirstIndex] = useState(-1); const [firstIndex, setFirstIndex] = useState(-1);
const [numPages, setNumPages] = useState(0); const [numPages, setNumPages] = useState(0);
+1 -15
View File
@@ -80,21 +80,7 @@ $item-height: 3.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 1.5rem;
button {
margin-top: 1.75rem;
}
}
.empty-state__content {
max-width: none;
span {
max-width: none;
white-space: nowrap;
font-size: clamp(1.5rem, 4vw, 2.25rem);
line-height: 1.1;
}
} }
.list-container { .list-container {
@@ -43,7 +43,7 @@ export default function CountdownLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />; return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
} }
return <Countdown {...data} />; return <Countdown {...data} />;
@@ -87,7 +87,7 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
{!hasEvents && ( {!hasEvents && (
<div className='empty-state'> <div className='empty-state'>
<Empty text={getLocalizedString('common.no_data')} className='empty-state__content' /> <Empty text={getLocalizedString('common.no_data')} />
</div> </div>
)} )}
@@ -121,7 +121,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (subscriptions.length === 0) { if (subscriptions.length === 0) {
return ( return (
<div className='empty-state'> <div className='empty-state'>
<Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' /> <Empty text={getLocalizedString('countdown.select_event')} />
<Button variant='primary' size='xlarge' onClick={goToEditMode}> <Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add <IoAdd /> Add
</Button> </Button>
@@ -137,7 +137,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (subscribedEvents.length === 0) { if (subscribedEvents.length === 0) {
return ( return (
<div className='empty-state'> <div className='empty-state'>
<Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' /> <Empty text={getLocalizedString('countdown.select_event')} />
<Button variant='primary' size='xlarge' onClick={goToEditMode}> <Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add <IoAdd /> Add
</Button> </Button>
@@ -154,7 +154,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (eventsToShow.length === 0) { if (eventsToShow.length === 0) {
return ( return (
<div className='empty-state'> <div className='empty-state'>
<Empty text={getLocalizedString('countdown.all_have_finished')} className='empty-state__content' /> <Empty text={getLocalizedString('countdown.all_have_finished')} />
</div> </div>
); );
} }
@@ -21,7 +21,7 @@ export function useCountdownData(): ViewData<CountdownData> {
const isMirrored = useViewOptionsStore((state) => state.mirror); const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data // HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata(null); const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus } = useProjectData(); const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings(); const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields(); const { data: customFields, status: customFieldsStatus } = useCustomFields();
@@ -14,3 +14,7 @@
'table'; 'table';
color: $ui-white; color: $ui-white;
} }
.rundownSelect {
min-width: min(20rem, calc(100vw - 6rem));
}
+28 -23
View File
@@ -4,46 +4,51 @@ import { IoApps } from 'react-icons/io5';
import IconButton from '../../common/components/buttons/IconButton'; import IconButton from '../../common/components/buttons/IconButton';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu'; import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
import { EntryActionsProvider } from '../../common/context/EntryActionsContext'; import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
import { RundownSelectionContextProvider } from '../../common/context/RundownSelectionContext'; import { useScopedRundown } from '../../common/hooks-query/useScopedRundown';
import { useScopedEntryActions } from '../../common/hooks/useEntryAction';
import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { getIsNavigationLocked } from '../../externals'; import { getIsNavigationLocked } from '../../externals';
import CuesheetOverview from '../../features/overview/CuesheetOverview'; import CuesheetOverview from '../../features/overview/CuesheetOverview';
import EntryEditModal from './cuesheet-edit-modal/EntryEditModal'; import EntryEditModal from './cuesheet-edit-modal/EntryEditModal';
import CuesheetProgress from './cuesheet-progress/CuesheetProgress'; import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
import CuesheetTableWrapper from './CuesheetTableWrapper'; import CuesheetTableWrapper from './CuesheetTableWrapper';
import { FOLLOW_LOADED_RUNDOWN_ID, useCuesheetRundownSelection } from './useCuesheetRundownSelection';
import styles from './CuesheetPage.module.scss'; import styles from './CuesheetPage.module.scss';
export default function CuesheetPage() { export default function CuesheetPage() {
'use memo'; 'use memo';
const [isMenuOpen, menuHandler] = useDisclosure(); const [isMenuOpen, menuHandler] = useDisclosure();
const { selectedRundownId, loadedRundownId, setSelectedRundownId, projectRundowns } = useCuesheetRundownSelection();
const source = useScopedRundown(selectedRundownId === FOLLOW_LOADED_RUNDOWN_ID ? loadedRundownId : selectedRundownId);
const actions = useScopedEntryActions(source.rundownId);
useWindowTitle('Cuesheet'); useWindowTitle('Cuesheet');
const isLocked = getIsNavigationLocked(); const isLocked = getIsNavigationLocked();
return ( return (
<RundownSelectionContextProvider> <EntryActionsProvider actions={actions}>
<EntryActionsProvider> <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} /> <EntryEditModal rundown={source.rundown} />
<EntryEditModal /> <div className={styles.tableWrapper} data-testid='cuesheet'>
<div className={styles.tableWrapper} data-testid='cuesheet'> <CuesheetOverview>
<CuesheetOverview> {!isLocked && (
{!isLocked && ( <IconButton aria-label='Toggle navigation' variant='subtle-white' size='xlarge' onClick={menuHandler.open}>
<IconButton <IoApps />
aria-label='Toggle navigation' </IconButton>
variant='subtle-white' )}
size='xlarge' </CuesheetOverview>
onClick={menuHandler.open} <CuesheetProgress />
> <CuesheetTableWrapper
<IoApps /> source={source}
</IconButton> selectedRundownId={selectedRundownId}
)} loadedRundownId={loadedRundownId}
</CuesheetOverview> setSelectedRundownId={setSelectedRundownId}
<CuesheetProgress /> projectRundowns={projectRundowns}
<CuesheetTableWrapper /> />
</div> </div>
</EntryActionsProvider> </EntryActionsProvider>
</RundownSelectionContextProvider>
); );
} }
@@ -1,41 +1,108 @@
import { MaybeString, ProjectRundown } from 'ontime-types';
import { memo, use, useMemo } from 'react'; import { memo, use, useMemo } from 'react';
import EmptyPage from '../../common/components/state/EmptyPage'; import Select from '../../common/components/select/Select';
import { PresetContext } from '../../common/context/PresetContext'; import { PresetContext } from '../../common/context/PresetContext';
import { useRundownSelectionContext } from '../../common/context/RundownSelectionContext';
import useCustomFields from '../../common/hooks-query/useCustomFields'; import useCustomFields from '../../common/hooks-query/useCustomFields';
import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
import { AppMode } from '../../ontimeConfig';
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd'; import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory'; import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
import CuesheetTable from './cuesheet-table/CuesheetTable'; import CuesheetTable from './cuesheet-table/CuesheetTable';
import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy'; import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy';
import { FOLLOW_LOADED_RUNDOWN_ID } from './useCuesheetRundownSelection';
import styles from './CuesheetPage.module.scss';
interface CuesheetTableWrapperProps {
source: RundownSource;
selectedRundownId: MaybeString;
loadedRundownId: string;
setSelectedRundownId: (rundownId: string) => void;
projectRundowns: ProjectRundown[];
}
export default memo(CuesheetTableWrapper); export default memo(CuesheetTableWrapper);
function CuesheetTableWrapper() { function CuesheetTableWrapper({
source,
selectedRundownId,
setSelectedRundownId,
loadedRundownId,
projectRundowns,
}: CuesheetTableWrapperProps) {
const preset = use(PresetContext); const preset = use(PresetContext);
const { isLoadedRundown } = useRundownSelectionContext(); const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isLoadedRundown }); const { data: customFields } = useCustomFields();
const { data: customFields, status: customFieldStatus } = useCustomFields();
const columns = useMemo( const columns = useMemo(
() => makeCuesheetColumns(customFields, cuesheetMode, preset), () => makeCuesheetColumns(customFields, cuesheetMode, preset),
[customFields, cuesheetMode, preset], [customFields, cuesheetMode, preset],
); );
const isLoading = !customFields || customFieldStatus === 'pending';
return ( return (
<CuesheetDnd columns={columns}> <CuesheetDnd columns={columns}>
{isLoading ? ( <CuesheetTable
<EmptyPage text='Loading...' /> columns={columns}
) : ( source={source}
<CuesheetTable cuesheetMode={cuesheetMode}
columns={columns} tableRoot='cuesheet'
cuesheetMode={cuesheetMode} setCuesheetMode={setCuesheetMode}
tableRoot='cuesheet' isCurrentRundown={isCurrentRundown}
setCuesheetMode={setCuesheetMode} insertElement={
/> <>
)} <RundownSelect
cuesheetMode={cuesheetMode}
selectedRundownId={selectedRundownId}
loadedRundownId={loadedRundownId}
setSelectedRundownId={setSelectedRundownId}
projectRundowns={projectRundowns}
/>
</>
}
/>
</CuesheetDnd> </CuesheetDnd>
); );
} }
interface RundownSelectProps {
cuesheetMode: AppMode;
selectedRundownId: MaybeString;
loadedRundownId: string;
setSelectedRundownId: (rundownId: string) => void;
projectRundowns: ProjectRundown[];
}
function RundownSelect({
cuesheetMode,
projectRundowns,
loadedRundownId,
selectedRundownId,
setSelectedRundownId,
}: RundownSelectProps) {
'use memo';
const options = projectRundowns.map(({ id, title }) => ({
value: id,
label: loadedRundownId === id ? `${title} (loaded)` : title,
}));
options.unshift({
value: FOLLOW_LOADED_RUNDOWN_ID,
label: 'Follow loaded', // TODO: Better wording and maybe icon? and translation
});
return (
<div className={styles.rundownSelect}>
<Select
value={selectedRundownId ?? undefined}
options={options}
onValueChange={(value) => {
if (value) {
setSelectedRundownId(value);
}
}}
disabled={cuesheetMode === AppMode.Run}
fluid
/>
</div>
);
}
@@ -0,0 +1,21 @@
import {
FOLLOW_LOADED_RUNDOWN_ID,
getCuesheetRundownStorageKey,
resolveSelectedRundownId,
} from '../useCuesheetRundownSelection';
describe('useCuesheetRundownSelection helpers', () => {
it('builds a project-scoped storage key', () => {
expect(getCuesheetRundownStorageKey('http://localhost:4001', 'My Project')).toBe(
'cuesheet-selected-rundown:http://localhost:4001:My Project',
);
});
it('falls back to the follow loaded rundown when the stored selection is missing', () => {
expect(resolveSelectedRundownId('missing', new Set(['loaded', 'other']))).toBe(FOLLOW_LOADED_RUNDOWN_ID);
});
it('keeps the stored selection when it still exists in the current project', () => {
expect(resolveSelectedRundownId('other', new Set(['loaded', 'other']))).toBe('other');
});
});
@@ -1,13 +1,16 @@
import { Rundown } from 'ontime-types';
import { memo } from 'react'; import { memo } from 'react';
import Modal from '../../../common/components/modal/Modal'; import Modal from '../../../common/components/modal/Modal';
import { useContextRundownEditModal } from '../../../common/hooks-query/useContextRundown';
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor'; import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
import { useEditModal } from './useEditModal'; import { useEditModal } from './useEditModal';
interface EntryEditModalProps {
rundown: Rundown;
}
export default memo(EntryEditModal); export default memo(EntryEditModal);
function EntryEditModal() { function EntryEditModal({ rundown }: EntryEditModalProps) {
const { rundown } = useContextRundownEditModal();
const entryId = useEditModal((state) => state.selectedEntryId); const entryId = useEditModal((state) => state.selectedEntryId);
const closeModal = useEditModal((state) => state.clearSelection); const closeModal = useEditModal((state) => state.clearSelection);
@@ -5,6 +5,10 @@ $table-header-font-size: calc(1rem - 2px);
@include rows.cuesheet-row-columns($table-header-font-size); @include rows.cuesheet-row-columns($table-header-font-size);
.tableLoading {
grid-area: table;
}
.cuesheet { .cuesheet {
font-size: $table-font-size; font-size: $table-font-size;
font-weight: 400; font-weight: 400;
@@ -1,7 +1,7 @@
import { useTableNav } from '@table-nav/react'; import { useTableNav } from '@table-nav/react';
import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table'; import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { ComponentProps, memo, useCallback, useEffect, useMemo, useRef } from 'react'; import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { import {
ContextProp, ContextProp,
ItemProps, ItemProps,
@@ -11,14 +11,15 @@ import {
TableVirtuosoHandle, TableVirtuosoHandle,
} from 'react-virtuoso'; } from 'react-virtuoso';
import EmptyPage from '../../../common/components/state/EmptyPage'; import EmptyFill from '../../../common/components/state/EmptyFill';
import EmptyTableBody from '../../../common/components/state/EmptyTableBody'; import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextRundownTable } from '../../../common/hooks-query/useContextRundown'; import type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata'; import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options'; import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
import { useEventSelection } from '../../../features/rundown/useEventSelection'; import { useEventSelection } from '../../../features/rundown/useEventSelection';
import { AppMode } from '../../../ontimeConfig'; import { AppMode } from '../../../ontimeConfig';
import { useTranslation } from '../../../translation/TranslationProvider';
import { usePersistedCuesheetOptions } from '../cuesheet.options'; import { usePersistedCuesheetOptions } from '../cuesheet.options';
import { useCuesheetPermissions } from '../useTablePermissions'; import { useCuesheetPermissions } from '../useTablePermissions';
import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader'; import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader';
@@ -35,23 +36,36 @@ import style from './CuesheetTable.module.scss';
type CuesheetTableBaseProps = { type CuesheetTableBaseProps = {
columns: ColumnDef<ExtendedEntry>[]; columns: ColumnDef<ExtendedEntry>[];
cuesheetMode: AppMode; cuesheetMode: AppMode;
source: RundownSource;
insertElement?: ReactNode;
}; };
type EditorCuesheetTableProps = CuesheetTableBaseProps & { type EditorCuesheetTableProps = CuesheetTableBaseProps & {
tableRoot: 'editor'; tableRoot: 'editor';
setCuesheetMode?: undefined; setCuesheetMode?: undefined;
isCurrentRundown?: undefined;
}; };
type ViewCuesheetTableProps = CuesheetTableBaseProps & { type ViewCuesheetTableProps = CuesheetTableBaseProps & {
tableRoot: 'cuesheet'; tableRoot: 'cuesheet';
setCuesheetMode: (mode: AppMode) => void; setCuesheetMode: (mode: AppMode) => void;
isCurrentRundown?: boolean;
}; };
type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps; type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCuesheetMode }: CuesheetTableProps) { export default function CuesheetTable({
const { flatRundown, status, loadedEventId } = useContextRundownTable(); columns,
cuesheetMode,
source,
tableRoot,
setCuesheetMode,
isCurrentRundown,
insertElement,
}: CuesheetTableProps) {
const { flatRundown, status, selectedEventId } = source;
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext(); const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
const { getLocalizedString } = useTranslation();
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit; const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions; const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions;
@@ -131,17 +145,17 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
// in Run mode, follow the current event // in Run mode, follow the current event
useEffect(() => { useEffect(() => {
if (virtuosoRef.current === null || cuesheetMode !== AppMode.Run || !loadedEventId) { if (virtuosoRef.current === null || cuesheetMode !== AppMode.Run || !selectedEventId) {
return; return;
} }
const eventIndex = flatRundown.findIndex((event) => event.id === loadedEventId); const eventIndex = flatRundown.findIndex((event) => event.id === selectedEventId);
if (eventIndex === -1) { if (eventIndex === -1) {
return; return;
} }
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'auto', align: 'start', offset: -50 }); virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'auto', align: 'start', offset: -50 });
}, [cuesheetMode, flatRundown, loadedEventId]); }, [cuesheetMode, flatRundown, selectedEventId]);
// Provide an imperative scroll handler for explicit jumps (finder/keyboard) // Provide an imperative scroll handler for explicit jumps (finder/keyboard)
useEffect(() => { useEffect(() => {
@@ -216,10 +230,13 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
}); });
}, [cuesheetMode, hideIndexColumn, table]); }, [cuesheetMode, hideIndexColumn, table]);
const isLoading = !flatRundown || status === 'pending'; // avoid showing the editable empty state before we know whether the rundown is actually empty
if (status === 'pending') {
return <EmptyFill text='Loading…' className={style.tableLoading} />;
}
if (isLoading) { if (status === 'error') {
return <EmptyPage text='Loading...' />; return <EmptyFill text={getLocalizedString('common.no_data')} className={style.tableLoading} />;
} }
return ( return (
@@ -230,9 +247,17 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
handleResetResizing={resetColumnResizing} handleResetResizing={resetColumnResizing}
handleResetReordering={resetColumnOrder} handleResetReordering={resetColumnOrder}
handleClearToggles={setAllVisible} handleClearToggles={setAllVisible}
appMode={cuesheetMode} insertElement={insertElement}
tableRoot={tableRoot} modeControls={
setCuesheetMode={setCuesheetMode} tableRoot === 'cuesheet'
? {
cuesheetMode,
setCuesheetMode,
isCurrentRundown,
}
: undefined
}
showShare={tableRoot === 'cuesheet'}
/> />
<TableVirtuoso <TableVirtuoso
ref={virtuosoRef} ref={virtuosoRef}
@@ -10,9 +10,7 @@ import Button from '../../../../common/components/buttons/Button';
import Checkbox from '../../../../common/components/checkbox/Checkbox'; import Checkbox from '../../../../common/components/checkbox/Checkbox';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import PopoverContents from '../../../../common/components/popover/Popover'; import PopoverContents from '../../../../common/components/popover/Popover';
import { useRundownSelectionContext } from '../../../../common/context/RundownSelectionContext';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata'; import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { RundownSelect } from '../../../../features/rundown/common/RundownSelect';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetPermissions } from '../../useTablePermissions'; import { useCuesheetPermissions } from '../../useTablePermissions';
import CuesheetShareModal from './CuesheetShareModal'; import CuesheetShareModal from './CuesheetShareModal';
@@ -32,15 +30,21 @@ type TableHeaderOptionValues = Pick<
'hideTableSeconds' | 'hideIndexColumn' | 'showDelayedTimes' | 'hideDelays' 'hideTableSeconds' | 'hideIndexColumn' | 'showDelayedTimes' | 'hideDelays'
>; >;
type TableModeControls = {
cuesheetMode: AppMode;
setCuesheetMode: (mode: AppMode) => void;
isCurrentRundown?: boolean;
};
interface CuesheetTableHeaderToolbarProps { interface CuesheetTableHeaderToolbarProps {
columns: Column<ExtendedEntry, unknown>[]; columns: Column<ExtendedEntry, unknown>[];
optionsStore: TableHeaderOptionsStore; optionsStore: TableHeaderOptionsStore;
handleResetResizing: () => void; handleResetResizing: () => void;
handleResetReordering: () => void; handleResetReordering: () => void;
handleClearToggles: () => void; handleClearToggles: () => void;
setCuesheetMode?: (mode: AppMode) => void; insertElement?: ReactNode;
appMode: AppMode; modeControls?: TableModeControls;
tableRoot: 'editor' | 'cuesheet'; showShare?: boolean;
} }
export default function CuesheetTableHeaderToolbar({ export default function CuesheetTableHeaderToolbar({
@@ -49,23 +53,23 @@ export default function CuesheetTableHeaderToolbar({
handleResetResizing, handleResetResizing,
handleResetReordering, handleResetReordering,
handleClearToggles, handleClearToggles,
setCuesheetMode, insertElement,
tableRoot, modeControls,
appMode, showShare = false,
}: CuesheetTableHeaderToolbarProps) { }: CuesheetTableHeaderToolbarProps) {
const canChangeMode = useCuesheetPermissions((state) => state.canChangeMode) && tableRoot === 'cuesheet'; const canChangeMode = useCuesheetPermissions((state) => state.canChangeMode);
const canShare = useCuesheetPermissions((state) => state.canShare) && tableRoot === 'cuesheet'; const canShare = useCuesheetPermissions((state) => state.canShare);
const showRundownSelect = tableRoot === 'cuesheet';
const { isLoadedRundown } = useRundownSelectionContext();
const toggleCuesheetMode = (mode: AppMode[]) => { const toggleCuesheetMode = (mode: AppMode[]) => {
const newValue = mode.at(0); const newValue = mode.at(0);
if (!newValue || !setCuesheetMode) return; if (!newValue || !modeControls) return;
setCuesheetMode(newValue); modeControls.setCuesheetMode(newValue);
}; };
const isBackground = !(modeControls?.isCurrentRundown ?? true);
return ( return (
<Toolbar.Root className={style.tableSettings} data-background-rundown={!isLoadedRundown}> <Toolbar.Root className={style.tableSettings} data-background-rundown={isBackground}>
<ViewSettings optionsStore={optionsStore} /> <ViewSettings optionsStore={optionsStore} />
<ColumnSettings <ColumnSettings
columns={columns} columns={columns}
@@ -73,14 +77,14 @@ export default function CuesheetTableHeaderToolbar({
handleResetReordering={handleResetReordering} handleResetReordering={handleResetReordering}
handleClearToggles={handleClearToggles} handleClearToggles={handleClearToggles}
/> />
<div className={style.apart}> {modeControls && canChangeMode && (
{showRundownSelect && <RundownSelect appMode={appMode} />} <div className={style.apart}>
{canChangeMode && ( {insertElement}
<ToggleGroup <ToggleGroup
value={[appMode]} value={[modeControls.cuesheetMode]}
onValueChange={toggleCuesheetMode} onValueChange={toggleCuesheetMode}
className={style.group} className={style.group}
disabled={!isLoadedRundown} disabled={!modeControls.isCurrentRundown}
> >
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}> <Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
Run Run
@@ -89,10 +93,10 @@ export default function CuesheetTableHeaderToolbar({
Edit Edit
</Toolbar.Button> </Toolbar.Button>
</ToggleGroup> </ToggleGroup>
)} </div>
</div> )}
{canShare && ( {showShare && canShare && (
<> <>
<Editor.Separator orientation='vertical' /> <Editor.Separator orientation='vertical' />
<CuesheetShareModal /> <CuesheetShareModal />
@@ -0,0 +1,65 @@
import { useSessionStorage } from '@mantine/hooks';
import { startTransition, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router';
import { useOrderedProjectList } from '../../common/hooks-query/useProjectList';
import { useProjectRundowns } from '../../common/hooks-query/useProjectRundowns';
import { serverURL } from '../../externals';
export const FOLLOW_LOADED_RUNDOWN_ID = '__follow-loaded__' as const;
export function getCuesheetRundownStorageKey(server: string, projectFilename: string) {
return `cuesheet-selected-rundown:${server}:${projectFilename}`;
}
export function resolveSelectedRundownId(storedSelectedRundownId: string | null, availableRundownIds: Set<string>) {
if (storedSelectedRundownId && availableRundownIds.has(storedSelectedRundownId)) return storedSelectedRundownId;
return FOLLOW_LOADED_RUNDOWN_ID;
}
export function useCuesheetRundownSelection() {
'use memo';
const { data: projectRundowns } = useProjectRundowns();
const {
data: { lastLoadedProject },
} = useOrderedProjectList();
const storageKey = useMemo(() => getCuesheetRundownStorageKey(serverURL, lastLoadedProject), [lastLoadedProject]);
const [storedSelectedRundownId, setStoredSelectedRundownId] = useSessionStorage<string | null>({
key: storageKey,
defaultValue: FOLLOW_LOADED_RUNDOWN_ID,
});
const availableRundownIds = new Set(projectRundowns.rundowns.map(({ id }) => id)).add(FOLLOW_LOADED_RUNDOWN_ID);
const { loaded: loadedRundownId } = projectRundowns;
const selectedRundownId = resolveSelectedRundownId(storedSelectedRundownId, availableRundownIds);
return {
loadedRundownId,
selectedRundownId,
projectRundowns: projectRundowns.rundowns,
setSelectedRundownId: (rundownId: string) => {
startTransition(() => {
setStoredSelectedRundownId(rundownId);
});
},
};
}
export function useDirectLinkToBackgroundEdit() {
const {
data: { lastLoadedProject },
} = useOrderedProjectList();
const navigate = useNavigate();
const storageKey = getCuesheetRundownStorageKey(serverURL, lastLoadedProject);
const [_, setStoredSelectedRundownId] = useSessionStorage<string | null>({ key: storageKey, defaultValue: null });
return useCallback(
async (rundownId: string) => {
await navigate('/cuesheet');
startTransition(() => setStoredSelectedRundownId(rundownId));
},
[setStoredSelectedRundownId, navigate],
);
}
+35 -43
View File
@@ -1,6 +1,5 @@
import { lazy } from 'react'; import { lazy } from 'react';
import { RundownSelectionContextProvider } from '../../common/context/RundownSelectionContext';
import TrackingPlaybackBar from '../../features/control/playback/tracking-playback-bar/TrackingPlaybackBar'; import TrackingPlaybackBar from '../../features/control/playback/tracking-playback-bar/TrackingPlaybackBar';
import { AppMode } from '../../ontimeConfig'; import { AppMode } from '../../ontimeConfig';
import TitleList from './title-list/TitleList'; import TitleList from './title-list/TitleList';
@@ -15,51 +14,44 @@ const MessageControl = lazy(() => import('../../features/control/message/Message
export default function Editor() { export default function Editor() {
const { layoutMode } = useEditorLayout(); const { layoutMode } = useEditorLayout();
switch (layoutMode) { if (layoutMode === EditorLayoutMode.CONTROL) {
case EditorLayoutMode.TRACKING: { return (
return ( <div id='panels' className={styles.panelContainer}>
<div id='panels' className={`${styles.panelContainer} ${styles.panelContainerTracking}`}> <div className={styles.left}>
<div className={styles.rundownLayout}> <TimerControl />
<div className={styles.titlesPanel}> <MessageControl />
<RundownSelectionContextProvider>
<TitleList mode={AppMode.Run} />
</RundownSelectionContextProvider>
</div>
<div className={styles.rundownPanel}>
<Rundown />
</div>
</div>
<TrackingPlaybackBar />
</div> </div>
); <Rundown />
} </div>
case EditorLayoutMode.PLANNING: { );
return ( }
<div id='panels' className={styles.panelContainer}>
<div className={styles.rundownLayout}> if (layoutMode === EditorLayoutMode.TRACKING) {
<div className={styles.titlesPanel}> return (
<RundownSelectionContextProvider> <div id='panels' className={`${styles.panelContainer} ${styles.panelContainerTracking}`}>
<TitleList mode={AppMode.Edit} /> <div className={styles.rundownLayout}>
</RundownSelectionContextProvider> <div className={styles.titlesPanel}>
</div> <TitleList mode={AppMode.Run} />
<div className={styles.rundownPanel}> </div>
<Rundown /> <div className={styles.rundownPanel}>
</div> <Rundown />
</div> </div>
</div> </div>
); <TrackingPlaybackBar />
} </div>
case EditorLayoutMode.CONTROL: );
default: { }
return (
<div id='panels' className={styles.panelContainer}> return (
<div className={styles.left}> <div id='panels' className={styles.panelContainer}>
<TimerControl /> <div className={styles.rundownLayout}>
<MessageControl /> <div className={styles.titlesPanel}>
</div> <TitleList mode={AppMode.Edit} />
</div>
<div className={styles.rundownPanel}>
<Rundown /> <Rundown />
</div> </div>
); </div>
} </div>
} );
} }
@@ -1,7 +1,7 @@
import { EntryId, MaybeString, SupportedEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; import { EntryId, MaybeString, SupportedEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react'; import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useContextRundownFinder } from '../../../common/hooks-query/useContextRundown'; import { useFlatRundown } from '../../../common/hooks-query/useRundown';
import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry'; import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry';
const maxResults = 12; const maxResults = 12;
@@ -38,7 +38,7 @@ type FilterableMilestone = {
type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone; type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone;
export default function useFinder() { export default function useFinder() {
const { rundown: data, rundownId } = useContextRundownFinder(); const { data, rundownId } = useFlatRundown();
const [results, setResults] = useState<FilterableEntry[]>([]); const [results, setResults] = useState<FilterableEntry[]>([]);
const [error, setError] = useState<MaybeString>(null); const [error, setError] = useState<MaybeString>(null);
const lastSearchString = useRef(''); const lastSearchString = useRef('');
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'; import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import ScrollArea from '../../../common/components/scroll-area/ScrollArea'; import ScrollArea from '../../../common/components/scroll-area/ScrollArea';
import { useContextRundownList } from '../../../common/hooks-query/useContextRundown'; import useRundown from '../../../common/hooks-query/useRundown';
import { useSelectedEventId } from '../../../common/hooks/useSocket'; import { useSelectedEventId } from '../../../common/hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata } from '../../../common/utils/rundownMetadata'; import { ExtendedEntry, getFlatRundownMetadata } from '../../../common/utils/rundownMetadata';
import { useEventSelection } from '../../../features/rundown/useEventSelection'; import { useEventSelection } from '../../../features/rundown/useEventSelection';
@@ -20,7 +20,7 @@ interface TitleListProps {
} }
export default function TitleList({ mode }: TitleListProps) { export default function TitleList({ mode }: TitleListProps) {
const { rundown } = useContextRundownList(); const { data: rundown } = useRundown();
const selectedEventId = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const cursor = useEventSelection((state) => state.cursor); const cursor = useEventSelection((state) => state.cursor);
@@ -1,9 +1,6 @@
import { isValueOfEnum } from 'ontime-utils'; import { isValueOfEnum } from 'ontime-utils';
import { useCallback, useEffect } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { setSelectRundownInParams } from '../../common/context/RundownSelectionContext';
const layoutParam = 'layout'; const layoutParam = 'layout';
export enum EditorLayoutMode { export enum EditorLayoutMode {
@@ -23,29 +20,14 @@ function getEditorLayout(value: string | null): EditorLayoutMode {
} }
export function useEditorLayout() { export function useEditorLayout() {
'use memo';
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const layoutMode = getEditorLayout(searchParams.get(layoutParam)); const layoutMode = getEditorLayout(searchParams.get(layoutParam));
useEffect(() => { const setLayoutMode = (mode: EditorLayoutMode) => {
setSearchParams((searchParams) => { const nextParams = new URLSearchParams(searchParams);
if (layoutMode !== EditorLayoutMode.PLANNING) setSelectRundownInParams(null, searchParams); nextParams.set(layoutParam, mode);
return searchParams; setSearchParams(nextParams, { replace: true });
}); };
}, [setSearchParams, layoutMode]);
const setLayoutMode = useCallback(
(mode: EditorLayoutMode) => {
setSearchParams((searchParams) => {
searchParams.set(layoutParam, mode);
// Only the Planning layout is allowed to look at something other than the current rundown
if (mode !== EditorLayoutMode.PLANNING) setSelectRundownInParams(null, searchParams);
return searchParams;
});
},
[setSearchParams],
);
return { layoutMode, setLayoutMode }; return { layoutMode, setLayoutMode };
} }
@@ -1,5 +1,7 @@
@use '@/theme/viewerDefs' as *; @use '@/theme/viewerDefs' as *;
$content-width: min(100%, 1100px);
.project { .project {
margin: 0; margin: 0;
box-sizing: border-box; /* reset */ box-sizing: border-box; /* reset */
@@ -16,56 +18,104 @@
display: flex; display: flex;
flex-direction: column; 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 { .logo {
max-width: min(200px, 30vw); 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 { .info {
flex: 1; flex: 1;
max-height: 100%; width: $content-width;
margin-inline: auto; margin-inline: auto;
overflow-y: auto; overflow-y: auto;
width: min(calc(100vw - 4rem), 960px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: start;
gap: $view-element-gap; gap: $view-element-gap;
padding-block: $view-element-gap;
padding-bottom: 10vh; 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 { .info__label {
font-size: $timer-label-size;
font-weight: 600; font-weight: 600;
letter-spacing: 0.05em;
color: var(--label-color-override, $viewer-label-color); color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase; text-transform: uppercase;
} }
.info__value { .info__value {
white-space: break-spaces; white-space: break-spaces;
} line-height: 1.35;
overflow-wrap: anywhere;
.info__custom {
display: flex;
gap: 1rem;
} }
.info__image-container { .info__image-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
width: 192px; flex: 0 0 min(192px, 25%);
height: 192px;
} }
.info__image { .info__image {
display: block;
max-width: 100%; max-width: 100%;
max-height: 100%; height: auto;
object-fit: contain; object-fit: contain;
} }
.link.info__value { .link.info__value {
display: flex; display: inline-flex;
gap: $view-element-gap; gap: 0.35em;
align-items: center; align-items: center;
color: $action-text-color; color: $action-text-color;
@@ -78,12 +128,13 @@
/* =================== MOBILE ===================*/ /* =================== MOBILE ===================*/
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
.project { .project {
.project-header {
flex-direction: column;
align-items: start;
gap: 0.5rem;
}
.logo img { .logo img {
height: min(50px, 10vh); height: min(50px, 10vh);
} }
.info__image-container {
width: 96px;
height: 96px;
}
} }
} }
@@ -1,4 +1,5 @@
import { OntimeView } from 'ontime-types'; import { OntimeView } from 'ontime-types';
import { type ReactNode, useState } from 'react';
import { IoOpenOutline } from 'react-icons/io5'; import { IoOpenOutline } from 'react-icons/io5';
import EmptyPage from '../../common/components/state/EmptyPage'; import EmptyPage from '../../common/components/state/EmptyPage';
@@ -21,7 +22,7 @@ export default function ProjectInfoLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />; return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
} }
return <ProjectInfo {...data} />; return <ProjectInfo {...data} />;
@@ -41,56 +42,49 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
return ( return (
<> <>
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} /> <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 ( return (
<div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'> <div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'>
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} /> <ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
{projectData.logo && <ViewLogo name={projectData.logo} className='logo' />} {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>
)}
<div className='info'> <div className='info'>
{projectData.title && ( {projectData.info && <InfoCard label={getLocalizedString('project.info')}>{projectData.info}</InfoCard>}
<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 && ( {projectData.url && (
<div> <div className='info__card'>
<div className='info__label'>{getLocalizedString('project.url')}</div> <div className='info__label'>{getLocalizedString('project.url')}</div>
<a href={projectData.url} target='_blank' rel='noreferrer' className='info__value link'> <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> </a>
</div> </div>
)} )}
{projectData.custom.map((info, idx) => { {projectData.custom.map((info, idx) => {
const hasUrl = Boolean(info.url);
return ( return (
// oxlint-disable-next-line react/no-array-index-key - we only have the index to go of here // oxlint-disable-next-line react/no-array-index-key - we only have the index to go of here
<div key={`${info.title}-${idx}`} className='info__custom'> <div key={`${info.title}-${idx}`} className='info__card'>
{hasUrl && ( {info.title && <div className='info__label'>{info.title}</div>}
<div className='info__image-container'> {info.url ? (
<img className='info__image' src={info.url} loading='lazy' /> <div className='info__media'>
<InfoImage src={info.url} />
{info.value && <div className='info__value'>{info.value}</div>}
</div> </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> </div>
); );
})} })}
@@ -98,3 +92,31 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
</div> </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>
);
}
+1 -1
View File
@@ -25,7 +25,7 @@ export default function StudioLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />; return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
} }
return <Studio {...data} />; return <Studio {...data} />;
@@ -1,6 +1,7 @@
import { OntimeView } from 'ontime-types'; import { OntimeView } from 'ontime-types';
import { useMemo } from 'react'; import { useMemo } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage'; import EmptyPage from '../../common/components/state/EmptyPage';
import ViewLogo from '../../common/components/view-logo/ViewLogo'; import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
@@ -29,7 +30,7 @@ export default function TimelinePageLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />; return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
} }
return <TimelinePage {...data} />; return <TimelinePage {...data} />;
@@ -73,7 +74,7 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
totalDuration={totalDuration} totalDuration={totalDuration}
/> />
) : ( ) : (
<EmptyPage text={getLocalizedString('common.no_data')} /> <EmptyFill text={getLocalizedString('common.no_data')} />
)} )}
</div> </div>
); );
@@ -16,7 +16,7 @@ export interface TimelineData {
export function useTimelineData(): ViewData<TimelineData> { export function useTimelineData(): ViewData<TimelineData> {
// HTTP API data // HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata(null); const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus } = useProjectData(); const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings(); const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields(); const { data: customFields, status: customFieldsStatus } = useCustomFields();
+1 -1
View File
@@ -43,7 +43,7 @@ export default function TimerLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />; return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
} }
return <Timer {...data} />; return <Timer {...data} />;
+2 -2
View File
@@ -2,7 +2,7 @@ import { CustomFields, ProjectData, RundownEntries, Settings, ViewSettings } fro
import useCustomFields from '../../common/hooks-query/useCustomFields'; import useCustomFields from '../../common/hooks-query/useCustomFields';
import useProjectData from '../../common/hooks-query/useProjectData'; import useProjectData from '../../common/hooks-query/useProjectData';
import { useRundown } from '../../common/hooks-query/useRundown'; import useRundown from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings'; import useSettings from '../../common/hooks-query/useSettings';
import useViewSettings from '../../common/hooks-query/useViewSettings'; import useViewSettings from '../../common/hooks-query/useViewSettings';
import { useViewOptionsStore } from '../../common/stores/viewOptions'; import { useViewOptionsStore } from '../../common/stores/viewOptions';
@@ -26,7 +26,7 @@ export function useTimerData(): ViewData<TimerData> {
const { data: viewSettings, status: viewSettingsStatus } = useViewSettings(); const { data: viewSettings, status: viewSettingsStatus } = useViewSettings();
const { data: settings, status: settingsStatus } = useSettings(); const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields(); const { data: customFields, status: customFieldsStatus } = useCustomFields();
const { data: rundown, status: rundownStatus } = useRundown(null); const { data: rundown, status: rundownStatus } = useRundown();
const { entries } = rundown; const { entries } = rundown;
return { return {
@@ -1,10 +1,9 @@
import { copyFile } from 'fs/promises'; import { copyFile } from 'fs/promises';
import { join } from 'path'; import { join } from 'path';
import { DatabaseModel, LogOrigin, ProjectFileListResponse, RefetchKey } from 'ontime-types'; import { DatabaseModel, LogOrigin, ProjectFileListResponse } from 'ontime-types';
import { getErrorMessage, getFirstRundown } from 'ontime-utils'; import { getErrorMessage, getFirstRundown } from 'ontime-utils';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js'; import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js';
import { parseDatabaseModel } from '../../api-data/db/db.parser.js'; import { parseDatabaseModel } from '../../api-data/db/db.parser.js';
import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js'; import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js';
@@ -106,9 +105,6 @@ async function loadProject(projectData: DatabaseModel, fileName: string, rundown
currentProjectName: fileName, currentProjectName: fileName,
}; };
setImmediate(() => {
sendRefetch(RefetchKey.ProjectFiles);
});
return fileName; return fileName;
} }
@@ -267,10 +263,6 @@ export async function duplicateProjectFile(originalFile: string, newFilename: st
const pathToDuplicate = getPathToProject(newFilename); const pathToDuplicate = getPathToProject(newFilename);
await copyFile(projectFilePath, pathToDuplicate); await copyFile(projectFilePath, pathToDuplicate);
setImmediate(() => {
sendRefetch(RefetchKey.ProjectFiles);
});
return; return;
} }
@@ -301,10 +293,6 @@ export async function renameProjectFile(originalFile: string, newFilename: strin
const newFileName = await loadProject(projectData.data, newFilename); const newFileName = await loadProject(projectData.data, newFilename);
return newFileName; return newFileName;
} }
setImmediate(() => {
sendRefetch(RefetchKey.ProjectFiles);
});
return newFilename; return newFilename;
} }
@@ -344,9 +332,6 @@ export async function deleteProjectFile(filename: string) {
} }
await deleteFile(projectFilePath); await deleteFile(projectFilePath);
setImmediate(() => {
sendRefetch(RefetchKey.ProjectFiles);
});
} }
/** /**
@@ -389,7 +374,6 @@ export async function patchCurrentProject(data: Partial<DatabaseModel>) {
} }
} }
const updatedData = getDataProvider().getData(); const updatedData = await getDataProvider().getData();
return updatedData; return updatedData;
} }
@@ -1,7 +1,6 @@
export enum RefetchKey { export enum RefetchKey {
All = 'all', All = 'all',
CustomFields = 'custom-fields', CustomFields = 'custom-fields',
ProjectFiles = 'project-files',
ProjectData = 'project-data', ProjectData = 'project-data',
ProjectRundowns = 'project-rundowns', ProjectRundowns = 'project-rundowns',
Report = 'report', Report = 'report',
+40 -11
View File
@@ -137,8 +137,8 @@ importers:
specifier: 5.6.0 specifier: 5.6.0
version: 5.6.0(react@19.2.7) version: 5.6.0(react@19.2.7)
react-router: react-router:
specifier: ^8.0.1 specifier: ^8.3.0
version: 8.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: 8.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react-virtuoso: react-virtuoso:
specifier: ^4.18.7 specifier: ^4.18.7
version: 4.18.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: 4.18.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -1508,6 +1508,9 @@ packages:
'@jridgewell/trace-mapping@0.3.30': '@jridgewell/trace-mapping@0.3.30':
resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==} resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@lezer/common@1.5.1': '@lezer/common@1.5.1':
resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==} resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==}
@@ -2972,8 +2975,8 @@ packages:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
acorn@8.16.0: acorn@8.18.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
hasBin: true hasBin: true
@@ -3726,6 +3729,10 @@ packages:
resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==}
engines: {node: '>=14.14'} engines: {node: '>=14.14'}
fs-extra@11.4.0:
resolution: {integrity: sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==}
engines: {node: '>=14.14'}
fs-extra@7.0.1: fs-extra@7.0.1:
resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==}
engines: {node: '>=6 <7 || >=8'} engines: {node: '>=6 <7 || >=8'}
@@ -4049,6 +4056,9 @@ packages:
jsonfile@6.2.0: jsonfile@6.2.0:
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
jsonfile@6.2.1:
resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
jwa@2.0.0: jwa@2.0.0:
resolution: {integrity: sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==} resolution: {integrity: sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==}
@@ -4595,8 +4605,8 @@ packages:
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
react-router@8.0.1: react-router@8.3.0:
resolution: {integrity: sha512-5EL/fANovVUhRK50NLS8RYfX0BxrimoKsHWUPPy8v5UEl8i6vzF7e4POo3u+AhPItDwccUAJjMfIOmydxBJmQw==} resolution: {integrity: sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==}
engines: {node: '>=22.22.0'} engines: {node: '>=22.22.0'}
peerDependencies: peerDependencies:
react: '>=19.2.7' react: '>=19.2.7'
@@ -6303,7 +6313,7 @@ snapshots:
dependencies: dependencies:
cross-dirname: 0.1.0 cross-dirname: 0.1.0
debug: 4.4.3 debug: 4.4.3
fs-extra: 11.3.4 fs-extra: 11.4.0
minimist: 1.2.8 minimist: 1.2.8
postject: 1.0.0-alpha.6 postject: 1.0.0-alpha.6
transitivePeerDependencies: transitivePeerDependencies:
@@ -6627,7 +6637,7 @@ snapshots:
'@jridgewell/source-map@0.3.11': '@jridgewell/source-map@0.3.11':
dependencies: dependencies:
'@jridgewell/gen-mapping': 0.3.13 '@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.30 '@jridgewell/trace-mapping': 0.3.31
'@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/sourcemap-codec@1.5.5': {}
@@ -6636,6 +6646,11 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@lezer/common@1.5.1': {} '@lezer/common@1.5.1': {}
'@lezer/css@1.3.3': '@lezer/css@1.3.3':
@@ -7741,7 +7756,7 @@ snapshots:
mime-types: 3.0.1 mime-types: 3.0.1
negotiator: 1.0.0 negotiator: 1.0.0
acorn@8.16.0: {} acorn@8.18.0: {}
adler-32@1.3.1: {} adler-32@1.3.1: {}
@@ -8644,6 +8659,13 @@ snapshots:
jsonfile: 6.2.0 jsonfile: 6.2.0
universalify: 2.0.1 universalify: 2.0.1
fs-extra@11.4.0:
dependencies:
graceful-fs: 4.2.11
jsonfile: 6.2.1
universalify: 2.0.1
optional: true
fs-extra@7.0.1: fs-extra@7.0.1:
dependencies: dependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
@@ -9025,6 +9047,13 @@ snapshots:
optionalDependencies: optionalDependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
jsonfile@6.2.1:
dependencies:
universalify: 2.0.1
optionalDependencies:
graceful-fs: 4.2.11
optional: true
jwa@2.0.0: jwa@2.0.0:
dependencies: dependencies:
buffer-equal-constant-time: 1.0.1 buffer-equal-constant-time: 1.0.1
@@ -9512,7 +9541,7 @@ snapshots:
react-refresh@0.18.0: {} react-refresh@0.18.0: {}
react-router@8.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): react-router@8.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies: dependencies:
cookie-es: 3.1.1 cookie-es: 3.1.1
react: 19.2.7 react: 19.2.7
@@ -9928,7 +9957,7 @@ snapshots:
terser@5.46.2: terser@5.46.2:
dependencies: dependencies:
'@jridgewell/source-map': 0.3.11 '@jridgewell/source-map': 0.3.11
acorn: 8.16.0 acorn: 8.18.0
commander: 2.20.3 commander: 2.20.3
source-map-support: 0.5.21 source-map-support: 0.5.21