feat: editor layout

This commit is contained in:
Carlos Valente
2026-01-21 19:56:52 +01:00
committed by Carlos Valente
parent 967e92e2c8
commit da02ecd113
77 changed files with 2267 additions and 788 deletions
@@ -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;
}
@@ -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<OntimeEvent>[];
}, [rundown, resolvedFollowEventId]);
if (eventData.length === 0) {
return <TitleListEmpty />;
}
return (
<TitleListContent
mode={mode}
eventData={eventData}
selectedEventId={selectedEventId}
resolvedFollowEventId={resolvedFollowEventId}
rundownId={rundown.id}
/>
);
}
interface TitleListContentProps {
mode: AppMode;
eventData: ExtendedEntry<OntimeEvent>[];
selectedEventId: string | null;
resolvedFollowEventId: string | null;
rundownId: string;
}
function TitleListContent({
mode,
eventData,
selectedEventId,
resolvedFollowEventId,
rundownId,
}: TitleListContentProps) {
'use memo';
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
const scrollParentRef = useRef<HTMLDivElement | null>(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<OntimeEvent>) => {
const nextEntry = eventData[index + 1];
const isGroupEnd = Boolean(entry.parent) && entry.parent !== (nextEntry?.parent ?? null);
return (
<TitleListItem
key={entry.id}
entry={entry}
currentEventIndex={currentEventInfo.eventIndex}
upcomingChipLimit={currentEventInfo.upcomingChipLimit}
isRunning={mode === AppMode.Run && selectedEventId !== null}
mode={mode}
onSelect={handleSelect}
isGroupEnd={isGroupEnd}
/>
);
},
[eventData, currentEventInfo.eventIndex, currentEventInfo.upcomingChipLimit, mode, selectedEventId, handleSelect],
);
return (
<ScrollArea className={style.container} ref={scrollParentRef}>
<Virtuoso
ref={virtuosoRef}
data={eventData}
computeItemKey={(_index, entry) => entry.id}
itemContent={itemContent}
increaseViewportBy={{ top: 200, bottom: 200 }}
customScrollParent={scrollParentRef.current ?? undefined}
components={{
List: VirtuosoListComponent,
Footer: TitleListFooter,
}}
/>
</ScrollArea>
);
}
// Virtuoso list component - extracted to prevent recreation on every render
function VirtuosoListComponent({ children, ...props }: React.HTMLAttributes<HTMLUListElement>) {
return (
<ul {...props} className={style.list}>
{children}
</ul>
);
}
VirtuosoListComponent.displayName = 'VirtuosoListComponent';
function TitleListFooter() {
return <div className={style.bottomSpacer} />;
}
@@ -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);
}
@@ -0,0 +1,14 @@
import style from './TitleListEmpty.module.scss';
export default function TitleListEmpty() {
return (
<div className={style.container}>
<div className={style.emptyState}>
<div className={style.emptyMessage}>
<h3 className={style.emptyTitle}>No events yet</h3>
<p className={style.emptyBody}>Add events in the rundown to populate this list.</p>
</div>
</div>
</div>
);
}
@@ -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;
}
}
@@ -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<OntimeEvent>;
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 (
<li
data-state={state}
data-skipped={entry.skip || undefined}
data-group-end={isGroupEnd || undefined}
className={style.item}
onClick={handleClick}
>
<div className={style.colourBar} style={{ backgroundColor: entry.groupColour || 'transparent' }} />
<span className={style.title}>{entry.title || 'Untitled'}</span>
{shouldRenderChip && (
<TitleListTimeUntilChip
timeStart={entry.timeStart}
delay={entry.delay}
dayOffset={entry.dayOffset}
totalGap={entry.totalGap}
isLinkedToLoaded={entry.isLinkedToLoaded}
isLoaded={entry.isLoaded}
showOnHover={!showChipByDefault}
/>
)}
</li>
);
}
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 (
<Tooltip text='Expected time until start' className={style.chipSlot} data-hover-only={showOnHover || undefined}>
<span data-chip-status={chipStatus} className={style.chipText}>
{timeUntilString}
</span>
</Tooltip>
);
}
@@ -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<OntimeEvent>[]) {
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,
};
}