From da02ecd113995da34ff57428a3a07cc82d89147b Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Wed, 21 Jan 2026 19:56:52 +0100 Subject: [PATCH] feat: editor layout --- .../dropdown-menu/DropdownMenu.module.scss | 19 +- .../components/dropdown-menu/DropdownMenu.tsx | 19 +- .../playback/aux-timer/AuxTimer.module.scss | 3 +- .../playback-buttons/PlaybackButtons.tsx | 61 ++- .../playback-timer/PlaybackTimer.module.scss | 7 + .../playback/playback-timer/PlaybackTimer.tsx | 2 +- .../control/playback/playbackControl.utils.ts | 95 +++++ .../timer-display/TimerDisplay.module.scss | 31 +- .../playback/timer-display/TimerDisplay.tsx | 19 +- .../TrackingPlaybackBar.module.scss | 146 +++++++ .../TrackingPlaybackBar.tsx | 184 ++++++++ .../client/src/features/operator/Operator.tsx | 4 +- .../CustomFieldEditModal.module.scss} | 0 .../CustomFieldEditModal.tsx} | 6 +- .../features/overview/CuesheetOverview.tsx | 16 +- .../src/features/overview/EditorOverview.tsx | 68 ++- .../features/overview/Overview.module.scss | 17 +- .../src/features/overview/OverviewWrapper.tsx | 10 +- .../composite/TimeElements.module.scss | 7 + .../overview/composite/TimeElements.tsx | 115 +++-- .../src/features/rundown/Rundown.module.scss | 11 +- apps/client/src/features/rundown/Rundown.tsx | 118 +++--- .../rundown/RundownExport.module.scss | 6 +- .../src/features/rundown/RundownExport.tsx | 40 +- .../entry-editor/CuesheetEventEditor.tsx | 14 +- .../entry-editor/EntryEditor.module.scss | 2 +- .../entry-editor/RundownEntryEditor.tsx | 19 +- .../rundown/hooks/useRundownCommands.ts | 149 +++---- .../features/rundown/hooks/useRundownDnd.ts | 12 +- .../rundown/rundown-event/RundownEvent.tsx | 4 +- .../rundown-event/RundownEventInner.tsx | 9 +- .../rundown/rundown-group/RundownGroup.tsx | 4 +- .../rundown-header/RundownHeader.module.scss | 44 +- .../rundown/rundown-header/RundownHeader.tsx | 115 ++++- .../rundown-header/RundownHeaderMobile.tsx | 40 +- .../rundown/rundown-header/RundownMenu.tsx | 7 +- .../rundown-header/RundownSettings.tsx | 33 -- .../rundown-milestone/RundownMilestone.tsx | 4 +- .../rundown/rundown-table/RundownTable.tsx | 22 +- .../src/features/rundown/rundown.options.ts | 5 + .../src/features/rundown/rundown.utils.ts | 2 +- .../src/features/rundown/rundownViewMode.ts | 5 - .../features/rundown/useCollapsedGroups.ts | 47 +++ .../features/rundown/useEditorFollowMode.ts | 41 ++ .../src/features/rundown/useEventSelection.ts | 23 +- .../rundown/useSelectAndRevealEntry.ts | 29 ++ apps/client/src/theme/_ontimeStyles.scss | 2 - .../src/views/cuesheet/CuesheetPage.tsx | 4 +- .../cuesheet-edit-modal/CuesheetEditModal.tsx | 26 -- ...EditorEditModal.tsx => EntryEditModal.tsx} | 10 +- .../useCuesheetEditModal.tsx | 14 - ...seEditorEditModal.tsx => useEditModal.tsx} | 2 +- .../cuesheet-table/CuesheetTable.module.scss | 5 + .../cuesheet/cuesheet-table/CuesheetTable.tsx | 31 +- .../cuesheet-table-menu/EditorTableMenu.tsx | 85 ---- .../{CuesheetTableMenu.tsx => TableMenu.tsx} | 8 +- .../src/views/editor/Editor.module.scss | 33 ++ apps/client/src/views/editor/Editor.tsx | 47 ++- .../src/views/editor/EditorLayoutOptions.tsx | 46 ++ .../editor/EditorViewOptions.module.scss | 67 --- .../src/views/editor/EditorViewOptions.tsx | 50 --- .../src/views/editor/ProtectedEditor.tsx | 4 +- .../src/views/editor/finder/useFinder.tsx | 31 +- .../editor/title-list/TitleList.module.scss | 20 + .../src/views/editor/title-list/TitleList.tsx | 164 ++++++++ .../title-list/TitleListEmpty.module.scss | 33 ++ .../editor/title-list/TitleListEmpty.tsx | 14 + .../title-list/TitleListItem.module.scss | 142 +++++++ .../views/editor/title-list/TitleListItem.tsx | 115 +++++ .../editor/title-list/titleList.utils.ts | 19 + .../src/views/editor/useEditorLayout.ts | 33 ++ e2e/tests/features/211-report-data.spec.ts | 4 + e2e/tests/features/212-time-until.spec.ts | 6 +- .../213-arrange-while-playing.spec.ts | 2 + .../src/rundown-utils/rundownUtils.mock.ts | 397 ++++++++++++++++++ .../src/rundown-utils/rundownUtils.test.ts | 6 +- packages/utils/tsconfig.tests.json | 1 + 77 files changed, 2267 insertions(+), 788 deletions(-) create mode 100644 apps/client/src/features/control/playback/playbackControl.utils.ts create mode 100644 apps/client/src/features/control/playback/tracking-playback-bar/TrackingPlaybackBar.module.scss create mode 100644 apps/client/src/features/control/playback/tracking-playback-bar/TrackingPlaybackBar.tsx rename apps/client/src/features/operator/{edit-modal/EditModal.module.scss => custom-field-edit-modal/CustomFieldEditModal.module.scss} (100%) rename apps/client/src/features/operator/{edit-modal/EditModal.tsx => custom-field-edit-modal/CustomFieldEditModal.tsx} (94%) delete mode 100644 apps/client/src/features/rundown/rundown-header/RundownSettings.tsx delete mode 100644 apps/client/src/features/rundown/rundownViewMode.ts create mode 100644 apps/client/src/features/rundown/useCollapsedGroups.ts create mode 100644 apps/client/src/features/rundown/useEditorFollowMode.ts create mode 100644 apps/client/src/features/rundown/useSelectAndRevealEntry.ts delete mode 100644 apps/client/src/views/cuesheet/cuesheet-edit-modal/CuesheetEditModal.tsx rename apps/client/src/views/cuesheet/cuesheet-edit-modal/{EditorEditModal.tsx => EntryEditModal.tsx} (60%) delete mode 100644 apps/client/src/views/cuesheet/cuesheet-edit-modal/useCuesheetEditModal.tsx rename apps/client/src/views/cuesheet/cuesheet-edit-modal/{useEditorEditModal.tsx => useEditModal.tsx} (84%) delete mode 100644 apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/EditorTableMenu.tsx rename apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/{CuesheetTableMenu.tsx => TableMenu.tsx} (92%) create mode 100644 apps/client/src/views/editor/EditorLayoutOptions.tsx delete mode 100644 apps/client/src/views/editor/EditorViewOptions.module.scss delete mode 100644 apps/client/src/views/editor/EditorViewOptions.tsx create mode 100644 apps/client/src/views/editor/title-list/TitleList.module.scss create mode 100644 apps/client/src/views/editor/title-list/TitleList.tsx create mode 100644 apps/client/src/views/editor/title-list/TitleListEmpty.module.scss create mode 100644 apps/client/src/views/editor/title-list/TitleListEmpty.tsx create mode 100644 apps/client/src/views/editor/title-list/TitleListItem.module.scss create mode 100644 apps/client/src/views/editor/title-list/TitleListItem.tsx create mode 100644 apps/client/src/views/editor/title-list/titleList.utils.ts create mode 100644 apps/client/src/views/editor/useEditorLayout.ts create mode 100644 packages/utils/src/rundown-utils/rundownUtils.mock.ts diff --git a/apps/client/src/common/components/dropdown-menu/DropdownMenu.module.scss b/apps/client/src/common/components/dropdown-menu/DropdownMenu.module.scss index 6e6bf6872..83a76bafd 100644 --- a/apps/client/src/common/components/dropdown-menu/DropdownMenu.module.scss +++ b/apps/client/src/common/components/dropdown-menu/DropdownMenu.module.scss @@ -35,8 +35,8 @@ display: flex; justify-content: space-between; + align-items: flex-start; gap: 1rem; - line-height: 1em; svg { color: $gray-500; @@ -70,10 +70,27 @@ } } +.item .iconStrong { + color: $ui-white; +} + +.content { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + .label { display: inline-flex; align-items: center; gap: 0.5rem; + line-height: 1.2; +} + +.description { + color: $gray-400; + font-size: calc(1rem - 2px); + line-height: 1.3; } .shortcut { diff --git a/apps/client/src/common/components/dropdown-menu/DropdownMenu.tsx b/apps/client/src/common/components/dropdown-menu/DropdownMenu.tsx index 557aa10b9..58b6577f7 100644 --- a/apps/client/src/common/components/dropdown-menu/DropdownMenu.tsx +++ b/apps/client/src/common/components/dropdown-menu/DropdownMenu.tsx @@ -8,6 +8,7 @@ type DropdownMenuItemDivider = { type: 'divider' }; type DropdownMenuItem = { type: 'item' | 'destructive'; label: string; + description?: string; icon?: IconType; disabled?: boolean; onClick: () => void; @@ -39,9 +40,12 @@ export function DropdownMenu({ items, children, ...triggerProps }: PropsWithChil disabled={item.disabled} data-type={item.type} > - - {item.icon && } - {item.label} + + + {item.icon && } + {item.label} + + {item.description && {item.description}} {item.shortcut && {item.shortcut}} @@ -79,9 +83,12 @@ export function PositionedDropdownMenu({ items, isOpen, position, onClose }: Pos } return ( - - {item.icon && } - {item.label} + + + {item.icon && } + {item.label} + + {item.description && {item.description}} {item.shortcut && {item.shortcut}} diff --git a/apps/client/src/features/control/playback/aux-timer/AuxTimer.module.scss b/apps/client/src/features/control/playback/aux-timer/AuxTimer.module.scss index 599592fa0..679b038f9 100644 --- a/apps/client/src/features/control/playback/aux-timer/AuxTimer.module.scss +++ b/apps/client/src/features/control/playback/aux-timer/AuxTimer.module.scss @@ -41,5 +41,4 @@ padding-inline: 0.5em; outline: none; - -} \ No newline at end of file +} diff --git a/apps/client/src/features/control/playback/playback-buttons/PlaybackButtons.tsx b/apps/client/src/features/control/playback/playback-buttons/PlaybackButtons.tsx index 059902467..515e13238 100644 --- a/apps/client/src/features/control/playback/playback-buttons/PlaybackButtons.tsx +++ b/apps/client/src/features/control/playback/playback-buttons/PlaybackButtons.tsx @@ -1,9 +1,8 @@ -import { useMemo } from 'react'; import { IoPause, IoPlay, IoPlaySkipBack, IoPlaySkipForward, IoReload, IoStop } from 'react-icons/io5'; import { Playback, TimerPhase } from 'ontime-types'; -import { validatePlayback } from 'ontime-utils'; import { setPlayback } from '../../../../common/hooks/useSocket'; +import { getPlaybackControlState } from '../playbackControl.utils'; import TapButton from '../tap-button/TapButton'; import style from './PlaybackButtons.module.scss'; @@ -15,44 +14,32 @@ interface PlaybackButtonsProps { timerPhase: TimerPhase; } -export default function PlaybackButtons(props: PlaybackButtonsProps) { - const { playback, numEvents, selectedEventIndex, timerPhase } = props; - - const isRolling = playback === Playback.Roll; - const isPlaying = playback === Playback.Play; - const isPaused = playback === Playback.Pause; - const isArmed = playback === Playback.Armed; - - const isFirst = selectedEventIndex === 0; - const isLast = selectedEventIndex === numEvents - 1; - const noEvents = numEvents === 0; - - const disableGo = isRolling || noEvents; - const disableNext = isRolling || noEvents || isLast; - const disablePrev = isRolling || noEvents || isFirst; - - const playbackCan = validatePlayback(playback, timerPhase); - const disableStart = !playbackCan.start; - const disablePause = !playbackCan.pause; - const disableRoll = !playbackCan.roll || noEvents; - const disableStop = !playbackCan.stop; - const disableReload = !playbackCan.reload; - - const [goModeAction, goModeText] = useMemo(() => { - if (isArmed) { - return [setPlayback.start, 'Start']; - } else if (isLast) { - return [setPlayback.stop, 'Finish']; - } else if (selectedEventIndex === null) { - return [setPlayback.startNext, 'Start']; - } - return [setPlayback.startNext, 'Next']; - }, [isArmed, isLast, selectedEventIndex]); +export default function PlaybackButtons({ playback, numEvents, selectedEventIndex, timerPhase }: PlaybackButtonsProps) { + const { + isPlaying, + isPaused, + isRolling, + disableGo, + disableNext, + disablePrev, + disableStart, + disablePause, + disableRoll, + disableStop, + disableReload, + goAction, + goLabel, + } = getPlaybackControlState({ + playback, + numEvents, + selectedEventIndex, + timerPhase, + }); return (
- - {goModeText} + + {goLabel}
diff --git a/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.module.scss b/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.module.scss index 972b37618..025b838fc 100644 --- a/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.module.scss +++ b/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.module.scss @@ -8,6 +8,13 @@ gap: $element-inner-spacing; } +.timerDisplay { + grid-area: timer; + max-width: 18.75rem; + min-width: 5em; + text-align: center; +} + // ---------> INDICATORS .indicators { diff --git a/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.tsx b/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.tsx index 048e90d50..fca31935e 100644 --- a/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.tsx +++ b/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.tsx @@ -42,7 +42,7 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
} className={style.indicatorDelay} data-active={hasAddedTime} />
- +
{isWaiting ? ( Roll: Countdown to start diff --git a/apps/client/src/features/control/playback/playbackControl.utils.ts b/apps/client/src/features/control/playback/playbackControl.utils.ts new file mode 100644 index 000000000..f176c83ae --- /dev/null +++ b/apps/client/src/features/control/playback/playbackControl.utils.ts @@ -0,0 +1,95 @@ +import { Playback, TimerPhase } from 'ontime-types'; +import { validatePlayback } from 'ontime-utils'; + +import { setPlayback } from '../../../common/hooks/useSocket'; + +export interface PlaybackControlInput { + playback: Playback; + numEvents: number; + selectedEventIndex: number | null; + timerPhase: TimerPhase; +} + +export interface PlaybackControlState { + // Playback states + isPlaying: boolean; + isPaused: boolean; + isRolling: boolean; + isArmed: boolean; + isStopped: boolean; + + // Position states + isFirst: boolean; + isLast: boolean; + noEvents: boolean; + + // Disable flags + disableGo: boolean; + disableNext: boolean; + disablePrev: boolean; + disableStart: boolean; + disablePause: boolean; + disableRoll: boolean; + disableStop: boolean; + disableReload: boolean; + disableAddTime: boolean; + + // Go button configuration + goAction: () => void; + goLabel: 'Start' | 'Next' | 'Finish'; +} + +/** + * Centralized playback control state calculator. + * Consolidates all playback logic, disable states, and derived values in one place. + */ +export function getPlaybackControlState({ + playback, + numEvents, + selectedEventIndex, + timerPhase, +}: PlaybackControlInput): PlaybackControlState { + const isFirst = selectedEventIndex === 0; + const isLast = selectedEventIndex === numEvents - 1; + const noEvents = numEvents === 0; + const isRolling = playback === Playback.Roll; + + const playbackCan = validatePlayback(playback, timerPhase); + const { action: goAction, label: goLabel } = getGoAction(playback, selectedEventIndex, isLast); + + return { + isPlaying: playback === Playback.Play, + isPaused: playback === Playback.Pause, + isRolling, + isArmed: playback === Playback.Armed, + isStopped: playback === Playback.Stop, + isFirst, + isLast, + noEvents, + disableGo: isRolling || noEvents, + disableNext: isRolling || noEvents || isLast, + disablePrev: isRolling || noEvents || isFirst, + disableStart: !playbackCan.start, + disablePause: !playbackCan.pause, + disableRoll: !playbackCan.roll || noEvents, + disableStop: !playbackCan.stop, + disableReload: !playbackCan.reload, + disableAddTime: playback !== Playback.Play && playback !== Playback.Pause, + goAction, + goLabel, + }; +} + +/** + * Determines the action and label for the "Go" button based on playback state. + */ +function getGoAction( + playback: Playback, + selectedEventIndex: number | null, + isLast: boolean, +): { action: () => void; label: 'Start' | 'Next' | 'Finish' } { + if (playback === Playback.Armed) return { action: setPlayback.start, label: 'Start' }; + if (isLast) return { action: setPlayback.stop, label: 'Finish' }; + if (selectedEventIndex === null) return { action: setPlayback.startNext, label: 'Start' }; + return { action: setPlayback.startNext, label: 'Next' }; +} diff --git a/apps/client/src/features/control/playback/timer-display/TimerDisplay.module.scss b/apps/client/src/features/control/playback/timer-display/TimerDisplay.module.scss index 91ec60e84..645edae69 100644 --- a/apps/client/src/features/control/playback/timer-display/TimerDisplay.module.scss +++ b/apps/client/src/features/control/playback/timer-display/TimerDisplay.module.scss @@ -1,23 +1,34 @@ @use '@/theme/viewerDefs' as *; .timer { - grid-area: timer; white-space: nowrap; - max-width: 18.75rem; - min-width: 5em; - color: $timer-color; - line-height: 0.9em; - text-align: center; + line-height: 0.9; letter-spacing: 0.1em; font-weight: 600; font-size: 3.5rem; - - &.finished { - color: $timer-finished-color; + + &[data-phase='default'] { + color: $timer-color; } - &.muted { + &[data-phase='warning'] { + color: $warning-orange; + } + + &[data-phase='danger'] { + color: $error-red; + } + + &[data-phase='overtime'] { + color: $playback-negative; + } + + &[data-phase='pending'] { + color: $ontime-roll; + } + + &[data-phase='none'] { color: $muted-gray; } } diff --git a/apps/client/src/features/control/playback/timer-display/TimerDisplay.tsx b/apps/client/src/features/control/playback/timer-display/TimerDisplay.tsx index 945ac5d68..07cdff284 100644 --- a/apps/client/src/features/control/playback/timer-display/TimerDisplay.tsx +++ b/apps/client/src/features/control/playback/timer-display/TimerDisplay.tsx @@ -1,4 +1,4 @@ -import { MaybeNumber } from 'ontime-types'; +import { MaybeNumber, TimerPhase } from 'ontime-types'; import { millisToString } from 'ontime-utils'; import { cx, timerPlaceholder } from '../../../../common/utils/styleUtils'; @@ -7,19 +7,20 @@ import style from './TimerDisplay.module.scss'; interface TimerDisplayProps { time: MaybeNumber; + phase: TimerPhase; + className?: string; } /** * Displays time in ms in formatted timetag * Used in editor */ -export default function TimerDisplay(props: TimerDisplayProps) { - const { time } = props; +export default function TimerDisplay({ time, phase, className }: TimerDisplayProps) { + const display = millisToString(time, { fallback: timerPlaceholder }).replace('-', ''); - const isNegative = (time ?? 0) < 0; - const display = - time == null ? timerPlaceholder : millisToString(time, { fallback: timerPlaceholder }).replace('-', ''); - const classes = cx([style.timer, isNegative ? style.finished : null, time === null && style.muted]); - - return
{display}
; + return ( +
+ {display} +
+ ); } diff --git a/apps/client/src/features/control/playback/tracking-playback-bar/TrackingPlaybackBar.module.scss b/apps/client/src/features/control/playback/tracking-playback-bar/TrackingPlaybackBar.module.scss new file mode 100644 index 000000000..59028ccd5 --- /dev/null +++ b/apps/client/src/features/control/playback/tracking-playback-bar/TrackingPlaybackBar.module.scss @@ -0,0 +1,146 @@ +@use '../../../../theme/ontimeColours' as *; +@use '../../../../theme/ontimeStyles' as *; + +$element-height: 4.5rem; // matches go button height in PlaybackButtons + +.container { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + align-items: center; + gap: 1rem; + padding: $panel-gap $section-spacing; + background: $bg-container-l2; + border-radius: $panel-border-radius; +} + +.itemGroup { + display: flex; + align-items: center; + gap: $element-spacing; + flex-shrink: 0; +} + +.goButton, +.iconButtonWithLabel, +.iconButton { + width: $element-height; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + svg { + font-size: 1.25rem; + } +} + +.goButton { + height: $element-height; + min-width: 8rem; + line-height: 1.1; +} + +.goLabel { + font-size: 1.25rem; + font-weight: 600; + line-height: 1.2; +} + +.shortcutHint { + font-size: calc(1rem - 4px); + opacity: 0.6; +} + +.addSection { + display: contents; +} + +.separator { + width: 1px; + height: 1.5rem; + background: $white-10; + margin-inline: 0.25rem; +} + +.timerSection { + display: flex; + justify-content: center; + min-width: 0; + gap: 0.5rem; +} + +.negativeIndicator { + font-size: 2.25rem; + font-weight: 700; + line-height: 1; + color: $playback-negative; + opacity: 0; + + &[data-active='true'] { + opacity: 1; + } +} + +// -----> Aux timer section (right) + +.auxSection { + justify-self: end; + display: flex; + align-items: center; + gap: $element-inner-spacing; + flex-shrink: 0; +} + +.auxControls { + display: flex; + gap: $element-inner-spacing; +} + +.auxTimeInput, +.auxTimeDisplay { + width: 6.5rem; + min-width: 6.5rem; + max-width: 6.5rem; + height: $element-height; + + text-align: center; + background: $gray-1050; + border-radius: $component-border-radius-md; + color: $ui-white; +} + +.auxTimeDisplay { + display: grid; + place-content: center; + + font-size: 1rem; + font-weight: 400; + color: $gray-200; + font-variant-numeric: tabular-nums; + letter-spacing: 0.5px; + + padding-inline: 0.5em; + outline: none; +} + +// hardcoded value where the timer is over the buttons +@media (max-width: 1048px) { + .container { + grid-template-columns: 1fr 1fr; + } + + .timerSection { + justify-self: end; + } + + .auxSection { + display: none; + } +} + +// hardcoded value where the timer is over the buttons +@media (max-width: 728px) { + .addSection { + display: none; + } +} diff --git a/apps/client/src/features/control/playback/tracking-playback-bar/TrackingPlaybackBar.tsx b/apps/client/src/features/control/playback/tracking-playback-bar/TrackingPlaybackBar.tsx new file mode 100644 index 000000000..859c5478d --- /dev/null +++ b/apps/client/src/features/control/playback/tracking-playback-bar/TrackingPlaybackBar.tsx @@ -0,0 +1,184 @@ +import { IoAdd, IoArrowDown, IoArrowUp, IoPause, IoPlay, IoPlaySkipForward, IoRemove, IoStop } from 'react-icons/io5'; +import { useHotkeys, useLocalStorage } from '@mantine/hooks'; +import { Playback, SimpleDirection, SimplePlayback, TimerPhase } from 'ontime-types'; +import { millisToString, parseUserTime } from 'ontime-utils'; + +import TimeInput from '../../../../common/components/input/time-input/TimeInput'; +import { + setAuxTimer, + setPlayback, + useAuxTimerControl, + useAuxTimerTime, + usePlaybackControl, + useTimer, +} from '../../../../common/hooks/useSocket'; +import { enDash } from '../../../../common/utils/styleUtils'; +import { formatDuration } from '../../../../common/utils/time'; +import { getPlaybackControlState } from '../playbackControl.utils'; +import TapButton from '../tap-button/TapButton'; +import TimerDisplay from '../timer-display/TimerDisplay'; + +import style from './TrackingPlaybackBar.module.scss'; + +export default function TrackingPlaybackBar() { + const timer = useTimer(); + + const { playback, numEvents, selectedEventIndex } = usePlaybackControl(); + + const { playback: auxPlayback, direction: auxDirection } = useAuxTimerControl(1); + const auxTime = useAuxTimerTime(1); + + const [addTimeInMs] = useLocalStorage({ key: 'add-time', defaultValue: 300_000 }); + + const { disableGo, disableNext, disableAddTime, isPlaying, goAction, goLabel } = getPlaybackControlState({ + playback, + numEvents, + selectedEventIndex, + timerPhase: timer.phase, + }); + + const disableAddTimeWithAmount = disableAddTime || addTimeInMs === 0; + + const handleAddTime = (direction: 'add' | 'remove') => { + if (disableAddTimeWithAmount) return; + if (direction === 'add') { + setPlayback.addTime(addTimeInMs); + } else { + setPlayback.addTime(-1 * addTimeInMs); + } + }; + + const handleAuxPlayPause = () => { + if (auxPlayback === SimplePlayback.Start) { + setAuxTimer.pause(1); + } else { + setAuxTimer.start(1); + } + }; + + const handleAuxStop = () => { + setAuxTimer.stop(1); + }; + + const handleAuxDirectionToggle = () => { + const newDirection = + auxDirection === SimpleDirection.CountDown ? SimpleDirection.CountUp : SimpleDirection.CountDown; + setAuxTimer.setDirection(1, newDirection); + }; + + const handleAuxTimeChange = (_field: string, value: string) => { + const newTimeInMs = parseUserTime(value); + setAuxTimer.setDuration(1, newTimeInMs); + }; + + useHotkeys([ + ['Space', () => !disableGo && goAction(), { preventDefault: true }], + ['N', () => !disableNext && setPlayback.next(), { preventDefault: true }], + ['Escape', () => playback !== Playback.Stop && setPlayback.stop(), { preventDefault: true }], + ]); + + const isWaiting = timer.phase === TimerPhase.Pending; + const isOvertime = timer.phase === TimerPhase.Overtime; + const displayTime = isWaiting ? timer.secondaryTimer : timer.current; + + const addTimeLabel = formatDuration(addTimeInMs); + + return ( +
+
+ + {goLabel} + [space] + + + + + [n] + + + + + [esc] + + +
+
+ handleAddTime('remove')} + disabled={disableAddTimeWithAmount} + className={style.iconButtonWithLabel} + aspect='square' + > + + {addTimeLabel} + + handleAddTime('add')} + disabled={disableAddTimeWithAmount} + className={style.iconButtonWithLabel} + aspect='square' + > + + {addTimeLabel} + +
+
+ +
+ + {enDash} + + +
+ +
+ + {auxPlayback === SimplePlayback.Start ? : } + + + + + {auxPlayback !== SimplePlayback.Stop ? ( +
{millisToString(auxTime)}
+ ) : ( + + )} + + {auxDirection === SimpleDirection.CountDown ? : } + +
+
+ ); +} diff --git a/apps/client/src/features/operator/Operator.tsx b/apps/client/src/features/operator/Operator.tsx index 43e1aaabb..a9ccfa459 100644 --- a/apps/client/src/features/operator/Operator.tsx +++ b/apps/client/src/features/operator/Operator.tsx @@ -12,7 +12,7 @@ import { getDefaultFormat } from '../../common/utils/time'; import { isTouchDevice } from '../../externals'; import Loader from '../../views/common/loader/Loader'; -import EditModal from './edit-modal/EditModal'; +import CustomFieldEditModal from './custom-field-edit-modal/CustomFieldEditModal'; import FollowButton from './follow-button/FollowButton'; import OperatorEvent from './operator-event/OperatorEvent'; import OperatorGroup from './operator-group/OperatorGroup'; @@ -118,7 +118,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator return (
- {editEvent && setEditEvent(null)} />} + {editEvent && setEditEvent(null)} />} diff --git a/apps/client/src/features/operator/edit-modal/EditModal.module.scss b/apps/client/src/features/operator/custom-field-edit-modal/CustomFieldEditModal.module.scss similarity index 100% rename from apps/client/src/features/operator/edit-modal/EditModal.module.scss rename to apps/client/src/features/operator/custom-field-edit-modal/CustomFieldEditModal.module.scss diff --git a/apps/client/src/features/operator/edit-modal/EditModal.tsx b/apps/client/src/features/operator/custom-field-edit-modal/CustomFieldEditModal.tsx similarity index 94% rename from apps/client/src/features/operator/edit-modal/EditModal.tsx rename to apps/client/src/features/operator/custom-field-edit-modal/CustomFieldEditModal.tsx index a43ddf5fb..3fc7c2ce4 100644 --- a/apps/client/src/features/operator/edit-modal/EditModal.tsx +++ b/apps/client/src/features/operator/custom-field-edit-modal/CustomFieldEditModal.tsx @@ -9,14 +9,14 @@ import Textarea from '../../../common/components/input/textarea/Textarea'; import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { EditEvent } from '../operator.types'; -import style from './EditModal.module.scss'; +import style from './CustomFieldEditModal.module.scss'; -interface EditModalProps { +interface CustomFieldEditModalProps { event: EditEvent; onClose: () => void; } -export default function EditModal(props: EditModalProps) { +export default function CustomFieldEditModal(props: CustomFieldEditModalProps) { const { event, onClose } = props; const { updateEntry } = useEntryActions(); diff --git a/apps/client/src/features/overview/CuesheetOverview.tsx b/apps/client/src/features/overview/CuesheetOverview.tsx index 85c09f2c5..1ea5d8bc3 100644 --- a/apps/client/src/features/overview/CuesheetOverview.tsx +++ b/apps/client/src/features/overview/CuesheetOverview.tsx @@ -2,7 +2,13 @@ import { memo, PropsWithChildren } from 'react'; import { useIsMobileScreen } from '../../common/hooks/useIsMobileScreen'; -import { ClockOverview, MetadataTimes, OffsetOverview, StartTimes, TimerOverview } from './composite/TimeElements'; +import { + ClockOverview, + MetadataTimes, + OffsetOverview, + StartTimesRuntime, + TimerOverview, +} from './composite/TimeElements'; import TitleOverview from './composite/TitleOverview'; import { OverviewWrapper } from './OverviewWrapper'; @@ -10,8 +16,10 @@ export default memo(CuesheetOverview); function CuesheetOverview({ children }: PropsWithChildren) { const isMobileScreen = useIsMobileScreen(); - if (isMobileScreen) return {children}; - else return {children}; + if (isMobileScreen) { + return {children}; + } + return {children}; } function CuesheetMobile({ children }: PropsWithChildren) { @@ -27,7 +35,7 @@ function CuesheetDesktop({ children }: PropsWithChildren) { return ( - + diff --git a/apps/client/src/features/overview/EditorOverview.tsx b/apps/client/src/features/overview/EditorOverview.tsx index b3ab5c9ce..76b64c2a7 100644 --- a/apps/client/src/features/overview/EditorOverview.tsx +++ b/apps/client/src/features/overview/EditorOverview.tsx @@ -1,19 +1,73 @@ import { memo, PropsWithChildren } from 'react'; -import { ClockOverview, MetadataTimes, OffsetOverview, ProgressOverview, StartTimes } from './composite/TimeElements'; +import { EditorLayoutMode, useEditorLayout } from '../../views/editor/useEditorLayout'; + +import { + ClockOverview, + MetadataTimes, + OffsetOverview, + PlanningStats, + ProgressOverview, + StartTimesPlanning, + StartTimesRuntime, +} from './composite/TimeElements'; import TitleOverview from './composite/TitleOverview'; import { OverviewWrapper } from './OverviewWrapper'; +import style from './Overview.module.scss'; + export default memo(EditorOverview); + function EditorOverview({ children }: PropsWithChildren) { + const { layoutMode } = useEditorLayout(); + return ( - - - - - - + {layoutMode === EditorLayoutMode.PLANNING && } + {layoutMode === EditorLayoutMode.TRACKING && } + {layoutMode === EditorLayoutMode.CONTROL && } ); } + +function OverviewPlanning() { + return ( + <> +
+ + + +
+ + + ); +} + +function OverviewTracking() { + return ( + <> +
+ + + +
+ + + + ); +} + +function OverviewControl() { + return ( + <> + +
+ + + +
+ + + + ); +} diff --git a/apps/client/src/features/overview/Overview.module.scss b/apps/client/src/features/overview/Overview.module.scss index 198265118..fca517b5b 100644 --- a/apps/client/src/features/overview/Overview.module.scss +++ b/apps/client/src/features/overview/Overview.module.scss @@ -29,11 +29,22 @@ } .info { - flex: 1; padding-left: 1rem; display: flex; align-items: center; justify-content: space-between; - overflow-x: auto; - overflow-y: hidden; + width: max-content; + height: 100%; +} + +.infoScroll { + flex: 1; + min-width: 0; + height: 100%; +} + +.inline { + display: flex; + align-items: center; + gap: 1rem; } diff --git a/apps/client/src/features/overview/OverviewWrapper.tsx b/apps/client/src/features/overview/OverviewWrapper.tsx index 9ea23ef8b..aeb13ff21 100644 --- a/apps/client/src/features/overview/OverviewWrapper.tsx +++ b/apps/client/src/features/overview/OverviewWrapper.tsx @@ -1,6 +1,7 @@ import { PropsWithChildren, ReactNode } from 'react'; import { ErrorBoundary } from '@sentry/react'; +import ScrollArea from '../../common/components/scroll-area/ScrollArea'; import { useIsOnline } from '../../common/hooks/useSocket'; import { cx } from '../../common/utils/styleUtils'; @@ -17,7 +18,14 @@ export function OverviewWrapper({ navElements, children }: PropsWithChildren
{navElements}
-
{children}
+ + {children} +
); diff --git a/apps/client/src/features/overview/composite/TimeElements.module.scss b/apps/client/src/features/overview/composite/TimeElements.module.scss index b926ec007..3eca4e050 100644 --- a/apps/client/src/features/overview/composite/TimeElements.module.scss +++ b/apps/client/src/features/overview/composite/TimeElements.module.scss @@ -14,6 +14,13 @@ gap: 0.5rem; } +.row2 { + display: grid; + grid-template-columns: 3rem 9rem; + align-items: center; + gap: 0.5rem; +} + .metadataRow { display: grid; grid-template-columns: minmax(3rem, 10rem) 8rem 8rem; diff --git a/apps/client/src/features/overview/composite/TimeElements.tsx b/apps/client/src/features/overview/composite/TimeElements.tsx index 7c1633b31..aaa6e5b69 100644 --- a/apps/client/src/features/overview/composite/TimeElements.tsx +++ b/apps/client/src/features/overview/composite/TimeElements.tsx @@ -27,7 +27,7 @@ import { import { useEntry } from '../../../common/hooks-query/useRundown'; import { getOffsetState, getOffsetText } from '../../../common/utils/offset'; import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils'; -import { formatTime } from '../../../common/utils/time'; +import { formatDuration, formatTime } from '../../../common/utils/time'; import SuperscriptPeriod from '../../../views/common/superscript-time/SuperscriptPeriod'; import { calculateEndAndDaySpan, formatDueTime } from '../overview.utils'; @@ -39,30 +39,22 @@ interface OverviewTimeElementsProps { shouldFormat?: boolean; } -export function StartTimes({ shouldFormat }: OverviewTimeElementsProps) { +const timeFormatOptions = { format12: 'hh:mm:ss a', format24: 'HH:mm:ss' }; + +function formatTimeValue(time: number | null, shouldFormat: boolean | undefined): string { + if (time === null) return timerPlaceholder; + if (shouldFormat) return formatTime(time, timeFormatOptions); + return millisToString(time, { fallback: timerPlaceholder }); +} + +export function StartTimesRuntime({ shouldFormat }: OverviewTimeElementsProps) { const { plannedEnd, plannedStart, actualStart } = useStartTimesOverview(); - const formatOptions = { format12: 'hh:mm:ss a', format24: 'HH:mm:ss' }; - - const plannedStartText = (() => { - if (plannedStart === null) return timerPlaceholder; - if (shouldFormat) return formatTime(plannedStart, formatOptions); - return millisToString(plannedStart, { fallback: timerPlaceholder }); - })(); - - const actualStartText = (() => { - if (actualStart === null) return timerPlaceholder; - if (shouldFormat) return formatTime(actualStart, formatOptions); - return millisToString(actualStart, { fallback: timerPlaceholder }); - })(); + const plannedStartText = formatTimeValue(plannedStart, shouldFormat); + const actualStartText = formatTimeValue(actualStart, shouldFormat); const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]); - - const plannedEndText = (() => { - if (maybePlannedEnd === null) return timerPlaceholder; - if (shouldFormat) return formatTime(maybePlannedEnd, formatOptions); - return millisToString(maybePlannedEnd, { fallback: timerPlaceholder }); - })(); + const plannedEndText = formatTimeValue(maybePlannedEnd, shouldFormat); const multipleDays = maybePlannedDaySpan > 0; const plannedEndTooltip = multipleDays @@ -122,6 +114,59 @@ export function StartTimes({ shouldFormat }: OverviewTimeElementsProps) { ); } +export function StartTimesPlanning({ shouldFormat }: OverviewTimeElementsProps) { + const { plannedEnd, plannedStart } = useStartTimesOverview(); + + const plannedStartText = formatTimeValue(plannedStart, shouldFormat); + + const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]); + const plannedEndText = formatTimeValue(maybePlannedEnd, shouldFormat); + + const multipleDays = maybePlannedDaySpan > 0; + const plannedEndTooltip = multipleDays + ? `Planned end time (rundown spans over ${maybePlannedDaySpan + 1} days)` + : 'Planned end time'; + + return ( +
+
+ Start + + + +
+ } + /> +
+ +
+ End + + + + {multipleDays && ( + + )} +
+ } + /> +
+
+ ); +} + /** * Shows the expected end for the rundown * Extracted to improve performance as this is a ticking value @@ -130,11 +175,7 @@ function RundownExpectedEnd({ shouldFormat }: OverviewTimeElementsProps) { const expectedEnd = useRundownExpectedEnd(); const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]); - const maybeExpectedEndText = (() => { - if (maybeExpectedEnd === null) return timerPlaceholder; - if (shouldFormat) return formatTime(maybeExpectedEnd, { format12: 'hh:mm:ss a', format24: 'HH:mm:ss' }); - return millisToString(maybeExpectedEnd, { fallback: timerPlaceholder }); - })(); + const maybeExpectedEndText = formatTimeValue(maybeExpectedEnd, shouldFormat); const multipleDays = maybeExpectedEnd !== null && maybeExpectedDaySpan > 0; const tooltip = multipleDays @@ -316,11 +357,27 @@ export function TimerOverview({ className }: { className?: string }) { const isWaiting = timer.phase === TimerPhase.Pending; const title = isWaiting ? 'Count to start' : 'Running timer'; const display = millisToString(isWaiting ? timer.secondaryTimer : timer.current, { fallback: timerPlaceholder }); - const timerState = (() => { + + function getTimerState(): 'waiting' | 'muted' | 'active' { if (isWaiting) return 'waiting'; if (timer.current === null) return 'muted'; return 'active'; - })(); + } - return ; + return ; +} + +export function PlanningStats() { + const { numEvents } = useProgressOverview(); + const { plannedEnd, plannedStart } = useStartTimesOverview(); + + const hasTimes = plannedStart !== null && plannedEnd !== null; + const formattedDuration = hasTimes ? formatDuration(plannedEnd - plannedStart) : timerPlaceholder; + + return ( + <> + + + + ); } diff --git a/apps/client/src/features/rundown/Rundown.module.scss b/apps/client/src/features/rundown/Rundown.module.scss index 743160330..87aa05ff2 100644 --- a/apps/client/src/features/rundown/Rundown.module.scss +++ b/apps/client/src/features/rundown/Rundown.module.scss @@ -4,8 +4,14 @@ } .rundownContainer { - margin-top: 1rem; - height: 100%; + padding-top: 1rem; + flex: 1 1 auto; + min-height: 0; + + :is([data-target='small-device']) & { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } } .list { @@ -17,6 +23,7 @@ :is([data-target='small-device']) & { padding-inline: 0; overflow-x: auto; + min-width: max-content; } } diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index 3af8f6433..9bdeb67d0 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -3,13 +3,19 @@ import { TbFlagFilled } from 'react-icons/tb'; import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'; import { closestCenter, DndContext } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import { useSessionStorage } from '@mantine/hooks'; -import { type EntryId, type Rundown, isOntimeEvent, isOntimeGroup, Playback, SupportedEntry } from 'ontime-types'; +import { + type EntryId, + type Rundown as RundownType, + isOntimeEvent, + isOntimeGroup, + Playback, + SupportedEntry, +} from 'ontime-types'; import { useEntryActionsContext } from '../../common/context/EntryActionsContext'; import { useEntryCopy } from '../../common/stores/entryCopyStore'; import { lastMetadataKey, RundownMetadataObject } from '../../common/utils/rundownMetadata'; -import { AppMode, sessionKeys } from '../../ontimeConfig'; +import { AppMode } from '../../ontimeConfig'; import QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons'; import QuickAddInline from './entry-editor/quick-add-cursor/QuickAddInline'; @@ -20,6 +26,8 @@ import RundownGroup from './rundown-group/RundownGroup'; import RundownGroupEnd from './rundown-group/RundownGroupEnd'; import { filterVisibleEntries, makeSortableList } from './rundown.utils'; import RundownEmpty from './RundownEmpty'; +import { useCollapsedGroups } from './useCollapsedGroups'; +import { useEditorFollowMode } from './useEditorFollowMode'; import { useEventSelection } from './useEventSelection'; import style from './Rundown.module.scss'; @@ -27,10 +35,10 @@ import style from './Rundown.module.scss'; const RundownEntry = lazy(() => import('./RundownEntry')); interface RundownProps { - entries: Rundown['entries']; - id: Rundown['id']; - order: Rundown['order']; - flatOrder: Rundown['flatOrder']; + entries: RundownType['entries']; + id: RundownType['id']; + order: RundownType['order']; + flatOrder: RundownType['flatOrder']; rundownMetadata: RundownMetadataObject; featureData: { playback: Playback; @@ -55,60 +63,36 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata setMetadata(rundownMetadata); }, [order, entries, rundownMetadata]); - const [collapsedGroups, setCollapsedGroups] = useSessionStorage({ - // we ensure that this is unique to the rundown - key: `rundown.${id}-editor-collapsed-groups`, - defaultValue: [], - }); - const collapsedGroupSet = useMemo(() => new Set(collapsedGroups), [collapsedGroups]); + const { getIsCollapsed, collapseGroup, expandGroup } = useCollapsedGroups(id); const entryActions = useEntryActionsContext(); const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId); // cursor - const [editorMode] = useSessionStorage({ - key: sessionKeys.editorMode, - defaultValue: AppMode.Edit, - }); + const { editorMode } = useEditorFollowMode(); const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents); - const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents); const cursor = useEventSelection((state) => state.cursor); const scrollToEntry = useEventSelection((state) => state.scrollToEntry); const setScrollHandler = useEventSelection((state) => state.setScrollHandler); + const selectEntry = useEventSelection((state) => state.setSelectedEvents); const cursorRef = useRef(null); const scrollRef = useRef(null); const virtuosoRef = useRef(null); - /** - * Checks whether a group is collapsed - */ - const getIsCollapsed = useCallback( - (groupId: EntryId): boolean => { - return collapsedGroupSet.has(groupId); - }, - [collapsedGroupSet], - ); - /** * Handles logic for collapsing groups */ const handleCollapseGroup = useCallback( - (collapsed: boolean, groupId: EntryId) => { - setCollapsedGroups((prev) => { - const isCollapsed = getIsCollapsed(groupId); - if (collapsed && !isCollapsed) { - const newSet = new Set(prev).add(groupId); - return [...newSet]; - } - if (!collapsed && isCollapsed) { - return [...prev].filter((id) => id !== groupId); - } - return prev; - }); + (collapsed: boolean, groupId: EntryId | undefined) => { + if (collapsed) { + collapseGroup(groupId); + } else { + expandGroup(groupId); + } }, - [getIsCollapsed, setCollapsedGroups], + [collapseGroup, expandGroup], ); // Commands layer - business logic @@ -116,7 +100,7 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata entries, flatOrder, entryActions, - setSelectedEvents, + selectEntry, handleCollapseGroup, }); @@ -145,9 +129,9 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata return filterVisibleEntries(sortableData, entries, getIsCollapsed); }, [sortableData, entries, getIsCollapsed]); - // Scroll to a specific entry when requested by keyboard/finder + // Provide an imperative scroll handler for explicit jumps (finder/keyboard) useEffect(() => { - setScrollHandler('rundown-list', (entryId) => { + setScrollHandler((entryId) => { if (!virtuosoRef.current || dnd.isDraggingRef.current) { return; } @@ -164,39 +148,45 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata }); return () => { - setScrollHandler('rundown-list', null); + setScrollHandler(null); }; }, [visibleData, dnd.isDraggingRef, setScrollHandler]); - // Follow-scroll in run mode via the shared scroll handler + // Keep selected cursor visible by expanding its parent group in any mode. + // Finder can select entries while layout is in Run mode, where we do not follow cursor scroll. useEffect(() => { - if (editorMode !== AppMode.Run || dnd.isDraggingRef.current) { + if (!cursor) { return; } - const targetId = featureData?.selectedEventId; - if (!targetId) { - return; + const entry = entries[cursor]; + if (entry && 'parent' in entry) { + expandGroup(entry.parent); } + }, [cursor, entries, expandGroup]); - scrollToEntry(targetId); - }, [editorMode, featureData?.selectedEventId, visibleData, dnd.isDraggingRef, scrollToEntry]); - - // in run mode, we follow the playback selection and open groups as needed + // Auto-follow behavior in Edit mode: follow the user's cursor useEffect(() => { - if (editorMode !== AppMode.Run || !featureData?.selectedEventId) { + if (dnd.isDraggingRef.current || editorMode !== AppMode.Edit || !cursor) { return; } - const index = order.findIndex((id) => id === featureData.selectedEventId); - // @ts-expect-error -- but we safely check if the parent property exists - const maybeParent = entries[featureData.selectedEventId]?.parent; - if (maybeParent) { - // open the group - setCollapsedGroups((prev) => [...prev].filter((id) => id !== maybeParent)); + + scrollToEntry(cursor); + }, [editorMode, cursor, scrollToEntry, dnd.isDraggingRef]); + + // Auto-follow behavior in Run mode: follow the currently playing event + useEffect(() => { + if (dnd.isDraggingRef.current || editorMode !== AppMode.Run || !featureData?.selectedEventId) { + return; } - setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index }); - }, [editorMode, entries, featureData.selectedEventId, order, setCollapsedGroups, setSelectedEvents]); + // Open parent group if the target is inside a collapsed group + const entry = entries[featureData.selectedEventId]; + if (entry && 'parent' in entry) { + expandGroup(entry.parent); + } + scrollToEntry(featureData.selectedEventId); + }, [editorMode, featureData?.selectedEventId, entries, expandGroup, scrollToEntry, dnd.isDraggingRef]); // gather presentation options const isEditMode = editorMode === AppMode.Edit; @@ -232,11 +222,9 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata ); } - // Regular entry handling - compute all values upfront const entry = entries[entryId]; const entryMetadata = metadata[entryId]; - // Null check after computing - return null if missing if (!entry || !entryMetadata) { return null; } diff --git a/apps/client/src/features/rundown/RundownExport.module.scss b/apps/client/src/features/rundown/RundownExport.module.scss index c9fdaa3c0..3a4fbdb1b 100644 --- a/apps/client/src/features/rundown/RundownExport.module.scss +++ b/apps/client/src/features/rundown/RundownExport.module.scss @@ -22,14 +22,18 @@ } .rundownRoot { - height: calc(100% - 1.5rem); + height: calc(100% - 1rem); width: 100%; + display: flex; + flex-direction: column; + min-height: 0; } .list { display: flex; height: inherit; padding-inline: 0; + padding-bottom: 0; box-shadow: $box-shadow-right; flex: 1 1 0; /* flex-grow: 1, flex-shrink: 1, flex-basis: 0 */ diff --git a/apps/client/src/features/rundown/RundownExport.tsx b/apps/client/src/features/rundown/RundownExport.tsx index ecd075f9a..dd389a2c7 100644 --- a/apps/client/src/features/rundown/RundownExport.tsx +++ b/apps/client/src/features/rundown/RundownExport.tsx @@ -11,8 +11,9 @@ import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice'; import { handleLinks } from '../../common/utils/linkUtils'; import { cx } from '../../common/utils/styleUtils'; import { getIsNavigationLocked } from '../../externals'; -import { AppMode, sessionKeys } from '../../ontimeConfig'; -import EditorEditModal from '../../views/cuesheet/cuesheet-edit-modal/EditorEditModal'; +import { AppMode } from '../../ontimeConfig'; +import EntryEditModal from '../../views/cuesheet/cuesheet-edit-modal/EntryEditModal'; +import { EditorLayoutMode, useEditorLayout } from '../../views/editor/useEditorLayout'; import RundownEntryEditor from './entry-editor/RundownEntryEditor'; import FinderPlacement from './placements/FinderPlacement'; @@ -20,8 +21,9 @@ import { RundownContextMenu } from './rundown-context-menu/RundownContextMenu'; import RundownHeader from './rundown-header/RundownHeader'; import RundownHeaderMobile from './rundown-header/RundownHeaderMobile'; import RundownTable from './rundown-table/RundownTable'; +import { RundownViewMode } from './rundown.options'; import RundownList from './RundownList'; -import { DEFAULT_RUNDOWN_VIEW_MODE, RUNDOWN_VIEW_MODE_STORAGE_KEY, RundownViewMode } from './rundownViewMode'; +import { useEditorFollowMode } from './useEditorFollowMode'; import style from './RundownExport.module.scss'; @@ -29,13 +31,11 @@ export default memo(RundownExport); function RundownExport() { const isExtracted = window.location.pathname.includes('/rundown'); - const [editorMode] = useSessionStorage({ - key: sessionKeys.editorMode, - defaultValue: AppMode.Edit, - }); + const { editorMode } = useEditorFollowMode(); + const { layoutMode } = useEditorLayout(); const [viewMode, setViewMode] = useSessionStorage({ - key: RUNDOWN_VIEW_MODE_STORAGE_KEY, - defaultValue: DEFAULT_RUNDOWN_VIEW_MODE, + key: 'rundown-view-mode', + defaultValue: RundownViewMode.List, }); const isSmallDevice = useIsSmallDevice(); const entryActions = useEntryActions(); @@ -53,7 +53,7 @@ function RundownExport() {
- +
@@ -63,7 +63,10 @@ function RundownExport() { ); } - const hideSideBar = (isExtracted && editorMode === 'run') || viewMode === 'table'; + const hideSideBar = + layoutMode === EditorLayoutMode.TRACKING || + (isExtracted && editorMode === AppMode.Run) || + viewMode === RundownViewMode.Table; return ( @@ -75,7 +78,7 @@ function RundownExport() { {!isExtracted && handleLinks('rundown', event)} />} - + @@ -95,16 +98,21 @@ function RundownExport() { interface RundownRootProps { isSmallDevice?: boolean; + isExtracted?: boolean; viewMode: RundownViewMode; setViewMode: (mode: RundownViewMode) => void; } -function RundownRoot({ isSmallDevice, viewMode, setViewMode }: RundownRootProps) { +function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) { return (
- {isSmallDevice ? : } - {viewMode === 'list' ? : } - + {isSmallDevice ? ( + + ) : ( + + )} + {viewMode === RundownViewMode.List ? : } + {viewMode === RundownViewMode.Table && }
); } diff --git a/apps/client/src/features/rundown/entry-editor/CuesheetEventEditor.tsx b/apps/client/src/features/rundown/entry-editor/CuesheetEventEditor.tsx index 63ff35124..587bf7886 100644 --- a/apps/client/src/features/rundown/entry-editor/CuesheetEventEditor.tsx +++ b/apps/client/src/features/rundown/entry-editor/CuesheetEventEditor.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useMemo } from 'react'; import { isOntimeEvent, isOntimeGroup, isOntimeMilestone, OntimeEntry } from 'ontime-types'; import useRundown from '../../../common/hooks-query/useRundown'; @@ -15,20 +15,14 @@ interface CuesheetEntryEditorProps { export default function CuesheetEntryEditor({ entryId }: CuesheetEntryEditorProps) { const { data } = useRundown(); - const [entry, setEntry] = useState(null); - useEffect(() => { + const entry = useMemo(() => { if (data.order.length === 0) { - setEntry(null); - return; + return null; } const event = data.entries[entryId]; - if (event) { - setEntry(event); - } else { - setEntry(null); - } + return event ?? null; }, [entryId, data.order, data.entries]); if (isOntimeEvent(entry)) { diff --git a/apps/client/src/features/rundown/entry-editor/EntryEditor.module.scss b/apps/client/src/features/rundown/entry-editor/EntryEditor.module.scss index 33e75eb27..1358f2305 100644 --- a/apps/client/src/features/rundown/entry-editor/EntryEditor.module.scss +++ b/apps/client/src/features/rundown/entry-editor/EntryEditor.module.scss @@ -1,6 +1,6 @@ .cuesheetEditor, .rundownEditor { - max-height: 100%; + height: 100%; display: flex; flex-direction: column; overflow-x: auto; diff --git a/apps/client/src/features/rundown/entry-editor/RundownEntryEditor.tsx b/apps/client/src/features/rundown/entry-editor/RundownEntryEditor.tsx index 6868c108e..799f1eeca 100644 --- a/apps/client/src/features/rundown/entry-editor/RundownEntryEditor.tsx +++ b/apps/client/src/features/rundown/entry-editor/RundownEntryEditor.tsx @@ -1,13 +1,5 @@ import { useMemo } from 'react'; -import { - isOntimeDelay, - isOntimeEvent, - isOntimeGroup, - isOntimeMilestone, - OntimeEvent, - OntimeGroup, - OntimeMilestone, -} from 'ontime-types'; +import { isOntimeEvent, isOntimeGroup, isOntimeMilestone, OntimeEntry } from 'ontime-types'; import useRundown from '../../../common/hooks-query/useRundown'; import { useEventSelection } from '../useEventSelection'; @@ -24,7 +16,7 @@ export default function RundownEntryEditor() { const selectedEvents = useEventSelection((state) => state.selectedEvents); const { data } = useRundown(); - const entry = useMemo(() => { + const entry = useMemo(() => { if (data.order.length === 0) { return null; } @@ -33,12 +25,9 @@ export default function RundownEntryEditor() { if (!selectedEventId) { return null; } - const event = data.entries[selectedEventId]; - if (event && !isOntimeDelay(event)) { - return event; - } - return null; + const event = data.entries[selectedEventId]; + return event ?? null; }, [data.order.length, data.entries, selectedEvents]); if (!entry) { diff --git a/apps/client/src/features/rundown/hooks/useRundownCommands.ts b/apps/client/src/features/rundown/hooks/useRundownCommands.ts index 96da8ae32..baf52a81d 100644 --- a/apps/client/src/features/rundown/hooks/useRundownCommands.ts +++ b/apps/client/src/features/rundown/hooks/useRundownCommands.ts @@ -1,25 +1,18 @@ import { useCallback } from 'react'; -import { type EntryId, type OntimeEntry, type Rundown, isOntimeGroup, SupportedEntry } from 'ontime-types'; -import { - getFirstNormal, - getLastNormal, - getNextGroupNormal, - getNextNormal, - getPreviousGroupNormal, - getPreviousNormal, -} from 'ontime-utils'; +import { type EntryId, type OntimeEntry, type Rundown, SupportedEntry } from 'ontime-types'; +import { getNextGroupNormal, getNextNormal, getPreviousGroupNormal, getPreviousNormal } from 'ontime-utils'; import type { useEntryActions } from '../../../common/hooks/useEntryAction'; import { useEntryCopy } from '../../../common/stores/entryCopyStore'; +import { SelectionMode } from '../useEventSelection'; -type SelectionMode = 'shift' | 'click' | 'ctrl'; const PAGE_SIZE = 5; interface UseRundownCommandsOptions { entries: Rundown['entries']; - order: Rundown['order']; + flatOrder: EntryId[]; entryActions: ReturnType; - setSelectedEvents: (selection: { id: EntryId; selectMode: SelectionMode; index: number }) => void; + selectEntry: (selection: { id: EntryId; selectMode: SelectionMode; index: number }) => void; handleCollapseGroup: (collapsed: boolean, groupId: EntryId) => void; } @@ -28,9 +21,9 @@ interface UseRundownCommandsOptions { */ export function useRundownCommands({ entries, - order, + flatOrder, entryActions, - setSelectedEvents, + selectEntry: applySelection, handleCollapseGroup, }: UseRundownCommandsOptions) { const { addEntry, clone, deleteEntry, move, reorderEntry } = entryActions; @@ -38,13 +31,13 @@ export function useRundownCommands({ const deleteAtCursor = useCallback( (cursor: string | null) => { if (!cursor) return; - const { entry, index } = getPreviousNormal(entries, order, cursor); + const { entry, index } = getPreviousNormal(entries, flatOrder, cursor); deleteEntry([cursor]); if (entry && index !== null) { - setSelectedEvents({ id: entry.id, selectMode: 'click', index }); + applySelection({ id: entry.id, selectMode: 'click', index }); } }, - [entries, order, deleteEntry, setSelectedEvents], + [entries, flatOrder, deleteEntry, applySelection], ); const insertCopyAtId = useCallback( @@ -67,7 +60,7 @@ export function useRundownCommands({ if (entryCopyMode === 'cut') { if (!normalisedAtId) { - const firstId = order[0]; + const firstId = flatOrder[0]; if (!firstId || firstId === entryCopyId) { return; } @@ -92,7 +85,7 @@ export function useRundownCommands({ before: above ? normalisedAtId ?? undefined : undefined, }); }, - [entries, order, clone, reorderEntry], + [entries, flatOrder, clone, reorderEntry], ); /** @@ -111,70 +104,39 @@ export function useRundownCommands({ const selectGroup = useCallback( (cursor: EntryId | null, direction: 'up' | 'down') => { - if (order.length < 1) { + if (flatOrder.length < 1) { return null; } - let newCursor = cursor; - if (cursor === null) { - // there is no cursor, we select the first or last depending on direction - const selected = direction === 'up' ? getLastNormal(entries, order) : getFirstNormal(entries, order); - - if (isOntimeGroup(selected)) { - setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 }); - return selected.id; - } - newCursor = selected?.id ?? null; - } - - if (newCursor === null) { - return null; - } - - // otherwise we select the next or previous const selected = direction === 'up' - ? getPreviousGroupNormal(entries, order, newCursor) - : getNextGroupNormal(entries, order, newCursor); + ? getPreviousGroupNormal(entries, flatOrder, cursor) + : getNextGroupNormal(entries, flatOrder, cursor); - if (selected.entry !== null && selected.index !== null) { - setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index }); + if (selected.entry && selected.index !== null) { + applySelection({ id: selected.entry.id, selectMode: 'click', index: selected.index }); return selected.entry.id; } return null; }, - [order, entries, setSelectedEvents], + [flatOrder, entries, applySelection], ); - /** - * TODO: getPreviousNormal and getNextNormal do not work across group boundaries - */ const selectEntry = useCallback( (cursor: EntryId | null, direction: 'up' | 'down') => { - if (order.length < 1) { + if (flatOrder.length < 1) { return null; } - if (cursor === null) { - // there is no cursor, we select the first or last depending on direction if it exists - const selected = direction === 'up' ? getLastNormal(entries, order) : getFirstNormal(entries, order); - if (selected !== null) { - setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 }); - return selected.id; - } - return null; - } - - // otherwise we select the next or previous const selected = - direction === 'up' ? getPreviousNormal(entries, order, cursor) : getNextNormal(entries, order, cursor); + direction === 'up' ? getPreviousNormal(entries, flatOrder, cursor) : getNextNormal(entries, flatOrder, cursor); - if (selected.entry !== null && selected.index !== null) { - setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index }); + if (selected.entry && selected.index !== null) { + applySelection({ id: selected.entry.id, selectMode: 'click', index: selected.index }); return selected.entry.id; } return null; }, - [order, entries, setSelectedEvents], + [flatOrder, entries, applySelection], ); const moveEntry = useCallback( @@ -204,67 +166,52 @@ export function useRundownCommands({ const selectEdge = useCallback( (direction: 'top' | 'bottom') => { - if (order.length < 1) { + if (flatOrder.length < 1) { return null; } - const selected = direction === 'top' ? getFirstNormal(entries, order) : getLastNormal(entries, order); - if (!selected) { - return null; - } + const index = direction === 'top' ? 0 : flatOrder.length - 1; + const selectedId = flatOrder[index]; + if (!selectedId) return null; - const index = order.indexOf(selected.id); - if (index === -1) { - return null; - } - - setSelectedEvents({ id: selected.id, selectMode: 'click', index }); - return selected.id; + applySelection({ id: selectedId, selectMode: 'click', index }); + return selectedId; }, - [entries, order, setSelectedEvents], + [flatOrder, applySelection], ); const selectPage = useCallback( (cursor: EntryId | null, direction: 'up' | 'down') => { - if (order.length < 1) { + if (flatOrder.length < 1) { return null; } - if (cursor === null) { - const selected = direction === 'down' ? getFirstNormal(entries, order) : getLastNormal(entries, order); - if (!selected) { - return null; - } - const index = order.indexOf(selected.id); - if (index !== -1) { - setSelectedEvents({ id: selected.id, selectMode: 'click', index }); - return selected.id; - } - return null; + let currentIndex = cursor ? flatOrder.indexOf(cursor) : -1; + if (currentIndex === -1) { + currentIndex = direction === 'down' ? -1 : flatOrder.length; } - let nextCursor = cursor; - let target: { entry: OntimeEntry | null; index: number | null } | null = null; - + let targetIndex = currentIndex; + let lastValidIndex: number | null = null; for (let step = 0; step < PAGE_SIZE; step += 1) { - const next = - direction === 'down' - ? getNextNormal(entries, order, nextCursor) - : getPreviousNormal(entries, order, nextCursor); - if (next.entry === null || next.index === null) { + targetIndex = direction === 'down' ? targetIndex + 1 : targetIndex - 1; + if (targetIndex < 0 || targetIndex >= flatOrder.length) { break; } - target = next; - nextCursor = next.entry.id; + lastValidIndex = targetIndex; } - if (target?.entry && target.index !== null) { - setSelectedEvents({ id: target.entry.id, selectMode: 'click', index: target.index }); - return target.entry.id; + if (lastValidIndex === null) { + return null; } - return null; + + const selectedId = flatOrder[lastValidIndex]; + if (!selectedId) return null; + + applySelection({ id: selectedId, selectMode: 'click', index: lastValidIndex }); + return selectedId; }, - [entries, order, setSelectedEvents], + [flatOrder, applySelection], ); return { diff --git a/apps/client/src/features/rundown/hooks/useRundownDnd.ts b/apps/client/src/features/rundown/hooks/useRundownDnd.ts index 531a3455e..c59dcb422 100644 --- a/apps/client/src/features/rundown/hooks/useRundownDnd.ts +++ b/apps/client/src/features/rundown/hooks/useRundownDnd.ts @@ -11,7 +11,7 @@ interface UseRundownDndOptions { sortableData: EntryId[]; setSortableData: Dispatch>; getIsCollapsed: (groupId: EntryId) => boolean; - handleCollapseGroup: (collapsed: boolean, groupId: EntryId) => void; + handleCollapseGroup: (collapsed: boolean, groupId: EntryId | undefined) => void; entryActions: ReturnType; } @@ -132,13 +132,11 @@ export function useRundownDnd({ if (event.over?.data.current?.type !== SupportedEntry.Group) { return; } - const groupId = event.over?.id as EntryId; - const isCollapsed = getIsCollapsed(groupId); - if (isCollapsed) { - handleCollapseGroup(false, groupId); - } + + const groupId = event.over?.id as EntryId | undefined; + handleCollapseGroup(false, groupId); }, - [getIsCollapsed, handleCollapseGroup], + [handleCollapseGroup], ); return useMemo( diff --git a/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx b/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx index 831b27edf..5f30da54f 100644 --- a/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx +++ b/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx @@ -103,8 +103,8 @@ export default function RundownEvent({ const isSelected = useEventSelection((state) => state.selectedEvents.has(eventId)); const unselect = useEventSelection((state) => state.unselect); - const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents); const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents); + const selectEntry = useEventSelection((state) => state.setSelectedEvents); const selectedEvents = useEventSelection((state) => state.selectedEvents); const entryCopyId = useEntryCopy((state) => state.entryCopyId); @@ -265,7 +265,7 @@ export default function RundownEvent({ // UI indexes are 1 based const index = eventIndex - 1; const editMode = getSelectionMode(event); - setSelectedEvents({ id: eventId, index, selectMode: editMode }); + selectEntry({ id: eventId, index, selectMode: editMode }); }; const isPlaying = playback ? isPlaybackActive(playback) : false; diff --git a/apps/client/src/features/rundown/rundown-event/RundownEventInner.tsx b/apps/client/src/features/rundown/rundown-event/RundownEventInner.tsx index e387afa7d..3552f5395 100644 --- a/apps/client/src/features/rundown/rundown-event/RundownEventInner.tsx +++ b/apps/client/src/features/rundown/rundown-event/RundownEventInner.tsx @@ -10,14 +10,14 @@ import { IoTime, } from 'react-icons/io5'; import { LuArrowDownToLine } from 'react-icons/lu'; -import { useSessionStorage } from '@mantine/hooks'; import { EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types'; import Tooltip from '../../../common/components/tooltip/Tooltip'; import { cx } from '../../../common/utils/styleUtils'; -import { AppMode, sessionKeys } from '../../../ontimeConfig'; +import { AppMode } from '../../../ontimeConfig'; import TitleEditor from '../common/TitleEditor'; import TimeInputFlow from '../time-input-flow/TimeInputFlow'; +import { useEditorFollowMode } from '../useEditorFollowMode'; import RundownEventChip from './composite/RundownEventChip'; import EventBlockPlayback from './composite/RundownEventPlayback'; @@ -76,10 +76,7 @@ function RundownEventInner({ isLinkedToLoaded, hasTriggers, }: RundownEventInnerProps) { - const [editorMode] = useSessionStorage({ - key: sessionKeys.editorMode, - defaultValue: AppMode.Edit, - }); + const { editorMode } = useEditorFollowMode(); const eventIsPlaying = playback === Playback.Play; const eventIsPaused = playback === Playback.Pause; diff --git a/apps/client/src/features/rundown/rundown-group/RundownGroup.tsx b/apps/client/src/features/rundown/rundown-group/RundownGroup.tsx index 0e3980168..8f0eabc93 100644 --- a/apps/client/src/features/rundown/rundown-group/RundownGroup.tsx +++ b/apps/client/src/features/rundown/rundown-group/RundownGroup.tsx @@ -40,7 +40,7 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }: const handleRef = useRef(null); const { clone, ungroup, deleteEntry } = useEntryActionsContext(); - const setSingleEntrySelection = useEventSelection((state) => state.setSingleEntrySelection); + const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection); const selectedEvents = useEventSelection((state) => state.selectedEvents); const entryCopyId = useEntryCopy((state) => state.entryCopyId); @@ -97,7 +97,7 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }: } // UI indexes are 1 based - setSingleEntrySelection({ id: data.id }); + selectSingleEntry({ id: data.id }); }; const binderColours = data.colour && getAccessibleColour(data.colour); diff --git a/apps/client/src/features/rundown/rundown-header/RundownHeader.module.scss b/apps/client/src/features/rundown/rundown-header/RundownHeader.module.scss index dc1b6fbc2..fce5901e4 100644 --- a/apps/client/src/features/rundown/rundown-header/RundownHeader.module.scss +++ b/apps/client/src/features/rundown/rundown-header/RundownHeader.module.scss @@ -2,6 +2,7 @@ padding-inline: 1rem 2rem; display: flex; align-items: center; + gap: 1rem; :is([data-target='small-device']) & { padding-inline: 0; @@ -57,52 +58,17 @@ } } +.toggleTooltipTrigger { + display: flex; +} + .apart { margin-left: auto; } -.separator { - margin-inline: 1rem; -} - .column { display: flex; flex-direction: column; gap: 0.5rem; align-self: start; } - -.sectionTitle { - text-transform: uppercase; - font-weight: 600; - font-size: 0.75rem; - color: $gray-400; // Guessing color based on cuesheet -} - -.popoverContent { - padding: 1rem; - display: flex; - flex-direction: column; - gap: 1rem; - min-width: 200px; -} - -.column { - display: flex; - flex-direction: column; - gap: 0.5rem; - align-self: start; -} - -.sectionTitle { - text-transform: uppercase; - font-weight: 600; - font-size: 0.75rem; - color: $gray-400; // Guessing color -} - -.popoverContent { - display: flex; - flex-direction: column; - gap: 1rem; -} diff --git a/apps/client/src/features/rundown/rundown-header/RundownHeader.tsx b/apps/client/src/features/rundown/rundown-header/RundownHeader.tsx index 8fd762f65..eac8ce2a0 100644 --- a/apps/client/src/features/rundown/rundown-header/RundownHeader.tsx +++ b/apps/client/src/features/rundown/rundown-header/RundownHeader.tsx @@ -2,25 +2,56 @@ import { memo } from 'react'; import { Toggle } from '@base-ui/react/toggle'; import { ToggleGroup } from '@base-ui/react/toggle-group'; import { Toolbar } from '@base-ui/react/toolbar'; -import { useSessionStorage } from '@mantine/hooks'; +import { OffsetMode } from 'ontime-types'; -import * as Editor from '../../../common/components/editor-utils/EditorUtils'; -import { AppMode, sessionKeys } from '../../../ontimeConfig'; -import { RundownViewMode } from '../rundownViewMode'; +import Tooltip from '../../../common/components/tooltip/Tooltip'; +import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket'; +import { AppMode } from '../../../ontimeConfig'; +import { EditorLayoutMode, useEditorLayout } from '../../../views/editor/useEditorLayout'; +import { RundownViewMode } from '../rundown.options'; +import { useEditorFollowMode } from '../useEditorFollowMode'; import RundownMenu from './RundownMenu'; -import RundownSettings from './RundownSettings'; import style from './RundownHeader.module.scss'; interface RundownHeaderProps { + isExtracted?: boolean; viewMode: RundownViewMode; setViewMode: (mode: RundownViewMode) => void; } +interface HeaderControlsConfig { + showRunEditToggle: boolean; + showOffsetToggle: boolean; + showOverflowMenu: boolean; +} + +export const HEADER_CONTROLS_CONFIG: Record = { + [EditorLayoutMode.CONTROL]: { + showRunEditToggle: true, + showOffsetToggle: true, + showOverflowMenu: true, + }, + [EditorLayoutMode.PLANNING]: { + showRunEditToggle: false, + showOffsetToggle: false, + showOverflowMenu: true, + }, + [EditorLayoutMode.TRACKING]: { + showRunEditToggle: false, + showOffsetToggle: true, + showOverflowMenu: false, + }, +} as const; + export default memo(RundownHeader); -function RundownHeader({ viewMode, setViewMode }: RundownHeaderProps) { - const [editorMode, setEditorMode] = useSessionStorage({ key: sessionKeys.editorMode, defaultValue: AppMode.Edit }); +function RundownHeader({ isExtracted, viewMode, setViewMode }: RundownHeaderProps) { + const { editorMode, setEditorMode } = useEditorFollowMode(); + const offsetMode = useOffsetMode(); + const { layoutMode } = useEditorLayout(); + + const { showRunEditToggle, showOffsetToggle, showOverflowMenu } = HEADER_CONTROLS_CONFIG[layoutMode]; const toggleAppMode = (mode: AppMode[]) => { // we need to stop user from deselecting a mode @@ -29,22 +60,70 @@ function RundownHeader({ viewMode, setViewMode }: RundownHeaderProps) { setEditorMode(newValue); }; + const toggleViewMode = (mode: RundownViewMode[]) => { + const newValue = mode.at(0); + if (!newValue) return; + setViewMode(newValue); + }; + + const toggleOffsetMode = (mode: OffsetMode[]) => { + const newValue = mode.at(0); + if (!newValue) return; + setOffsetMode(newValue); + }; + return ( - - } value={AppMode.Run} className={style.radioButton}> - Run - - } value={AppMode.Edit} className={style.radioButton}> - Edit - + {showRunEditToggle && ( + + } value={AppMode.Run} className={style.radioButton} />} + > + Run + + } value={AppMode.Edit} className={style.radioButton} />} + > + Edit + + + )} + + + } value={RundownViewMode.List} className={style.radioButton} />} + > + List + + } value={RundownViewMode.Table} className={style.radioButton} />} + > + Table + - + {showOffsetToggle && ( + + } value={OffsetMode.Absolute} className={style.radioButton} />} + > + Absolute + + } value={OffsetMode.Relative} className={style.radioButton} />} + > + Relative + + + )} - - - + {showOverflowMenu && } ); } diff --git a/apps/client/src/features/rundown/rundown-header/RundownHeaderMobile.tsx b/apps/client/src/features/rundown/rundown-header/RundownHeaderMobile.tsx index 7f8457f39..07e6d5e0d 100644 --- a/apps/client/src/features/rundown/rundown-header/RundownHeaderMobile.tsx +++ b/apps/client/src/features/rundown/rundown-header/RundownHeaderMobile.tsx @@ -2,23 +2,21 @@ import { memo } from 'react'; import { Toggle } from '@base-ui/react/toggle'; import { ToggleGroup } from '@base-ui/react/toggle-group'; import { Toolbar } from '@base-ui/react/toolbar'; -import { useSessionStorage } from '@mantine/hooks'; -import { OffsetMode } from 'ontime-types'; -import * as Editor from '../../../common/components/editor-utils/EditorUtils'; -import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket'; -import { AppMode, sessionKeys } from '../../../ontimeConfig'; +import { AppMode } from '../../../ontimeConfig'; +import { RundownViewMode } from '../rundown.options'; +import { useEditorFollowMode } from '../useEditorFollowMode'; import style from './RundownHeader.module.scss'; -export default memo(RundownHeader); -function RundownHeader() { - const [editorMode, setEditorMode] = useSessionStorage({ - key: sessionKeys.editorMode, - defaultValue: AppMode.Edit, - }); +interface RundownHeaderMobileProps { + viewMode: RundownViewMode; + setViewMode: (mode: RundownViewMode) => void; +} - const offsetMode = useOffsetMode(); +export default memo(RundownHeaderMobile); +function RundownHeaderMobile({ viewMode, setViewMode }: RundownHeaderMobileProps) { + const { editorMode, setEditorMode } = useEditorFollowMode(); const toggleAppMode = (mode: AppMode[]) => { // we need to stop user from deselecting a mode @@ -27,11 +25,10 @@ function RundownHeader() { setEditorMode(newValue); }; - const toggleOffsetMode = (mode: OffsetMode[]) => { - // we need to stop user from deselecting a mode + const toggleViewMode = (mode: RundownViewMode[]) => { const newValue = mode.at(0); if (!newValue) return; - setOffsetMode(newValue); + setViewMode(newValue); }; return ( @@ -40,19 +37,18 @@ function RundownHeader() { } value={AppMode.Run} className={style.radioButton}> Run + } value={AppMode.Edit} className={style.radioButton}> Edit - - - - } value={OffsetMode.Absolute} className={style.radioButton}> - Absolute + + } value={RundownViewMode.List} className={style.radioButton}> + List - } value={OffsetMode.Relative} className={style.radioButton}> - Relative + } value={RundownViewMode.Table} className={style.radioButton}> + Table diff --git a/apps/client/src/features/rundown/rundown-header/RundownMenu.tsx b/apps/client/src/features/rundown/rundown-header/RundownMenu.tsx index 211a839d6..e1db819fb 100644 --- a/apps/client/src/features/rundown/rundown-header/RundownMenu.tsx +++ b/apps/client/src/features/rundown/rundown-header/RundownMenu.tsx @@ -13,8 +13,12 @@ import { useEventSelection } from '../useEventSelection'; import style from './RundownHeader.module.scss'; +interface RundownMenuProps { + allowNavigation?: boolean; +} + export default memo(RundownMenu); -function RundownMenu() { +function RundownMenu({ allowNavigation }: RundownMenuProps) { const [isOpen, handlers] = useDisclosure(); const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents); @@ -38,6 +42,7 @@ function RundownMenu() { label: 'Manage Rundowns...', icon: IoList, onClick: () => setLocation('manage'), + disabled: !allowNavigation, }, { type: 'divider' }, { diff --git a/apps/client/src/features/rundown/rundown-header/RundownSettings.tsx b/apps/client/src/features/rundown/rundown-header/RundownSettings.tsx deleted file mode 100644 index 760e22dbe..000000000 --- a/apps/client/src/features/rundown/rundown-header/RundownSettings.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { memo } from 'react'; -import { Toggle } from '@base-ui/react/toggle'; -import { ToggleGroup } from '@base-ui/react/toggle-group'; -import { Toolbar } from '@base-ui/react/toolbar'; - -import { RundownViewMode } from '../rundownViewMode'; - -import style from './RundownHeader.module.scss'; - -interface RundownSettingsProps { - viewMode: RundownViewMode; - setViewMode: (mode: RundownViewMode) => void; -} - -export default memo(RundownSettings); -function RundownSettings({ viewMode, setViewMode }: RundownSettingsProps) { - const toggleViewMode = (mode: RundownViewMode[]) => { - const newValue = mode.at(0); - if (!newValue) return; - setViewMode(newValue); - }; - - return ( - - } value='list' className={style.radioButton}> - List - - } value='table' className={style.radioButton}> - Table - - - ); -} diff --git a/apps/client/src/features/rundown/rundown-milestone/RundownMilestone.tsx b/apps/client/src/features/rundown/rundown-milestone/RundownMilestone.tsx index 336c74ded..006f3a069 100644 --- a/apps/client/src/features/rundown/rundown-milestone/RundownMilestone.tsx +++ b/apps/client/src/features/rundown/rundown-milestone/RundownMilestone.tsx @@ -30,7 +30,7 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl const { updateEntry, deleteEntry } = useEntryActionsContext(); const selectedEvents = useEventSelection((state) => state.selectedEvents); - const setSingleEntrySelection = useEventSelection((state) => state.setSingleEntrySelection); + const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection); const entryCopyId = useEntryCopy((state) => state.entryCopyId); const [onContextMenu] = useContextMenu(() => [ @@ -69,7 +69,7 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl } // UI indexes are 1 based - setSingleEntrySelection({ id: entryId }); + selectSingleEntry({ id: entryId }); }; const handleUpdate = (field: 'cue' | 'title', value: string) => { diff --git a/apps/client/src/features/rundown/rundown-table/RundownTable.tsx b/apps/client/src/features/rundown/rundown-table/RundownTable.tsx index f17519d6f..cfdbb0428 100644 --- a/apps/client/src/features/rundown/rundown-table/RundownTable.tsx +++ b/apps/client/src/features/rundown/rundown-table/RundownTable.tsx @@ -1,22 +1,30 @@ -import { memo, useMemo } from 'react'; -import { useSessionStorage } from '@mantine/hooks'; +import { memo, useEffect, useMemo } from 'react'; import EmptyPage from '../../../common/components/state/EmptyPage'; import useCustomFields from '../../../common/hooks-query/useCustomFields'; -import { AppMode, sessionKeys } from '../../../ontimeConfig'; import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd'; import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable'; +import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions'; +import { useEditorFollowMode } from '../useEditorFollowMode'; import { makeRundownColumns } from './makeRundownColumns'; export default memo(RundownTable); function RundownTable() { const { data: customFields, status: customFieldStatus } = useCustomFields(); + const setPermissions = useCuesheetPermissions((state) => state.setPermissions); + const { editorMode } = useEditorFollowMode(); - const [editorMode] = useSessionStorage({ - key: sessionKeys.editorMode, - defaultValue: AppMode.Edit, - }); + // Editor always has full permissions + useEffect(() => { + setPermissions({ + canChangeMode: true, + canCreateEntries: true, + canEditEntries: true, + canFlag: true, + canShare: true, + }); + }, [setPermissions]); const columns = useMemo(() => makeRundownColumns(customFields), [customFields]); diff --git a/apps/client/src/features/rundown/rundown.options.ts b/apps/client/src/features/rundown/rundown.options.ts index ec773948f..41e195c41 100644 --- a/apps/client/src/features/rundown/rundown.options.ts +++ b/apps/client/src/features/rundown/rundown.options.ts @@ -58,3 +58,8 @@ export function makeRundownCustomColumns(customFields: CustomFields) { }; }); } + +export enum RundownViewMode { + List = 'list', + Table = 'table', +} diff --git a/apps/client/src/features/rundown/rundown.utils.ts b/apps/client/src/features/rundown/rundown.utils.ts index 8bafd7625..ee7092e7d 100644 --- a/apps/client/src/features/rundown/rundown.utils.ts +++ b/apps/client/src/features/rundown/rundown.utils.ts @@ -1,4 +1,4 @@ -import { EntryId, isOntimeEvent, isOntimeGroup, RundownEntries, SupportedEntry } from 'ontime-types'; +import { EntryId, isOntimeGroup, RundownEntries, SupportedEntry } from 'ontime-types'; /** * Creates a sortable list of entries diff --git a/apps/client/src/features/rundown/rundownViewMode.ts b/apps/client/src/features/rundown/rundownViewMode.ts deleted file mode 100644 index 7bd7e6929..000000000 --- a/apps/client/src/features/rundown/rundownViewMode.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const RUNDOWN_VIEW_MODES = ['list', 'table'] as const; -export type RundownViewMode = (typeof RUNDOWN_VIEW_MODES)[number]; - -export const DEFAULT_RUNDOWN_VIEW_MODE: RundownViewMode = 'list'; -export const RUNDOWN_VIEW_MODE_STORAGE_KEY = 'rundown-view-mode'; diff --git a/apps/client/src/features/rundown/useCollapsedGroups.ts b/apps/client/src/features/rundown/useCollapsedGroups.ts new file mode 100644 index 000000000..d23ee182d --- /dev/null +++ b/apps/client/src/features/rundown/useCollapsedGroups.ts @@ -0,0 +1,47 @@ +import { useCallback, useMemo } from 'react'; +import { useSessionStorage } from '@mantine/hooks'; +import { EntryId } from 'ontime-types'; + +/** + * Keeps track of which groups are collapsed + * This information is saved in session storage as an array (serializable) + * but provides Set-like operations for fast lookups + */ +export function useCollapsedGroups(rundownId: string) { + const [collapsedGroups, setCollapsedGroups] = useSessionStorage({ + key: `rundown.${rundownId}-editor-collapsed-groups`, + defaultValue: [], + }); + + const collapsedGroupSet = useMemo(() => new Set(collapsedGroups), [collapsedGroups]); + + const getIsCollapsed = useCallback( + (groupId: EntryId): boolean => { + return collapsedGroupSet.has(groupId); + }, + [collapsedGroupSet], + ); + + const collapseGroup = useCallback( + (groupId: EntryId | null | undefined) => { + if (!groupId) return; + setCollapsedGroups((prev) => { + if (prev.includes(groupId)) { + return prev; + } + return [...prev, groupId]; + }); + }, + [setCollapsedGroups], + ); + + const expandGroup = useCallback( + (groupId: EntryId | null | undefined) => { + if (!groupId) return; + setCollapsedGroups((prev) => prev.filter((id) => id !== groupId)); + }, + [setCollapsedGroups], + ); + + return { getIsCollapsed, collapseGroup, expandGroup }; +} diff --git a/apps/client/src/features/rundown/useEditorFollowMode.ts b/apps/client/src/features/rundown/useEditorFollowMode.ts new file mode 100644 index 000000000..6f13b39e9 --- /dev/null +++ b/apps/client/src/features/rundown/useEditorFollowMode.ts @@ -0,0 +1,41 @@ +import { useSessionStorage } from '@mantine/hooks'; + +import { AppMode, sessionKeys } from '../../ontimeConfig'; +import { EditorLayoutMode, useEditorLayout } from '../../views/editor/useEditorLayout'; + +/** + * Manages the editor mode (Edit/Run) derived from layout mode + * + * Editor mode is automatically determined by the layout: + * - PLANNING: Always Edit mode (follows user selection) + * - TRACKING: Always Run mode (follows current event) + * - CONTROL: User preference (defaults to Run mode), persisted in session storage + * + * Edit: Manual editing, follows user selection + * Run: Live playback view, auto-follows current event + */ +export function useEditorFollowMode() { + const { layoutMode } = useEditorLayout(); + + // Only used for CONTROL layout - stores user preference + const [controlModePreference, setControlModePreference] = useSessionStorage({ + key: sessionKeys.editorMode, + defaultValue: AppMode.Run, + }); + + // Derive editor mode from layout mode + const editorMode = (() => { + if (layoutMode === EditorLayoutMode.CONTROL) return controlModePreference; + if (layoutMode === EditorLayoutMode.PLANNING) return AppMode.Edit; + return AppMode.Run; + })(); + + // setEditorMode only affects CONTROL layout + const setEditorMode = (mode: AppMode) => { + if (layoutMode === EditorLayoutMode.CONTROL) { + setControlModePreference(mode); + } + }; + + return { editorMode, setEditorMode }; +} diff --git a/apps/client/src/features/rundown/useEventSelection.ts b/apps/client/src/features/rundown/useEventSelection.ts index e48649ea0..75ea7235f 100644 --- a/apps/client/src/features/rundown/useEventSelection.ts +++ b/apps/client/src/features/rundown/useEventSelection.ts @@ -6,7 +6,7 @@ import { RUNDOWN } from '../../common/api/constants'; import { ontimeQueryClient } from '../../common/queryClient'; import { isMacOS } from '../../common/utils/deviceUtils'; -type SelectionMode = 'shift' | 'click' | 'ctrl'; +export type SelectionMode = 'shift' | 'click' | 'ctrl'; interface EventSelectionStore { selectedEvents: Set; @@ -14,23 +14,25 @@ interface EventSelectionStore { cursor: EntryId | null; entryMode: 'event' | 'single' | null; scrollHandler: ((id: EntryId) => void) | null; - scrollHandlerSource: string | null; setSingleEntrySelection: (selectionArgs: { id: EntryId }) => void; setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void; clearSelectedEvents: () => void; clearMultiSelect: () => void; unselect: (id: EntryId) => void; - setScrollHandler: (source: string, handler: ((id: EntryId) => void) | null) => void; + setScrollHandler: (handler: ((id: EntryId) => void) | null) => void; scrollToEntry: (id: EntryId) => void; } +/** + * Keeps track of the selected entries and selection mode + * Provides methods to update the selection based on user interactions + */ export const useEventSelection = create()((set, get) => ({ selectedEvents: new Set(), anchoredIndex: null, cursor: null, entryMode: null, scrollHandler: null, - scrollHandlerSource: null, setSingleEntrySelection: ({ id }) => { set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'single' }); }, @@ -123,16 +125,9 @@ export const useEventSelection = create()((set, get) => ({ entryMode: selectedEvents.size === 0 ? null : entryMode, }); }, - setScrollHandler: (source, handler) => - set((state) => { - if (handler) { - return { scrollHandler: handler, scrollHandlerSource: source }; - } - if (state.scrollHandlerSource !== source) { - return state; - } - return { scrollHandler: null, scrollHandlerSource: null }; - }), + // Sets the scroll handler for programmatic scrolling to entries + setScrollHandler: (handler) => set({ scrollHandler: handler }), + // Scrolls to the specified entry using the registered scroll handler scrollToEntry: (id: EntryId) => { const handler = get().scrollHandler; if (handler) { diff --git a/apps/client/src/features/rundown/useSelectAndRevealEntry.ts b/apps/client/src/features/rundown/useSelectAndRevealEntry.ts new file mode 100644 index 000000000..ee878187c --- /dev/null +++ b/apps/client/src/features/rundown/useSelectAndRevealEntry.ts @@ -0,0 +1,29 @@ +import { useCallback } from 'react'; +import { EntryId, MaybeString } from 'ontime-types'; + +import { useCollapsedGroups } from './useCollapsedGroups'; +import { useEventSelection } from './useEventSelection'; + +type SelectAndRevealOptions = { + id: EntryId; + index: number; + parent?: MaybeString; +}; + +export function useSelectAndRevealEntry(rundownId: string) { + const { expandGroup } = useCollapsedGroups(rundownId); + const selectEntry = useEventSelection((state) => state.setSelectedEvents); + const scrollToEntry = useEventSelection((state) => state.scrollToEntry); + + return useCallback( + ({ id, index, parent }: SelectAndRevealOptions) => { + expandGroup(parent); + selectEntry({ id, index, selectMode: 'click' }); + // Wait one frame so collapsed-group expansion is reflected in rendered/visible entries before scrolling. + requestAnimationFrame(() => { + scrollToEntry(id); + }); + }, + [expandGroup, scrollToEntry, selectEntry], + ); +} diff --git a/apps/client/src/theme/_ontimeStyles.scss b/apps/client/src/theme/_ontimeStyles.scss index 3178c3095..8ad665a79 100644 --- a/apps/client/src/theme/_ontimeStyles.scss +++ b/apps/client/src/theme/_ontimeStyles.scss @@ -49,8 +49,6 @@ $playback-under: $green-500; $bg-container-l1: $gray-1350; $bg-container-l2: $gray-1300; -$bg-container-onlight: $gray-100; - $backdrop-color: rgba(0, 0, 0, 0.7); $box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0; $box-shadow-l2: rgba(0, 0, 0, 0.15) 0 3px 3px 0; diff --git a/apps/client/src/views/cuesheet/CuesheetPage.tsx b/apps/client/src/views/cuesheet/CuesheetPage.tsx index eb4a43999..cdcf507d1 100644 --- a/apps/client/src/views/cuesheet/CuesheetPage.tsx +++ b/apps/client/src/views/cuesheet/CuesheetPage.tsx @@ -9,7 +9,7 @@ import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { getIsNavigationLocked } from '../../externals'; import CuesheetOverview from '../../features/overview/CuesheetOverview'; -import CuesheetEditModal from './cuesheet-edit-modal/CuesheetEditModal'; +import EntryEditModal from './cuesheet-edit-modal/EntryEditModal'; import CuesheetProgress from './cuesheet-progress/CuesheetProgress'; import CuesheetTableWrapper from './CuesheetTableWrapper'; @@ -26,7 +26,7 @@ export default function CuesheetPage() { return ( - +
{!isLocked && ( diff --git a/apps/client/src/views/cuesheet/cuesheet-edit-modal/CuesheetEditModal.tsx b/apps/client/src/views/cuesheet/cuesheet-edit-modal/CuesheetEditModal.tsx deleted file mode 100644 index 5c99bbe87..000000000 --- a/apps/client/src/views/cuesheet/cuesheet-edit-modal/CuesheetEditModal.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { memo } from 'react'; - -import Modal from '../../../common/components/modal/Modal'; -import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor'; - -import { useCuesheetEditModal } from './useCuesheetEditModal'; - -export default memo(CuesheetEditModal); -function CuesheetEditModal() { - const entryId = useCuesheetEditModal((state) => state.selectedEntryId); - const closeModal = useCuesheetEditModal((state) => state.clearSelection); - - if (entryId === null) { - return null; - } - - return ( - } - /> - ); -} diff --git a/apps/client/src/views/cuesheet/cuesheet-edit-modal/EditorEditModal.tsx b/apps/client/src/views/cuesheet/cuesheet-edit-modal/EntryEditModal.tsx similarity index 60% rename from apps/client/src/views/cuesheet/cuesheet-edit-modal/EditorEditModal.tsx rename to apps/client/src/views/cuesheet/cuesheet-edit-modal/EntryEditModal.tsx index 514d85f0a..7fc3ec060 100644 --- a/apps/client/src/views/cuesheet/cuesheet-edit-modal/EditorEditModal.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-edit-modal/EntryEditModal.tsx @@ -3,12 +3,12 @@ import { memo } from 'react'; import Modal from '../../../common/components/modal/Modal'; import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor'; -import { useEditorEditModal } from './useEditorEditModal'; +import { useEditModal } from './useEditModal'; -export default memo(EditorEditModal); -function EditorEditModal() { - const entryId = useEditorEditModal((state) => state.selectedEntryId); - const closeModal = useEditorEditModal((state) => state.clearSelection); +export default memo(EntryEditModal); +function EntryEditModal() { + const entryId = useEditModal((state) => state.selectedEntryId); + const closeModal = useEditModal((state) => state.clearSelection); if (entryId === null) { return null; diff --git a/apps/client/src/views/cuesheet/cuesheet-edit-modal/useCuesheetEditModal.tsx b/apps/client/src/views/cuesheet/cuesheet-edit-modal/useCuesheetEditModal.tsx deleted file mode 100644 index 8bf881b63..000000000 --- a/apps/client/src/views/cuesheet/cuesheet-edit-modal/useCuesheetEditModal.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { EntryId } from 'ontime-types'; -import { create } from 'zustand'; - -interface SelectedEntryState { - selectedEntryId: EntryId | null; - setEditableEntry: (entryId: EntryId) => void; - clearSelection: () => void; -} - -export const useCuesheetEditModal = create((set) => ({ - selectedEntryId: null, - setEditableEntry: (entryId: EntryId) => set({ selectedEntryId: entryId }), - clearSelection: () => set({ selectedEntryId: null }), -})); diff --git a/apps/client/src/views/cuesheet/cuesheet-edit-modal/useEditorEditModal.tsx b/apps/client/src/views/cuesheet/cuesheet-edit-modal/useEditModal.tsx similarity index 84% rename from apps/client/src/views/cuesheet/cuesheet-edit-modal/useEditorEditModal.tsx rename to apps/client/src/views/cuesheet/cuesheet-edit-modal/useEditModal.tsx index 32ea72fcf..ac37bb22b 100644 --- a/apps/client/src/views/cuesheet/cuesheet-edit-modal/useEditorEditModal.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-edit-modal/useEditModal.tsx @@ -7,7 +7,7 @@ interface SelectedEntryState { clearSelection: () => void; } -export const useEditorEditModal = create((set) => ({ +export const useEditModal = create((set) => ({ selectedEntryId: null, setEditableEntry: (entryId: EntryId) => set({ selectedEntryId: entryId }), clearSelection: () => set({ selectedEntryId: null }), diff --git a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.module.scss b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.module.scss index fc3282ce2..f95eb6468 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.module.scss +++ b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.module.scss @@ -11,6 +11,11 @@ $table-header-font-size: calc(1rem - 2px); color: $ui-white; padding-bottom: 70vh; // allow focus to reach last elements + :is([data-target='small-device']) & { + width: max-content; + min-width: 100%; + } + thead { tr { &::before { diff --git a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx index 7aed9bcf6..63b17c547 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx @@ -21,8 +21,7 @@ import DelayRow from './cuesheet-table-elements/DelayRow'; import EventRow from './cuesheet-table-elements/EventRow'; import GroupRow from './cuesheet-table-elements/GroupRow'; import MilestoneRow from './cuesheet-table-elements/MilestoneRow'; -import CuesheetTableMenu from './cuesheet-table-menu/CuesheetTableMenu'; -import EditorTableMenu from './cuesheet-table-menu/EditorTableMenu'; +import TableMenu from './cuesheet-table-menu/TableMenu'; import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings'; import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager'; @@ -45,7 +44,6 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues const selectedEventId = useSelectedEventId(); const cursor = useEventSelection((state) => state.cursor); - const scrollToEntry = useEventSelection((state) => state.scrollToEntry); const setScrollHandler = useEventSelection((state) => state.setScrollHandler); const virtuosoRef = useRef(null); @@ -116,18 +114,23 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues setColumnSizing({}); }, [setColumnSizing]); - // Auto-scroll only in run mode, routed through the shared scroll handler + // in Run mode, follow the current event useEffect(() => { - if (cuesheetMode !== AppMode.Run || !selectedEventId) { + if (virtuosoRef.current === null || cuesheetMode !== AppMode.Run || !selectedEventId) { return; } - scrollToEntry(selectedEventId); - }, [cuesheetMode, data, selectedEventId, scrollToEntry]); + const eventIndex = data.findIndex((event) => event.id === selectedEventId); + if (eventIndex === -1) { + return; + } + + virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'auto', align: 'start', offset: -50 }); + }, [cuesheetMode, data, selectedEventId]); // Provide an imperative scroll handler for explicit jumps (finder/keyboard) useEffect(() => { - setScrollHandler(`cuesheet-table-${tableRoot}`, (entryId) => { + const handler = (entryId: string) => { if (virtuosoRef.current === null) { return; } @@ -137,13 +140,15 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues return; } - virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'smooth', align: 'start', offset: -50 }); - }); + virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'auto', align: 'start', offset: -50 }); + }; + + setScrollHandler(handler); return () => { - setScrollHandler(`cuesheet-table-${tableRoot}`, null); + setScrollHandler(null); }; - }, [data, setScrollHandler, tableRoot]); + }, [data, setScrollHandler]); /** * To improve performance on resizing, we memoise the column sizes @@ -174,7 +179,6 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues // control components need different implementations for handling permissions const TableRootSettings = tableRoot === 'editor' ? EditorTableSettings : CuesheetTableSettings; - const TableMenu = tableRoot === 'editor' ? EditorTableMenu : CuesheetTableMenu; return ( <> @@ -187,6 +191,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues , diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/EditorTableMenu.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/EditorTableMenu.tsx deleted file mode 100644 index ad5ccfe38..000000000 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/EditorTableMenu.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { memo } from 'react'; -import { IoAdd, IoArrowDown, IoArrowUp, IoDuplicateOutline, IoOptions, IoTrash } from 'react-icons/io5'; -import { SupportedEntry } from 'ontime-types'; - -import { PositionedDropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu'; -import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext'; -import { useCuesheetEditModal } from '../../cuesheet-edit-modal/useCuesheetEditModal'; - -import { useCuesheetTableMenu } from './useCuesheetTableMenu'; - -export default memo(EditorTableMenu); - -function EditorTableMenu() { - const { isOpen, entryId, entryIndex, parentId, flag, position, closeMenu } = useCuesheetTableMenu(); - const { addEntry, clone, deleteEntry, move, updateEntry } = useEntryActionsContext(); - const showModal = useCuesheetEditModal((state) => state.setEditableEntry); - - if (!isOpen) { - return null; - } - - return ( - showModal(entryId), - icon: IoOptions, - }, - { type: 'divider' }, - { - type: 'item', - label: flag ? 'Remove flag' : 'Add flag', - onClick: () => updateEntry({ id: entryId, flag: !flag }), - icon: IoDuplicateOutline, - disabled: flag === null, - }, - { type: 'divider' }, - { - type: 'item', - label: 'Add event above', - onClick: () => addEntry({ type: SupportedEntry.Event, parent: parentId }, { before: entryId }), - icon: IoAdd, - }, - { - type: 'item', - label: 'Add event below', - onClick: () => addEntry({ type: SupportedEntry.Event, parent: parentId }, { after: entryId }), - icon: IoAdd, - }, - { - type: 'item', - label: 'Clone event', - onClick: () => clone(entryId), - icon: IoDuplicateOutline, - }, - { type: 'divider' }, - { - type: 'item', - label: 'Move up', - onClick: () => move(entryId, 'up'), - icon: IoArrowUp, - disabled: entryIndex < 1, - }, - { - type: 'item', - label: 'Move down', - onClick: () => move(entryId, 'down'), - icon: IoArrowDown, - }, - { type: 'divider' }, - { - type: 'item', - label: 'Delete', - onClick: () => deleteEntry([entryId]), - icon: IoTrash, - }, - ]} - position={position} - /> - ); -} diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenu.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/TableMenu.tsx similarity index 92% rename from apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenu.tsx rename to apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/TableMenu.tsx index 0577cb982..a29366a79 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenu.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/TableMenu.tsx @@ -4,17 +4,17 @@ import { SupportedEntry } from 'ontime-types'; import { PositionedDropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu'; import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext'; -import { useCuesheetEditModal } from '../../cuesheet-edit-modal/useCuesheetEditModal'; +import { useEditModal } from '../../cuesheet-edit-modal/useEditModal'; import { useCuesheetPermissions } from '../../useTablePermissions'; import { useCuesheetTableMenu } from './useCuesheetTableMenu'; -export default memo(CuesheetTableMenu); +export default memo(TableMenu); -function CuesheetTableMenu() { +function TableMenu() { const { isOpen, entryId, entryIndex, parentId, flag, position, closeMenu } = useCuesheetTableMenu(); const { addEntry, clone, deleteEntry, move, updateEntry } = useEntryActionsContext(); - const showModal = useCuesheetEditModal((state) => state.setEditableEntry); + const showModal = useEditModal((state) => state.setEditableEntry); const permissions = useCuesheetPermissions(); if (!isOpen) { diff --git a/apps/client/src/views/editor/Editor.module.scss b/apps/client/src/views/editor/Editor.module.scss index f437c4563..2b0434079 100644 --- a/apps/client/src/views/editor/Editor.module.scss +++ b/apps/client/src/views/editor/Editor.module.scss @@ -7,6 +7,13 @@ $max-playback-width: 30rem; display: flex; gap: $panel-gap; overflow: hidden; + height: 100%; + min-height: 0; +} + +.panelContainerTracking { + flex-direction: column; + min-height: 0; } .left { @@ -17,3 +24,29 @@ $max-playback-width: 30rem; flex-direction: column; gap: $panel-gap; } + +.rundownLayout { + flex: 1 1 auto; + display: flex; + gap: $panel-gap; + min-width: 0; + min-height: 0; +} + +.rundownPanel { + flex: 1 1 auto; + min-width: 0; + min-height: 0; + display: flex; +} + +.titlesPanel { + // align rundown start with the + - buttons in the playback controls + // start + iconbtn + iconbt + 2 x gap + $overview-width: calc(8rem + 5rem + 5rem + 2rem); + + flex: 0 0 $overview-width; + min-width: $overview-width; + max-width: $overview-width; + overflow: hidden; +} diff --git a/apps/client/src/views/editor/Editor.tsx b/apps/client/src/views/editor/Editor.tsx index 157b00bd2..49e83c3a7 100644 --- a/apps/client/src/views/editor/Editor.tsx +++ b/apps/client/src/views/editor/Editor.tsx @@ -1,5 +1,11 @@ import { lazy } from 'react'; +import TrackingPlaybackBar from '../../features/control/playback/tracking-playback-bar/TrackingPlaybackBar'; +import { AppMode } from '../../ontimeConfig'; + +import TitleList from './title-list/TitleList'; +import { EditorLayoutMode, useEditorLayout } from './useEditorLayout'; + import styles from './Editor.module.scss'; const Rundown = lazy(() => import('../../features/rundown/RundownExport')); @@ -7,13 +13,46 @@ const TimerControl = lazy(() => import('../../features/control/playback/TimerCon const MessageControl = lazy(() => import('../../features/control/message/MessageControlExport')); export default function Editor() { + const { layoutMode } = useEditorLayout(); + + if (layoutMode === EditorLayoutMode.CONTROL) { + return ( +
+
+ + +
+ +
+ ); + } + + if (layoutMode === EditorLayoutMode.TRACKING) { + return ( +
+
+
+ +
+
+ +
+
+ +
+ ); + } + return (
-
- - +
+
+ +
+
+ +
-
); } diff --git a/apps/client/src/views/editor/EditorLayoutOptions.tsx b/apps/client/src/views/editor/EditorLayoutOptions.tsx new file mode 100644 index 000000000..f100f132b --- /dev/null +++ b/apps/client/src/views/editor/EditorLayoutOptions.tsx @@ -0,0 +1,46 @@ +import { memo, useMemo } from 'react'; +import { IoCheckmark } from 'react-icons/io5'; +import { LuLayoutDashboard } from 'react-icons/lu'; + +import IconButton from '../../common/components/buttons/IconButton'; +import { DropdownMenu, DropdownMenuOption } from '../../common/components/dropdown-menu/DropdownMenu'; + +import { EditorLayoutMode, useEditorLayout } from './useEditorLayout'; + +export default memo(EditorLayoutOptions); +function EditorLayoutOptions() { + const { layoutMode, setLayoutMode } = useEditorLayout(); + + const items = useMemo( + () => [ + { + type: 'item', + label: 'Planning', + description: 'Edit-focused list with planning stats', + icon: layoutMode === EditorLayoutMode.PLANNING ? IoCheckmark : undefined, + onClick: () => setLayoutMode(EditorLayoutMode.PLANNING), + }, + { + type: 'item', + label: 'Tracking', + description: 'Live timing view with progress and offsets', + icon: layoutMode === EditorLayoutMode.TRACKING ? IoCheckmark : undefined, + onClick: () => setLayoutMode(EditorLayoutMode.TRACKING), + }, + { + type: 'item', + label: 'Control', + description: 'All controls and rundown together', + icon: layoutMode === EditorLayoutMode.CONTROL ? IoCheckmark : undefined, + onClick: () => setLayoutMode(EditorLayoutMode.CONTROL), + }, + ], + [layoutMode, setLayoutMode], + ); + + return ( + } items={items}> + + + ); +} diff --git a/apps/client/src/views/editor/EditorViewOptions.module.scss b/apps/client/src/views/editor/EditorViewOptions.module.scss deleted file mode 100644 index d9738e15c..000000000 --- a/apps/client/src/views/editor/EditorViewOptions.module.scss +++ /dev/null @@ -1,67 +0,0 @@ -.group { - display: flex; - align-items: center; - gap: 2px; - padding-inline: 2px; - background: $gray-1100; - border-radius: $component-border-radius-md; - height: 2rem; -} - -.radioButton { - box-sizing: border-box; - display: flex; - align-items: center; - justify-content: center; - height: calc(2rem - 4px); // padding inline * 2 - padding-inline: 1em; - border: 1px solid transparent; - border-radius: $component-border-radius-md; - color: $gray-400; - font-size: calc(1rem - 2px); - font-weight: 600; - - &:focus-visible { - background: transparent; - outline: 1px solid $blue-500; - } - - &:hover:not(:disabled):not(:active) { - background: $gray-1000; - } - - &:active { - background: $gray-1100; - } - - &[data-pressed] { - background: $blue-700; - color: $ui-white; - - &:hover:not(:disabled):not(:active) { - background: $blue-600; - } - } -} - -.popoverContent { - padding: 1rem; - display: flex; - flex-direction: column; - gap: 1rem; - min-width: 200px; -} - -.column { - display: flex; - flex-direction: column; - gap: 0.5rem; - align-self: start; -} - -.sectionTitle { - text-transform: uppercase; - font-weight: 600; - font-size: 0.75rem; - color: $gray-400; -} diff --git a/apps/client/src/views/editor/EditorViewOptions.tsx b/apps/client/src/views/editor/EditorViewOptions.tsx deleted file mode 100644 index 567e57e1f..000000000 --- a/apps/client/src/views/editor/EditorViewOptions.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { memo } from 'react'; -import { LuLayoutDashboard } from 'react-icons/lu'; -import { Popover } from '@base-ui/react/popover'; -import { Toggle } from '@base-ui/react/toggle'; -import { ToggleGroup } from '@base-ui/react/toggle-group'; -import { OffsetMode } from 'ontime-types'; - -import IconButton from '../../common/components/buttons/IconButton'; -import * as Editor from '../../common/components/editor-utils/EditorUtils'; -import PopoverContents from '../../common/components/popover/Popover'; -import { setOffsetMode, useOffsetMode } from '../../common/hooks/useSocket'; - -import style from './EditorViewOptions.module.scss'; - -export default memo(EditorViewOptions); - -function EditorViewOptions() { - const offsetMode = useOffsetMode(); - - const toggleOffsetMode = (mode: OffsetMode[]) => { - const newValue = mode.at(0); - if (!newValue) return; - setOffsetMode(newValue); - }; - - return ( - - - - - } - /> - -
- Offset Mode - - - Absolute - - - Relative - - -
-
-
- ); -} diff --git a/apps/client/src/views/editor/ProtectedEditor.tsx b/apps/client/src/views/editor/ProtectedEditor.tsx index e936c6b27..4f0c725b4 100644 --- a/apps/client/src/views/editor/ProtectedEditor.tsx +++ b/apps/client/src/views/editor/ProtectedEditor.tsx @@ -13,7 +13,7 @@ import EditorOverview from '../../features/overview/EditorOverview'; import WelcomePlacement from './welcome/WelcomePlacement'; import Editor from './Editor'; -import EditorViewOptions from './EditorViewOptions'; +import EditorLayoutOptions from './EditorLayoutOptions'; import styles from './ProtectedEditor.module.scss'; @@ -58,7 +58,7 @@ export default function ProtectedEditor() { {isSettingsOpen ? : } - +
diff --git a/apps/client/src/views/editor/finder/useFinder.tsx b/apps/client/src/views/editor/finder/useFinder.tsx index a3bff61cf..58fbb7559 100644 --- a/apps/client/src/views/editor/finder/useFinder.tsx +++ b/apps/client/src/views/editor/finder/useFinder.tsx @@ -1,9 +1,8 @@ import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react'; -import { useSessionStorage } from '@mantine/hooks'; import { EntryId, isOntimeEvent, isOntimeGroup, isOntimeMilestone, MaybeString, SupportedEntry } from 'ontime-types'; import { useFlatRundown } from '../../../common/hooks-query/useRundown'; -import { useEventSelection } from '../../../features/rundown/useEventSelection'; +import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry'; const maxResults = 12; @@ -44,14 +43,7 @@ export default function useFinder() { const [error, setError] = useState(null); const lastSearchString = useRef(''); - const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents); - const scrollToEntry = useEventSelection((state) => state.scrollToEntry); - - const [collapsedGroups, setCollapsedGroups] = useSessionStorage({ - // we ensure that this is unique to the rundown - key: `rundown.${rundownId}-editor-collapsed-groups`, - defaultValue: [], - }); + const selectAndRevealEntry = useSelectAndRevealEntry(rundownId); /** Filters the rundown to a given evaluation */ const find = useCallback( @@ -224,20 +216,13 @@ export default function useFinder() { const select = useCallback( (selectedEvent: FilterableEntry) => { - // First expand the parent group if this is an event inside a group - if ('parent' in selectedEvent && selectedEvent.parent !== null) { - // Try direct state update instead of using callback - const currentGroups = [...new Set(collapsedGroups)]; - const newGroups = currentGroups.filter((id) => id !== selectedEvent.parent); - // Force a direct update - setCollapsedGroups(newGroups); - } - - // Then select the event - setSelectedEvents({ id: selectedEvent.id, index: selectedEvent.index, selectMode: 'click' }); - scrollToEntry(selectedEvent.id); + selectAndRevealEntry({ + id: selectedEvent.id, + index: selectedEvent.index, + parent: 'parent' in selectedEvent ? selectedEvent.parent : null, + }); }, - [collapsedGroups, setCollapsedGroups, setSelectedEvents, scrollToEntry], + [selectAndRevealEntry], ); /** clear results when source data changes */ diff --git a/apps/client/src/views/editor/title-list/TitleList.module.scss b/apps/client/src/views/editor/title-list/TitleList.module.scss new file mode 100644 index 000000000..4b58855a5 --- /dev/null +++ b/apps/client/src/views/editor/title-list/TitleList.module.scss @@ -0,0 +1,20 @@ +@use '../../../theme/ontimeColours' as *; + +.container { + height: 100%; + width: 100%; + background: $bg-container-l1; + padding-top: 1rem; +} + +.list { + margin: 0; + display: flex; + flex-direction: column; +} + +.bottomSpacer { + width: 100%; + flex-shrink: 0; + height: 70vh; +} diff --git a/apps/client/src/views/editor/title-list/TitleList.tsx b/apps/client/src/views/editor/title-list/TitleList.tsx new file mode 100644 index 000000000..635c96427 --- /dev/null +++ b/apps/client/src/views/editor/title-list/TitleList.tsx @@ -0,0 +1,164 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'; +import { isOntimeEvent, OntimeEvent } from 'ontime-types'; + +import ScrollArea from '../../../common/components/scroll-area/ScrollArea'; +import { useSelectedEventId } from '../../../common/hooks/useSocket'; +import useRundown from '../../../common/hooks-query/useRundown'; +import { ExtendedEntry, getFlatRundownMetadata } from '../../../common/utils/rundownMetadata'; +import { useEventSelection } from '../../../features/rundown/useEventSelection'; +import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry'; +import { AppMode } from '../../../ontimeConfig'; + +import { getCurrentEventInfo } from './titleList.utils'; +import TitleListEmpty from './TitleListEmpty'; +import TitleListItem from './TitleListItem'; + +import style from './TitleList.module.scss'; + +interface TitleListProps { + mode: AppMode; +} + +export default function TitleList({ mode }: TitleListProps) { + const { data: rundown } = useRundown(); + const selectedEventId = useSelectedEventId(); + const cursor = useEventSelection((state) => state.cursor); + + // In Run mode, follow the currently playing event + // In Edit mode, follow the user's selection + const resolvedFollowEventId = useMemo(() => { + return mode === AppMode.Run ? selectedEventId : cursor; + }, [mode, selectedEventId, cursor]); + + // Filter and memoize event-only data + const eventData = useMemo(() => { + const flatData = getFlatRundownMetadata(rundown, resolvedFollowEventId); + return flatData.filter(isOntimeEvent) as ExtendedEntry[]; + }, [rundown, resolvedFollowEventId]); + + if (eventData.length === 0) { + return ; + } + + return ( + + ); +} + +interface TitleListContentProps { + mode: AppMode; + eventData: ExtendedEntry[]; + selectedEventId: string | null; + resolvedFollowEventId: string | null; + rundownId: string; +} + +function TitleListContent({ + mode, + eventData, + selectedEventId, + resolvedFollowEventId, + rundownId, +}: TitleListContentProps) { + 'use memo'; + + const virtuosoRef = useRef(null); + const scrollParentRef = useRef(null); + const selectAndRevealEntry = useSelectAndRevealEntry(rundownId); + + // Calculate current event info + const currentEventInfo = useMemo(() => { + return getCurrentEventInfo(eventData); + }, [eventData]); + + const followIndex = useMemo(() => { + if (!resolvedFollowEventId) return -1; + return eventData.findIndex((entry) => entry.id === resolvedFollowEventId); + }, [eventData, resolvedFollowEventId]); + + // Stable selection handler + const handleSelect = useCallback( + (params: { id: string; index: number; parent?: string }) => { + selectAndRevealEntry(params); + }, + [selectAndRevealEntry], + ); + + // Auto-scroll to keep current item at sticky position using Virtuoso + useEffect(() => { + if (!virtuosoRef.current) return; + + const indexToFollow = followIndex !== -1 ? followIndex : currentEventInfo.index; + if (indexToFollow === -1) return; + + // In Run mode, always scroll to follow + // In Edit mode, only scroll if beyond sticky position (3) + if (mode === AppMode.Edit && indexToFollow <= 3) return; + + virtuosoRef.current.scrollToIndex({ + index: indexToFollow, + align: 'start', + behavior: 'smooth', + offset: -50, + }); + }, [currentEventInfo.index, followIndex, mode]); + + // Virtuoso item renderer + const itemContent = useCallback( + (index: number, entry: ExtendedEntry) => { + const nextEntry = eventData[index + 1]; + const isGroupEnd = Boolean(entry.parent) && entry.parent !== (nextEntry?.parent ?? null); + return ( + + ); + }, + [eventData, currentEventInfo.eventIndex, currentEventInfo.upcomingChipLimit, mode, selectedEventId, handleSelect], + ); + + return ( + + entry.id} + itemContent={itemContent} + increaseViewportBy={{ top: 200, bottom: 200 }} + customScrollParent={scrollParentRef.current ?? undefined} + components={{ + List: VirtuosoListComponent, + Footer: TitleListFooter, + }} + /> + + ); +} + +// Virtuoso list component - extracted to prevent recreation on every render +function VirtuosoListComponent({ children, ...props }: React.HTMLAttributes) { + return ( +
    + {children} +
+ ); +} +VirtuosoListComponent.displayName = 'VirtuosoListComponent'; + +function TitleListFooter() { + return
; +} diff --git a/apps/client/src/views/editor/title-list/TitleListEmpty.module.scss b/apps/client/src/views/editor/title-list/TitleListEmpty.module.scss new file mode 100644 index 000000000..05700ea48 --- /dev/null +++ b/apps/client/src/views/editor/title-list/TitleListEmpty.module.scss @@ -0,0 +1,33 @@ +@use '../../../theme/ontimeColours' as *; +@use '../../../theme/ontimeStyles' as *; + +.container { + height: 100%; + background: $bg-container-l1; +} + +.emptyState { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; +} + +.emptyMessage { + width: min(320px, 100%); + text-align: center; + color: rgba($gray-200, 0.55); +} + +.emptyTitle { + margin: 0 0 0.25rem; + font-size: calc(1rem + 2px); + font-weight: 400; + color: rgba($gray-200, 0.72); +} + +.emptyBody { + margin: 0; + font-size: calc(1rem - 3px); +} diff --git a/apps/client/src/views/editor/title-list/TitleListEmpty.tsx b/apps/client/src/views/editor/title-list/TitleListEmpty.tsx new file mode 100644 index 000000000..ff04d0e0e --- /dev/null +++ b/apps/client/src/views/editor/title-list/TitleListEmpty.tsx @@ -0,0 +1,14 @@ +import style from './TitleListEmpty.module.scss'; + +export default function TitleListEmpty() { + return ( +
+
+
+

No events yet

+

Add events in the rundown to populate this list.

+
+
+
+ ); +} diff --git a/apps/client/src/views/editor/title-list/TitleListItem.module.scss b/apps/client/src/views/editor/title-list/TitleListItem.module.scss new file mode 100644 index 000000000..97fce2aee --- /dev/null +++ b/apps/client/src/views/editor/title-list/TitleListItem.module.scss @@ -0,0 +1,142 @@ +@use '../../../theme/ontimeColours' as *; +@use '../../../theme/ontimeStyles' as *; + +$font-size-current: calc(1rem + 2px); +$font-size-next: 1rem; +$font-size-default: calc(1rem - 2px); + +.item { + --color-bar-gap: 0.5rem; + --color-bar-width: 4px; + // color bar + gap x 2 + --title-left: calc(4px + 0.5rem + 0.5rem); + --indicator-width: 2rem; + --chip-width: 3.25rem; + --chip-padding: 0.5rem; + + position: relative; + display: flex; + align-items: center; + height: 2.5rem; + gap: 1rem; + cursor: pointer; + transition: background-color 0.15s ease; + + &:hover { + background: $white-3; + border-radius: $component-border-radius-md; + + .title { + color: $ui-white; + font-size: $font-size-current; + } + } + + &[data-state='past'] { + opacity: 0.4; + } + + &[data-state='running'], + &[data-state='selected'] { + gap: 0.25rem; + background: $white-7; + border-radius: $component-border-radius-md; + + .title { + color: $ui-white; + font-weight: 500; + padding-left: 0.25rem; + padding-right: calc(var(--chip-width) + var(--chip-padding)); + font-size: $font-size-current; + text-align: left; + } + } + + &[data-state='running'] { + background: color-mix(in srgb, $playback-start 22%, $bg-container-l1); + + .title { + color: $ui-white; + } + } + + &[data-state='next'] { + .title { + color: $ui-white; + font-size: $font-size-next; + } + } + + &[data-skipped] { + opacity: 0.3; + + .title { + text-decoration: line-through; + } + } + + &[data-group-end='true'] { + margin-bottom: $panel-gap; + } +} + +.colourBar { + position: relative; + z-index: 1; + flex-shrink: 0; + width: 4px; + height: 100%; +} + +.title { + position: relative; + flex: 1; + min-width: 0; + padding-block: 0.5rem; + padding-left: 0.25rem; + padding-right: calc(var(--chip-width) + var(--chip-padding)); + + color: $gray-400; + font-size: $font-size-default; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + transition: color 0.15s ease; +} + +.chipSlot { + position: absolute; + right: var(--chip-padding); + top: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: flex-end; + min-width: var(--chip-width); + + &[data-hover-only] { + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; + } +} + +.item:hover .chipSlot[data-hover-only] { + opacity: 1; + pointer-events: auto; +} + +.chipText { + white-space: nowrap; + font-size: calc(1rem - 3px); + color: $label-gray; + + &[data-chip-status='live'] { + color: $ui-white; + } + + &[data-chip-status='due'] { + color: $warning-orange; + } +} diff --git a/apps/client/src/views/editor/title-list/TitleListItem.tsx b/apps/client/src/views/editor/title-list/TitleListItem.tsx new file mode 100644 index 000000000..8d10d8ed6 --- /dev/null +++ b/apps/client/src/views/editor/title-list/TitleListItem.tsx @@ -0,0 +1,115 @@ +import { memo, useCallback } from 'react'; +import { OntimeEvent } from 'ontime-types'; +import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils'; + +import Tooltip from '../../../common/components/tooltip/Tooltip'; +import { ExtendedEntry } from '../../../common/utils/rundownMetadata'; +import { formatDuration, useTimeUntilExpectedStart } from '../../../common/utils/time'; +import { AppMode } from '../../../ontimeConfig'; + +import style from './TitleListItem.module.scss'; + +interface TitleListItemProps { + entry: ExtendedEntry; + currentEventIndex: number; + upcomingChipLimit: number; + isRunning: boolean; + mode: AppMode; + isGroupEnd: boolean; + onSelect: (params: { id: string; index: number; parent?: string }) => void; +} + +export default memo(TitleListItem); +function TitleListItem({ + entry, + currentEventIndex, + upcomingChipLimit, + isRunning, + mode, + isGroupEnd, + onSelect, +}: TitleListItemProps) { + const handleClick = useCallback(() => { + onSelect({ id: entry.id, index: entry.eventIndex - 1, parent: entry.parent ?? undefined }); + }, [onSelect, entry.id, entry.eventIndex, entry.parent]); + + // Show "next" highlight for 2 events after current + const isNext = + currentEventIndex > 0 && entry.eventIndex > currentEventIndex && entry.eventIndex <= currentEventIndex + 2; + const isPast = mode === AppMode.Run && entry.isPast; + + const state = (() => { + if (entry.isLoaded) { + return mode === AppMode.Run ? 'running' : 'selected'; + } + if (isPast) return 'past'; + if (isNext) return 'next'; + return 'default'; + })(); + + const shouldRenderChip = isRunning && !entry.isLoaded && !entry.isPast && !entry.skip; + const showChipByDefault = shouldRenderChip && entry.eventIndex <= upcomingChipLimit; + + return ( +
  • +
    + {entry.title || 'Untitled'} + {shouldRenderChip && ( + + )} +
  • + ); +} + +interface TitleListTimeUntilChipProps { + timeStart: number; + delay: number; + dayOffset: number; + totalGap: number; + isLinkedToLoaded: boolean; + isLoaded: boolean; + showOnHover: boolean; +} + +const TitleListTimeUntilChip = memo(TitleListTimeUntilChipImpl); +function TitleListTimeUntilChipImpl({ + timeStart, + delay, + dayOffset, + totalGap, + isLinkedToLoaded, + isLoaded, + showOnHover, +}: TitleListTimeUntilChipProps) { + const timeUntil = useTimeUntilExpectedStart({ timeStart, delay, dayOffset }, { totalGap, isLinkedToLoaded }); + const isDue = !isLoaded && timeUntil < MILLIS_PER_SECOND; + + let timeUntilString = 'LIVE'; + if (!isLoaded) { + timeUntilString = isDue ? 'DUE' : formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE); + } + + const chipStatus = isLoaded ? 'live' : isDue ? 'due' : 'pending'; + + return ( + + + {timeUntilString} + + + ); +} diff --git a/apps/client/src/views/editor/title-list/titleList.utils.ts b/apps/client/src/views/editor/title-list/titleList.utils.ts new file mode 100644 index 000000000..a51b1aa27 --- /dev/null +++ b/apps/client/src/views/editor/title-list/titleList.utils.ts @@ -0,0 +1,19 @@ +import { OntimeEvent } from 'ontime-types'; + +import { ExtendedEntry } from '../../../common/utils/rundownMetadata'; + +// Display time chips for current + next 5 upcoming events +const UPCOMING_CHIP_COUNT = 5; + +export function getCurrentEventInfo(data: ExtendedEntry[]) { + const index = data.findIndex((entry) => entry.isLoaded); + const event = index !== -1 ? data[index] : null; + const eventIndex = event?.eventIndex ?? 0; + + return { + index, + id: event?.id ?? null, + eventIndex, + upcomingChipLimit: eventIndex > 0 ? eventIndex + UPCOMING_CHIP_COUNT : UPCOMING_CHIP_COUNT, + }; +} diff --git a/apps/client/src/views/editor/useEditorLayout.ts b/apps/client/src/views/editor/useEditorLayout.ts new file mode 100644 index 000000000..a4b3d5fa6 --- /dev/null +++ b/apps/client/src/views/editor/useEditorLayout.ts @@ -0,0 +1,33 @@ +import { useSearchParams } from 'react-router'; +import { isValueOfEnum } from 'ontime-utils'; + +const layoutParam = 'layout'; + +export enum EditorLayoutMode { + CONTROL = 'control', + PLANNING = 'planning', + TRACKING = 'tracking', +} + +/** + * Resolves the current editor layout mode from a nullable string value + */ +function getEditorLayout(value: string | null): EditorLayoutMode { + if (isValueOfEnum(EditorLayoutMode, value)) { + return value; + } + return EditorLayoutMode.CONTROL; +} + +export function useEditorLayout() { + const [searchParams, setSearchParams] = useSearchParams(); + const layoutMode = getEditorLayout(searchParams.get(layoutParam)); + + const setLayoutMode = (mode: EditorLayoutMode) => { + const nextParams = new URLSearchParams(searchParams); + nextParams.set(layoutParam, mode); + setSearchParams(nextParams, { replace: true }); + }; + + return { layoutMode, setLayoutMode }; +} diff --git a/e2e/tests/features/211-report-data.spec.ts b/e2e/tests/features/211-report-data.spec.ts index fe7e34ab7..e8030f887 100644 --- a/e2e/tests/features/211-report-data.spec.ts +++ b/e2e/tests/features/211-report-data.spec.ts @@ -3,6 +3,8 @@ import { expect, test } from '@playwright/test'; test('show warning when event crosses midnight', async ({ page }) => { await page.goto('http://localhost:4001/editor'); + await page.getByRole('button', { name: 'Edit' }).click(); + await page.getByRole('button', { name: 'Rundown menu' }).click(); await page.getByRole('menuitem', { name: 'Clear all' }).click(); await page.getByRole('button', { name: 'Delete all' }).click(); @@ -24,6 +26,8 @@ test('show warning when event crosses midnight', async ({ page }) => { test('show warning when event starts next day midnight', async ({ page }) => { await page.goto('http://localhost:4001/editor'); + await page.getByRole('button', { name: 'Edit' }).click(); + await page.getByRole('button', { name: 'Rundown menu' }).click(); await page.getByRole('menuitem', { name: 'Clear all' }).click(); await page.getByRole('button', { name: 'Delete all' }).click(); diff --git a/e2e/tests/features/212-time-until.spec.ts b/e2e/tests/features/212-time-until.spec.ts index cc4dbc3d2..ed52d52b6 100644 --- a/e2e/tests/features/212-time-until.spec.ts +++ b/e2e/tests/features/212-time-until.spec.ts @@ -9,8 +9,9 @@ test('time until absolute', async ({ context }) => { await op.goto('/op'); await timeline.goto('/timeline'); + await editor.getByRole('button', { name: 'Edit' }).click(); await editor.getByRole('button', { name: 'Rundown menu' }).click(); - await editor.getByRole('button', { name: 'Clear all' }).click(); + await editor.getByRole('menuitem', { name: 'Clear all' }).click(); await editor.getByRole('button', { name: 'Delete all' }).click(); await editor.getByRole('button', { name: 'Create Event' }).click(); @@ -130,8 +131,9 @@ test('time until relative', async ({ context }) => { const editor = await context.newPage(); editor.goto('http://localhost:4001/editor'); + await editor.getByRole('button', { name: 'Edit' }).click(); await editor.getByRole('button', { name: 'Rundown menu' }).click(); - await editor.getByRole('button', { name: 'Clear all' }).click(); + await editor.getByRole('menuitem', { name: 'Clear all' }).click(); await editor.getByRole('button', { name: 'Delete all' }).click(); await editor.getByRole('button', { name: 'Create Event' }).click(); diff --git a/e2e/tests/features/213-arrange-while-playing.spec.ts b/e2e/tests/features/213-arrange-while-playing.spec.ts index 688773dd5..293d058e6 100644 --- a/e2e/tests/features/213-arrange-while-playing.spec.ts +++ b/e2e/tests/features/213-arrange-while-playing.spec.ts @@ -2,6 +2,7 @@ import { test, expect } from '@playwright/test'; test('Rearrange while playing', async ({ page }) => { await page.goto('http://localhost:4001/rundown'); + await page.getByRole('button', { name: 'Edit' }).click(); // clear rundown await page.getByRole('button', { name: 'Rundown menu' }).click(); @@ -34,6 +35,7 @@ test('Rearrange while playing', async ({ page }) => { test('flag and unflag an event while playing', async ({ page }) => { await page.goto('http://localhost:4001/editor/'); + await page.getByRole('button', { name: 'Edit' }).click(); await page.getByRole('button', { name: 'Rundown menu' }).click(); await page.getByRole('menuitem', { name: 'Clear all' }).click(); diff --git a/packages/utils/src/rundown-utils/rundownUtils.mock.ts b/packages/utils/src/rundown-utils/rundownUtils.mock.ts new file mode 100644 index 000000000..311f1ae35 --- /dev/null +++ b/packages/utils/src/rundown-utils/rundownUtils.mock.ts @@ -0,0 +1,397 @@ +import type { DatabaseModel } from 'ontime-types'; +import { EndAction, OntimeView, SupportedEntry, TimerType, TimeStrategy } from 'ontime-types'; + +export const demoDb: DatabaseModel = { + rundowns: { + default: { + id: 'default', + title: 'Demo project', + order: ['e2163f', '7eaf99', 'f60403', '6b0edb'], + flatOrder: [ + 'e2163f', + '7eaf99', + '9bf60f', + 'bf71a2', + 'c2697f', + 'fa593e', + 'a8b0b3', + 'f60403', + '0aaa7d', + '6b0edb', + '02afca', + '75ce86', + 'e10ed9', + '07df89', + ], + entries: { + e2163f: { + id: 'e2163f', + type: SupportedEntry.Milestone, + cue: 'Demo', + title: 'Clear all, or Create New Project to start fresh', + note: 'Moderator - Emma Thompson\n\nSpeakers\n- Liam Carter + Sophia Patel\n- Ethan Brooks\n- Lucas Bennett', + colour: '#9d9d9d', + custom: {}, + parent: null, + revision: 0, + }, + '7eaf99': { + id: '7eaf99', + type: SupportedEntry.Group, + title: 'Morning Sessions', + note: '', + entries: ['9bf60f', 'bf71a2', 'c2697f', 'fa593e', 'a8b0b3'], + targetDuration: null, + colour: '#339E4E', + custom: {}, + revision: 0, + timeStart: 36000000, + timeEnd: 43200000, + duration: 7200000, + isFirstLinked: false, + }, + '9bf60f': { + id: '9bf60f', + type: SupportedEntry.Event, + flag: false, + title: 'Pre-show Countdown', + timeStart: 36000000, + timeEnd: 39600000, + duration: 3600000, + timeStrategy: TimeStrategy.LockEnd, + linkStart: false, + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + skip: false, + note: 'Music plays, holding slide on screens', + colour: '#77C785', + delay: 0, + dayOffset: 0, + gap: 0, + cue: '1', + parent: '7eaf99', + revision: 0, + timeWarning: 600000, + timeDanger: 300000, + custom: { + PowerPoint_Slide: 'https://www.getontime.no/images/aux/demo-slide1.webp', + Video_Notes: 'Camera + PowerPoint on stream\nPowerPoint on screens', + Audio_Notes: '2x Wireless Hand Helds', + PowerPoint_Name: 'HoldingSlide.pptx', + }, + triggers: [], + }, + bf71a2: { + id: 'bf71a2', + type: SupportedEntry.Milestone, + cue: 'Standby', + title: '10:45 - Presenters ready side stage', + note: '', + colour: '#A790F5', + revision: 0, + custom: {}, + parent: '7eaf99', + }, + c2697f: { + id: 'c2697f', + type: SupportedEntry.Event, + flag: false, + title: 'Welcome', + timeStart: 39600000, + timeEnd: 40200000, + duration: 600000, + timeStrategy: TimeStrategy.LockDuration, + linkStart: true, + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + skip: false, + note: 'Emma Thompson', + colour: '#FFCC78', + delay: 0, + dayOffset: 0, + gap: 0, + cue: '1.1', + parent: '7eaf99', + revision: 0, + timeWarning: 120000, + timeDanger: 60000, + custom: { + PowerPoint_Slide: 'https://www.getontime.no/images/aux/demo-slide1.webp', + Video_Notes: 'Cameras on stream\nPowerPoint on screens', + Audio_Notes: '1x Wireless Hand Held', + PowerPoint_Name: 'HoldingSlide.pptx', + }, + triggers: [], + }, + fa593e: { + id: 'fa593e', + type: SupportedEntry.Event, + flag: true, + title: 'Session 1', + timeStart: 40200000, + timeEnd: 43200000, + duration: 3000000, + timeStrategy: TimeStrategy.LockDuration, + linkStart: true, + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + skip: false, + note: 'Liam Carter, Sophia Patel + PowerPoint', + colour: '#77C785', + delay: 0, + dayOffset: 0, + gap: 0, + cue: '1.2', + parent: '7eaf99', + revision: 0, + timeWarning: 120000, + timeDanger: 60000, + custom: { + PowerPoint_Slide: 'https://www.getontime.no/images/aux/demo-slide2.webp', + Video_Notes: 'Camera + PowerPoint on stream\nPowerPoint on screens', + Audio_Notes: '2x Wireless Hand Helds', + PowerPoint_Name: 'Session1.pptx', + }, + triggers: [], + }, + a8b0b3: { + id: 'a8b0b3', + type: SupportedEntry.Milestone, + cue: 'House', + title: '11:30 - House staff setup lunch in lobby', + note: '', + colour: '#A790F5', + revision: 0, + custom: {}, + parent: '7eaf99', + }, + f60403: { + id: 'f60403', + type: SupportedEntry.Group, + title: 'Lunch', + note: '', + entries: ['0aaa7d'], + targetDuration: null, + colour: '#3E75E8', + custom: {}, + revision: 0, + timeStart: 43200000, + timeEnd: 46800000, + duration: 3600000, + isFirstLinked: true, + }, + '0aaa7d': { + id: '0aaa7d', + type: SupportedEntry.Event, + flag: false, + title: 'Lunch / Countdown to next session', + timeStart: 43200000, + timeEnd: 46800000, + duration: 3600000, + timeStrategy: TimeStrategy.LockDuration, + linkStart: true, + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + skip: false, + note: 'Buffet in lobby', + colour: '#779BE7', + delay: 0, + dayOffset: 0, + gap: 0, + cue: '2.1', + parent: 'f60403', + revision: 0, + timeWarning: 120000, + timeDanger: 60000, + custom: { + PowerPoint_Slide: 'https://www.getontime.no/images/aux/demo-slide1.webp', + Video_Notes: 'Holding slide on screens', + Audio_Notes: 'House music', + PowerPoint_Name: 'HoldingSlide.pptx', + }, + triggers: [], + }, + '6b0edb': { + id: '6b0edb', + type: SupportedEntry.Group, + title: 'Afternoon Sessions', + note: '', + entries: ['02afca', '75ce86', 'e10ed9', '07df89'], + targetDuration: null, + colour: '#339E4E', + custom: {}, + revision: 0, + timeStart: 46800000, + timeEnd: 50400000, + duration: 3600000, + isFirstLinked: true, + }, + '02afca': { + id: '02afca', + type: SupportedEntry.Milestone, + cue: 'Standby', + title: '12:45 - Presenters ready side stage', + note: '', + colour: '#A790F5', + revision: 0, + custom: {}, + parent: '6b0edb', + }, + '75ce86': { + id: '75ce86', + type: SupportedEntry.Event, + flag: false, + title: 'Session 2', + timeStart: 46800000, + timeEnd: 49800000, + duration: 3000000, + timeStrategy: TimeStrategy.LockDuration, + linkStart: true, + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + skip: false, + note: 'Ethan Brooks + PowerPoint + Video playback', + colour: '#77C785', + delay: 0, + dayOffset: 0, + gap: 0, + cue: '3.1', + parent: '6b0edb', + revision: 0, + timeWarning: 120000, + timeDanger: 60000, + custom: { + PowerPoint_Slide: 'https://www.getontime.no/images/aux/demo-slide3.webp', + Video_Notes: 'Camera + PPT + Video on stream\nPowerPoint + Video on screens\n\nVideo file: Session2.mp4', + Audio_Notes: '1x Wireless Hand Held\n1x Video with audio', + PowerPoint_Name: 'Session2.pptx', + }, + triggers: [], + }, + e10ed9: { + id: 'e10ed9', + type: SupportedEntry.Event, + flag: false, + title: 'Wrap up', + timeStart: 49800000, + timeEnd: 50400000, + duration: 600000, + timeStrategy: TimeStrategy.LockDuration, + linkStart: false, + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + skip: false, + note: 'Lucas Bennett', + colour: '#FFCC78', + delay: 0, + dayOffset: 0, + gap: 0, + cue: '3.2', + parent: '6b0edb', + revision: 0, + timeWarning: 120000, + timeDanger: 60000, + custom: { + PowerPoint_Slide: 'https://www.getontime.no/images/aux/demo-slide1.webp', + Video_Notes: 'Holding slide on screens', + Audio_Notes: '1x Wireless Hand Held', + PowerPoint_Name: 'HoldingSlide.pptx', + }, + triggers: [], + }, + '07df89': { + id: '07df89', + type: SupportedEntry.Milestone, + cue: 'Strike', + title: '14:00 - AV & Room strike', + note: '', + colour: '#A790F5', + revision: 0, + custom: {}, + parent: '6b0edb', + }, + }, + revision: 0, + }, + }, + project: { + title: 'Ontime Demo Project', + description: 'Demo Project to get you started', + url: 'https://docs.getontime.no/', + info: 'Use Project info to share information to various Ontime views.\nie. Venue info, wifi, staff details, etc.', + logo: 'ontime-logo.png', + custom: [ + { + title: 'Custom data', + value: + 'Add additional, custom data fields to the project along with optional links to images. \nThe image will be rendered in the views', + url: '', + }, + ], + }, + settings: { + version: '-', + serverPort: 4001, + editorKey: null, + operatorKey: null, + timeFormat: '24', + language: 'en', + }, + viewSettings: { + dangerColor: '#ff7300', + normalColor: '#ffffffcc', + overrideStyles: false, + warningColor: '#ffa528', + }, + urlPresets: [ + { + enabled: true, + alias: 'clock', + target: OntimeView.Timer, + search: + 'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true', + }, + { + enabled: true, + alias: 'minimal', + target: OntimeView.Timer, + search: + 'hideclock=true&hidecards=true&hideprogress=true&hidemessage=true&hidesecondary=true&hidelogo=true&font=arial+black&keycolour=00ff00&timerColour=ffffff', + }, + ], + customFields: { + Video_Notes: { + type: 'text', + colour: '#FFAB33', + label: 'Video Notes', + }, + Audio_Notes: { + type: 'text', + colour: '#339E4E', + label: 'Audio Notes', + }, + PowerPoint_Name: { + type: 'text', + colour: '#3E75E8', + label: 'PowerPoint Name', + }, + PowerPoint_Slide: { + type: 'image', + colour: '#ED3333', + label: 'PowerPoint Slide', + }, + }, + automation: { + enabledAutomations: false, + enabledOscIn: false, + oscPortIn: 8888, + triggers: [], + automations: {}, + }, +}; diff --git a/packages/utils/src/rundown-utils/rundownUtils.test.ts b/packages/utils/src/rundown-utils/rundownUtils.test.ts index 44f9ffc3f..225df243f 100644 --- a/packages/utils/src/rundown-utils/rundownUtils.test.ts +++ b/packages/utils/src/rundown-utils/rundownUtils.test.ts @@ -2,14 +2,14 @@ import type { OntimeDelay, OntimeEntry, OntimeEvent, OntimeGroup } from 'ontime- import { SupportedEntry } from 'ontime-types'; import { + getFirstGroupNormal, getLastEvent, - getLastNormal, getLastGroupNormal, + getLastNormal, getNext, getNextEvent, getNextGroupNormal, getNextNormal, - getFirstGroupNormal, getPrevious, getPreviousEvent, getPreviousGroup, @@ -17,7 +17,7 @@ import { getPreviousNormal, swapEventData, } from './rundownUtils'; -import { demoDb } from '../../../../apps/server/src/models/demoProject'; +import { demoDb } from './rundownUtils.mock'; describe('getNext()', () => { it('returns the next event of type event', () => { diff --git a/packages/utils/tsconfig.tests.json b/packages/utils/tsconfig.tests.json index 2b3ed90e3..23dbea08f 100644 --- a/packages/utils/tsconfig.tests.json +++ b/packages/utils/tsconfig.tests.json @@ -15,5 +15,6 @@ "include": [ "src/**/*.test.ts", "src/**/*.spec.ts", + "src/**/*.mock.ts", ] }