mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-20 06:29:07 +00:00
feat: allow editing field from operator (#609)
* feat: allow editing field from operator Co-authored-by: arc-alex <ac@omnivox.dk> * style: functional and presentation tweaks --------- Co-authored-by: arc-alex <ac@omnivox.dk>
This commit is contained in:
@@ -0,0 +1,64 @@
|
|||||||
|
import { MouseEvent, SyntheticEvent, TouchEvent, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
|
type LongPressOptions = {
|
||||||
|
threshold?: number;
|
||||||
|
onStart?: (e: SyntheticEvent) => void;
|
||||||
|
onFinish?: (e: SyntheticEvent) => void;
|
||||||
|
onCancel?: (e: SyntheticEvent) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LongPressFns = {
|
||||||
|
onMouseDown: (e: MouseEvent) => void;
|
||||||
|
onMouseUp: (e: MouseEvent) => void;
|
||||||
|
onMouseLeave: (e: MouseEvent) => void;
|
||||||
|
onTouchStart: (e: TouchEvent) => void;
|
||||||
|
onTouchEnd: (e: TouchEvent) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function useLongPress(callback: () => void, options: LongPressOptions = {}): LongPressFns {
|
||||||
|
const { threshold = 400, onStart, onFinish, onCancel } = options;
|
||||||
|
const isLongPressActive = useRef(false);
|
||||||
|
const isPressed = useRef(false);
|
||||||
|
const timerId = useRef<NodeJS.Timer>();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const start = (event: SyntheticEvent) => {
|
||||||
|
if (onStart) {
|
||||||
|
onStart(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
isPressed.current = true;
|
||||||
|
timerId.current = setTimeout(() => {
|
||||||
|
callback();
|
||||||
|
isLongPressActive.current = true;
|
||||||
|
}, threshold);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = (event: SyntheticEvent) => {
|
||||||
|
if (isLongPressActive.current) {
|
||||||
|
if (onFinish) {
|
||||||
|
onFinish(event);
|
||||||
|
}
|
||||||
|
} else if (isPressed.current) {
|
||||||
|
if (onCancel) {
|
||||||
|
onCancel(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isLongPressActive.current = false;
|
||||||
|
isPressed.current = false;
|
||||||
|
|
||||||
|
if (timerId.current) {
|
||||||
|
clearTimeout(timerId.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
onMouseDown: start,
|
||||||
|
onMouseUp: cancel,
|
||||||
|
onMouseLeave: cancel,
|
||||||
|
onTouchStart: start,
|
||||||
|
onTouchEnd: cancel,
|
||||||
|
};
|
||||||
|
}, [callback, threshold, onCancel, onFinish, onStart]);
|
||||||
|
}
|
||||||
@@ -22,3 +22,24 @@
|
|||||||
.spacer {
|
.spacer {
|
||||||
min-height: 95vh;
|
min-height: 95vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editPrompt {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
background: rgba(black, 0.6);
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 0.5em 2em;
|
||||||
|
color: gold;
|
||||||
|
|
||||||
|
opacity: 0;
|
||||||
|
transition-property: opacity;
|
||||||
|
transition-duration: 0.3s;
|
||||||
|
|
||||||
|
&.show {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { isOntimeEvent, OntimeEvent, SupportedEvent, UserFields } from 'ontime-types';
|
import { isOntimeEvent, OntimeEvent, SupportedEvent, UserFields } from 'ontime-types';
|
||||||
import { getFirstEvent, getLastEvent } from 'ontime-utils';
|
import { getFirstEvent, getLastEvent } from 'ontime-utils';
|
||||||
@@ -15,6 +15,7 @@ import useUserFields from '../../common/hooks-query/useUserFields';
|
|||||||
import { debounce } from '../../common/utils/debounce';
|
import { debounce } from '../../common/utils/debounce';
|
||||||
import { isStringBoolean } from '../../common/utils/viewUtils';
|
import { isStringBoolean } from '../../common/utils/viewUtils';
|
||||||
|
|
||||||
|
import EditModal from './edit-modal/EditModal';
|
||||||
import FollowButton from './follow-button/FollowButton';
|
import FollowButton from './follow-button/FollowButton';
|
||||||
import OperatorBlock from './operator-block/OperatorBlock';
|
import OperatorBlock from './operator-block/OperatorBlock';
|
||||||
import OperatorEvent from './operator-event/OperatorEvent';
|
import OperatorEvent from './operator-event/OperatorEvent';
|
||||||
@@ -25,14 +26,24 @@ import style from './Operator.module.scss';
|
|||||||
const selectedOffset = 50;
|
const selectedOffset = 50;
|
||||||
|
|
||||||
type TitleFields = Pick<OntimeEvent, 'title' | 'subtitle' | 'presenter'>;
|
type TitleFields = Pick<OntimeEvent, 'title' | 'subtitle' | 'presenter'>;
|
||||||
|
export type EditEvent = Pick<OntimeEvent, 'id' | 'cue'> & { fieldLabel?: string; fieldValue: string };
|
||||||
|
export type PartialEdit = EditEvent & {
|
||||||
|
field: keyof UserFields;
|
||||||
|
};
|
||||||
|
|
||||||
export default function Operator() {
|
export default function Operator() {
|
||||||
const { data, status } = useRundown();
|
const { data, status } = useRundown();
|
||||||
const { data: userFields, status: userFieldsStatus } = useUserFields();
|
const { data: userFields, status: userFieldsStatus } = useUserFields();
|
||||||
const { data: projectData, status: projectDataStatus } = useProjectData();
|
const { data: projectData, status: projectDataStatus } = useProjectData();
|
||||||
|
|
||||||
|
const timeoutId = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const featureData = useOperator();
|
const featureData = useOperator();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const [showEditPrompt, setShowEditPrompt] = useState(false);
|
||||||
|
const [editEvent, setEditEvent] = useState<PartialEdit | null>(null);
|
||||||
|
|
||||||
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
||||||
const selectedRef = useRef<HTMLDivElement | null>(null);
|
const selectedRef = useRef<HTMLDivElement | null>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -78,6 +89,30 @@ export default function Operator() {
|
|||||||
};
|
};
|
||||||
const debouncedHandleScroll = debounce(handleUserScroll, 1000);
|
const debouncedHandleScroll = debounce(handleUserScroll, 1000);
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (timeoutId.current) {
|
||||||
|
clearTimeout(timeoutId.current);
|
||||||
|
}
|
||||||
|
timeoutId.current = setTimeout(() => {
|
||||||
|
setShowEditPrompt(false);
|
||||||
|
}, 700);
|
||||||
|
|
||||||
|
setShowEditPrompt(true);
|
||||||
|
|
||||||
|
debouncedHandleScroll();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = useCallback(
|
||||||
|
(event: EditEvent) => {
|
||||||
|
const field = searchParams.get('subscribe') as keyof UserFields | null;
|
||||||
|
|
||||||
|
if (field) {
|
||||||
|
setEditEvent({ ...event, field });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
const missingData = !data || !userFields || !projectData;
|
const missingData = !data || !userFields || !projectData;
|
||||||
const isLoading = status === 'pending' || userFieldsStatus === 'pending' || projectDataStatus === 'pending';
|
const isLoading = status === 'pending' || userFieldsStatus === 'pending' || projectDataStatus === 'pending';
|
||||||
|
|
||||||
@@ -103,6 +138,7 @@ export default function Operator() {
|
|||||||
<div className={style.operatorContainer}>
|
<div className={style.operatorContainer}>
|
||||||
<NavigationMenu />
|
<NavigationMenu />
|
||||||
<ViewParamsEditor paramFields={operatorOptions} />
|
<ViewParamsEditor paramFields={operatorOptions} />
|
||||||
|
{editEvent && <EditModal event={editEvent} onClose={() => setEditEvent(null)} />}
|
||||||
|
|
||||||
<StatusBar
|
<StatusBar
|
||||||
projectTitle={projectData.title}
|
projectTitle={projectData.title}
|
||||||
@@ -114,12 +150,13 @@ export default function Operator() {
|
|||||||
lastId={lastEvent?.id}
|
lastId={lastEvent?.id}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
{subscribe && (
|
||||||
className={style.operatorEvents}
|
<div className={`${style.editPrompt} ${showEditPrompt ? style.show : undefined}`}>
|
||||||
onWheel={debouncedHandleScroll}
|
Press and hold to edit user field
|
||||||
onTouchMove={debouncedHandleScroll}
|
</div>
|
||||||
ref={scrollRef}
|
)}
|
||||||
>
|
|
||||||
|
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
|
||||||
{data.map((entry) => {
|
{data.map((entry) => {
|
||||||
if (isOntimeEvent(entry)) {
|
if (isOntimeEvent(entry)) {
|
||||||
const isSelected = featureData.selectedEventId === entry.id;
|
const isSelected = featureData.selectedEventId === entry.id;
|
||||||
@@ -139,6 +176,7 @@ export default function Operator() {
|
|||||||
return (
|
return (
|
||||||
<OperatorEvent
|
<OperatorEvent
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
|
id={entry.id}
|
||||||
colour={entry.colour}
|
colour={entry.colour}
|
||||||
cue={entry.cue}
|
cue={entry.cue}
|
||||||
main={mainField}
|
main={mainField}
|
||||||
@@ -153,6 +191,7 @@ export default function Operator() {
|
|||||||
showSeconds={showSeconds}
|
showSeconds={showSeconds}
|
||||||
isPast={isPast}
|
isPast={isPast}
|
||||||
selectedRef={isSelected ? selectedRef : undefined}
|
selectedRef={isSelected ? selectedRef : undefined}
|
||||||
|
onLongPress={subscribe ? handleEdit : () => undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
@use '../../../theme/v2Styles' as *;
|
||||||
|
@use '../../../theme/ontimeColours' as *;
|
||||||
|
|
||||||
|
.editModal {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
margin: 0 auto;
|
||||||
|
top: 20%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: $gray-1250;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
min-width: min(400px, 90vw);
|
||||||
|
box-shadow: $box-shadow-l1;
|
||||||
|
|
||||||
|
.buttonRow {
|
||||||
|
margin-top: auto;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { Button, Textarea } from '@chakra-ui/react';
|
||||||
|
import { OntimeEvent } from 'ontime-types';
|
||||||
|
|
||||||
|
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||||
|
import type { PartialEdit } from '../Operator';
|
||||||
|
|
||||||
|
import style from './EditModal.module.scss';
|
||||||
|
|
||||||
|
interface EditModalProps {
|
||||||
|
event: PartialEdit;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditModal(props: EditModalProps) {
|
||||||
|
const { event, onClose } = props;
|
||||||
|
|
||||||
|
const { updateEvent } = useEventAction();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const newValue = inputRef.current?.value;
|
||||||
|
|
||||||
|
const partialEvent: Partial<OntimeEvent> = {
|
||||||
|
id: event.id,
|
||||||
|
[event.field]: newValue,
|
||||||
|
};
|
||||||
|
await updateEvent(partialEvent);
|
||||||
|
setLoading(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldLabel = event?.fieldLabel ?? event.field;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.editModal}>
|
||||||
|
<div>{`Editing field ${fieldLabel} in cue ${event.cue}`}</div>
|
||||||
|
<Textarea
|
||||||
|
ref={inputRef}
|
||||||
|
variant='ontime-filled'
|
||||||
|
placeholder={`Add value for ${fieldLabel} field`}
|
||||||
|
defaultValue={event.fieldValue}
|
||||||
|
isDisabled={loading}
|
||||||
|
/>
|
||||||
|
<div className={style.buttonRow}>
|
||||||
|
<Button variant='ontime-subtle' onClick={onClose} isDisabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant='ontime-filled' onClick={handleSave} isDisabled={loading}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
import { memo, RefObject } from 'react';
|
import { memo, RefObject, SyntheticEvent } from 'react';
|
||||||
|
|
||||||
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
|
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
|
||||||
|
import useLongPress from '../../../common/hooks/useLongPress';
|
||||||
import { useTimer } from '../../../common/hooks/useSocket';
|
import { useTimer } from '../../../common/hooks/useSocket';
|
||||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||||
import { formatTime } from '../../../common/utils/time';
|
import { formatTime } from '../../../common/utils/time';
|
||||||
|
import type { EditEvent } from '../Operator';
|
||||||
|
|
||||||
import style from './OperatorEvent.module.scss';
|
import style from './OperatorEvent.module.scss';
|
||||||
|
|
||||||
interface OperatorEventProps {
|
interface OperatorEventProps {
|
||||||
|
id: string;
|
||||||
colour: string;
|
colour: string;
|
||||||
cue: string;
|
cue: string;
|
||||||
main: string;
|
main: string;
|
||||||
@@ -22,6 +25,7 @@ interface OperatorEventProps {
|
|||||||
showSeconds: boolean;
|
showSeconds: boolean;
|
||||||
isPast: boolean;
|
isPast: boolean;
|
||||||
selectedRef?: RefObject<HTMLDivElement>;
|
selectedRef?: RefObject<HTMLDivElement>;
|
||||||
|
onLongPress: (event: EditEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// extract this to contain re-renders
|
// extract this to contain re-renders
|
||||||
@@ -32,6 +36,7 @@ function RollingTime() {
|
|||||||
|
|
||||||
function OperatorEvent(props: OperatorEventProps) {
|
function OperatorEvent(props: OperatorEventProps) {
|
||||||
const {
|
const {
|
||||||
|
id,
|
||||||
colour,
|
colour,
|
||||||
cue,
|
cue,
|
||||||
main,
|
main,
|
||||||
@@ -46,8 +51,17 @@ function OperatorEvent(props: OperatorEventProps) {
|
|||||||
showSeconds,
|
showSeconds,
|
||||||
isPast,
|
isPast,
|
||||||
selectedRef,
|
selectedRef,
|
||||||
|
onLongPress,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
const handleLongPress = (event?: SyntheticEvent) => {
|
||||||
|
// we dont have an event out of useLongPress
|
||||||
|
event?.preventDefault();
|
||||||
|
onLongPress({ id, cue, fieldLabel: subscribedAlias, fieldValue: subscribed ?? '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const mouseHandlers = useLongPress(handleLongPress, { threshold: 800 });
|
||||||
|
|
||||||
const start = formatTime(timeStart, { showSeconds });
|
const start = formatTime(timeStart, { showSeconds });
|
||||||
const end = formatTime(timeEnd, { showSeconds });
|
const end = formatTime(timeEnd, { showSeconds });
|
||||||
|
|
||||||
@@ -61,7 +75,7 @@ function OperatorEvent(props: OperatorEventProps) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={operatorClasses} ref={selectedRef}>
|
<div className={operatorClasses} ref={selectedRef} onContextMenu={handleLongPress} {...mouseHandlers}>
|
||||||
<div className={style.binder} style={{ ...cueColours }}>
|
<div className={style.binder} style={{ ...cueColours }}>
|
||||||
<span className={style.cue}>{cue}</span>
|
<span className={style.cue}>{cue}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user