mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-04 22:09:10 +00:00
feat: edit groups
This commit is contained in:
committed by
Carlos Valente
parent
4c08482258
commit
473f50b493
@@ -0,0 +1,226 @@
|
||||
@use '../blockMixins' as *;
|
||||
|
||||
$skip-opacity: 0.2;
|
||||
|
||||
.rundownEvent {
|
||||
@include block-styling;
|
||||
background-color: $block-bg;
|
||||
margin-block: 0.25rem;
|
||||
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'binder ... ... ...'
|
||||
'binder pb-actions times chip'
|
||||
'binder pb-actions title title'
|
||||
'binder pb-actions estatus estatus'
|
||||
'binder ... ... ...';
|
||||
|
||||
grid-template-columns: $block-binder-width 3rem 1fr auto;
|
||||
grid-template-rows: 0.125rem 2rem 2rem auto 0.125rem;
|
||||
align-items: center;
|
||||
padding-right: $block-clearance;
|
||||
gap: 2px;
|
||||
row-gap: 0.25rem;
|
||||
|
||||
transition-property: background-color;
|
||||
transition-duration: $transition-time-feedback;
|
||||
|
||||
@mixin declare-overrides() {
|
||||
--status-color-override: #{$gray-200};
|
||||
--status-color-active-override: #{$green-400};
|
||||
}
|
||||
|
||||
&.loaded {
|
||||
background-color: $gray-1325;
|
||||
}
|
||||
|
||||
&.play {
|
||||
background-color: $active-green;
|
||||
@include declare-overrides;
|
||||
}
|
||||
|
||||
&.roll {
|
||||
background-color: $blue-700;
|
||||
@include declare-overrides;
|
||||
}
|
||||
|
||||
&.pause {
|
||||
background-color: rgba($ontime-paused, 0.6);
|
||||
@include declare-overrides;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
outline: 1px solid $block-selected-color;
|
||||
}
|
||||
|
||||
&.hasCursor {
|
||||
outline: 1px solid $block-cursor-color;
|
||||
}
|
||||
|
||||
&.past:not(.skip) {
|
||||
.timerNote,
|
||||
.statusElements,
|
||||
.eventTitle,
|
||||
.eventNote,
|
||||
.eventTimers,
|
||||
.eventStatus,
|
||||
.playbackActions,
|
||||
.binder {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
&.skip {
|
||||
border: 1px solid $white-3;
|
||||
|
||||
.timerNote,
|
||||
.eventTitle,
|
||||
.eventNote,
|
||||
.binder,
|
||||
.eventTimers,
|
||||
.eventStatus {
|
||||
opacity: $skip-opacity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.binder {
|
||||
grid-area: binder;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
border-radius: $block-border-radius 0 0 $block-border-radius;
|
||||
|
||||
background-color: $gray-1050; // to override inline
|
||||
color: $section-white;
|
||||
font-size: 1rem;
|
||||
|
||||
.drag {
|
||||
@include drag-style;
|
||||
position: absolute;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.cue {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 5.5rem;
|
||||
text-align: center;
|
||||
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
rotate: -90deg;
|
||||
}
|
||||
}
|
||||
|
||||
.playbackActions {
|
||||
grid-area: pb-actions;
|
||||
align-self: flex-start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 0.5rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.eventTimers {
|
||||
grid-area: times;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $block-clearance;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chipSection {
|
||||
grid-area: chip;
|
||||
}
|
||||
|
||||
.titleSection {
|
||||
grid-area: title;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.nextTag {
|
||||
font-size: 1rem;
|
||||
color: $orange-500;
|
||||
letter-spacing: 0.03px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.eventTitle {
|
||||
overflow: hidden;
|
||||
max-height: calc(2.5em + 2px);
|
||||
line-height: 1.25em;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.progressBg {
|
||||
grid-area: progb;
|
||||
border-radius: 1px;
|
||||
background-color: $gray-1100;
|
||||
opacity: 1;
|
||||
height: 100%;
|
||||
overflow: hidden; /* clip foreground border radius*/
|
||||
}
|
||||
|
||||
.progressBg.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.flip {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.statusElements {
|
||||
grid-area: estatus;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'notes status'
|
||||
'progb progb';
|
||||
gap: 2px;
|
||||
grid-template-rows: auto 0.25rem;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.eventNote {
|
||||
grid-area: notes;
|
||||
display: block;
|
||||
font-size: calc(1rem - 3px);
|
||||
line-height: 1em;
|
||||
color: $block-text-color;
|
||||
|
||||
max-width: 35rem;
|
||||
|
||||
// allow multi-line text but trim before
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.eventStatus {
|
||||
grid-area: status;
|
||||
align-self: flex-end;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
color: var(--status-color-override, $gray-500);
|
||||
|
||||
.statusIcon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.statusIcon.active {
|
||||
color: var(--status-color-active-override, $active-indicator);
|
||||
}
|
||||
|
||||
.statusIcon.disabled {
|
||||
color: $gray-1000;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
IoAdd,
|
||||
IoDuplicateOutline,
|
||||
IoFolder,
|
||||
IoLink,
|
||||
IoReorderTwo,
|
||||
IoSwapVertical,
|
||||
IoTrash,
|
||||
IoUnlink,
|
||||
} from 'react-icons/io5';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { EndAction, EntryId, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||
|
||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import type { EventItemActions } from '../RundownEntry';
|
||||
import { useEventIdSwapping } from '../useEventIdSwapping';
|
||||
import { getSelectionMode, useEventSelection } from '../useEventSelection';
|
||||
|
||||
import RundownEventInner from './RundownEventInner';
|
||||
import RundownIndicators from './RundownIndicators';
|
||||
|
||||
import style from './RundownEvent.module.scss';
|
||||
|
||||
interface RundownEventProps {
|
||||
eventId: EntryId;
|
||||
cue: string;
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
duration: number;
|
||||
timeStrategy: TimeStrategy;
|
||||
linkStart: boolean;
|
||||
countToEnd: boolean;
|
||||
eventIndex: number;
|
||||
endAction: EndAction;
|
||||
timerType: TimerType;
|
||||
title: string;
|
||||
note: string;
|
||||
delay: number;
|
||||
colour: string;
|
||||
isPast: boolean;
|
||||
isNext: boolean;
|
||||
skip: boolean;
|
||||
parent: EntryId | null;
|
||||
loaded: boolean;
|
||||
hasCursor: boolean;
|
||||
playback?: Playback;
|
||||
isRolling: boolean;
|
||||
gap: number;
|
||||
isNextDay: boolean;
|
||||
dayOffset: number;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
actionHandler: (
|
||||
action: EventItemActions,
|
||||
payload?:
|
||||
| number
|
||||
| {
|
||||
field: keyof Omit<OntimeEvent, 'duration'> | 'durationOverride';
|
||||
value: unknown;
|
||||
},
|
||||
) => void;
|
||||
hasTriggers: boolean;
|
||||
}
|
||||
|
||||
export default function RundownEvent({
|
||||
eventId,
|
||||
cue,
|
||||
timeStart,
|
||||
timeEnd,
|
||||
duration,
|
||||
timeStrategy,
|
||||
linkStart,
|
||||
countToEnd,
|
||||
eventIndex,
|
||||
endAction,
|
||||
timerType,
|
||||
title,
|
||||
note,
|
||||
delay,
|
||||
colour,
|
||||
isPast,
|
||||
isNext,
|
||||
skip = false,
|
||||
parent,
|
||||
loaded,
|
||||
hasCursor,
|
||||
playback,
|
||||
isRolling,
|
||||
gap,
|
||||
isNextDay,
|
||||
dayOffset,
|
||||
totalGap,
|
||||
isLinkedToLoaded,
|
||||
actionHandler,
|
||||
hasTriggers,
|
||||
}: RundownEventProps) {
|
||||
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
|
||||
const { selectedEvents, setSelectedEvents } = useEventSelection();
|
||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const [onContextMenu] = useContextMenu<HTMLDivElement>(
|
||||
selectedEvents.size > 1
|
||||
? [
|
||||
{
|
||||
label: 'Link to previous',
|
||||
icon: IoLink,
|
||||
onClick: () =>
|
||||
actionHandler('update', {
|
||||
field: 'linkStart',
|
||||
value: 'true',
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Unlink from previous',
|
||||
icon: IoUnlink,
|
||||
onClick: () =>
|
||||
actionHandler('update', {
|
||||
field: 'linkStart',
|
||||
value: null,
|
||||
}),
|
||||
},
|
||||
{ withDivider: true, label: 'Group', icon: IoFolder, onClick: () => actionHandler('group') },
|
||||
{ withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: 'Toggle link to previous',
|
||||
icon: IoLink,
|
||||
onClick: () =>
|
||||
actionHandler('update', {
|
||||
field: 'linkStart',
|
||||
value: linkStart,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Add to swap',
|
||||
icon: IoAdd,
|
||||
onClick: () => setSelectedEventId(eventId),
|
||||
withDivider: true,
|
||||
},
|
||||
{
|
||||
label: `Swap this event with ${selectedEventId ?? ''}`,
|
||||
icon: IoSwapVertical,
|
||||
onClick: () => {
|
||||
actionHandler('swap', { field: 'id', value: selectedEventId });
|
||||
clearSelectedEventId();
|
||||
},
|
||||
isDisabled: selectedEventId == null || selectedEventId === eventId,
|
||||
},
|
||||
{ withDivider: false, label: 'Clone', icon: IoDuplicateOutline, onClick: () => actionHandler('clone') },
|
||||
{ withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
|
||||
],
|
||||
);
|
||||
|
||||
const {
|
||||
isDragging,
|
||||
attributes: dragAttributes,
|
||||
listeners: dragListeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({
|
||||
id: eventId,
|
||||
data: {
|
||||
type: 'event',
|
||||
parent,
|
||||
},
|
||||
animateLayoutChanges: () => false,
|
||||
});
|
||||
|
||||
const dragStyle = {
|
||||
zIndex: isDragging ? 2 : 'inherit',
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const binderColours = colour && getAccessibleColour(colour);
|
||||
|
||||
// move focus to element if necessary
|
||||
useEffect(() => {
|
||||
if (!hasCursor || handleRef?.current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elementInFocus = document.activeElement;
|
||||
// we know the block is the grandparent of our binder
|
||||
const blockElement = handleRef.current.closest('#event-block');
|
||||
|
||||
// we only move focus if the block doesnt already contain focus
|
||||
if (blockElement && !blockElement.contains(elementInFocus)) {
|
||||
handleRef.current.focus();
|
||||
}
|
||||
}, [hasCursor]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
threshold: 1,
|
||||
},
|
||||
);
|
||||
|
||||
const handleRefCurrent = handleRef.current;
|
||||
if (handleRefCurrent) {
|
||||
observer.observe(handleRefCurrent);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (handleRefCurrent) {
|
||||
observer.unobserve(handleRefCurrent);
|
||||
}
|
||||
};
|
||||
}, [handleRef]);
|
||||
|
||||
const isSelected = selectedEvents.has(eventId);
|
||||
const blockClasses = cx([
|
||||
style.rundownEvent,
|
||||
skip ? style.skip : null,
|
||||
isPast ? style.past : null,
|
||||
loaded ? style.loaded : null,
|
||||
playback ? style[playback] : null,
|
||||
isSelected ? style.selected : null,
|
||||
hasCursor ? style.hasCursor : null,
|
||||
]);
|
||||
|
||||
const handleFocusClick = (event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
|
||||
// event.button === 2 is a right-click
|
||||
// disable selection if the user selected events and right clicks
|
||||
// so the context menu shows up
|
||||
if (selectedEvents.size > 1 && event.button === 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
// UI indexes are 1 based
|
||||
const index = eventIndex - 1;
|
||||
const editMode = getSelectionMode(event);
|
||||
setSelectedEvents({ id: eventId, index, selectMode: editMode });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={blockClasses}
|
||||
ref={setNodeRef}
|
||||
style={dragStyle}
|
||||
onClick={handleFocusClick}
|
||||
onContextMenu={onContextMenu}
|
||||
id='event-block'
|
||||
>
|
||||
<RundownIndicators timeStart={timeStart} delay={delay} gap={gap} isNextDay={isNextDay} />
|
||||
|
||||
<div className={style.binder} style={{ ...binderColours }} tabIndex={-1}>
|
||||
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
|
||||
<IoReorderTwo />
|
||||
</span>
|
||||
<span className={style.cue}>{cue}</span>
|
||||
</div>
|
||||
|
||||
{isVisible && (
|
||||
<RundownEventInner
|
||||
timeStart={timeStart}
|
||||
timeEnd={timeEnd}
|
||||
duration={duration}
|
||||
linkStart={linkStart}
|
||||
countToEnd={countToEnd}
|
||||
timeStrategy={timeStrategy}
|
||||
eventId={eventId}
|
||||
eventIndex={eventIndex}
|
||||
endAction={endAction}
|
||||
timerType={timerType}
|
||||
title={title}
|
||||
note={note}
|
||||
delay={delay}
|
||||
isNext={isNext}
|
||||
skip={skip}
|
||||
loaded={loaded}
|
||||
playback={playback}
|
||||
isRolling={isRolling}
|
||||
dayOffset={dayOffset}
|
||||
isPast={isPast}
|
||||
totalGap={totalGap}
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
hasTriggers={hasTriggers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import {
|
||||
IoArrowDown,
|
||||
IoArrowUp,
|
||||
IoBan,
|
||||
IoFlag,
|
||||
IoFlash,
|
||||
IoPlay,
|
||||
IoPlayForward,
|
||||
IoPlaySkipForward,
|
||||
IoTime,
|
||||
} from 'react-icons/io5';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
import EditableBlockTitle from '../common/EditableBlockTitle';
|
||||
import TimeInputFlow from '../time-input-flow/TimeInputFlow';
|
||||
|
||||
import RundownEventChip from './composite/RundownEventChip';
|
||||
import EventBlockPlayback from './composite/RundownEventPlayback';
|
||||
import EventBlockProgressBar from './composite/RundownEventProgressBar';
|
||||
|
||||
import style from './RundownEvent.module.scss';
|
||||
|
||||
interface RundownEventInnerProps {
|
||||
eventId: string;
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
duration: number;
|
||||
timeStrategy: TimeStrategy;
|
||||
linkStart: boolean;
|
||||
countToEnd: boolean;
|
||||
eventIndex: number;
|
||||
endAction: EndAction;
|
||||
timerType: TimerType;
|
||||
title: string;
|
||||
note: string;
|
||||
delay: number;
|
||||
isNext: boolean;
|
||||
skip: boolean;
|
||||
loaded: boolean;
|
||||
playback?: Playback;
|
||||
isRolling: boolean;
|
||||
dayOffset: number;
|
||||
isPast: boolean;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
hasTriggers: boolean;
|
||||
}
|
||||
|
||||
export default memo(RundownEventInner);
|
||||
function RundownEventInner({
|
||||
eventId,
|
||||
timeStart,
|
||||
timeEnd,
|
||||
duration,
|
||||
timeStrategy,
|
||||
linkStart,
|
||||
countToEnd,
|
||||
endAction,
|
||||
timerType,
|
||||
title,
|
||||
note,
|
||||
delay,
|
||||
isNext,
|
||||
skip = false,
|
||||
loaded,
|
||||
playback,
|
||||
isRolling,
|
||||
dayOffset,
|
||||
isPast,
|
||||
totalGap,
|
||||
isLinkedToLoaded,
|
||||
hasTriggers,
|
||||
}: RundownEventInnerProps) {
|
||||
const [renderInner, setRenderInner] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setRenderInner(true);
|
||||
}, []);
|
||||
|
||||
const eventIsPlaying = playback === Playback.Play;
|
||||
const eventIsPaused = playback === Playback.Pause;
|
||||
|
||||
const playBtnStyles = { _hover: {} };
|
||||
if (!skip && eventIsPlaying) {
|
||||
playBtnStyles._hover = { bg: '#c05621' }; // $ontime-paused
|
||||
} else if (!skip && !eventIsPlaying) {
|
||||
playBtnStyles._hover = {};
|
||||
}
|
||||
|
||||
return !renderInner ? null : (
|
||||
<>
|
||||
<div className={style.eventTimers}>
|
||||
<TimeInputFlow
|
||||
eventId={eventId}
|
||||
timeStart={timeStart}
|
||||
timeEnd={timeEnd}
|
||||
duration={duration}
|
||||
delay={delay}
|
||||
timeStrategy={timeStrategy}
|
||||
linkStart={linkStart}
|
||||
countToEnd={countToEnd}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.titleSection}>
|
||||
<EditableBlockTitle title={title} eventId={eventId} placeholder='Event title' className={style.eventTitle} />
|
||||
{isNext && <span className={style.nextTag}>UP NEXT</span>}
|
||||
</div>
|
||||
<EventBlockPlayback
|
||||
eventId={eventId}
|
||||
skip={skip}
|
||||
isPlaying={eventIsPlaying}
|
||||
isPaused={eventIsPaused}
|
||||
loaded={loaded}
|
||||
disablePlayback={skip || isRolling}
|
||||
/>
|
||||
{!skip && (
|
||||
<RundownEventChip
|
||||
className={style.chipSection}
|
||||
id={eventId}
|
||||
timeStart={timeStart}
|
||||
delay={delay}
|
||||
dayOffset={dayOffset}
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
isPast={isPast}
|
||||
isLoaded={loaded}
|
||||
totalGap={totalGap}
|
||||
duration={duration}
|
||||
/>
|
||||
)}
|
||||
<div className={style.statusElements} id='block-status' data-timertype={timerType}>
|
||||
<span className={style.eventNote}>{note}</span>
|
||||
<div className={loaded ? style.progressBg : `${style.progressBg} ${style.hidden}`}>
|
||||
{loaded && <EventBlockProgressBar />}
|
||||
</div>
|
||||
<div className={style.eventStatus} tabIndex={-1}>
|
||||
<Tooltip label={`Time type: ${timerType}`} openDelay={tooltipDelayMid}>
|
||||
<span>
|
||||
<TimerIcon type={timerType} className={style.statusIcon} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip label={`End action: ${endAction}`} openDelay={tooltipDelayMid}>
|
||||
<span>
|
||||
<EndActionIcon action={endAction} className={style.statusIcon} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip label={`${countToEnd ? 'Count to End' : 'Count duration'}`} openDelay={tooltipDelayMid}>
|
||||
<span>
|
||||
<IoFlag className={`${style.statusIcon} ${countToEnd ? style.active : style.disabled}`} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip label='Event has Triggers' openDelay={tooltipDelayMid}>
|
||||
<span>
|
||||
<IoFlash className={`${style.statusIcon} ${hasTriggers ? style.active : style.disabled}`} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EndActionIcon(props: { action: EndAction; className: string }) {
|
||||
const { action, className } = props;
|
||||
const maybeActiveClasses = cx([action !== EndAction.None && style.active, className]);
|
||||
|
||||
if (action === EndAction.LoadNext) {
|
||||
return <IoPlaySkipForward className={maybeActiveClasses} />;
|
||||
}
|
||||
if (action === EndAction.PlayNext) {
|
||||
return <IoPlayForward className={maybeActiveClasses} />;
|
||||
}
|
||||
return <IoPlay className={className} />;
|
||||
}
|
||||
|
||||
function TimerIcon(props: { type: TimerType; className: string }) {
|
||||
const { type, className } = props;
|
||||
if (type === TimerType.CountUp) {
|
||||
return <IoArrowUp className={className} />;
|
||||
}
|
||||
if (type === TimerType.Clock) {
|
||||
return <IoTime className={className} />;
|
||||
}
|
||||
if (type === TimerType.None) {
|
||||
return <IoBan className={className} />;
|
||||
}
|
||||
return <IoArrowDown className={className} />;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/** binder + gap + pb-actions */
|
||||
$gap-left: calc(2rem + 0.25rem + 3rem);
|
||||
|
||||
.indicators {
|
||||
font-size: calc(1rem - 5px);
|
||||
position: absolute;
|
||||
top: -1em;
|
||||
z-index: $zindex-floating;;
|
||||
margin-left: $gap-left;
|
||||
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@mixin indicator($bg-colour) {
|
||||
padding: 0 0.5rem;
|
||||
border-radius: 2px;
|
||||
background-color: $bg-colour;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
.delay {
|
||||
@include indicator($ontime-delay);
|
||||
}
|
||||
|
||||
.gap {
|
||||
@include indicator($blue-500);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { formatDelay, formatGap } from './rundownEvent.utils';
|
||||
|
||||
import style from './RundownIndicators.module.scss';
|
||||
|
||||
interface RundownIndicatorProps {
|
||||
timeStart: number;
|
||||
isNextDay: boolean;
|
||||
delay: number;
|
||||
gap: number;
|
||||
}
|
||||
|
||||
export default function RundownIndicators({ timeStart, delay, gap, isNextDay }: RundownIndicatorProps) {
|
||||
const hasGap = formatGap(gap, isNextDay);
|
||||
const hasDelay = formatDelay(timeStart, delay);
|
||||
|
||||
return (
|
||||
<div className={style.indicators}>
|
||||
{hasDelay && <div className={style.delay}>{hasDelay}</div>}
|
||||
{hasGap && <div className={style.gap}>{hasGap}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { formatDelay } from '../rundownEvent.utils';
|
||||
|
||||
describe('formatDelay()', () => {
|
||||
it('adds a given delay to the start time', () => {
|
||||
const timeStart = 60000; // 1 min
|
||||
const delay = 60000; // 1 min
|
||||
const result = formatDelay(timeStart, delay);
|
||||
expect(result).toEqual('New start 00:02');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
.chip {
|
||||
background-color: $gray-1100;
|
||||
white-space: nowrap;
|
||||
|
||||
font-size: calc(1rem - 3px);
|
||||
color: $label-gray;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 2px;
|
||||
|
||||
&.over {
|
||||
color: $ontime-delay-text;
|
||||
}
|
||||
|
||||
&.under {
|
||||
color: $playback-ahead;
|
||||
}
|
||||
|
||||
&.due {
|
||||
color: $warning-orange;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useMemo } from 'react';
|
||||
import { IoCheckmarkCircle } from 'react-icons/io5';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { isPlaybackActive, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||
|
||||
import { usePlayback } from '../../../../common/hooks/useSocket';
|
||||
import useReport from '../../../../common/hooks-query/useReport';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import { formatDuration, formatTime, useTimeUntilStart } from '../../../../common/utils/time';
|
||||
import { tooltipDelayFast } from '../../../../ontimeConfig';
|
||||
|
||||
import style from './RundownEventChip.module.scss';
|
||||
|
||||
interface RundownEventChipProps {
|
||||
id: string;
|
||||
timeStart: number;
|
||||
delay: number;
|
||||
dayOffset: number;
|
||||
isPast: boolean;
|
||||
isLoaded: boolean;
|
||||
className: string;
|
||||
totalGap: number;
|
||||
duration: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
}
|
||||
|
||||
export default function RundownEventChip({
|
||||
timeStart,
|
||||
delay,
|
||||
dayOffset,
|
||||
isPast,
|
||||
isLoaded,
|
||||
className,
|
||||
totalGap,
|
||||
id,
|
||||
duration,
|
||||
isLinkedToLoaded,
|
||||
}: RundownEventChipProps) {
|
||||
const { playback } = usePlayback();
|
||||
|
||||
if (isLoaded) {
|
||||
return null; //TODO: the is a small flash of 'DUE' on the loaded event as clock data arrives before isLoaded propagates
|
||||
}
|
||||
|
||||
const playbackActive = isPlaybackActive(playback);
|
||||
|
||||
if (!playbackActive || isPast) {
|
||||
return <EventReport className={className} id={id} duration={duration} />;
|
||||
}
|
||||
|
||||
if (playbackActive) {
|
||||
// we extracted the component to avoid unnecessary calculations and re-renders
|
||||
return (
|
||||
<Tooltip label='Expected time until start' openDelay={tooltipDelayFast}>
|
||||
<div className={className}>
|
||||
<EventUntil
|
||||
timeStart={timeStart}
|
||||
delay={delay}
|
||||
dayOffset={dayOffset}
|
||||
totalGap={totalGap}
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface EventUntilProps {
|
||||
timeStart: number;
|
||||
delay: number;
|
||||
dayOffset: number;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
}
|
||||
|
||||
function EventUntil(props: EventUntilProps) {
|
||||
const { timeStart, delay, dayOffset, totalGap, isLinkedToLoaded } = props;
|
||||
|
||||
const timeUntil = useTimeUntilStart({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded });
|
||||
const isDue = timeUntil < MILLIS_PER_SECOND;
|
||||
|
||||
const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`;
|
||||
|
||||
return <div className={cx([style.chip, isDue && style.due])}>{timeUntilString}</div>;
|
||||
}
|
||||
|
||||
interface EventReportProps {
|
||||
className: string;
|
||||
id: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
function EventReport(props: EventReportProps) {
|
||||
const { className, id, duration } = props;
|
||||
const { data } = useReport();
|
||||
const currentReport = data[id];
|
||||
|
||||
const [value, overUnderStyle, tooltip] = useMemo(() => {
|
||||
if (!currentReport) {
|
||||
return [null, 'none', ''];
|
||||
}
|
||||
|
||||
const { startedAt, endedAt } = currentReport;
|
||||
if (!startedAt || !endedAt) {
|
||||
return [null, 'none', ''];
|
||||
}
|
||||
|
||||
const actualDuration = endedAt - startedAt;
|
||||
const difference = actualDuration - duration;
|
||||
const absDifference = Math.abs(difference);
|
||||
|
||||
if (absDifference < MILLIS_PER_SECOND) {
|
||||
return ['ontime', 'ontime', 'Event finished ontime'];
|
||||
}
|
||||
|
||||
const isOver = difference > 0;
|
||||
|
||||
const fullTimeValue = formatTime(absDifference);
|
||||
|
||||
const tooltip = `Event ran ${isOver ? 'over' : 'under'} time by ${fullTimeValue}`;
|
||||
|
||||
const value = `${isOver ? '+' : '-'}${formatDuration(absDifference, absDifference > 2 * MILLIS_PER_MINUTE)}`;
|
||||
return [value, isOver ? 'over' : 'under', tooltip];
|
||||
}, [currentReport, duration]);
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip label={tooltip} openDelay={tooltipDelayFast}>
|
||||
<div className={cx([style.chip, style[overUnderStyle], className])}>
|
||||
{value === 'ontime' ? <IoCheckmarkCircle size='1.1rem' /> : value}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { memo, MouseEvent } from 'react';
|
||||
import { IoPause, IoPlay, IoReload, IoRemoveCircle, IoRemoveCircleOutline } from 'react-icons/io5';
|
||||
|
||||
import TooltipActionBtn from '../../../../common/components/buttons/TooltipActionBtn';
|
||||
import { useEntryActions } from '../../../../common/hooks/useEntryAction';
|
||||
import { setEventPlayback } from '../../../../common/hooks/useSocket';
|
||||
import { tooltipDelayMid } from '../../../../ontimeConfig';
|
||||
|
||||
import style from '../RundownEvent.module.scss';
|
||||
|
||||
const blockBtnStyle = {
|
||||
size: 'sm',
|
||||
};
|
||||
|
||||
type StyleVariant = {
|
||||
'aria-label': string;
|
||||
tooltip: string;
|
||||
backgroundColor: string;
|
||||
_hover: { backgroundColor?: string };
|
||||
};
|
||||
|
||||
const tooltipProps = {
|
||||
openDelay: tooltipDelayMid,
|
||||
};
|
||||
|
||||
interface RundownEventPlaybackProps {
|
||||
eventId: string;
|
||||
skip: boolean;
|
||||
isPlaying: boolean;
|
||||
isPaused: boolean;
|
||||
loaded: boolean;
|
||||
disablePlayback: boolean;
|
||||
}
|
||||
|
||||
export default memo(RundownEventPlayback);
|
||||
function RundownEventPlayback({
|
||||
eventId,
|
||||
skip,
|
||||
isPlaying,
|
||||
isPaused,
|
||||
loaded,
|
||||
disablePlayback,
|
||||
}: RundownEventPlaybackProps) {
|
||||
const { updateEntry } = useEntryActions();
|
||||
|
||||
const toggleSkip = (event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
updateEntry({ id: eventId, skip: !skip });
|
||||
};
|
||||
|
||||
const actionHandler = (event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
// is playing -> pause
|
||||
// is paused -> continue
|
||||
// otherwise -> start
|
||||
if (isPlaying) {
|
||||
setEventPlayback.pause();
|
||||
} else if (isPaused) {
|
||||
setEventPlayback.start();
|
||||
} else {
|
||||
setEventPlayback.startEvent(eventId);
|
||||
}
|
||||
};
|
||||
|
||||
const load = (event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
setEventPlayback.loadEvent(eventId);
|
||||
};
|
||||
|
||||
const buttonVariant: Partial<StyleVariant> = {};
|
||||
|
||||
if (isPaused) {
|
||||
// continue
|
||||
buttonVariant['aria-label'] = 'Continue event';
|
||||
buttonVariant.tooltip = 'Continue event';
|
||||
buttonVariant.backgroundColor = '#339E4E';
|
||||
buttonVariant._hover = { backgroundColor: '#339E4Eee' };
|
||||
} else if (isPlaying) {
|
||||
// pause
|
||||
buttonVariant['aria-label'] = 'Pause event';
|
||||
buttonVariant.tooltip = 'Pause event';
|
||||
buttonVariant.backgroundColor = '#c05621';
|
||||
buttonVariant._hover = { backgroundColor: '#c05621ee' };
|
||||
} else {
|
||||
// start
|
||||
buttonVariant['aria-label'] = 'Start event';
|
||||
buttonVariant.tooltip = 'Start event';
|
||||
if (!disablePlayback) {
|
||||
buttonVariant._hover = { backgroundColor: '#339E4E' };
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.playbackActions}>
|
||||
<TooltipActionBtn
|
||||
variant='ontime-subtle-white'
|
||||
aria-label='Skip event'
|
||||
tooltip='Skip event'
|
||||
icon={skip ? <IoRemoveCircle /> : <IoRemoveCircleOutline />}
|
||||
backgroundColor={skip ? '#B20000' : undefined}
|
||||
_hover={{ backgroundColor: '#FF7878' }}
|
||||
{...tooltipProps}
|
||||
{...blockBtnStyle}
|
||||
clickHandler={toggleSkip}
|
||||
tabIndex={-1}
|
||||
isDisabled={loaded}
|
||||
/>
|
||||
<TooltipActionBtn
|
||||
variant='ontime-subtle-white'
|
||||
aria-label='Load event'
|
||||
tooltip='Load event'
|
||||
icon={<IoReload className={style.flip} />}
|
||||
isDisabled={disablePlayback}
|
||||
{...tooltipProps}
|
||||
{...blockBtnStyle}
|
||||
clickHandler={load}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<TooltipActionBtn
|
||||
variant='ontime-subtle-white'
|
||||
aria-label='Start event'
|
||||
tooltip='Start event'
|
||||
icon={!isPlaying ? <IoPlay /> : <IoPause />}
|
||||
isDisabled={disablePlayback}
|
||||
{...tooltipProps}
|
||||
{...blockBtnStyle}
|
||||
{...buttonVariant}
|
||||
clickHandler={actionHandler}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
.progressBar {
|
||||
height: 100%;
|
||||
width: 0;
|
||||
border-radius: 1px 0 0 1px;
|
||||
|
||||
transition: 1s linear;
|
||||
transition-property: width;
|
||||
background-color: $gray-200;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useTimer } from '../../../../common/hooks/useSocket';
|
||||
import { getProgress } from '../../../../common/utils/getProgress';
|
||||
|
||||
import style from './RundownEventProgressBar.module.scss';
|
||||
|
||||
export default function RundownEventProgressBar() {
|
||||
const timer = useTimer();
|
||||
|
||||
const progress = getProgress(timer.current, timer.duration);
|
||||
|
||||
return <div className={style.progressBar} style={{ width: `${progress}%` }} />;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { millisToString, removeTrailingZero } from 'ontime-utils';
|
||||
|
||||
import { formatDuration } from '../../../common/utils/time';
|
||||
|
||||
export function formatDelay(timeStart: number, delay: number): string | undefined {
|
||||
if (!delay) return;
|
||||
|
||||
const delayedStart = Math.max(0, timeStart + delay);
|
||||
|
||||
const timeTag = removeTrailingZero(millisToString(delayedStart));
|
||||
return `New start ${timeTag}`;
|
||||
}
|
||||
export function formatGap(gap: number, isNextDay: boolean) {
|
||||
if (gap === 0) {
|
||||
if (isNextDay) {
|
||||
// We show a next day warning even if there is no gap
|
||||
return '(next day)';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const gapString = formatDuration(Math.abs(gap), false);
|
||||
return `${gap < 0 ? 'Overlap' : 'Gap'} ${gapString}${isNextDay ? ' (next day)' : ''}`;
|
||||
}
|
||||
Reference in New Issue
Block a user