mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-19 22:24:11 +00:00
V2 monorepo (#285)
* refactor(project structure): UI * refactor(project structure): extract utilities * refactor(project structure): remove unused * refactor(project structure): electron * refactor(project structure): server refactor: migrate to vitest refactor: monorepo config * refactor: extract application menu * refactor: exit process * refactor: extract tray menu * chore: electron build * Added Seconds in studio clock #282 --------- Co-authored-by: Fabian Posenau <fabian@fphome.de> --------- Co-authored-by: Fabian Posenau <fabian.p99@gmx.de> Co-authored-by: Fabian Posenau <fabian@fphome.de>
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
.eventContainer {
|
||||
margin-top: 1em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 4px 8px 0;
|
||||
overflow-y: scroll;
|
||||
-ms-overflow-style: -ms-autohiding-scrollbar;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.empty {
|
||||
opacity: 0.3;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.alignCenter {
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
.spaceTop {
|
||||
margin-top: 24px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { createRef, Fragment, useCallback, useContext, useEffect } from 'react';
|
||||
import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defaultPublicAtom, showQuickEntryAtom, startTimeIsLastEndAtom } from '../../common/atoms/LocalEventSettings';
|
||||
import Empty from '../../common/components/state/Empty';
|
||||
import { CursorContext } from '../../common/context/CursorContext';
|
||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||
import { useRundownEditor } from '../../common/hooks/useSocket';
|
||||
import { OntimeRundown, SupportedEvent } from '../../common/models/EventTypes';
|
||||
import { cloneEvent } from '../../common/utils/eventsManager';
|
||||
|
||||
import QuickAddBlock from './quick-add-block/QuickAddBlock';
|
||||
import RundownEntry from './RundownEntry';
|
||||
|
||||
import style from './Rundown.module.scss';
|
||||
|
||||
interface RundownProps {
|
||||
entries: OntimeRundown;
|
||||
}
|
||||
|
||||
export default function Rundown(props: RundownProps) {
|
||||
const { entries } = props;
|
||||
const { data } = useRundownEditor();
|
||||
const { cursor, moveCursorUp, moveCursorDown, moveCursorTo, isCursorLocked } =
|
||||
useContext(CursorContext);
|
||||
const startTimeIsLastEnd = useAtomValue(startTimeIsLastEndAtom);
|
||||
const defaultPublic = useAtomValue(defaultPublicAtom);
|
||||
const { addEvent, reorderEvent } = useEventAction();
|
||||
const cursorRef = createRef<HTMLDivElement>();
|
||||
const showQuickEntry = useAtomValue(showQuickEntryAtom);
|
||||
|
||||
const insertAtCursor = useCallback(
|
||||
(type: SupportedEvent | 'clone', cursor: number) => {
|
||||
if (cursor === -1) {
|
||||
if (type === 'clone') {
|
||||
return;
|
||||
}
|
||||
addEvent({ type });
|
||||
} else {
|
||||
const previousEvent = entries?.[cursor];
|
||||
const nextEvent = entries?.[cursor + 1];
|
||||
|
||||
// prevent adding two non-event blocks consecutively
|
||||
const isPreviousDifferent = previousEvent?.type !== type;
|
||||
const isNextDifferent = nextEvent?.type !== type;
|
||||
if (type === 'clone' && previousEvent?.type === SupportedEvent.Event) {
|
||||
const newEvent = cloneEvent(previousEvent);
|
||||
newEvent.after = previousEvent.id;
|
||||
addEvent(newEvent);
|
||||
} else if (type === SupportedEvent.Event) {
|
||||
const newEvent = {
|
||||
type: SupportedEvent.Event,
|
||||
};
|
||||
const options = {
|
||||
defaultPublic: defaultPublic,
|
||||
startTimeIsLastEnd: startTimeIsLastEnd,
|
||||
lastEventId: previousEvent.id,
|
||||
after: previousEvent.id,
|
||||
};
|
||||
addEvent(newEvent, options);
|
||||
} else if (isPreviousDifferent && isNextDifferent && type !== 'clone') {
|
||||
addEvent({ type }, { after: previousEvent.id });
|
||||
}
|
||||
}
|
||||
},
|
||||
[addEvent, defaultPublic, entries, startTimeIsLastEnd],
|
||||
);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyPress = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
// handle held key
|
||||
if (event.repeat) return;
|
||||
// Check if the alt key is pressed
|
||||
if (event.altKey && (!event.ctrlKey || !event.shiftKey)) {
|
||||
switch (event.code) {
|
||||
case 'ArrowDown': {
|
||||
if (cursor < entries.length - 1) moveCursorDown();
|
||||
break;
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
if (cursor > 0) moveCursorUp();
|
||||
break;
|
||||
}
|
||||
case 'KeyE': {
|
||||
event.preventDefault();
|
||||
if (cursor === -1) return;
|
||||
insertAtCursor(SupportedEvent.Event, cursor);
|
||||
break;
|
||||
}
|
||||
case 'KeyD': {
|
||||
event.preventDefault();
|
||||
if (cursor < 0) return;
|
||||
insertAtCursor(SupportedEvent.Delay, cursor);
|
||||
break;
|
||||
}
|
||||
case 'KeyB': {
|
||||
event.preventDefault();
|
||||
if (cursor < 0) return;
|
||||
insertAtCursor(SupportedEvent.Block, cursor);
|
||||
break;
|
||||
}
|
||||
case 'KeyC': {
|
||||
event.preventDefault();
|
||||
if (cursor < 0) return;
|
||||
insertAtCursor('clone', cursor);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[cursor, entries.length, insertAtCursor, moveCursorDown, moveCursorUp],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// attach the event listener
|
||||
document.addEventListener('keydown', handleKeyPress);
|
||||
|
||||
if (cursor > entries.length - 1) moveCursorTo(entries.length - 1);
|
||||
if (entries.length > 0 && cursor === -1) moveCursorTo(0);
|
||||
|
||||
// remove the event listener
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyPress);
|
||||
};
|
||||
}, [handleKeyPress, cursor, entries, moveCursorTo]);
|
||||
|
||||
// when cursor moves, view should follow
|
||||
useEffect(() => {
|
||||
if (cursorRef.current == null) return;
|
||||
cursorRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'start',
|
||||
});
|
||||
}, [cursorRef]);
|
||||
|
||||
// if selected event
|
||||
// or cursor settings changed
|
||||
useEffect(() => {
|
||||
// and if we are locked
|
||||
if (!isCursorLocked || !data?.selectedEventId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// move cursor
|
||||
let gotoIndex = -1;
|
||||
let found = false;
|
||||
for (const e of entries) {
|
||||
gotoIndex++;
|
||||
if (e.id === data.selectedEventId) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) {
|
||||
// move cursor
|
||||
moveCursorTo(gotoIndex);
|
||||
}
|
||||
}, [data?.selectedEventId, entries, isCursorLocked, moveCursorTo]);
|
||||
|
||||
const handleOnDragEnd = useCallback(
|
||||
(result: DropResult) => {
|
||||
// drop outside of area
|
||||
if (!result?.destination) return;
|
||||
|
||||
// no change
|
||||
if (result.destination.index === result.source.index) return;
|
||||
|
||||
// Call API
|
||||
reorderEvent(result.draggableId, result.source.index, result.destination.index);
|
||||
},
|
||||
[reorderEvent],
|
||||
);
|
||||
|
||||
if (!entries.length) {
|
||||
return (
|
||||
<div className={style.alignCenter}>
|
||||
<Empty text='No data yet' style={{ marginTop: '7vh' }} />
|
||||
<Button
|
||||
onClick={() => insertAtCursor(SupportedEvent.Event, -1)}
|
||||
variant='ontime-filled'
|
||||
className={style.spaceTop}
|
||||
leftIcon={<IoAdd />}
|
||||
>
|
||||
Create Event
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
let cumulativeDelay = 0;
|
||||
let eventIndex = -1;
|
||||
let previousEnd = 0;
|
||||
let thisEnd = 0;
|
||||
let previousEventId: string | undefined;
|
||||
|
||||
return (
|
||||
<div className={style.eventContainer}>
|
||||
<DragDropContext onDragEnd={handleOnDragEnd}>
|
||||
<Droppable droppableId='eventlist'>
|
||||
{(provided) => (
|
||||
<div className={style.list} {...provided.droppableProps} ref={provided.innerRef}>
|
||||
{entries.map((entry, index) => {
|
||||
if (index === 0) {
|
||||
cumulativeDelay = 0;
|
||||
eventIndex = -1;
|
||||
}
|
||||
if (entry.type === 'delay' && entry.duration != null) {
|
||||
cumulativeDelay += entry.duration;
|
||||
} else if (entry.type === 'block') {
|
||||
cumulativeDelay = 0;
|
||||
} else if (entry.type === 'event') {
|
||||
eventIndex++;
|
||||
previousEnd = thisEnd;
|
||||
thisEnd = entry.timeEnd;
|
||||
previousEventId = entry.id;
|
||||
}
|
||||
const isLast = index === entries.length - 1;
|
||||
const isSelected = data?.selectedEventId === entry.id;
|
||||
const isNext = data?.nextEventId === entry.id;
|
||||
|
||||
return (
|
||||
<Fragment key={entry.id}>
|
||||
<div ref={cursor === index ? cursorRef : undefined}>
|
||||
<RundownEntry
|
||||
type={entry.type}
|
||||
index={index}
|
||||
eventIndex={eventIndex}
|
||||
data={entry}
|
||||
selected={isSelected}
|
||||
hasCursor={cursor === index}
|
||||
next={isNext}
|
||||
delay={cumulativeDelay}
|
||||
previousEnd={previousEnd}
|
||||
previousEventId={previousEventId}
|
||||
playback={isSelected ? data.playback || undefined : undefined}
|
||||
/>
|
||||
</div>
|
||||
{((showQuickEntry && index === cursor) || isLast) && (
|
||||
<QuickAddBlock
|
||||
showKbd={index === cursor}
|
||||
eventId={entry.id}
|
||||
previousEventId={previousEventId}
|
||||
disableAddDelay={entry.type === 'delay'}
|
||||
disableAddBlock={entry.type === 'block'}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Rundown.propTypes = {
|
||||
entries: PropTypes.array,
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
|
||||
import { defaultPublicAtom, editorEventId, startTimeIsLastEndAtom } from '../../common/atoms/LocalEventSettings';
|
||||
import { CursorContext } from '../../common/context/CursorContext';
|
||||
import { LoggingContext } from '../../common/context/LoggingContext';
|
||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||
import { OntimeEvent, OntimeRundownEntry, SupportedEvent } from '../../common/models/EventTypes';
|
||||
import { Playback } from '../../common/models/OntimeTypes';
|
||||
import { cloneEvent } from '../../common/utils/eventsManager';
|
||||
import { calculateDuration } from '../../common/utils/timesManager';
|
||||
|
||||
import BlockBlock from './block-block/BlockBlock';
|
||||
import DelayBlock from './delay-block/DelayBlock';
|
||||
import EventBlock from './event-block/EventBlock';
|
||||
|
||||
export type EventItemActions =
|
||||
'set-cursor'
|
||||
| 'event'
|
||||
| 'delay'
|
||||
| 'block'
|
||||
| 'delete'
|
||||
| 'clone'
|
||||
| 'update'
|
||||
|
||||
interface RundownEntryProps {
|
||||
type: SupportedEvent;
|
||||
index: number;
|
||||
eventIndex: number;
|
||||
data: OntimeRundownEntry;
|
||||
selected: boolean;
|
||||
hasCursor: boolean;
|
||||
next: boolean;
|
||||
delay: number;
|
||||
previousEnd: number;
|
||||
previousEventId?: string;
|
||||
playback?: Playback; // we only care about this if this event is playing
|
||||
}
|
||||
|
||||
export default function RundownEntry(props: RundownEntryProps) {
|
||||
const {
|
||||
index,
|
||||
eventIndex,
|
||||
data,
|
||||
selected,
|
||||
hasCursor,
|
||||
next,
|
||||
delay,
|
||||
previousEnd,
|
||||
previousEventId,
|
||||
playback,
|
||||
} = props;
|
||||
const { emitError } = useContext(LoggingContext);
|
||||
const startTimeIsLastEnd = useAtomValue(startTimeIsLastEndAtom);
|
||||
const defaultPublic = useAtomValue(defaultPublicAtom);
|
||||
const { addEvent, updateEvent, deleteEvent } = useEventAction();
|
||||
const { moveCursorTo } = useContext(CursorContext);
|
||||
const [openId, setOpenId] = useAtom(editorEventId);
|
||||
|
||||
// Create / delete new events
|
||||
type FieldValue = {
|
||||
field: keyof Omit<OntimeEvent, 'duration'> | 'durationOverride';
|
||||
value: unknown;
|
||||
}
|
||||
const actionHandler = useCallback(
|
||||
(action: EventItemActions, payload?: number | FieldValue) => {
|
||||
switch (action) {
|
||||
case 'set-cursor': {
|
||||
moveCursorTo(payload as number);
|
||||
break;
|
||||
}
|
||||
case 'event': {
|
||||
const newEvent = { type: SupportedEvent.Event };
|
||||
const options = {
|
||||
startTimeIsLastEnd,
|
||||
defaultPublic,
|
||||
lastEventId: previousEventId,
|
||||
after: data.id,
|
||||
};
|
||||
addEvent(newEvent, options);
|
||||
break;
|
||||
}
|
||||
case 'delay': {
|
||||
addEvent({ type: SupportedEvent.Delay }, { after: data.id });
|
||||
break;
|
||||
}
|
||||
case 'block': {
|
||||
addEvent({ type: SupportedEvent.Block }, { after: data.id });
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
if (openId === data.id) {
|
||||
setOpenId(null);
|
||||
}
|
||||
deleteEvent(data.id);
|
||||
break;
|
||||
}
|
||||
case 'clone': {
|
||||
const newEvent = cloneEvent(data as OntimeEvent, data.id);
|
||||
addEvent(newEvent);
|
||||
break;
|
||||
}
|
||||
case 'update': {
|
||||
// Handles and filters update requests
|
||||
const { field, value } = payload as FieldValue;
|
||||
const newData: Partial<OntimeEvent> = { id: data.id };
|
||||
|
||||
if (field === 'durationOverride' && data.type === SupportedEvent.Event) {
|
||||
// duration defines timeEnd
|
||||
newData.duration = value as number;
|
||||
newData.timeEnd = data.timeStart + (value as number);
|
||||
updateEvent(newData);
|
||||
} else if (field === 'timeStart' && data.type === SupportedEvent.Event) {
|
||||
newData.duration = calculateDuration(value as number, data.timeEnd);
|
||||
newData.timeStart = value as number;
|
||||
updateEvent(newData);
|
||||
} else if (field === 'timeEnd' && data.type === SupportedEvent.Event) {
|
||||
newData.duration = calculateDuration(data.timeStart, value as number);
|
||||
newData.timeEnd = value as number;
|
||||
updateEvent(newData);
|
||||
} else if (field in data) {
|
||||
// @ts-expect-error not sure how to type this
|
||||
newData[field] = value;
|
||||
updateEvent(newData);
|
||||
} else {
|
||||
emitError(`Unknown field: ${field}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
emitError(`Unknown action called: ${action}`);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[
|
||||
addEvent,
|
||||
data,
|
||||
defaultPublic,
|
||||
deleteEvent,
|
||||
emitError,
|
||||
moveCursorTo,
|
||||
openId,
|
||||
previousEventId,
|
||||
setOpenId,
|
||||
startTimeIsLastEnd,
|
||||
updateEvent,
|
||||
],
|
||||
);
|
||||
|
||||
if (data.type === SupportedEvent.Event) {
|
||||
return (
|
||||
<EventBlock
|
||||
timeStart={data.timeStart}
|
||||
timeEnd={data.timeEnd}
|
||||
duration={data.duration}
|
||||
index={index}
|
||||
eventIndex={eventIndex + 1}
|
||||
eventId={data.id}
|
||||
isPublic={data.isPublic}
|
||||
title={data.title}
|
||||
note={data.note}
|
||||
delay={delay}
|
||||
previousEnd={previousEnd}
|
||||
colour={data.colour}
|
||||
next={next}
|
||||
skip={data.skip}
|
||||
selected={selected}
|
||||
hasCursor={hasCursor}
|
||||
playback={playback}
|
||||
actionHandler={actionHandler}
|
||||
/>
|
||||
);
|
||||
} else if (data.type === SupportedEvent.Block) {
|
||||
return <BlockBlock
|
||||
index={index}
|
||||
data={data}
|
||||
hasCursor={hasCursor}
|
||||
actionHandler={actionHandler}
|
||||
/>;
|
||||
} else if (data.type === SupportedEvent.Delay) {
|
||||
return <DelayBlock
|
||||
index={index}
|
||||
data={data}
|
||||
hasCursor={hasCursor}
|
||||
actionHandler={actionHandler}
|
||||
/>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
|
||||
import { CursorProvider } from '../../common/context/CursorContext';
|
||||
import { handleLinks } from '../../common/utils/linkUtils';
|
||||
|
||||
import RundownWrapper from './RundownWrapper';
|
||||
|
||||
import style from '../editors/Editor.module.scss';
|
||||
|
||||
export default function RundownExport() {
|
||||
return (
|
||||
<CursorProvider>
|
||||
<Box className={style.editor} data-testid='panel-rundown'>
|
||||
<IoArrowUp
|
||||
className={style.corner}
|
||||
onClick={(event) => handleLinks(event, 'rundown')}
|
||||
/>
|
||||
<ErrorBoundary>
|
||||
<RundownWrapper />
|
||||
</ErrorBoundary>
|
||||
</Box>
|
||||
</CursorProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import Empty from '../../common/components/state/Empty';
|
||||
import useRundown from '../../common/hooks-query/useRundown';
|
||||
import RundownMenu from '../../features/menu/RundownMenu';
|
||||
|
||||
import Rundown from './Rundown';
|
||||
|
||||
import styles from '../editors/Editor.module.scss';
|
||||
|
||||
export default function RundownWrapper() {
|
||||
const { data, status } = useRundown();
|
||||
|
||||
return (
|
||||
<>
|
||||
<RundownMenu />
|
||||
<div className={styles.content}>
|
||||
{status === 'success' && data ? (
|
||||
<Rundown entries={data} />
|
||||
) : (
|
||||
<Empty text='Connecting to server' />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
@use '../../theme/ontimeColours' as *;
|
||||
@use '../../theme/v2Styles' as *;
|
||||
|
||||
$block-gap: 4px;
|
||||
$block-element-spacing: 4px;
|
||||
$block-binder-width: 32px;
|
||||
$block-clearance: 8px;
|
||||
$block-border-radius: 8px;
|
||||
$block-text-color: $gray-50;
|
||||
$block-bg: $gray-1200;
|
||||
$block-box-shadow: rgba(0, 0, 0, 0.5) 0 0 3px 2px;
|
||||
$secondary-block-height: 40px;
|
||||
$block-cursor-color: $blue-400;
|
||||
|
||||
@mixin block-styling() {
|
||||
box-sizing: content-box;
|
||||
background-color: $block-bg;
|
||||
border: 1px solid $white-10;
|
||||
font-family: $ontime-font-family;
|
||||
border-radius: $block-border-radius;
|
||||
margin: 4px 2px;
|
||||
}
|
||||
|
||||
@mixin block-spacing() {
|
||||
padding: 4px 8px 4px 2px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
@mixin drag-style() {
|
||||
font-size: 20px;
|
||||
justify-self: center;
|
||||
opacity: 0.3;
|
||||
cursor: grab;
|
||||
transition: opacity 0.3s;
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
@use '../blockMixins' as *;
|
||||
|
||||
.block {
|
||||
@include block-spacing;
|
||||
@include block-styling;
|
||||
box-sizing: content-box;
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr auto;
|
||||
align-items: center;
|
||||
height: $secondary-block-height;
|
||||
|
||||
&.hasCursor {
|
||||
outline: 1px solid $block-cursor-color;
|
||||
}
|
||||
}
|
||||
|
||||
.drag {
|
||||
@include drag-style;
|
||||
}
|
||||
|
||||
.actionOverlay {
|
||||
justify-self: flex-end;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Draggable } from 'react-beautiful-dnd';
|
||||
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
|
||||
|
||||
import { OntimeBlock, OntimeEvent } from '../../../common/models/EventTypes';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import BlockActionMenu from '../event-block/composite/BlockActionMenu';
|
||||
import { EventItemActions } from '../RundownEntry';
|
||||
|
||||
import style from './BlockBlock.module.scss';
|
||||
|
||||
interface BlockBlockProps {
|
||||
index: number;
|
||||
data: OntimeBlock;
|
||||
hasCursor: boolean;
|
||||
actionHandler: (action: EventItemActions, payload?: number | { field: keyof OntimeEvent, value: unknown }) => void;
|
||||
}
|
||||
|
||||
export default function BlockBlock(props: BlockBlockProps) {
|
||||
const { index, data, hasCursor, actionHandler } = props;
|
||||
const onFocusRef = useRef<null | HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasCursor) {
|
||||
onFocusRef?.current?.focus();
|
||||
}
|
||||
}, [hasCursor])
|
||||
|
||||
const blockClasses = cx([
|
||||
style.block,
|
||||
hasCursor ? style.hasCursor : null,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Draggable key={data.id} draggableId={data.id} index={index}>
|
||||
{(provided) => (
|
||||
<div className={blockClasses} {...provided.draggableProps} ref={provided.innerRef}>
|
||||
<span className={style.drag} {...provided.dragHandleProps} ref={onFocusRef}>
|
||||
<IoReorderTwo />
|
||||
</span>
|
||||
<BlockActionMenu
|
||||
className={style.actionOverlay}
|
||||
showAdd
|
||||
showDelay
|
||||
enableDelete
|
||||
actionHandler={actionHandler}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
@use '../blockMixins' as *;
|
||||
|
||||
.delay {
|
||||
@include block-spacing;
|
||||
@include block-styling;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr auto;
|
||||
grid-template-areas: 'drag inpt btns';
|
||||
align-items: center;
|
||||
height: $secondary-block-height;
|
||||
gap: 8px;
|
||||
|
||||
&.hasCursor {
|
||||
outline: 1px solid $block-cursor-color;
|
||||
}
|
||||
}
|
||||
|
||||
.drag {
|
||||
@include drag-style;
|
||||
grid-area: drag;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { Draggable } from 'react-beautiful-dnd';
|
||||
import { Button, HStack } from '@chakra-ui/react';
|
||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
||||
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
|
||||
import DelayInput from '../../../common/components/input/delay-input/DelayInput';
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import { millisToMinutes } from '../../../common/utils/dateConfig';
|
||||
|
||||
import { OntimeDelay, OntimeEvent } from '../../../common/models/EventTypes';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import BlockActionMenu from '../event-block/composite/BlockActionMenu';
|
||||
import { EventItemActions } from '../RundownEntry';
|
||||
|
||||
import style from './DelayBlock.module.scss';
|
||||
|
||||
interface DelayBlockProps {
|
||||
data: OntimeDelay,
|
||||
index: number;
|
||||
hasCursor: boolean;
|
||||
actionHandler: (action: EventItemActions, payload?: number | { field: keyof OntimeEvent, value: unknown }) => void;
|
||||
}
|
||||
|
||||
export default function DelayBlock(props: DelayBlockProps) {
|
||||
const { data, index, hasCursor, actionHandler } = props;
|
||||
const { applyDelay, updateEvent } = useEventAction();
|
||||
const onFocusRef = useRef<null | HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasCursor) {
|
||||
onFocusRef?.current?.focus();
|
||||
}
|
||||
}, [hasCursor])
|
||||
|
||||
const applyDelayHandler = useCallback(() => {
|
||||
applyDelay(data.id);
|
||||
}, [data.id, applyDelay]);
|
||||
|
||||
const delaySubmitHandler = useCallback(
|
||||
(value: number) => {
|
||||
const newEvent = {
|
||||
id: data.id,
|
||||
duration: value * 60000,
|
||||
};
|
||||
|
||||
updateEvent(newEvent);
|
||||
},
|
||||
[data.id, updateEvent],
|
||||
);
|
||||
|
||||
const blockClasses = cx([
|
||||
style.delay,
|
||||
hasCursor ? style.hasCursor : null,
|
||||
]);
|
||||
|
||||
const delayValue = data.duration != null ? millisToMinutes(data.duration) : undefined;
|
||||
|
||||
return (
|
||||
<Draggable key={data.id} draggableId={data.id} index={index}>
|
||||
{(provided) => (
|
||||
<div className={blockClasses} {...provided.draggableProps} ref={provided.innerRef}>
|
||||
<span className={style.drag} {...provided.dragHandleProps} ref={onFocusRef}>
|
||||
<IoReorderTwo />
|
||||
</span>
|
||||
<DelayInput
|
||||
value={delayValue}
|
||||
submitHandler={delaySubmitHandler}
|
||||
/>
|
||||
<HStack spacing='8px' className={style.actionOverlay}>
|
||||
<Button
|
||||
onClick={applyDelayHandler}
|
||||
size='sm'
|
||||
leftIcon={<IoCheckmark />}
|
||||
variant='ontime-subtle-white'
|
||||
>
|
||||
Apply delay
|
||||
</Button>
|
||||
<BlockActionMenu showAdd enableDelete actionHandler={actionHandler} />
|
||||
</HStack>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
@use '../../../theme/ontimeColours' as *;
|
||||
@use '../blockMixins' as *;
|
||||
|
||||
.eventBlock {
|
||||
@include block-styling;
|
||||
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"binder ... ... ..."
|
||||
"binder pb-actions times actions"
|
||||
"binder pb-actions title title"
|
||||
"binder pb-actions estatus estatus"
|
||||
"binder ... ... ...";
|
||||
|
||||
grid-template-columns: $block-binder-width auto 1fr auto;
|
||||
grid-template-rows: 4px 36px 36px 36px 4px;
|
||||
align-items: center;
|
||||
padding-right: $block-clearance;
|
||||
gap: 2px;
|
||||
|
||||
&.selected {
|
||||
background-color: $gray-1350;
|
||||
}
|
||||
|
||||
&.hasCursor {
|
||||
outline: 1px solid $block-cursor-color;
|
||||
}
|
||||
|
||||
&.skip {
|
||||
border: 1px solid $white-3;
|
||||
box-shadow: none;
|
||||
|
||||
.delayNote,
|
||||
.eventTitle,
|
||||
.eventNote,
|
||||
.binder,
|
||||
.eventTimers,
|
||||
.eventStatus {
|
||||
opacity: $opacity-disabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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: 17px;
|
||||
|
||||
.drag {
|
||||
@include drag-style;
|
||||
position: absolute;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.playbackActions {
|
||||
grid-area: pb-actions;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.eventTimers {
|
||||
grid-area: times;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $block-clearance;
|
||||
height: 100%;
|
||||
|
||||
.delayNote {
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
color: $ontime-delay-text;
|
||||
}
|
||||
}
|
||||
|
||||
.eventTitle {
|
||||
grid-area: title;
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&.noTitle {
|
||||
.preview {
|
||||
opacity: $opacity-disabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.eventActions {
|
||||
grid-area: actions;
|
||||
display: flex;
|
||||
gap: $block-clearance;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
|
||||
.eventOptions {
|
||||
margin: $element-spacing 16px $element-spacing 0;
|
||||
}
|
||||
|
||||
.progressBg {
|
||||
grid-area: progb;
|
||||
border-radius: 2px;
|
||||
background-color: $gray-1100;
|
||||
opacity: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.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 4px;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.eventNote {
|
||||
grid-area: notes;
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: $block-text-color;
|
||||
line-height: 13px;
|
||||
}
|
||||
|
||||
.eventStatus {
|
||||
grid-area: status;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
|
||||
.statusIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: $gray-1000;
|
||||
}
|
||||
|
||||
.statusIcon.active {
|
||||
color: $active-indicator;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Draggable } from 'react-beautiful-dnd';
|
||||
import { Editable, EditableInput, EditablePreview, Tooltip } from '@chakra-ui/react';
|
||||
import { IoOptions } from '@react-icons/all-files/io5/IoOptions';
|
||||
import { IoPeople } from '@react-icons/all-files/io5/IoPeople';
|
||||
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
|
||||
import { IoPlayOutline } from '@react-icons/all-files/io5/IoPlayOutline';
|
||||
import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward';
|
||||
import { IoReload } from '@react-icons/all-files/io5/IoReload';
|
||||
import { IoRemoveCircle } from '@react-icons/all-files/io5/IoRemoveCircle';
|
||||
import { IoRemoveCircleOutline } from '@react-icons/all-files/io5/IoRemoveCircleOutline';
|
||||
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
|
||||
import { editorEventId } from '../../../common/atoms/LocalEventSettings';
|
||||
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { useAtom } from 'jotai';
|
||||
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import { setEventPlayback } from '../../../common/hooks/useSocket';
|
||||
import { Playback } from '../../../common/models/OntimeTypes';
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
import { EventItemActions } from '../RundownEntry';
|
||||
|
||||
import BlockActionMenu from './composite/BlockActionMenu';
|
||||
import EventBlockProgressBar from './composite/EventBlockProgressBar';
|
||||
import EventBlockTimers from './composite/EventBlockTimers';
|
||||
|
||||
import style from './EventBlock.module.scss';
|
||||
|
||||
const blockBtnStyle = {
|
||||
size: 'sm',
|
||||
};
|
||||
|
||||
const tooltipProps = {
|
||||
openDelay: tooltipDelayMid,
|
||||
};
|
||||
|
||||
interface EventBlockProps {
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
duration: number;
|
||||
index: number;
|
||||
eventIndex: number;
|
||||
eventId: string;
|
||||
isPublic: boolean;
|
||||
title: string;
|
||||
note: string;
|
||||
delay: number;
|
||||
previousEnd: number;
|
||||
colour: string;
|
||||
next: boolean;
|
||||
skip: boolean;
|
||||
selected: boolean;
|
||||
hasCursor: boolean;
|
||||
playback?: Playback;
|
||||
actionHandler: (action: EventItemActions, payload?: any) => void;
|
||||
}
|
||||
|
||||
export default function EventBlock(props: EventBlockProps) {
|
||||
const {
|
||||
timeStart,
|
||||
timeEnd,
|
||||
duration,
|
||||
index,
|
||||
eventIndex,
|
||||
eventId,
|
||||
isPublic = true,
|
||||
title,
|
||||
note,
|
||||
delay,
|
||||
previousEnd,
|
||||
colour,
|
||||
next,
|
||||
skip = false,
|
||||
selected,
|
||||
hasCursor,
|
||||
playback,
|
||||
actionHandler,
|
||||
} = props;
|
||||
|
||||
const [openId, setOpenId] = useAtom(editorEventId);
|
||||
const { updateEvent } = useEventAction();
|
||||
const [blockTitle, setBlockTitle] = useState<string>(title || '');
|
||||
const onFocusRef = useRef<null | HTMLSpanElement>(null);
|
||||
|
||||
const binderColours = colour && getAccessibleColour(colour);
|
||||
|
||||
// Todo: could I re-render the item without causing a state change here?
|
||||
// ?? use refs instead?
|
||||
useEffect(() => {
|
||||
setBlockTitle(title);
|
||||
}, [title]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasCursor) {
|
||||
onFocusRef?.current?.focus();
|
||||
}
|
||||
}, [hasCursor]);
|
||||
|
||||
const handleTitle = useCallback(
|
||||
(text: string) => {
|
||||
if (text === title) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanVal = text.trim();
|
||||
setBlockTitle(cleanVal);
|
||||
|
||||
updateEvent({ id: eventId, title: cleanVal });
|
||||
},
|
||||
[title, updateEvent, eventId],
|
||||
);
|
||||
|
||||
const eventIsPlaying = selected && playback === 'play';
|
||||
const playBtnStyles = { _hover: {} };
|
||||
if (!skip && eventIsPlaying) {
|
||||
playBtnStyles._hover = { bg: '#c05621' };
|
||||
} else if (!skip && !eventIsPlaying) {
|
||||
playBtnStyles._hover = {};
|
||||
}
|
||||
|
||||
const blockClasses = cx([
|
||||
style.eventBlock,
|
||||
skip ? style.skip : null,
|
||||
selected ? style.selected : null,
|
||||
hasCursor ? style.hasCursor : null,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Draggable key={eventId} draggableId={eventId} index={index}>
|
||||
{(provided) => (
|
||||
<div
|
||||
className={blockClasses}
|
||||
{...provided.draggableProps}
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
<div
|
||||
className={style.binder}
|
||||
style={{ ...binderColours }}
|
||||
tabIndex={-1}
|
||||
onClick={() => actionHandler('set-cursor', index)}
|
||||
>
|
||||
<span className={style.drag} {...provided.dragHandleProps} ref={onFocusRef}>
|
||||
<IoReorderTwo />
|
||||
</span>
|
||||
{eventIndex}
|
||||
</div>
|
||||
<div className={style.playbackActions}>
|
||||
<TooltipActionBtn
|
||||
variant='ontime-subtle-white'
|
||||
aria-label='Skip event'
|
||||
tooltip='Skip event'
|
||||
icon={skip ? <IoRemoveCircle /> : <IoRemoveCircleOutline />}
|
||||
{...tooltipProps}
|
||||
{...blockBtnStyle}
|
||||
clickHandler={() => actionHandler('update', { field: 'skip', value: !skip })}
|
||||
tabIndex={-1}
|
||||
disabled={selected}
|
||||
/>
|
||||
<TooltipActionBtn
|
||||
variant='ontime-subtle-white'
|
||||
aria-label='Load event'
|
||||
tooltip='Load event'
|
||||
icon={<IoReload className={style.flip} />}
|
||||
disabled={skip}
|
||||
{...tooltipProps}
|
||||
{...blockBtnStyle}
|
||||
clickHandler={() => setEventPlayback.loadEvent(eventId)}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<TooltipActionBtn
|
||||
variant='ontime-subtle-white'
|
||||
aria-label='Start event'
|
||||
tooltip='Start event'
|
||||
icon={eventIsPlaying ? <IoPlay /> : <IoPlayOutline />}
|
||||
disabled={skip}
|
||||
{...tooltipProps}
|
||||
{...blockBtnStyle}
|
||||
clickHandler={() => setEventPlayback.startEvent(eventId)}
|
||||
backgroundColor={eventIsPlaying ? '#58A151' : undefined}
|
||||
_hover={{ backgroundColor: eventIsPlaying ? '#58A151' : undefined }}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</div>
|
||||
<EventBlockTimers
|
||||
timeStart={timeStart}
|
||||
timeEnd={timeEnd}
|
||||
duration={duration}
|
||||
delay={delay}
|
||||
actionHandler={actionHandler}
|
||||
previousEnd={previousEnd}
|
||||
/>
|
||||
<Editable
|
||||
variant='ontime'
|
||||
value={blockTitle}
|
||||
className={`${style.eventTitle} ${!title ? style.noTitle : ''}`}
|
||||
placeholder='Event title'
|
||||
onChange={(value) => setBlockTitle(value)}
|
||||
onSubmit={(value) => handleTitle(value)}
|
||||
>
|
||||
<EditablePreview className={style.preview} />
|
||||
<EditableInput />
|
||||
</Editable>
|
||||
<div className={style.statusElements}>
|
||||
<span className={style.eventNote}>{note}</span>
|
||||
<div className={selected ? style.progressBg : `${style.progressBg} ${style.hidden}`}>
|
||||
<EventBlockProgressBar playback={playback} />
|
||||
</div>
|
||||
<div className={style.eventStatus} tabIndex={-1}
|
||||
>
|
||||
<Tooltip
|
||||
label='Next event'
|
||||
isDisabled={!next}
|
||||
{...tooltipProps}
|
||||
>
|
||||
<span>
|
||||
<IoPlaySkipForward
|
||||
className={`${style.statusIcon} ${next ? style.active : ''}`} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label={`${isPublic ? 'Event is public' : 'Event is private'}`}
|
||||
{...tooltipProps}
|
||||
>
|
||||
<span>
|
||||
<IoPeople className={`${style.statusIcon} ${isPublic ? style.active : ''}`} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.eventActions}>
|
||||
<TooltipActionBtn
|
||||
{...blockBtnStyle}
|
||||
variant='ontime-subtle-white'
|
||||
size='sm'
|
||||
icon={<IoOptions />}
|
||||
clickHandler={() => setOpenId((prev) => prev === eventId ? null : eventId)}
|
||||
tooltip='Event options'
|
||||
aria-label='Event options'
|
||||
tabIndex={-1}
|
||||
backgroundColor={openId === eventId ? '#2B5ABC' : undefined}
|
||||
color={openId === eventId ? 'white' : '#f6f6f6'}
|
||||
/>
|
||||
<BlockActionMenu
|
||||
showAdd
|
||||
showDelay
|
||||
showBlock
|
||||
showClone
|
||||
enableDelete={!selected}
|
||||
actionHandler={actionHandler}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuDivider,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Tooltip,
|
||||
} from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { IoDuplicateOutline } from '@react-icons/all-files/io5/IoDuplicateOutline';
|
||||
import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHorizontal';
|
||||
import { IoRemoveCircleOutline } from '@react-icons/all-files/io5/IoRemoveCircleOutline';
|
||||
import { IoTimerOutline } from '@react-icons/all-files/io5/IoTimerOutline';
|
||||
import { IoTrashBinSharp } from '@react-icons/all-files/io5/IoTrashBinSharp';
|
||||
|
||||
import { tooltipDelayMid } from '../../../../ontimeConfig';
|
||||
import { EventItemActions } from '../../RundownEntry';
|
||||
|
||||
interface BlockActionMenuProps {
|
||||
showAdd?: boolean;
|
||||
showDelay?: boolean;
|
||||
showBlock?: boolean;
|
||||
enableDelete?: boolean;
|
||||
showClone?: boolean;
|
||||
actionHandler: (action: EventItemActions, payload?: any) => void;
|
||||
className?: string;
|
||||
}
|
||||
export default function BlockActionMenu(props: BlockActionMenuProps) {
|
||||
const { showAdd, showDelay, showBlock, enableDelete, showClone, actionHandler, className } = props;
|
||||
|
||||
const handleAddEvent = useCallback(() => actionHandler("event"), [actionHandler])
|
||||
const handleAddDelay = useCallback(() => actionHandler("delay"), [actionHandler])
|
||||
const handleAddBlock = useCallback(() => actionHandler("block"), [actionHandler])
|
||||
const handleClone = useCallback(() => actionHandler("clone"), [actionHandler])
|
||||
const handleDelete = useCallback(() => actionHandler("delete"), [actionHandler])
|
||||
|
||||
return (
|
||||
<Menu isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
|
||||
<Tooltip label='Add ...' openDelay={tooltipDelayMid}>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
aria-label='Event options'
|
||||
icon={<IoEllipsisHorizontal />}
|
||||
tabIndex={-1}
|
||||
variant='ontime-subtle'
|
||||
color='#f6f6f6'
|
||||
size='sm'
|
||||
className={className}
|
||||
/>
|
||||
</Tooltip>
|
||||
<MenuList>
|
||||
<MenuItem icon={<IoAdd />} onClick={handleAddEvent} isDisabled={!showAdd}>
|
||||
Add Event after
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<IoTimerOutline />}
|
||||
onClick={handleAddDelay}
|
||||
isDisabled={!showDelay}
|
||||
>
|
||||
Add Delay after
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<IoRemoveCircleOutline />}
|
||||
onClick={handleAddBlock}
|
||||
isDisabled={!showBlock}
|
||||
>
|
||||
Add Block after
|
||||
</MenuItem>
|
||||
{showClone && (
|
||||
<MenuItem
|
||||
icon={<IoDuplicateOutline />}
|
||||
onClick={handleClone}
|
||||
isDisabled={!showBlock}
|
||||
>
|
||||
Clone event
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuDivider />
|
||||
<MenuItem
|
||||
icon={<IoTrashBinSharp />}
|
||||
onClick={handleDelete}
|
||||
isDisabled={!enableDelete}
|
||||
color='#D20300'
|
||||
>
|
||||
Delete event
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
@use '../../../../theme/v2Styles' as *;
|
||||
|
||||
.progressBar {
|
||||
// layout
|
||||
height: 100%;
|
||||
width: 0;
|
||||
border-radius: 1px 0 0 1px;
|
||||
|
||||
// animations
|
||||
transition: 1s linear;
|
||||
transition-property: width;
|
||||
|
||||
&.play {
|
||||
background-color: $playback-start;
|
||||
}
|
||||
|
||||
&.pause {
|
||||
background-color: $ontime-paused;
|
||||
}
|
||||
|
||||
&.roll {
|
||||
background-color: $ontime-roll;
|
||||
}
|
||||
|
||||
&.overtime {
|
||||
background-color: $playback-negative;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useTimer } from '../../../../common/hooks/useSocket';
|
||||
import { Playback } from '../../../../common/models/OntimeTypes';
|
||||
import { clamp } from '../../../../common/utils/math';
|
||||
|
||||
import style from './EventBlockProgressBar.module.scss';
|
||||
|
||||
interface EventBlockProgressBarProps {
|
||||
playback?: Playback;
|
||||
}
|
||||
|
||||
export default function EventBlockProgressBar(props: EventBlockProgressBarProps) {
|
||||
const { playback } = props;
|
||||
const { data: timer } = useTimer();
|
||||
const now = Math.floor(Math.max((timer?.current ?? 1) / 1000, 0));
|
||||
const complete = (timer?.duration ?? 1) / 1000;
|
||||
const elapsed = clamp(100 - (now * 100) / complete, 0, 100);
|
||||
const progress = `${elapsed}%`;
|
||||
|
||||
if ((timer?.current ?? 0) < 0) {
|
||||
return (
|
||||
<div
|
||||
className={`${style.progressBar} ${style.overtime}`}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${style.progressBar} ${playback ? style[playback] : ''}`}
|
||||
style={{ width: progress }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useCallback, useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
||||
import { LoggingContext } from '../../../../common/context/LoggingContext';
|
||||
import { millisToMinutes } from '../../../../common/utils/dateConfig';
|
||||
import { stringFromMillis } from '../../../../common/utils/time';
|
||||
import { validateEntry } from '../../../../common/utils/timesManager';
|
||||
|
||||
import style from '../EventBlock.module.scss';
|
||||
|
||||
export default function EventBlockTimers(props) {
|
||||
const { timeStart, timeEnd, duration, delay, actionHandler, previousEnd } = props;
|
||||
const { emitWarning } = useContext(LoggingContext);
|
||||
|
||||
const delayTime = `${delay >= 0 ? '+' : '-'} ${millisToMinutes(Math.abs(delay))}`;
|
||||
const newTime = stringFromMillis(timeStart + delay);
|
||||
|
||||
/**
|
||||
* @description Validates a time input against its pair
|
||||
* @param {string} entry - field to validate: timeStart, timeEnd, durationOverride
|
||||
* @param {number} val - field value
|
||||
* @return {boolean}
|
||||
*/
|
||||
const handleValidation = useCallback(
|
||||
(field, value) => {
|
||||
const valid = validateEntry(field, value, timeStart, timeEnd);
|
||||
if (valid.catch) {
|
||||
emitWarning(`Time Input Warning: ${valid.catch}`);
|
||||
}
|
||||
return valid.value;
|
||||
},
|
||||
[emitWarning, timeEnd, timeStart]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(field, value) => {
|
||||
actionHandler('update', { field, value });
|
||||
},
|
||||
[actionHandler]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={style.eventTimers}>
|
||||
<TimeInput
|
||||
name='timeStart'
|
||||
submitHandler={handleSubmit}
|
||||
validationHandler={handleValidation}
|
||||
time={timeStart}
|
||||
delay={delay}
|
||||
placeholder='Start'
|
||||
previousEnd={previousEnd}
|
||||
/>
|
||||
<TimeInput
|
||||
name='timeEnd'
|
||||
submitHandler={handleSubmit}
|
||||
validationHandler={handleValidation}
|
||||
time={timeEnd}
|
||||
delay={delay}
|
||||
placeholder='End'
|
||||
previousEnd={previousEnd}
|
||||
/>
|
||||
<TimeInput
|
||||
name='durationOverride'
|
||||
submitHandler={handleSubmit}
|
||||
validationHandler={handleValidation}
|
||||
time={duration}
|
||||
placeholder='Duration'
|
||||
previousEnd={previousEnd}
|
||||
/>
|
||||
{delay !== 0 && delay !== null && (
|
||||
<div className={style.delayNote}>
|
||||
{`${delayTime} minutes`}
|
||||
<br />
|
||||
{`New start: ${newTime}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
EventBlockTimers.propTypes = {
|
||||
timeStart: PropTypes.number,
|
||||
timeEnd: PropTypes.number,
|
||||
duration: PropTypes.number,
|
||||
delay: PropTypes.number,
|
||||
actionHandler: PropTypes.func,
|
||||
previousEnd: PropTypes.number,
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
|
||||
.quickAdd {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
margin: 4px 0;
|
||||
font-size: 12px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.btnRow {
|
||||
justify-self: center;
|
||||
display: flex;
|
||||
gap: 10%;
|
||||
|
||||
.quickBtn {
|
||||
width: auto;
|
||||
padding: 0 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.keyboard {
|
||||
margin-left: 8px;
|
||||
padding: 0 4px;
|
||||
color: $label-gray;
|
||||
border-radius: 2px;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useCallback, useContext, useRef } from 'react';
|
||||
import { Button, Checkbox, Tooltip } from '@chakra-ui/react';
|
||||
import { defaultPublicAtom, startTimeIsLastEndAtom } from '../../../common/atoms/LocalEventSettings';
|
||||
import { LoggingContext } from '../../../common/context/LoggingContext';
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import { SupportedEvent } from '../../../common/models/EventTypes';
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
|
||||
import style from './QuickAddBlock.module.scss';
|
||||
|
||||
interface QuickAddBlockProps {
|
||||
showKbd: boolean;
|
||||
eventId: string;
|
||||
previousEventId?: string;
|
||||
disableAddDelay?: boolean;
|
||||
disableAddBlock: boolean;
|
||||
}
|
||||
|
||||
export default function QuickAddBlock(props: QuickAddBlockProps) {
|
||||
const {
|
||||
showKbd,
|
||||
eventId,
|
||||
previousEventId,
|
||||
disableAddDelay = true,
|
||||
disableAddBlock,
|
||||
} = props;
|
||||
const { addEvent } = useEventAction();
|
||||
const { emitError } = useContext(LoggingContext);
|
||||
const startTimeIsLastEnd = useAtomValue(startTimeIsLastEndAtom);
|
||||
const defaultPublic = useAtomValue(defaultPublicAtom);
|
||||
const doStartTime = useRef<HTMLInputElement | null>(null);
|
||||
const doPublic = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const handleCreateEvent = useCallback((eventType: SupportedEvent) => {
|
||||
switch (eventType) {
|
||||
case 'event': {
|
||||
const isPublicOption = doPublic?.current?.checked;
|
||||
const startTimeIsLastEndOption = doStartTime?.current?.checked;
|
||||
|
||||
const newEvent = { type: SupportedEvent.Event };
|
||||
const options = {
|
||||
defaultPublic: isPublicOption,
|
||||
startTimeIsLastEnd: startTimeIsLastEndOption,
|
||||
lastEventId: previousEventId,
|
||||
after: eventId,
|
||||
};
|
||||
addEvent(newEvent, options);
|
||||
break;
|
||||
}
|
||||
case 'delay': {
|
||||
const options = {
|
||||
lastEventId: previousEventId,
|
||||
after: eventId,
|
||||
}
|
||||
addEvent({ type: SupportedEvent.Delay }, options);
|
||||
break;
|
||||
}
|
||||
case 'block': {
|
||||
const options= {
|
||||
lastEventId: previousEventId,
|
||||
after: eventId,
|
||||
}
|
||||
addEvent({ type: SupportedEvent.Block }, options);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
emitError(`Cannot create unknown event type: ${eventType}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
}, [previousEventId, eventId, addEvent, emitError]);
|
||||
|
||||
return (
|
||||
<div className={style.quickAdd}>
|
||||
<div className={style.btnRow}>
|
||||
<Tooltip label='Add Event' openDelay={tooltipDelayMid}>
|
||||
<Button
|
||||
onClick={() => handleCreateEvent(SupportedEvent.Event)}
|
||||
size='xs'
|
||||
variant='ontime-subtle-white'
|
||||
className={style.quickBtn}
|
||||
>
|
||||
Event {showKbd && <span className={style.keyboard}>Alt + E</span>}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label='Add Delay' openDelay={tooltipDelayMid}>
|
||||
<Button
|
||||
onClick={() => handleCreateEvent(SupportedEvent.Delay)}
|
||||
size='xs'
|
||||
variant='ontime-subtle-white'
|
||||
disabled={disableAddDelay}
|
||||
className={style.quickBtn}
|
||||
>
|
||||
Delay {showKbd && <span className={style.keyboard}>Alt + D</span>}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label='Add Block' openDelay={tooltipDelayMid}>
|
||||
<Button
|
||||
onClick={() => handleCreateEvent(SupportedEvent.Block)}
|
||||
size='xs'
|
||||
variant='ontime-subtle-white'
|
||||
disabled={disableAddBlock}
|
||||
className={style.quickBtn}
|
||||
>
|
||||
Block {showKbd && <span className={style.keyboard}>Alt + B</span>}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={style.options}>
|
||||
<Checkbox
|
||||
ref={doStartTime}
|
||||
size='sm'
|
||||
variant='ontime-ondark'
|
||||
defaultChecked={startTimeIsLastEnd}
|
||||
>
|
||||
Start time is last end
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
ref={doPublic}
|
||||
size='sm'
|
||||
variant='ontime-ondark'
|
||||
defaultChecked={defaultPublic}
|
||||
>
|
||||
Event is public
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user