diff --git a/.husky/pre-commit b/.husky/pre-commit index 58993aaee..a5a29d9f7 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" -pnpm lint +pnpm lint-staged diff --git a/apps/client/package.json b/apps/client/package.json index 653c5ee96..68b686d00 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -1,6 +1,6 @@ { "name": "ontime-ui", - "version": "2.16.2", + "version": "2.21.3", "private": true, "dependencies": { "@chakra-ui/react": "^2.7.0", @@ -41,6 +41,7 @@ "build:electron": "cross-env NODE_ENV=local vite build", "build:docker": "vite build", "lint": "eslint . --quiet", + "lint-staged": "eslint", "test": "vitest", "test:pipeline": "vitest run", "cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build" diff --git a/apps/client/src/common/components/input/time-input/TimeInput.tsx b/apps/client/src/common/components/input/time-input/TimeInput.tsx index 04fdf5b31..d584b8f02 100644 --- a/apps/client/src/common/components/input/time-input/TimeInput.tsx +++ b/apps/client/src/common/components/input/time-input/TimeInput.tsx @@ -212,6 +212,7 @@ export default function TimeInput(props: TimeInputProps) { onKeyDown={onKeyDownHandler} value={value} maxLength={8} + autoComplete='off' /> ); diff --git a/apps/client/src/common/components/multi-part-progress-bar/MultiPartProgressBar.scss b/apps/client/src/common/components/multi-part-progress-bar/MultiPartProgressBar.scss index 4b3ed43cd..b9378798c 100644 --- a/apps/client/src/common/components/multi-part-progress-bar/MultiPartProgressBar.scss +++ b/apps/client/src/common/components/multi-part-progress-bar/MultiPartProgressBar.scss @@ -10,6 +10,7 @@ $progress-bar-br: 3px; border-radius: $progress-bar-br; background-color: var(--timer-progress-bg-override, $viewer-card-bg-color); display: flex; + overflow: hidden; &--hidden { display: none; @@ -31,7 +32,6 @@ $progress-bar-br: 3px; position: absolute; height: inherit; right: 0; - border-radius: $progress-bar-br; width: 100%; } @@ -39,12 +39,10 @@ $progress-bar-br: 3px; position: absolute; height: inherit; right: 0; - border-radius: 0 $progress-bar-br $progress-bar-br 0; } .multiprogress-bar__bg-danger { position: absolute; height: inherit; right: 0; - border-radius: 0 $progress-bar-br $progress-bar-br 0; } \ No newline at end of file diff --git a/apps/client/src/common/components/multi-part-progress-bar/MultiPartProgressBar.tsx b/apps/client/src/common/components/multi-part-progress-bar/MultiPartProgressBar.tsx index d44725f52..151e3efad 100644 --- a/apps/client/src/common/components/multi-part-progress-bar/MultiPartProgressBar.tsx +++ b/apps/client/src/common/components/multi-part-progress-bar/MultiPartProgressBar.tsx @@ -3,7 +3,7 @@ import { clamp } from '../../utils/math'; import './MultiPartProgressBar.scss'; interface MultiPartProgressBar { - now: number; + now: number | null; complete: number; normalColor: string; warning: number; @@ -17,23 +17,27 @@ interface MultiPartProgressBar { export default function MultiPartProgressBar(props: MultiPartProgressBar) { const { now, complete, normalColor, warning, warningColor, danger, dangerColor, hidden, className = '' } = props; - const percentComplete = 100 - clamp(100 - (Math.max(now, 0) * 100) / complete, 0, 100); + const percentComplete = 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100); const dangerWidth = clamp((danger / complete) * 100, 0, 100); const warningWidth = clamp((warning / complete) * 100, 0, 100); return (
-
-
-
-
+ {now !== null && ( + <> +
+
+
+
+ + )}
); } diff --git a/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx b/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx index 7924291ba..8d72fed80 100644 --- a/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx +++ b/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx @@ -12,7 +12,6 @@ import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical'; import { navigatorConstants } from '../../../viewerConfig'; import useClickOutside from '../../hooks/useClickOutside'; import useFullscreen from '../../hooks/useFullscreen'; -import { useKeyDown } from '../../hooks/useKeyDown'; import { useViewOptionsStore } from '../../stores/viewOptions'; import RenameClientModal from './rename-client-modal/RenameClientModal'; @@ -28,13 +27,14 @@ function NavigationMenu() { const [searchParams, setSearchParams] = useSearchParams(); const [showMenu, setShowMenu] = useState(false); const menuRef = useRef(null); + useClickOutside(menuRef, () => setShowMenu(false)); const { isOpen, onOpen, onClose } = useDisclosure(); const toggleMenu = () => setShowMenu((prev) => !prev); - useKeyDown(toggleMenu, ' ', { isDisabled: searchParams.get('edit') === 'true' || isOpen }); + // show on mouse move useEffect(() => { let fadeOut: NodeJS.Timeout | null = null; const setShowMenuTrue = () => { diff --git a/apps/client/src/common/components/view-params-editor/ParamInput.tsx b/apps/client/src/common/components/view-params-editor/ParamInput.tsx index 54fb5a183..282e3212e 100644 --- a/apps/client/src/common/components/view-params-editor/ParamInput.tsx +++ b/apps/client/src/common/components/view-params-editor/ParamInput.tsx @@ -1,5 +1,5 @@ import { useSearchParams } from 'react-router-dom'; -import { Input, Select, Switch } from '@chakra-ui/react'; +import { Input, InputGroup, InputLeftElement, Select, Switch } from '@chakra-ui/react'; import { isStringBoolean } from '../../utils/viewUtils'; @@ -9,16 +9,22 @@ interface EditFormInputProps { paramField: ParamField; } -export default function ParamInput({ paramField }: EditFormInputProps) { +export default function ParamInput(props: EditFormInputProps) { const [searchParams] = useSearchParams(); - const { id, type } = paramField; + const { paramField } = props; + const { id, type, defaultValue } = paramField; if (type === 'option') { const optionFromParams = searchParams.get(id); - const defaultOptionValue = optionFromParams || undefined; + const defaultOptionValue = optionFromParams || defaultValue; return ( - {Object.entries(paramField.values).map(([key, value]) => (
); diff --git a/apps/client/src/features/event-editor/composite/CountedTextInput.tsx b/apps/client/src/features/event-editor/composite/CountedTextInput.tsx index 7c9ebf351..e9dc52cc6 100644 --- a/apps/client/src/features/event-editor/composite/CountedTextInput.tsx +++ b/apps/client/src/features/event-editor/composite/CountedTextInput.tsx @@ -40,6 +40,7 @@ export default function CountedTextInput(props: CountedTextInputProps) { onChange={onChange} onBlur={onBlur} onKeyDown={onKeyDown} + autoComplete='off' />
); diff --git a/apps/client/src/features/info/Info.module.scss b/apps/client/src/features/info/Info.module.scss index fadabd735..b64b5b141 100644 --- a/apps/client/src/features/info/Info.module.scss +++ b/apps/client/src/features/info/Info.module.scss @@ -5,6 +5,7 @@ display: flex; justify-content: space-between; gap: 1rem; + font-size: $inner-section-text-size; .title { white-space: nowrap; @@ -14,12 +15,12 @@ .selected { min-width: max-content; - font-size: $inner-section-text-size; color: $label-gray; } } .description { + font-size: $inner-section-text-size; color: $label-gray; white-space: nowrap; overflow: hidden; diff --git a/apps/client/src/features/operator/Operator.module.scss b/apps/client/src/features/operator/Operator.module.scss index 0a72d0d8b..8d2cc826f 100644 --- a/apps/client/src/features/operator/Operator.module.scss +++ b/apps/client/src/features/operator/Operator.module.scss @@ -22,3 +22,24 @@ .spacer { 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; + } +} diff --git a/apps/client/src/features/operator/Operator.tsx b/apps/client/src/features/operator/Operator.tsx index b43b9ed9a..c00b2d6db 100644 --- a/apps/client/src/features/operator/Operator.tsx +++ b/apps/client/src/features/operator/Operator.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { isOntimeEvent, OntimeEvent, SupportedEvent, UserFields } from 'ontime-types'; import { getFirstEvent, getLastEvent } from 'ontime-utils'; @@ -11,10 +11,12 @@ import useFollowComponent from '../../common/hooks/useFollowComponent'; import { useOperator } from '../../common/hooks/useSocket'; import useProjectData from '../../common/hooks-query/useProjectData'; import useRundown from '../../common/hooks-query/useRundown'; +import useSettings from '../../common/hooks-query/useSettings'; import useUserFields from '../../common/hooks-query/useUserFields'; import { debounce } from '../../common/utils/debounce'; import { isStringBoolean } from '../../common/utils/viewUtils'; +import EditModal from './edit-modal/EditModal'; import FollowButton from './follow-button/FollowButton'; import OperatorBlock from './operator-block/OperatorBlock'; import OperatorEvent from './operator-event/OperatorEvent'; @@ -25,13 +27,24 @@ import style from './Operator.module.scss'; const selectedOffset = 50; type TitleFields = Pick; +export type EditEvent = Pick & { fieldLabel?: string; fieldValue: string }; +export type PartialEdit = EditEvent & { + field: keyof UserFields; +}; + export default function Operator() { const { data, status } = useRundown(); const { data: userFields, status: userFieldsStatus } = useUserFields(); const { data: projectData, status: projectDataStatus } = useProjectData(); + const timeoutId = useRef(null); + const featureData = useOperator(); const [searchParams] = useSearchParams(); + const { data: settings } = useSettings(); + + const [showEditPrompt, setShowEditPrompt] = useState(false); + const [editEvent, setEditEvent] = useState(null); const [lockAutoScroll, setLockAutoScroll] = useState(false); const selectedRef = useRef(null); @@ -78,6 +91,30 @@ export default function Operator() { }; 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 isLoading = status === 'pending' || userFieldsStatus === 'pending' || projectDataStatus === 'pending'; @@ -92,7 +129,7 @@ export default function Operator() { const subscribedAlias = subscribe ? userFields[subscribe] : ''; const showSeconds = isStringBoolean(searchParams.get('showseconds')); - const operatorOptions = getOperatorOptions(userFields); + const operatorOptions = getOperatorOptions(userFields, settings?.timeFormat ?? '24'); let isPast = Boolean(featureData.selectedEventId); const hidePast = isStringBoolean(searchParams.get('hidepast')); @@ -103,6 +140,7 @@ export default function Operator() {
+ {editEvent && setEditEvent(null)} />} -
+ {subscribe && ( +
+ Press and hold to edit user field +
+ )} + +
{data.map((entry) => { if (isOntimeEvent(entry)) { const isSelected = featureData.selectedEventId === entry.id; @@ -139,6 +178,7 @@ export default function Operator() { return ( undefined} /> ); } diff --git a/apps/client/src/features/operator/edit-modal/EditModal.module.scss b/apps/client/src/features/operator/edit-modal/EditModal.module.scss new file mode 100644 index 000000000..0adf73b01 --- /dev/null +++ b/apps/client/src/features/operator/edit-modal/EditModal.module.scss @@ -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%; + } + } +} diff --git a/apps/client/src/features/operator/edit-modal/EditModal.tsx b/apps/client/src/features/operator/edit-modal/EditModal.tsx new file mode 100644 index 000000000..e82d99f59 --- /dev/null +++ b/apps/client/src/features/operator/edit-modal/EditModal.tsx @@ -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(null); + + const handleSave = async () => { + setLoading(true); + const newValue = inputRef.current?.value; + + const partialEvent: Partial = { + id: event.id, + [event.field]: newValue, + }; + await updateEvent(partialEvent); + setLoading(false); + onClose(); + }; + + const fieldLabel = event?.fieldLabel ?? event.field; + + return ( +
+
{`Editing field ${fieldLabel} in cue ${event.cue}`}
+