mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-31 20:09:11 +00:00
feat: editor layout
This commit is contained in:
committed by
Carlos Valente
parent
967e92e2c8
commit
da02ecd113
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user