refactor: timeline design review

This commit is contained in:
Carlos Valente
2025-07-14 08:55:45 +02:00
parent 55e34b5f9a
commit 6f9e13948d
4 changed files with 63 additions and 41 deletions
@@ -1,4 +1,5 @@
import { RefObject, useCallback, useEffect } from 'react'; import { RefObject, useCallback, useEffect } from 'react';
import { EntryId } from 'ontime-types';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>( function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: RefObject<ComponentRef> | null, componentRef: RefObject<ComponentRef> | null,
@@ -26,7 +27,7 @@ interface UseHorizontalFollowComponentOptions {
followRef: RefObject<HTMLElement | null>; followRef: RefObject<HTMLElement | null>;
scrollRef: RefObject<HTMLElement | null>; scrollRef: RefObject<HTMLElement | null>;
doFollow: boolean; doFollow: boolean;
hasSelectedElement?: boolean; selectedEventId: EntryId | null;
leftOffset?: number; leftOffset?: number;
setScrollFlag?: (newValue: boolean) => void; setScrollFlag?: (newValue: boolean) => void;
} }
@@ -39,7 +40,7 @@ export default function useHorizontalFollowComponent({
followRef, followRef,
scrollRef, scrollRef,
doFollow, doFollow,
hasSelectedElement, selectedEventId,
leftOffset = 0, leftOffset = 0,
setScrollFlag, setScrollFlag,
}: UseHorizontalFollowComponentOptions) { }: UseHorizontalFollowComponentOptions) {
@@ -52,25 +53,25 @@ export default function useHorizontalFollowComponent({
// Use requestAnimationFrame to ensure the component is fully loaded // Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
scrollToComponent( scrollToComponent(
hasSelectedElement ? (followRef as RefObject<HTMLElement>) : null, selectedEventId !== null ? (followRef as RefObject<HTMLElement>) : null,
scrollRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>,
leftOffset, leftOffset,
); );
setScrollFlag?.(false); setScrollFlag?.(false);
}); });
}, [followRef, scrollRef, doFollow, hasSelectedElement, leftOffset, setScrollFlag]); }, [followRef, scrollRef, doFollow, leftOffset, setScrollFlag, selectedEventId]);
const scrollToRefComponent = useCallback( const scrollToRefComponent = useCallback(
(componentRef = followRef, containerRef = scrollRef, offset = leftOffset) => { (componentRef = followRef, containerRef = scrollRef, offset = leftOffset) => {
if (containerRef.current) { if (containerRef.current) {
scrollToComponent( scrollToComponent(
hasSelectedElement ? (componentRef as RefObject<HTMLElement>) : null, selectedEventId !== null ? (componentRef as RefObject<HTMLElement>) : null,
containerRef as RefObject<HTMLElement>, containerRef as RefObject<HTMLElement>,
offset, offset,
); );
} }
}, },
[followRef, scrollRef, hasSelectedElement, leftOffset], [followRef, scrollRef, leftOffset, selectedEventId],
); );
return scrollToRefComponent; return scrollToRefComponent;
@@ -28,6 +28,13 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
height: 100%; height: 100%;
} }
.maybeInline {
display: flex;
align-items: center;
gap: 0.5em;
flex-wrap: wrap;
}
// generate combined timeline // generate combined timeline
.timelineBlock { .timelineBlock {
height: $timeline-height; height: $timeline-height;
@@ -65,7 +72,7 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2rem; gap: 1rem;
padding-top: 0.25rem; padding-top: 0.25rem;
padding-inline-start: 0.25rem; padding-inline-start: 0.25rem;
overflow: hidden; overflow: hidden;
@@ -76,32 +83,15 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
border-top: 2px solid var(--background-color-override, $viewer-background-color); border-top: 2px solid var(--background-color-override, $viewer-background-color);
box-shadow: 0 0.25rem 0 0 var(--color, $gray-300); box-shadow: 0 0.25rem 0 0 var(--color, $gray-300);
text-transform: capitalize;
white-space: normal;
&[data-status='done'] { &[data-status='done'] {
opacity: $opacity-disabled; opacity: $opacity-disabled;
} }
&[data-status='live'] { &[data-status='live'] {
box-shadow: 0 0.25rem 0 0 $active-red; box-shadow: 0 0.25rem 0 0 $active-red;
}
}
.delay {
margin-top: -2rem;
margin-bottom: -1rem;
}
.timeOverview {
padding-top: 0.25rem;
padding-inline-start: 0.25em;
text-transform: capitalize;
white-space: normal;
height: 6rem;
&[data-status='done'] {
opacity: $opacity-disabled;
}
&[data-status='live'] {
.status { .status {
color: $active-red; color: $active-red;
} }
@@ -114,6 +104,22 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
} }
} }
.delay {
color: $delay-color;
}
.timeOverview {
padding-top: 0.25rem;
padding-inline-start: 0.25em;
height: 3rem;
}
.cross { .cross {
text-decoration: line-through; text-decoration: line-through;
text-decoration-thickness: 2px;
text-decoration-color: $delay-color;
}
.separeLeft {
border-left: 1px solid var(--color, $gray-300);
} }
+2 -1
View File
@@ -38,7 +38,7 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel
followRef: selectedRef, followRef: selectedRef,
scrollRef: scrollContainerRef, scrollRef: scrollContainerRef,
doFollow: autosize, doFollow: autosize,
hasSelectedElement: selectedEventId !== null, selectedEventId: selectedEventId,
// No offset when hiding past events to ensure content starts at 0 // No offset when hiding past events to ensure content starts at 0
leftOffset: hidePast ? 0 : screenWidth / 6, leftOffset: hidePast ? 0 : screenWidth / 6,
}); });
@@ -92,6 +92,7 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel
colour={event.colour} colour={event.colour}
delay={event.delay ?? 0} delay={event.delay ?? 0}
duration={event.duration} duration={event.duration}
hasLink={Boolean(event.linkStart)}
left={position.left} left={position.left}
status={statusMap[event.id]} status={statusMap[event.id]}
start={event.timeStart + (event.dayOffset ?? 0) * dayInMs} start={event.timeStart + (event.dayOffset ?? 0) * dayInMs}
@@ -16,6 +16,7 @@ interface TimelineEntryProps {
colour: string; colour: string;
delay: number; delay: number;
duration: number; duration: number;
hasLink: boolean;
left: number; left: number;
status: ProgressStatus; status: ProgressStatus;
start: number; start: number;
@@ -29,21 +30,31 @@ const formatOptions = {
format24: 'HH:mm', format24: 'HH:mm',
}; };
export function TimelineEntry({ colour, delay, duration, left, status, start, title, width, ref }: TimelineEntryProps) { export function TimelineEntry({
colour,
delay,
duration,
hasLink,
left,
status,
start,
title,
width,
ref,
}: TimelineEntryProps) {
const formattedStartTime = formatTime(start, formatOptions); const formattedStartTime = formatTime(start, formatOptions);
const formattedDuration = formatDuration(duration); const formattedDuration = formatDuration(duration);
const delayedStart = start + delay; const delayedStart = start + delay;
const hasDelay = delay > 0; const hasDelay = delay > 0;
const lighterColour = alpha(colour, 0.7); const lighterColour = alpha(colour, 0.7);
const columnClasses = cx([style.column, width < 40 && style.smallArea]);
const contentClasses = cx([style.content, width < 20 && style.hide]);
const showTitle = width > 25; const showTitle = width > 25;
const smallArea = width < 40;
return ( return (
<div <div
ref={ref} ref={ref}
className={columnClasses} className={cx([style.column, smallArea && style.smallArea])}
style={{ style={{
'--color': colour, '--color': colour,
'--lighter': lighterColour ?? '', '--lighter': lighterColour ?? '',
@@ -53,24 +64,27 @@ export function TimelineEntry({ colour, delay, duration, left, status, start, ti
> >
{status === 'live' ? <ActiveBlock /> : <div data-status={status} className={style.timelineBlock} />} {status === 'live' ? <ActiveBlock /> : <div data-status={status} className={style.timelineBlock} />}
<div <div
className={contentClasses} className={cx([style.content, width < 20 && style.hide, !hasLink && style.separeLeft])}
data-status={status} data-status={status}
style={{ style={{
'--color': colour, '--color': colour,
}} }}
> >
<div className={hasDelay ? style.cross : undefined}>{formattedStartTime}</div> <div className={style.maybeInline}>
{hasDelay && <div className={style.delay}>{formatTime(delayedStart, formatOptions)}</div>} <div className={cx([hasDelay && style.cross])}>{formattedStartTime}</div>
{showTitle && <div>{title}</div>} {hasDelay && <div className={style.delay}>{formatTime(delayedStart, formatOptions)}</div>}
</div> {smallArea && <TimelineEntryStatus delay={delay} start={start} status={status} />}
<div className={style.timeOverview} data-status={status}> </div>
{status !== 'done' && ( {showTitle && (
<> <>
<div className={style.duration}>{formattedDuration}</div> {!smallArea && <TimelineEntryStatus delay={delay} start={start} status={status} />}
<TimelineEntryStatus delay={delay} start={start} status={status} /> <div>{title}</div>
</> </>
)} )}
</div> </div>
<div className={style.timeOverview} data-status={status}>
{status !== 'done' && <div className={style.duration}>{formattedDuration}</div>}
</div>
</div> </div>
); );
} }