mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-04 23:18:01 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7eab0bb300 | |||
| 55d1aca9b6 |
@@ -19,6 +19,7 @@
|
||||
"rules": {
|
||||
"no-useless-concat": "warn",
|
||||
"prefer-template": "warn",
|
||||
"no-throw-literal": "error",
|
||||
"no-console": [
|
||||
"warn",
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "3.0.0-alpha",
|
||||
"version": "3.0.0-beta.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -86,7 +86,7 @@
|
||||
"ontime-utils": "workspace:*",
|
||||
"prettier": "^3.0.3",
|
||||
"sass": "^1.57.1",
|
||||
"typescript": "^5.2.2",
|
||||
"typescript": "^5.4.3",
|
||||
"vite": "^5.1.0",
|
||||
"vite-plugin-compression2": "^0.12.0",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
|
||||
@@ -7,7 +7,7 @@ import withData from './features/viewers/ViewWrapper';
|
||||
|
||||
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
|
||||
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
|
||||
const Operator = lazy(() => import('./features/operator/Operator'));
|
||||
const Operator = lazy(() => import('./features/operator/OperatorExport'));
|
||||
|
||||
const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
|
||||
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
|
||||
@@ -49,12 +49,12 @@ export default function AppRouter() {
|
||||
<Route path='/backstage' element={<SBackstage />} />
|
||||
|
||||
<Route path='/public' element={<SPublic />} />
|
||||
|
||||
<Route path='/studio' element={<SStudio />} />
|
||||
{/*/!* Lower cannot have fallback *!/*/}
|
||||
|
||||
<Route path='/lower' element={<SLowerThird />} />
|
||||
|
||||
<Route path='/op' element={<Operator />} />
|
||||
<Route path='/operator' element={<Operator />} />
|
||||
|
||||
{/*/!* Protected Routes *!/*/}
|
||||
<Route path='/editor' element={<Editor />} />
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import {
|
||||
DatabaseModel,
|
||||
GetInfo,
|
||||
MessageResponse,
|
||||
ProjectData,
|
||||
ProjectFileListResponse,
|
||||
} from 'ontime-types';
|
||||
import { DatabaseModel, GetInfo, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types';
|
||||
|
||||
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { OptionWithoutGroup } from './ContextMenu';
|
||||
export const ContextMenuOption = ({ label, onClick, isDisabled, icon: Icon, withDivider }: OptionWithoutGroup) => (
|
||||
<>
|
||||
{withDivider && <MenuDivider />}
|
||||
<MenuItem icon={<Icon />} onClick={onClick} isDisabled={isDisabled}>
|
||||
<MenuItem icon={<Icon style={{ fontSize: '1rem' }} />} onClick={onClick} isDisabled={isDisabled}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
</>
|
||||
|
||||
@@ -19,7 +19,7 @@ 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, 0) * 100) / complete, 0, 100);
|
||||
const percentRemaining = complete === 0 ? 0 : 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
|
||||
|
||||
const dangerWidth = danger ? clamp((danger / complete) * 100, 0, 100) : 0;
|
||||
const warningWidth = warning ? clamp((warning / complete) * 100, 0, 100) : 0;
|
||||
@@ -37,7 +37,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
||||
className='multiprogress-bar__bg-danger'
|
||||
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
|
||||
/>
|
||||
<div className='multiprogress-bar__indicator' style={{ width: `${percentComplete}%` }} />
|
||||
<div className='multiprogress-bar__indicator' style={{ width: `${percentRemaining}%` }} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||
|
||||
import { debounce } from '../../utils/debounce';
|
||||
|
||||
import style from './NavigationMenu.module.scss';
|
||||
|
||||
interface FloatingNavigationProps {
|
||||
toggleMenu: () => void;
|
||||
toggleSettings: () => void;
|
||||
}
|
||||
|
||||
export default function FloatingNavigation(props: FloatingNavigationProps) {
|
||||
const { toggleMenu, toggleSettings } = props;
|
||||
const [showButton, setShowButton] = useState(false);
|
||||
|
||||
// show on mouse move
|
||||
useEffect(() => {
|
||||
let fadeOut: NodeJS.Timeout | null = null;
|
||||
const setShowMenuTrue = () => {
|
||||
setShowButton(true);
|
||||
if (fadeOut) {
|
||||
clearTimeout(fadeOut);
|
||||
}
|
||||
fadeOut = setTimeout(() => setShowButton(false), 3000);
|
||||
};
|
||||
|
||||
const debouncedShowMenu = debounce(setShowMenuTrue, 1000);
|
||||
|
||||
document.addEventListener('mousemove', debouncedShowMenu);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', debouncedShowMenu);
|
||||
if (fadeOut) {
|
||||
clearTimeout(fadeOut);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`${style.buttonContainer} ${!showButton ? style.hidden : ''}`}>
|
||||
<button
|
||||
onClick={toggleMenu}
|
||||
aria-label='toggle menu'
|
||||
className={style.navButton}
|
||||
data-testid='navigation__toggle-menu'
|
||||
>
|
||||
<IoApps />
|
||||
</button>
|
||||
<button
|
||||
className={style.button}
|
||||
onClick={toggleSettings}
|
||||
aria-label='toggle settings'
|
||||
data-testid='navigation__toggle-settings'
|
||||
>
|
||||
<IoSettingsOutline />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
@use '../../../theme/mixins' as *;
|
||||
|
||||
$menu-bg: $gray-1200;
|
||||
$menu-hover-bg: $gray-1350;
|
||||
$menu-focus-bg: $gray-1300;
|
||||
|
||||
$icon-color: $ui-white;
|
||||
$button-bg: $gray-1050;
|
||||
$button-size: 48px;
|
||||
$button-size: 3rem;
|
||||
|
||||
.buttonContainer {
|
||||
display: flex;
|
||||
@@ -21,7 +20,7 @@ $button-size: 48px;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
z-index: 12;
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
@@ -29,7 +28,7 @@ $button-size: 48px;
|
||||
}
|
||||
|
||||
.button {
|
||||
font-size: 24px;
|
||||
font-size: 1.5rem;
|
||||
color: $icon-color;
|
||||
background-color: $button-bg;
|
||||
width: $button-size;
|
||||
@@ -44,31 +43,10 @@ $button-size: 48px;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.menuContainer {
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: fit-content;
|
||||
position: absolute;
|
||||
background-color: $menu-bg;
|
||||
min-width: 200px;
|
||||
border-radius: 0 0 24px 0;
|
||||
border-right: 1px solid $border-color-ondark;
|
||||
|
||||
box-shadow: $box-shadow-l2;
|
||||
padding-bottom: 1rem;
|
||||
|
||||
max-height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.buttonsContainer {
|
||||
margin-top: calc(56px + 1rem);
|
||||
}
|
||||
|
||||
.link {
|
||||
@include action-link;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
gap: 0.5rem;
|
||||
|
||||
&:hover {
|
||||
background-color: $menu-hover-bg;
|
||||
@@ -91,7 +69,7 @@ $button-size: 48px;
|
||||
}
|
||||
|
||||
.linkIcon {
|
||||
display: inline-block;
|
||||
margin-left: auto;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { memo, useEffect, useRef, useState } from 'react';
|
||||
import { memo, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Link, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { useDisclosure } from '@chakra-ui/react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import { useFullscreen } from '@mantine/hooks';
|
||||
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
import { IoContract } from '@react-icons/all-files/io5/IoContract';
|
||||
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
|
||||
import { IoPencilSharp } from '@react-icons/all-files/io5/IoPencilSharp';
|
||||
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
|
||||
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
||||
|
||||
import { navigatorConstants } from '../../../viewerConfig';
|
||||
@@ -19,69 +26,42 @@ import RenameClientModal from './rename-client-modal/RenameClientModal';
|
||||
|
||||
import style from './NavigationMenu.module.scss';
|
||||
|
||||
function NavigationMenu() {
|
||||
const location = useLocation();
|
||||
interface NavigationMenuProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function NavigationMenu(props: NavigationMenuProps) {
|
||||
const { isOpen, onClose } = props;
|
||||
|
||||
const { isOpen: isRenameOpen, onOpen: onRenameOpen, onClose: onRenameClose } = useDisclosure();
|
||||
|
||||
const { fullscreen, toggle } = useFullscreen();
|
||||
const { toggleMirror } = useViewOptionsStore();
|
||||
const [showButton, setShowButton] = useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useClickOutside(menuRef, () => setShowMenu(false));
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const toggleMenu = () => setShowMenu((prev) => !prev);
|
||||
|
||||
// show on mouse move
|
||||
useEffect(() => {
|
||||
let fadeOut: NodeJS.Timeout | null = null;
|
||||
const setShowMenuTrue = () => {
|
||||
setShowButton(true);
|
||||
if (fadeOut) {
|
||||
clearTimeout(fadeOut);
|
||||
}
|
||||
fadeOut = setTimeout(() => setShowButton(false), 3000);
|
||||
};
|
||||
document.addEventListener('mousemove', setShowMenuTrue);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', setShowMenuTrue);
|
||||
if (fadeOut) {
|
||||
clearTimeout(fadeOut);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleFullscreen = () => toggle();
|
||||
const handleMirror = () => toggleMirror();
|
||||
|
||||
const showEditFormDrawer = () => {
|
||||
searchParams.set('edit', 'true');
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
useClickOutside(menuRef, () => onClose);
|
||||
|
||||
return createPortal(
|
||||
<div id='navigation-menu-portal' ref={menuRef}>
|
||||
<RenameClientModal isOpen={isOpen} onClose={onClose} />
|
||||
<div className={`${style.buttonContainer} ${!showButton && !showMenu ? style.hidden : ''}`}>
|
||||
<button onClick={toggleMenu} aria-label='toggle menu' className={style.navButton}>
|
||||
<IoApps />
|
||||
</button>
|
||||
<button className={style.button} onClick={showEditFormDrawer}>
|
||||
<IoPencilSharp />
|
||||
</button>
|
||||
{showMenu && (
|
||||
<div className={style.menuContainer} data-testid='navigation-menu'>
|
||||
<RenameClientModal isOpen={isRenameOpen} onClose={onRenameClose} />
|
||||
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerCloseButton size='lg' />
|
||||
Ontime
|
||||
</DrawerHeader>
|
||||
<DrawerBody padding={0}>
|
||||
<div className={style.buttonsContainer}>
|
||||
<div
|
||||
className={style.link}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onClick={handleFullscreen}
|
||||
onClick={toggle}
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && handleFullscreen();
|
||||
isKeyEnter(event) && toggle();
|
||||
}}
|
||||
>
|
||||
Toggle Fullscreen
|
||||
@@ -91,9 +71,9 @@ function NavigationMenu() {
|
||||
className={style.link}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onClick={handleMirror}
|
||||
onClick={() => toggleMirror()}
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && handleMirror();
|
||||
isKeyEnter(event) && toggleMirror();
|
||||
}}
|
||||
>
|
||||
Flip Screen
|
||||
@@ -103,20 +83,35 @@ function NavigationMenu() {
|
||||
className={style.link}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onClick={onOpen}
|
||||
onClick={onRenameOpen}
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && onOpen();
|
||||
isKeyEnter(event) && onRenameOpen();
|
||||
}}
|
||||
>
|
||||
Rename Client
|
||||
</div>
|
||||
</div>
|
||||
<hr className={style.separator} />
|
||||
<Link to='/cuesheet' className={style.link} tabIndex={0}>
|
||||
<Link
|
||||
to='/editor'
|
||||
className={`${style.link} ${location.pathname === '/editor' ? style.current : ''}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
<IoLockClosedOutline />
|
||||
Editor
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
<Link
|
||||
to='/cuesheet'
|
||||
className={`${style.link} ${location.pathname === '/cuesheet' ? style.current : ''}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
<IoLockClosedOutline />
|
||||
Cuesheet
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
<Link to='/op' className={style.link} tabIndex={0}>
|
||||
<Link to='/op' className={`${style.link} ${location.pathname === '/op' ? style.current : ''}`} tabIndex={0}>
|
||||
<IoLockClosedOutline />
|
||||
Operator
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
@@ -132,11 +127,10 @@ function NavigationMenu() {
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>,
|
||||
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import NavigationMenu from './NavigationMenu';
|
||||
|
||||
interface ProductionNavigationMenuProps {
|
||||
isMenuOpen: boolean;
|
||||
onMenuClose: () => void;
|
||||
}
|
||||
|
||||
function ProductionNavigationMenu(props: ProductionNavigationMenuProps) {
|
||||
const { isMenuOpen, onMenuClose } = props;
|
||||
|
||||
return <NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />;
|
||||
}
|
||||
|
||||
export default memo(ProductionNavigationMenu);
|
||||
@@ -0,0 +1,27 @@
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useDisclosure } from '@chakra-ui/react';
|
||||
|
||||
import FloatingNavigation from './FloatingNavigation';
|
||||
import NavigationMenu from './NavigationMenu';
|
||||
|
||||
function ViewNavigationMenu() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
|
||||
|
||||
const showEditFormDrawer = useCallback(() => {
|
||||
searchParams.set('edit', 'true');
|
||||
setSearchParams(searchParams);
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
|
||||
|
||||
return (
|
||||
<>
|
||||
<FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
|
||||
<NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ViewNavigationMenu);
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
.modalBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
+3
-5
@@ -13,8 +13,6 @@ import {
|
||||
import { setClientName } from '../../../hooks/useSocket';
|
||||
import { useSocketClientName } from '../../../stores/connectionName';
|
||||
|
||||
import style from './RenameClientModal.module.scss';
|
||||
|
||||
interface RenameClientModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -45,18 +43,18 @@ export default function RenameClientModal({ isOpen, onClose }: RenameClientModal
|
||||
motionPreset='slideInBottom'
|
||||
scrollBehavior='inside'
|
||||
preserveScrollBarGap
|
||||
variant='ontime-small'
|
||||
variant='ontime'
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Rename client</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody className={style.modalBody}>
|
||||
<ModalBody>
|
||||
<Input
|
||||
placeholder='Connection must have a name'
|
||||
defaultValue={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
variant='ontime-filled-on-light'
|
||||
variant='ontime-filled'
|
||||
/>
|
||||
<Button
|
||||
isDisabled={newName === clientName || !newName}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
$progress-bar-size: 12px;
|
||||
$progress-bar-br: 6px;
|
||||
$progress-bar-br: 3px;
|
||||
|
||||
.progress-bar__bg {
|
||||
width: 100%;
|
||||
height: $progress-bar-size;
|
||||
border-radius: $progress-bar-br;
|
||||
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
|
||||
overflow: clip;
|
||||
|
||||
&--hidden {
|
||||
display: none;
|
||||
@@ -18,6 +19,5 @@ $progress-bar-br: 6px;
|
||||
height: $progress-bar-size;
|
||||
background-color: var(--timer-progress-override, $accent-color);
|
||||
transition: 1s linear;
|
||||
border-radius: $progress-bar-br 0 0 $progress-bar-br;
|
||||
transition-property: width;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { HStack, IconButton, PinInput, PinInputField } from '@chakra-ui/react';
|
||||
import { IconButton, PinInput, PinInputField } from '@chakra-ui/react';
|
||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
||||
|
||||
import style from './ProtectRoute.module.scss';
|
||||
@@ -39,8 +39,8 @@ export default function PinPage(props: PinPageProps) {
|
||||
|
||||
return (
|
||||
<div className={style.container}>
|
||||
{`Ontime ${permission || ''}`}
|
||||
<HStack spacing='10px' className={failed ? style.pin__failed : style.pin}>
|
||||
{`Ontime ${permission}`}
|
||||
<div className={failed ? style.pin__failed : style.pin}>
|
||||
<PinInput
|
||||
type='alphanumeric'
|
||||
size='lg'
|
||||
@@ -57,8 +57,15 @@ export default function PinPage(props: PinPageProps) {
|
||||
<PinInputField />
|
||||
<PinInputField />
|
||||
</PinInput>
|
||||
<IconButton aria-label='Enter' size='lg' isRound icon={<IoCheckmark />} onClick={validate} />
|
||||
</HStack>
|
||||
<IconButton
|
||||
variant='ontime-filled'
|
||||
aria-label='Enter'
|
||||
size='lg'
|
||||
isRound
|
||||
icon={<IoCheckmark />}
|
||||
onClick={validate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
.container {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
height: 100vh;
|
||||
padding-bottom: 30vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 25vh;
|
||||
|
||||
background: $bg-container-l1;
|
||||
color: $ontime-color;
|
||||
font-family: $ontime-font-family;
|
||||
font-weight: 200;
|
||||
text-align: center;
|
||||
font-size: 3vw;
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.pin,
|
||||
.pin__failed {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
gap: 0.125em;
|
||||
padding-block: 0.5em;
|
||||
|
||||
input {
|
||||
border-radius: 50%;
|
||||
border-radius: 99px;
|
||||
border-color: $gray-500;
|
||||
|
||||
&:hover {
|
||||
border-color: $blue-500;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
margin-left: 20px;
|
||||
margin-left: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,9 +37,9 @@
|
||||
|
||||
@keyframes colourFade {
|
||||
from {
|
||||
background: $action-blue;
|
||||
background: $red-500;
|
||||
}
|
||||
to {
|
||||
background: rgba($action-blue, 0);
|
||||
background: rgba($red-500, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,9 @@
|
||||
.drawerContent {
|
||||
background-color: $gray-1250;
|
||||
}
|
||||
|
||||
.drawerHeader {
|
||||
@extend .drawerContent;
|
||||
color: $section-white;
|
||||
}
|
||||
|
||||
.drawerFooter {
|
||||
@extend .drawerContent;
|
||||
display: flex;
|
||||
justify-content: start;
|
||||
justify-content: end;
|
||||
gap: $section-spacing;
|
||||
|
||||
button[type='reset'] {
|
||||
padding: 0 2em;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
button[type='submit'] {
|
||||
button {
|
||||
padding: 0 2em;
|
||||
}
|
||||
}
|
||||
@@ -26,6 +11,9 @@
|
||||
.label {
|
||||
font-size: $inner-section-text-size;
|
||||
color: $label-gray;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem
|
||||
}
|
||||
|
||||
.columnSection {
|
||||
|
||||
@@ -52,15 +52,16 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
||||
}
|
||||
}, [searchParams, onOpen]);
|
||||
|
||||
const onCloseWithoutSaving = () => {
|
||||
onClose();
|
||||
|
||||
const handleClose = () => {
|
||||
searchParams.delete('edit');
|
||||
setSearchParams(searchParams);
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
const resetParams = () => {
|
||||
setSearchParams();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
|
||||
@@ -69,18 +70,20 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
||||
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
|
||||
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, paramFields);
|
||||
setSearchParams(newSearchParams);
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer isOpen={isOpen} placement='right' onClose={onCloseWithoutSaving} size='lg'>
|
||||
<Drawer isOpen={isOpen} placement='right' onClose={handleClose} variant='ontime' size='lg'>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent>
|
||||
<DrawerHeader className={style.drawerHeader}>
|
||||
<DrawerCloseButton _hover={{ bg: '#ebedf0', color: '#333' }} size='lg' />
|
||||
<DrawerHeader>
|
||||
<DrawerCloseButton size='lg' />
|
||||
Customise
|
||||
</DrawerHeader>
|
||||
|
||||
<DrawerBody className={style.drawerContent}>
|
||||
<DrawerBody>
|
||||
<form id='edit-params-form' onSubmit={onParamsFormSubmit}>
|
||||
{paramFields.map((field) => (
|
||||
<div key={field.title} className={style.columnSection}>
|
||||
@@ -96,10 +99,7 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
||||
|
||||
<DrawerFooter className={style.drawerFooter}>
|
||||
<Button variant='ontime-ghosted' onClick={resetParams} type='reset'>
|
||||
Reset
|
||||
</Button>
|
||||
<Button variant='ontime-subtle' onClick={onCloseWithoutSaving}>
|
||||
Cancel
|
||||
Reset to default
|
||||
</Button>
|
||||
<Button variant='ontime-filled' form='edit-params-form' type='submit'>
|
||||
Save
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function useCustomFields() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: CUSTOM_FIELDS,
|
||||
queryFn: getCustomFields,
|
||||
placeholderData: placeholder,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchInterval,
|
||||
|
||||
@@ -11,14 +11,13 @@ export function useHttpSettings() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: HTTP_SETTINGS,
|
||||
queryFn: getHTTP,
|
||||
placeholderData: httpPlaceholder,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt: number) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
// we need to jump through some hoops because of the type op port
|
||||
return { data: data ?? httpPlaceholder, status, isFetching, isError, refetch };
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function useInfo() {
|
||||
const { data, status, isError, refetch, isFetching } = useQuery<GetInfo>({
|
||||
queryKey: APP_INFO,
|
||||
queryFn: getInfo,
|
||||
placeholderData: ontimePlaceholderInfo,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function useOscSettings() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: OSC_SETTINGS,
|
||||
queryFn: getOSC,
|
||||
placeholderData: oscPlaceholderSettings,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt: number) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function useProjectData() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: PROJECT_DATA,
|
||||
queryFn: getProjectData,
|
||||
placeholderData: projectDataPlaceholder,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
|
||||
@@ -14,7 +14,7 @@ export function useProjectList() {
|
||||
const { data, status, refetch } = useQuery({
|
||||
queryKey: PROJECT_LIST,
|
||||
queryFn: getProjects,
|
||||
placeholderData: placeholderProjectList,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt: number) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function useRundown() {
|
||||
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
|
||||
queryKey: RUNDOWN,
|
||||
queryFn: fetchNormalisedRundown,
|
||||
placeholderData: cachedRundownPlaceholder,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchInterval,
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function useSettings() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: APP_SETTINGS,
|
||||
queryFn: getSettings,
|
||||
placeholderData: ontimePlaceholderSettings,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function useUrlPresets() {
|
||||
const { data, status, isError, refetch } = useQuery({
|
||||
queryKey: URL_PRESETS,
|
||||
queryFn: getUrlPresets,
|
||||
placeholderData: [],
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
|
||||
@@ -9,12 +9,12 @@ export default function useViewSettings() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: VIEW_SETTINGS,
|
||||
queryFn: getView,
|
||||
placeholderData: viewsSettingsPlaceholder,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
return { data, status, isError, refetch, isFetching };
|
||||
return { data: data ?? viewsSettingsPlaceholder, status, isError, refetch, isFetching };
|
||||
}
|
||||
|
||||
@@ -59,12 +59,12 @@ export const setPlayback = {
|
||||
start: () => socketSendJson('start'),
|
||||
pause: () => socketSendJson('pause'),
|
||||
roll: () => socketSendJson('roll'),
|
||||
startNext: () => socketSendJson('start', { next: true }),
|
||||
startNext: () => socketSendJson('start', 'next'),
|
||||
previous: () => {
|
||||
socketSendJson('load', { previous: true });
|
||||
socketSendJson('load', 'previous');
|
||||
},
|
||||
next: () => {
|
||||
socketSendJson('load', { next: true });
|
||||
socketSendJson('load', 'next');
|
||||
},
|
||||
stop: () => {
|
||||
socketSendJson('stop');
|
||||
@@ -90,26 +90,26 @@ export const useInfoPanel = () => {
|
||||
};
|
||||
|
||||
export const useExtraTimerTime = () => {
|
||||
const featureSelector = (state: RuntimeStore) => state.timer1.current;
|
||||
const featureSelector = (state: RuntimeStore) => state.auxtimer1.current;
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
|
||||
export const useExtraTimerControl = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
playback: state.timer1.playback,
|
||||
direction: state.timer1.direction,
|
||||
playback: state.auxtimer1.playback,
|
||||
direction: state.auxtimer1.direction,
|
||||
});
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
|
||||
export const setExtraTimer = {
|
||||
start: () => socketSendJson('extratimer', SimplePlayback.Start),
|
||||
pause: () => socketSendJson('extratimer', SimplePlayback.Pause),
|
||||
stop: () => socketSendJson('extratimer', SimplePlayback.Stop),
|
||||
setDirection: (direction: SimpleDirection) => socketSendJson('extratimer', { direction }),
|
||||
setTime: (time: number) => socketSendJson('extratimer', { settime: time }),
|
||||
start: () => socketSendJson('auxtimer', { '1': SimplePlayback.Start }),
|
||||
pause: () => socketSendJson('auxtimer', { '1': SimplePlayback.Pause }),
|
||||
stop: () => socketSendJson('auxtimer', { '1': SimplePlayback.Stop }),
|
||||
setDirection: (direction: SimpleDirection) => socketSendJson('auxtimer', { '1': { direction } }),
|
||||
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
|
||||
};
|
||||
|
||||
export const useCuesheet = () => {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Sets tab title
|
||||
* @param title
|
||||
*/
|
||||
export function useWindowTitle(title: string) {
|
||||
useEffect(() => {
|
||||
document.title = `ontime - ${title}`;
|
||||
}, []);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { create } from 'zustand';
|
||||
import { booleanFromLocalStorage } from '../utils/localStorage';
|
||||
|
||||
type EditorSettings = {
|
||||
showQuickEntry: boolean;
|
||||
linkPrevious: boolean;
|
||||
defaultPublic: boolean;
|
||||
defaultDuration: string;
|
||||
@@ -12,14 +11,12 @@ type EditorSettings = {
|
||||
type EditorSettingsStore = {
|
||||
eventSettings: EditorSettings;
|
||||
setLocalEventSettings: (newState: EditorSettings) => void;
|
||||
setShowQuickEntry: (showQuickEntry: boolean) => void;
|
||||
setLinkPrevious: (linkPrevious: boolean) => void;
|
||||
setDefaultPublic: (defaultPublic: boolean) => void;
|
||||
setDefaultDuration: (defaultDuration: string) => void;
|
||||
};
|
||||
|
||||
enum EditorSettingsKeys {
|
||||
ShowQuickEntry = 'ontime-show-quick-entry',
|
||||
LinkPrevious = 'ontime-link-previous',
|
||||
DefaultPublic = 'ontime-default-public',
|
||||
DefaultDuration = 'ontime-default-duration',
|
||||
@@ -27,7 +24,6 @@ enum EditorSettingsKeys {
|
||||
|
||||
export const useEditorSettings = create<EditorSettingsStore>((set) => ({
|
||||
eventSettings: {
|
||||
showQuickEntry: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, false),
|
||||
linkPrevious: booleanFromLocalStorage(EditorSettingsKeys.LinkPrevious, true),
|
||||
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, true),
|
||||
defaultDuration: localStorage.getItem(EditorSettingsKeys.DefaultDuration) ?? '00:10:00',
|
||||
@@ -35,18 +31,11 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => ({
|
||||
|
||||
setLocalEventSettings: (value) =>
|
||||
set(() => {
|
||||
localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(value.showQuickEntry));
|
||||
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(value.linkPrevious));
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(value.defaultPublic));
|
||||
return { eventSettings: value };
|
||||
}),
|
||||
|
||||
setShowQuickEntry: (showQuickEntry) =>
|
||||
set((state) => {
|
||||
localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(showQuickEntry));
|
||||
return { eventSettings: { ...state.eventSettings, showQuickEntry } };
|
||||
}),
|
||||
|
||||
setLinkPrevious: (linkPrevious) =>
|
||||
set((state) => {
|
||||
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(linkPrevious));
|
||||
|
||||
@@ -49,7 +49,7 @@ export const runtimeStorePlaceholder: RuntimeStore = {
|
||||
eventNext: null,
|
||||
publicEventNow: null,
|
||||
publicEventNext: null,
|
||||
timer1: {
|
||||
auxtimer1: {
|
||||
current: 0,
|
||||
direction: SimpleDirection.CountUp,
|
||||
duration: 0,
|
||||
|
||||
@@ -8,4 +8,5 @@ export const isIPAddress = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/;
|
||||
export const startsWithHttp = /^http:\/\//;
|
||||
export const startsWithSlash = /^\//;
|
||||
export const isAlphanumeric = /^[a-z0-9]+$/i;
|
||||
export const isASCII = /^[ -~]+$/; //https://catonmat.net/my-favorite-regex
|
||||
export const isNotEmpty = /\S/;
|
||||
|
||||
@@ -114,9 +114,9 @@ export const connectSocket = (preferredClientName?: string) => {
|
||||
updateDevTools({ publicEventNext: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-timer1': {
|
||||
patchRuntime('timer1', payload);
|
||||
updateDevTools({ timer1: payload });
|
||||
case 'ontime-auxtimer1': {
|
||||
patchRuntime('auxtimer1', payload);
|
||||
updateDevTools({ auxtimer1: payload });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,3 +33,4 @@ export const cx = (classNames: any[]) => classNames.filter(Boolean).join(' ');
|
||||
export const enDash = '–';
|
||||
|
||||
export const timerPlaceholder = '––:––:––';
|
||||
export const timerPlaceholderMin = '––:––';
|
||||
|
||||
@@ -3,12 +3,12 @@ import { ErrorBoundary } from '@sentry/react';
|
||||
import { useKeyDown } from '../../common/hooks/useKeyDown';
|
||||
|
||||
import AboutPanel from './panel/about-panel/AboutPanel';
|
||||
import FeatureSettingsPanel from './panel/feature-settings-panel/FeatureSettingsPanel';
|
||||
import GeneralPanel from './panel/general-panel/GeneralPanel';
|
||||
import IntegrationsPanel from './panel/integrations-panel/IntegrationsPanel';
|
||||
import InterfacePanel from './panel/interface-panel/InterfacePanel';
|
||||
import LogPanel from './panel/log-panel/LogPanel';
|
||||
import ProjectPanel from './panel/project-panel/ProjectPanel';
|
||||
import ProjectSettingsPanel from './panel/project-settings-panel/ProjectSettingsPanel';
|
||||
import ShutdownPanel from './panel/shutdown-panel/ShutdownPanel';
|
||||
import SourcesPanel from './panel/sources-panel/SourcesPanel';
|
||||
import PanelContent from './panel-content/PanelContent';
|
||||
import PanelList from './panel-list/PanelList';
|
||||
@@ -27,12 +27,12 @@ export default function AppSettings() {
|
||||
<PanelContent onClose={close}>
|
||||
{panel === 'project' && <ProjectPanel location={location} />}
|
||||
{panel === 'general' && <GeneralPanel location={location} />}
|
||||
{panel === 'project_settings' && <ProjectSettingsPanel />}
|
||||
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
|
||||
{panel === 'sources' && <SourcesPanel />}
|
||||
{panel === 'interface' && <InterfacePanel />}
|
||||
{panel === 'integrations' && <IntegrationsPanel location={location} />}
|
||||
{panel === 'about' && <AboutPanel />}
|
||||
{panel === 'log' && <LogPanel />}
|
||||
{panel === 'shutdown' && <ShutdownPanel />}
|
||||
</PanelContent>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
|
||||
+18
-1
@@ -4,7 +4,12 @@
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
gap: 0.5px;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.fieldForm {
|
||||
@@ -20,3 +25,15 @@
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.fit {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.aliasConstrain {
|
||||
min-width: 12em;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { PanelBaseProps } from '../../settingsStore';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import CustomFields from './custom-fields/CustomFields';
|
||||
import UrlPresetsForm from './UrlPresetsForm';
|
||||
|
||||
export default function FeatureSettingsPanel({ location }: PanelBaseProps) {
|
||||
const customFieldsRef = useScrollIntoView<HTMLDivElement>('custom', location);
|
||||
const urlPresetsRef = useScrollIntoView<HTMLDivElement>('urlpresets', location);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Feature Settings</Panel.Header>
|
||||
<div ref={customFieldsRef}>
|
||||
<CustomFields />
|
||||
</div>
|
||||
|
||||
<div ref={urlPresetsRef}>
|
||||
<UrlPresetsForm />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -15,7 +15,7 @@ import { handleLinks } from '../../../../common/utils/linkUtils';
|
||||
import { validateUrlPresetPath } from '../../../../common/utils/urlPresets';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import style from './GeneralPanel.module.scss';
|
||||
import style from './FeatureSettings.module.scss';
|
||||
|
||||
const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/';
|
||||
|
||||
@@ -91,7 +91,7 @@ export default function UrlPresetsForm() {
|
||||
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} data-testid='url-preset-form'>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
URL Presets
|
||||
URL presets
|
||||
<div className={style.actionButtons}>
|
||||
<Button variant='ontime-ghosted' size='md' onClick={onReset} isDisabled={!canSubmit}>
|
||||
Revert to saved
|
||||
+6
-7
@@ -4,13 +4,14 @@ import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
import { CustomField, CustomFieldLabel } from 'ontime-types';
|
||||
|
||||
import Swatch from '../../../../common/components/input/colour-input/Swatch';
|
||||
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
|
||||
|
||||
import CustomFieldForm from './CustomFieldForm';
|
||||
|
||||
import style from './ProjectSettingsPanel.module.scss';
|
||||
import style from '../FeatureSettings.module.scss';
|
||||
|
||||
interface CustomFieldEntryProps {
|
||||
field: string;
|
||||
colour: string;
|
||||
label: string;
|
||||
onEdit: (label: CustomFieldLabel, patch: CustomField) => Promise<void>;
|
||||
@@ -18,13 +19,11 @@ interface CustomFieldEntryProps {
|
||||
}
|
||||
|
||||
export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
||||
const { colour, label, onEdit, onDelete } = props;
|
||||
|
||||
const { colour, label, onEdit, onDelete, field } = props;
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const handleEdit = async (patch: CustomField) => {
|
||||
const oldLabel = label;
|
||||
await onEdit(oldLabel, patch);
|
||||
await onEdit(field, patch);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
@@ -64,7 +63,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
||||
color='#FA5656' // $red-500
|
||||
icon={<IoTrash />}
|
||||
aria-label='Delete entry'
|
||||
onClick={() => onDelete(label)}
|
||||
onClick={() => onDelete(field)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
+4
-4
@@ -4,11 +4,11 @@ import { Button, Input } from '@chakra-ui/react';
|
||||
import { CustomField } from 'ontime-types';
|
||||
import { isAlphanumeric } from 'ontime-utils';
|
||||
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import SwatchSelect from '../../../../common/components/input/colour-input/SwatchSelect';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import { maybeAxiosError } from '../../../../../common/api/utils';
|
||||
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
|
||||
import * as Panel from '../../PanelUtils';
|
||||
|
||||
import style from './ProjectSettingsPanel.module.scss';
|
||||
import style from '../FeatureSettings.module.scss';
|
||||
|
||||
interface CustomFieldsFormProps {
|
||||
onSubmit: (field: CustomField) => Promise<void>;
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, AlertDescription, AlertIcon, Button } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { CustomField, CustomFieldLabel } from 'ontime-types';
|
||||
|
||||
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
|
||||
import ExternalLink from '../../../../../common/components/external-link/ExternalLink';
|
||||
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||
import * as Panel from '../../PanelUtils';
|
||||
|
||||
import CustomFieldEntry from './CustomFieldEntry';
|
||||
import CustomFieldForm from './CustomFieldForm';
|
||||
|
||||
const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
|
||||
|
||||
export default function CustomFields() {
|
||||
const { data, refetch } = useCustomFields();
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
const handleInitiateCreate = () => {
|
||||
setIsAdding(true);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsAdding(false);
|
||||
};
|
||||
|
||||
const handleCreate = async (customField: CustomField) => {
|
||||
await postCustomField(customField);
|
||||
refetch();
|
||||
setIsAdding(false);
|
||||
};
|
||||
|
||||
const handleEditField = async (label: CustomFieldLabel, customField: CustomField) => {
|
||||
await editCustomField(label, customField);
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleDelete = async (label: string) => {
|
||||
try {
|
||||
await deleteCustomField(label);
|
||||
refetch();
|
||||
} catch (_error) {
|
||||
/** we do not handle errors here */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Custom fields
|
||||
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleInitiateCreate}>
|
||||
New
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Alert status='info' variant='ontime-on-dark-info'>
|
||||
<AlertIcon />
|
||||
<AlertDescription>
|
||||
Custom fields allow for additional information to be added to an event (eg. light, sound, camera). <br />
|
||||
<br />
|
||||
This data is not used by Ontime.
|
||||
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
{isAdding && <CustomFieldForm onSubmit={handleCreate} onCancel={handleCancel} />}
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Colour</th>
|
||||
<th>Name</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(data).map(([key, { colour, label }]) => {
|
||||
return (
|
||||
<CustomFieldEntry
|
||||
key={key}
|
||||
field={key}
|
||||
colour={colour}
|
||||
label={label}
|
||||
onEdit={handleEditField}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
@@ -2,23 +2,3 @@
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.pad {
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.fit {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.aliasConstrain {
|
||||
min-width: 12em;
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { PanelBaseProps } from '../../settingsStore';
|
||||
import EditorSettingsForm from '../interface-panel/EditorSettingsForm';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import GeneralPanelForm from './GeneralPanelForm';
|
||||
import UrlPresetsForm from './UrlPresetsForm';
|
||||
import ViewSettingsForm from './ViewSettingsForm';
|
||||
|
||||
export default function GeneralPanel({ location }: PanelBaseProps) {
|
||||
const manageRef = useScrollIntoView<HTMLDivElement>('manage', location);
|
||||
const generalRef = useScrollIntoView<HTMLDivElement>('settings', location);
|
||||
const editorRef = useScrollIntoView<HTMLDivElement>('editor', location);
|
||||
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
|
||||
const urlPresetsRef = useScrollIntoView<HTMLDivElement>('urlpresets', location);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Settings</Panel.Header>
|
||||
<div ref={manageRef}>
|
||||
<Panel.Header>App Settings</Panel.Header>
|
||||
<div ref={generalRef}>
|
||||
<GeneralPanelForm />
|
||||
</div>
|
||||
<div ref={editorRef}>
|
||||
<EditorSettingsForm />
|
||||
</div>
|
||||
<div ref={viewRef}>
|
||||
<ViewSettingsForm />
|
||||
</div>
|
||||
<div ref={urlPresetsRef}>
|
||||
<UrlPresetsForm />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { Alert, AlertDescription, AlertIcon, Button, Input, Switch } from '@chakra-ui/react';
|
||||
import { ViewSettings } from 'ontime-types';
|
||||
|
||||
@@ -99,7 +99,13 @@ export default function ViewSettingsForm() {
|
||||
title='Override CSS styles'
|
||||
description='Enables overriding view styles with custom stylesheet'
|
||||
/>
|
||||
<Switch {...register('overrideStyles')} variant='ontime' size='lg' />
|
||||
<Controller
|
||||
control={control}
|
||||
name='overrideStyles'
|
||||
render={({ field: { onChange, value, ref } }) => (
|
||||
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
|
||||
)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
<Panel.ListGroup>
|
||||
@@ -122,7 +128,13 @@ export default function ViewSettingsForm() {
|
||||
title='Freeze timer on end'
|
||||
description='Timer in views will stop from going negative after reaching'
|
||||
/>
|
||||
<Switch {...register('freezeEnd')} variant='ontime' size='lg' />
|
||||
<Controller
|
||||
control={control}
|
||||
name='freezeEnd'
|
||||
render={({ field: { onChange, value, ref } }) => (
|
||||
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
|
||||
)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
||||
import { Button, IconButton, Input, Select, Switch } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
@@ -75,7 +75,7 @@ export default function HttpIntegrations() {
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
HTTP
|
||||
HTTP settings
|
||||
<div className={style.flex}>
|
||||
<Button variant='ontime-ghosted' size='sm' onClick={() => reset()} isDisabled={!canSubmit}>
|
||||
Revert to saved
|
||||
@@ -99,7 +99,13 @@ export default function HttpIntegrations() {
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='HTTP Output' description='Provide feedback from Ontime through HTTP' />
|
||||
<Switch variant='ontime' size='lg' {...register('enabledOut')} />
|
||||
<Controller
|
||||
control={control}
|
||||
name='enabledOut'
|
||||
render={({ field: { onChange, value, ref } }) => (
|
||||
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
|
||||
)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
|
||||
|
||||
+4
@@ -2,6 +2,10 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.halfWidth {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.fitContents.fitContents {
|
||||
width: max-content; /* override chakra */
|
||||
}
|
||||
|
||||
+50
-18
@@ -1,4 +1,4 @@
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
||||
import { Button, IconButton, Input, Select, Switch } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
@@ -8,7 +8,7 @@ import { generateId } from 'ontime-utils';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
|
||||
import { isKeyEscape } from '../../../../common/utils/keyEvent';
|
||||
import { isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
|
||||
import { isASCII, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import { cycles } from './integrationUtils';
|
||||
@@ -65,7 +65,8 @@ export default function OscIntegrations() {
|
||||
prepend({
|
||||
id: generateId(),
|
||||
cycle: 'onLoad',
|
||||
message: '',
|
||||
address: '',
|
||||
payload: '',
|
||||
enabled: false,
|
||||
});
|
||||
};
|
||||
@@ -80,7 +81,7 @@ export default function OscIntegrations() {
|
||||
return (
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Open Sound Control
|
||||
OSC settings
|
||||
<div className={style.flex}>
|
||||
<Button variant='ontime-ghosted' size='sm' onClick={() => reset()} isDisabled={!canSubmit}>
|
||||
Revert to saved
|
||||
@@ -102,12 +103,18 @@ export default function OscIntegrations() {
|
||||
|
||||
<Panel.Section as='form' id='osc-form' onSubmit={handleSubmit(onSubmit)} onKeyDown={preventEscape}>
|
||||
<Panel.Loader isLoading={isLoading} />
|
||||
<Panel.Title>OSC Settings</Panel.Title>
|
||||
<Panel.Title>General OSC settings</Panel.Title>
|
||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='OSC input' description='Allow control of Ontime through OSC' />
|
||||
<Switch variant='ontime' size='lg' {...register('enabledIn')} />
|
||||
<Controller
|
||||
control={control}
|
||||
name='enabledIn'
|
||||
render={({ field: { onChange, value, ref } }) => (
|
||||
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
|
||||
)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
@@ -140,7 +147,13 @@ export default function OscIntegrations() {
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='OSC output' description='Provide feedback from Ontime with OSC' />
|
||||
<Switch variant='ontime' size='lg' {...register('enabledOut')} />
|
||||
<Controller
|
||||
control={control}
|
||||
name='enabledOut'
|
||||
render={({ field: { onChange, value, ref } }) => (
|
||||
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
|
||||
)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
@@ -195,7 +208,7 @@ export default function OscIntegrations() {
|
||||
<Panel.Divider />
|
||||
|
||||
<Panel.Title>
|
||||
OSC Integrations
|
||||
OSC integrations
|
||||
<Button variant='ontime-subtle' size='sm' rightIcon={<IoAdd />} onClick={handleAddNewSubscription}>
|
||||
Add
|
||||
</Button>
|
||||
@@ -207,14 +220,15 @@ export default function OscIntegrations() {
|
||||
<tr>
|
||||
<th>Enabled</th>
|
||||
<th>Cycle</th>
|
||||
<th className={style.fullWidth}>Message</th>
|
||||
<th className={style.halfWidth}>Address</th>
|
||||
<th className={style.halfWidth}>Payload</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fields.map((field, index) => {
|
||||
// @ts-expect-error -- not sure why it is not finding the type, it is ok
|
||||
const maybeError = errors.subscriptions?.[index]?.message?.message;
|
||||
const maybeAddressError = errors.subscriptions?.[index]?.address?.message;
|
||||
const maybePayloadError = errors.subscriptions?.[index]?.payload?.message;
|
||||
return (
|
||||
<tr key={field.id}>
|
||||
<td>
|
||||
@@ -234,22 +248,40 @@ export default function OscIntegrations() {
|
||||
))}
|
||||
</Select>
|
||||
</td>
|
||||
<td className={style.fullWidth}>
|
||||
<td className={style.halfWidth}>
|
||||
<Input
|
||||
key={field.id}
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
autoComplete='off'
|
||||
placeholder='/from-ontime/{{timer.current}}'
|
||||
{...register(`subscriptions.${index}.message`, {
|
||||
placeholder='/from-ontime/'
|
||||
{...register(`subscriptions.${index}.address`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
pattern: {
|
||||
value: startsWithSlash,
|
||||
message: 'OSC messages should start with a forward slash',
|
||||
validate: {
|
||||
oscStartsWithSlash: (value) =>
|
||||
startsWithSlash.test(value) || 'OSC address should start with a forward slash',
|
||||
oscStringIsAscii: (value) =>
|
||||
isASCII.test(value) || 'OSC address only allow ASCII characters',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{maybeError && <Panel.Error>{maybeError}</Panel.Error>}
|
||||
{maybeAddressError && <Panel.Error>{maybeAddressError}</Panel.Error>}
|
||||
</td>
|
||||
<td className={style.halfWidth}>
|
||||
<Input
|
||||
key={field.id}
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
autoComplete='off'
|
||||
placeholder='{{timer.current}}'
|
||||
{...register(`subscriptions.${index}.payload`, {
|
||||
validate: {
|
||||
oscStringIsAscii: (value) =>
|
||||
isASCII.test(value) || 'OSC payloads only allow ASCII characters',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{maybePayloadError && <Panel.Error>{maybePayloadError}</Panel.Error>}
|
||||
</td>
|
||||
<td>
|
||||
<IconButton
|
||||
|
||||
+76
-48
@@ -7,7 +7,6 @@ import * as Panel from '../PanelUtils';
|
||||
|
||||
export default function EditorSettingsForm() {
|
||||
const eventSettings = useEditorSettings((state) => state.eventSettings);
|
||||
const setShowQuickEntry = useEditorSettings((state) => state.setShowQuickEntry);
|
||||
const setLinkPrevious = useEditorSettings((state) => state.setLinkPrevious);
|
||||
const setDefaultPublic = useEditorSettings((state) => state.setDefaultPublic);
|
||||
const setDefaultDuration = useEditorSettings((state) => state.setDefaultDuration);
|
||||
@@ -19,53 +18,82 @@ export default function EditorSettingsForm() {
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>Editor settings</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Show quick entry'
|
||||
description='Whether the quick entry buttons show under selected event'
|
||||
/>
|
||||
<Switch
|
||||
variant='ontime'
|
||||
size='lg'
|
||||
defaultChecked={eventSettings.showQuickEntry}
|
||||
onChange={(event) => setShowQuickEntry(event.target.checked)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Link previous'
|
||||
description='New events start time will be linked to the previous event'
|
||||
/>
|
||||
<Switch
|
||||
variant='ontime'
|
||||
size='lg'
|
||||
defaultChecked={eventSettings.linkPrevious}
|
||||
onChange={(event) => setLinkPrevious(event.target.checked)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Default duration'
|
||||
description='When creating a new event, what is the default duration'
|
||||
/>
|
||||
<TimeInput<'defaultDuration'>
|
||||
name='defaultDuration'
|
||||
submitHandler={(_field, value) => setDefaultDuration(value)}
|
||||
time={durationInMs}
|
||||
placeholder='00:10:00'
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Default public' description='New events will be public' />
|
||||
<Switch
|
||||
variant='ontime'
|
||||
size='lg'
|
||||
defaultChecked={eventSettings.defaultPublic}
|
||||
onChange={(event) => setDefaultPublic(event.target.checked)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
<Panel.Section>
|
||||
<Panel.Title>Rundown options</Panel.Title>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Default duration'
|
||||
description='When creating a new event, what is the default duration'
|
||||
/>
|
||||
<TimeInput<'defaultDuration'>
|
||||
name='defaultDuration'
|
||||
submitHandler={(_field, value) => setDefaultDuration(value)}
|
||||
time={durationInMs}
|
||||
placeholder='00:10:00'
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Link previous'
|
||||
description='New events start time will be linked to the previous event'
|
||||
/>
|
||||
<Switch
|
||||
variant='ontime'
|
||||
size='lg'
|
||||
defaultChecked={eventSettings.linkPrevious}
|
||||
onChange={(event) => setLinkPrevious(event.target.checked)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Default public' description='New events will be public' />
|
||||
<Switch
|
||||
variant='ontime'
|
||||
size='lg'
|
||||
defaultChecked={eventSettings.defaultPublic}
|
||||
onChange={(event) => setDefaultPublic(event.target.checked)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
<Panel.Title>Play mode</Panel.Title>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Show quick entry'
|
||||
description='Whether the quick entry buttons show above / under selected event'
|
||||
/>
|
||||
<Switch variant='ontime' size='lg' defaultChecked={false} isDisabled />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Follow playback'
|
||||
description='Whether view automatically follows the event being played'
|
||||
/>
|
||||
<Switch variant='ontime' size='lg' defaultChecked isDisabled />
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
<Panel.Title>Edit mode</Panel.Title>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Show quick entry'
|
||||
description='Whether the quick entry buttons show above / under selected event'
|
||||
/>
|
||||
<Switch variant='ontime' size='lg' defaultChecked isDisabled />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Follow playback'
|
||||
description='Whether view automatically follows the event being played'
|
||||
/>
|
||||
<Switch variant='ontime' size='lg' defaultChecked={false} isDisabled />
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import InfoNif from './NetworkInterfaces';
|
||||
export default function LogPanel() {
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Application log</Panel.Header>
|
||||
<Panel.Header>Log</Panel.Header>
|
||||
<Panel.Section>
|
||||
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
|
||||
</Panel.Section>
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function ProjectData() {
|
||||
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)}>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Project Data
|
||||
Project data
|
||||
<div className={style.headerButtons}>
|
||||
<Button variant='ontime-ghosted' size='sm' onClick={onReset} isDisabled={isSubmitting || !isDirty}>
|
||||
Revert to saved
|
||||
|
||||
@@ -29,22 +29,21 @@ export default function ProjectList() {
|
||||
};
|
||||
|
||||
const reorderedProjectFiles = useMemo(() => {
|
||||
if (!data?.files?.length) return [];
|
||||
if (!data.files?.length) return [];
|
||||
|
||||
const currentlyLoadedIndex = files.findIndex((project) => project.filename === lastLoadedProject);
|
||||
const projectFiles = [...files];
|
||||
const current = projectFiles.splice(currentlyLoadedIndex, 1)?.[0];
|
||||
|
||||
return [current, ...projectFiles];
|
||||
}, [data.files.length, files, lastLoadedProject]);
|
||||
}, [data.files?.length, files, lastLoadedProject]);
|
||||
|
||||
return (
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={style.containCell}>Project Name</th>
|
||||
<th>Date Created</th>
|
||||
<th>Date Modified</th>
|
||||
<th>Last Used</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -53,7 +52,6 @@ export default function ProjectList() {
|
||||
<ProjectListItem
|
||||
key={project.filename}
|
||||
filename={project.filename}
|
||||
createdAt={project.createdAt}
|
||||
updatedAt={project.updatedAt}
|
||||
onToggleEditMode={handleToggleEditMode}
|
||||
onSubmit={handleClear}
|
||||
|
||||
@@ -21,7 +21,6 @@ export type EditMode = 'rename' | 'duplicate' | null;
|
||||
interface ProjectListItemProps {
|
||||
current?: boolean;
|
||||
filename: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
onToggleEditMode: (editMode: EditMode, filename: string | null) => void;
|
||||
onSubmit: () => void;
|
||||
@@ -32,14 +31,13 @@ interface ProjectListItemProps {
|
||||
|
||||
export default function ProjectListItem({
|
||||
current,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
editingFilename,
|
||||
editingMode,
|
||||
filename,
|
||||
onRefetch,
|
||||
onSubmit,
|
||||
onToggleEditMode,
|
||||
updatedAt,
|
||||
}: ProjectListItemProps) {
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
@@ -102,7 +100,6 @@ export default function ProjectListItem({
|
||||
) : (
|
||||
<>
|
||||
<td className={style.containCell}>{filename}</td>
|
||||
<td>{new Date(createdAt).toLocaleString()}</td>
|
||||
<td>{new Date(updatedAt).toLocaleString()}</td>
|
||||
<td className={style.actionButton}>
|
||||
<ActionMenu
|
||||
|
||||
@@ -6,7 +6,7 @@ import ManageProjects from './ManageProjects';
|
||||
import ProjectData from './ProjectData';
|
||||
|
||||
export default function ProjectPanel({ location }: PanelBaseProps) {
|
||||
const projectRef = useScrollIntoView<HTMLDivElement>('project', location);
|
||||
const projectRef = useScrollIntoView<HTMLDivElement>('data', location);
|
||||
const manageRef = useScrollIntoView<HTMLDivElement>('manage', location);
|
||||
|
||||
return (
|
||||
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, AlertDescription, AlertIcon, Button } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { CustomField, CustomFieldLabel } from 'ontime-types';
|
||||
|
||||
import { deleteCustomField, editCustomField, postCustomField } from '../../../../common/api/customFields';
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import CustomFieldEntry from './CustomFieldEntry';
|
||||
import CustomFieldForm from './CustomFieldForm';
|
||||
|
||||
const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
|
||||
|
||||
export default function ProjectSettingsPanel() {
|
||||
const { data, refetch } = useCustomFields();
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
const handleInitiateCreate = () => {
|
||||
setIsAdding(true);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsAdding(false);
|
||||
};
|
||||
|
||||
const handleCreate = async (customField: CustomField) => {
|
||||
await postCustomField(customField);
|
||||
refetch();
|
||||
setIsAdding(false);
|
||||
};
|
||||
|
||||
const handleEditField = async (label: CustomFieldLabel, customField: CustomField) => {
|
||||
await editCustomField(label, customField);
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleDelete = async (label: string) => {
|
||||
try {
|
||||
await deleteCustomField(label);
|
||||
refetch();
|
||||
} catch (_error) {
|
||||
/** we do not handle errors here */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Project Settings</Panel.Header>
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Custom fields
|
||||
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleInitiateCreate}>
|
||||
New
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Alert status='info' variant='ontime-on-dark-info'>
|
||||
<AlertIcon />
|
||||
<AlertDescription>
|
||||
Custom fields allow for additional information to be added to an event (eg. light, sound, camera).{' '}
|
||||
<br />
|
||||
<br />
|
||||
This data is not used by Ontime.
|
||||
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
{isAdding && <CustomFieldForm onSubmit={handleCreate} onCancel={handleCancel} />}
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Colour</th>
|
||||
<th>Name</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(data).map(([key, { colour, label }]) => {
|
||||
return (
|
||||
<CustomFieldEntry
|
||||
key={key}
|
||||
colour={colour}
|
||||
label={label}
|
||||
onEdit={handleEditField}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Button } from '@chakra-ui/react';
|
||||
|
||||
import useElectronEvent from '../../../../common/hooks/useElectronEvent';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
export default function ShutdownPanel() {
|
||||
const { isElectron, sendToElectron } = useElectronEvent();
|
||||
|
||||
const sendShutdown = () => {
|
||||
sendToElectron('shutdown', 'now');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Shutdown Ontime</Panel.Header>
|
||||
<Panel.Section>
|
||||
<Panel.Paragraph>
|
||||
This will shutdown the Ontime server. <br />
|
||||
The runtime state will be lost, but your project is kept for next time.
|
||||
</Panel.Paragraph>
|
||||
<Button colorScheme='red' onClick={sendShutdown} isDisabled={!isElectron}>
|
||||
Shutdown ontime
|
||||
</Button>
|
||||
</Panel.Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ChangeEvent, useEffect, useState } from 'react';
|
||||
import { Button, Input } from '@chakra-ui/react';
|
||||
import { Button, Input, Spinner } from '@chakra-ui/react';
|
||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
||||
import { IoShieldCheckmarkOutline } from '@react-icons/all-files/io5/IoShieldCheckmarkOutline';
|
||||
|
||||
import { getWorksheetNames } from '../../../../common/api/sheets';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
||||
import { openLink } from '../../../../common/utils/linkUtils';
|
||||
import * as Panel from '../PanelUtils';
|
||||
@@ -29,7 +30,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
||||
const sheetId = useSheetStore((state) => state.sheetId);
|
||||
const setSheetId = useSheetStore((state) => state.setSheetId);
|
||||
const setWorksheets = useSheetStore((state) => state.setWorksheets);
|
||||
|
||||
const patchStepData = useSheetStore((state) => state.patchStepData);
|
||||
const authenticationStatus = useSheetStore((state) => state.authenticationStatus);
|
||||
const setAuthenticationStatus = useSheetStore((state) => state.setAuthenticationStatus);
|
||||
|
||||
@@ -91,8 +92,13 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
||||
setAuthenticationStatus(result.authenticated);
|
||||
if (result.authenticated !== 'pending') {
|
||||
if (result.authenticated == 'authenticated') {
|
||||
const names = await getWorksheetNames(result.sheetId);
|
||||
setWorksheets(names);
|
||||
try {
|
||||
const names = await getWorksheetNames(result.sheetId);
|
||||
setWorksheets(names);
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
patchStepData({ worksheet: { available: false, error: message } });
|
||||
}
|
||||
}
|
||||
setLoading('');
|
||||
return;
|
||||
@@ -184,6 +190,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
||||
) : (
|
||||
<Panel.ListGroup>
|
||||
<div className={style.buttonRow}>
|
||||
{isAuthenticating && <Spinner />}
|
||||
<CopyTag label='Google Auth Key' disabled={!canAuthenticate} size='sm'>
|
||||
{authKey ? authKey : 'Upload files to generate Auth Key'}
|
||||
</CopyTag>
|
||||
@@ -192,8 +199,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
||||
size='sm'
|
||||
leftIcon={<IoShieldCheckmarkOutline />}
|
||||
onClick={handleAuthenticate}
|
||||
isDisabled={!canAuthenticate || isLoading}
|
||||
isLoading={loading === 'authenticate' || isAuthenticating}
|
||||
isDisabled={!canAuthenticate}
|
||||
>
|
||||
Authenticate
|
||||
</Button>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
.buttonRow {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ChangeEvent, useRef, useState } from 'react';
|
||||
import { Button, Input } from '@chakra-ui/react';
|
||||
import { IoCloudOutline } from '@react-icons/all-files/io5/IoCloudOutline';
|
||||
import { IoDownloadOutline } from '@react-icons/all-files/io5/IoDownloadOutline';
|
||||
import { ImportMap, unpackError } from 'ontime-utils';
|
||||
import { getErrorMessage, ImportMap } from 'ontime-utils';
|
||||
|
||||
import {
|
||||
getWorksheetNames as getWorksheetNamesExcel,
|
||||
@@ -59,7 +59,7 @@ export default function SourcesPanel() {
|
||||
setImportFlow('excel');
|
||||
setHasFile('done');
|
||||
} catch (error) {
|
||||
const errorMessage = unpackError(error);
|
||||
const errorMessage = getErrorMessage(error);
|
||||
setError(`Error uploading file: ${errorMessage}`);
|
||||
setWorksheets(null);
|
||||
setHasFile('none');
|
||||
|
||||
+7
-8
@@ -141,14 +141,13 @@ export default function ImportMapForm(props: ImportMapFormProps) {
|
||||
size='sm'
|
||||
{...register(label as keyof NamedImportMap)}
|
||||
>
|
||||
{worksheetNames &&
|
||||
worksheetNames.map((name) => {
|
||||
return (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
{worksheetNames?.map((name) => {
|
||||
return (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</td>
|
||||
<td className={style.singleActionCell} />
|
||||
|
||||
@@ -11,23 +11,28 @@ export const settingPanels: Readonly<SettingsOption[]> = [
|
||||
{
|
||||
id: 'project',
|
||||
label: 'Project',
|
||||
secondary: [{ id: 'project__manage', label: 'Manage project files' }],
|
||||
},
|
||||
{
|
||||
id: 'general',
|
||||
label: 'General',
|
||||
secondary: [
|
||||
{ id: 'general__manage', label: 'Manage Ontime settings' },
|
||||
{ id: 'general__view', label: 'View settings' },
|
||||
{ id: 'general__urlpresets', label: 'URL presets' },
|
||||
{ id: 'project__data', label: 'Project data' },
|
||||
{ id: 'project__manage', label: 'Manage projects' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'project_settings',
|
||||
label: 'Project Settings',
|
||||
secondary: [{ id: 'project_settings__custom', label: 'Custom fields' }],
|
||||
id: 'general',
|
||||
label: 'App Settings',
|
||||
secondary: [
|
||||
{ id: 'general__settings', label: 'General settings' },
|
||||
{ id: 'general__editor', label: 'Editor settings' },
|
||||
{ id: 'general__view', label: 'View settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'feature_settings',
|
||||
label: 'Feature Settings',
|
||||
secondary: [
|
||||
{ id: 'feature_settings__custom', label: 'Custom fields' },
|
||||
{ id: 'feature_settings__urlpresets', label: 'URL Presets' },
|
||||
],
|
||||
},
|
||||
{ id: 'interface', label: 'Interface', secondary: [{ id: 'general__editor', label: 'Editor settings' }] },
|
||||
{
|
||||
id: 'sources',
|
||||
label: 'Data Sources',
|
||||
@@ -51,9 +56,15 @@ export const settingPanels: Readonly<SettingsOption[]> = [
|
||||
label: 'About',
|
||||
split: true,
|
||||
},
|
||||
{
|
||||
id: 'shutdown',
|
||||
label: 'Shutdown',
|
||||
split: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type SettingsOptionId = (typeof settingPanels)[number]['id'];
|
||||
|
||||
export interface PanelBaseProps {
|
||||
location?: string;
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function MessageControl() {
|
||||
onClick={() => setMessage.timerBlink(!blink)}
|
||||
data-testid='toggle timer blink'
|
||||
>
|
||||
Blink message
|
||||
Blink
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
@@ -66,7 +66,7 @@ export default function MessageControl() {
|
||||
</Button>
|
||||
</div>
|
||||
<InputRow
|
||||
label='External Message (readonly)'
|
||||
label='External Message (read only)'
|
||||
placeholder={enDash}
|
||||
readonly
|
||||
text={message.external.text || ''}
|
||||
|
||||
@@ -32,8 +32,8 @@ export function ExtraTimer() {
|
||||
<div className={style.extraRow}>
|
||||
<ExtraTimeInput />
|
||||
<TapButton onClick={toggleDirection} aspect='tight'>
|
||||
{direction === SimpleDirection.CountDown && <IoArrowDown />}
|
||||
{direction === SimpleDirection.CountUp && <IoArrowUp />}
|
||||
{direction === SimpleDirection.CountDown && <IoArrowDown data-testid='aux-timer-direction' />}
|
||||
{direction === SimpleDirection.CountUp && <IoArrowUp data-testid='aux-timer-direction' />}
|
||||
</TapButton>
|
||||
|
||||
<TapButton
|
||||
@@ -42,7 +42,7 @@ export function ExtraTimer() {
|
||||
active={playback === SimplePlayback.Start}
|
||||
disabled={!userCan.start}
|
||||
>
|
||||
<IoPlay />
|
||||
<IoPlay data-testid='aux-timer-start' />
|
||||
</TapButton>
|
||||
<TapButton
|
||||
onClick={pause}
|
||||
@@ -50,10 +50,10 @@ export function ExtraTimer() {
|
||||
active={playback === SimplePlayback.Pause}
|
||||
disabled={!userCan.pause}
|
||||
>
|
||||
<IoPause />
|
||||
<IoPause data-testid='aux-timer-pause' />
|
||||
</TapButton>
|
||||
<TapButton onClick={stop} theme={Playback.Stop} disabled={!userCan.stop}>
|
||||
<IoStop />
|
||||
<IoStop data-testid='aux-timer-stop' />
|
||||
</TapButton>
|
||||
</div>
|
||||
);
|
||||
@@ -61,14 +61,14 @@ export function ExtraTimer() {
|
||||
|
||||
function ExtraTimeInput() {
|
||||
const time = useExtraTimerTime();
|
||||
const { setTime } = setExtraTimer;
|
||||
const { setDuration } = setExtraTimer;
|
||||
|
||||
const handleTimeUpdate = (_field: string, value: string) => {
|
||||
const newTime = forgivingStringToMillis(value);
|
||||
setTime(newTime);
|
||||
setDuration(newTime / 1000); //frontend api is seconds based;
|
||||
};
|
||||
|
||||
return (
|
||||
<TimeInput<'extraTimer'> submitHandler={handleTimeUpdate} name='extraTimer' time={time} placeholder='Timer 1' />
|
||||
<TimeInput<'auxTimer'> submitHandler={handleTimeUpdate} name='auxTimer' time={time} placeholder='Aux Timer 1' />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
|
||||
const noEvents = numEvents === 0;
|
||||
|
||||
const disableGo = isRolling || noEvents || (isLast && !isArmed);
|
||||
const disableNext = isRolling || noEvents || isLast;
|
||||
const disablePrev = isRolling || noEvents || isFirst;
|
||||
|
||||
const playbackCan = validatePlayback(playback);
|
||||
@@ -73,7 +74,7 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
|
||||
</TapButton>
|
||||
</Tooltip>
|
||||
<Tooltip label='Next event' openDelay={tooltipDelayMid}>
|
||||
<TapButton onClick={setPlayback.next} disabled={disableGo}>
|
||||
<TapButton onClick={setPlayback.next} disabled={disableNext}>
|
||||
<IoPlaySkipForward />
|
||||
</TapButton>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
.tableWrapper {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
padding: 1rem;
|
||||
padding: 1rem 0.5rem;
|
||||
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
grid-template-rows: 3rem auto 1fr;
|
||||
grid-template-areas:
|
||||
'header'
|
||||
'overview'
|
||||
'settings'
|
||||
'table';
|
||||
gap: 1rem;
|
||||
|
||||
background-color: $gray-1300;
|
||||
color: white;
|
||||
|
||||
& > * {
|
||||
border: 1px solid $white-10;
|
||||
border-radius: 3px;
|
||||
}
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { CustomFieldLabel, isOntimeEvent, ProjectData } from 'ontime-types';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { IconButton, useDisclosure } from '@chakra-ui/react';
|
||||
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||
import { CustomFieldLabel, isOntimeEvent } from 'ontime-types';
|
||||
|
||||
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
|
||||
import Empty from '../../common/components/state/Empty';
|
||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||
import { useCuesheet } from '../../common/hooks/useSocket';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||
import { useFlatRundown } from '../../common/hooks-query/useRundown';
|
||||
import { CuesheetOverview } from '../overview/Overview';
|
||||
|
||||
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
|
||||
import CuesheetTableHeader from './cuesheet-table-header/CuesheetTableHeader';
|
||||
import { useCuesheetSettings } from './store/CuesheetSettings';
|
||||
import Cuesheet from './Cuesheet';
|
||||
import { makeCuesheetColumns } from './cuesheetCols';
|
||||
import { makeCSV, makeTable } from './cuesheetUtils';
|
||||
|
||||
import styles from './CuesheetWrapper.module.scss';
|
||||
|
||||
@@ -19,15 +24,14 @@ export default function CuesheetWrapper() {
|
||||
// TODO: can we use the normalised rundown for the table?
|
||||
const { data: flatRundown, status: rundownStatus } = useFlatRundown();
|
||||
const { data: customFields } = useCustomFields();
|
||||
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const { updateCustomField } = useEventAction();
|
||||
const featureData = useCuesheet();
|
||||
const columns = useMemo(() => makeCuesheetColumns(customFields), [customFields]);
|
||||
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
|
||||
|
||||
// Set window title
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Cuesheet';
|
||||
}, []);
|
||||
useWindowTitle('Cuesheet');
|
||||
|
||||
/**
|
||||
* Handles updating a field
|
||||
@@ -74,38 +78,29 @@ export default function CuesheetWrapper() {
|
||||
[flatRundown, rundownStatus, updateCustomField],
|
||||
);
|
||||
|
||||
const exportHandler = useCallback(
|
||||
(headerData: ProjectData) => {
|
||||
if (!flatRundown || rundownStatus !== 'success') {
|
||||
return;
|
||||
}
|
||||
const sheetData = makeTable(headerData, flatRundown, customFields);
|
||||
const csvContent = makeCSV(sheetData);
|
||||
|
||||
const fileName = 'ontime rundown.csv';
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', fileName);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
// Clean up the URL.createObjectURL to release resources
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
},
|
||||
[flatRundown, rundownStatus, customFields],
|
||||
);
|
||||
|
||||
if (!customFields || !flatRundown || rundownStatus !== 'success') {
|
||||
return <Empty text='Loading...' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
||||
<CuesheetTableHeader handleExport={exportHandler} featureData={featureData} />
|
||||
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
|
||||
<CuesheetOverview>
|
||||
<IconButton
|
||||
aria-label='Toggle settings'
|
||||
variant='ontime-subtle-white'
|
||||
size='lg'
|
||||
icon={<IoApps />}
|
||||
onClick={onOpen}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label='Toggle navigation'
|
||||
variant='ontime-subtle-white'
|
||||
size='lg'
|
||||
icon={<IoSettingsOutline />}
|
||||
onClick={() => toggleSettings()}
|
||||
/>
|
||||
</CuesheetOverview>
|
||||
<CuesheetProgress />
|
||||
<Cuesheet
|
||||
data={flatRundown}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import { memo, useEffect } from 'react';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import {
|
||||
closestCenter,
|
||||
@@ -31,6 +31,12 @@ function CuesheetHeader(props: CuesheetHeaderProps) {
|
||||
const { headerGroups } = props;
|
||||
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>('table-order', initialColumnOrder);
|
||||
|
||||
useEffect(() => {
|
||||
if (!localStorage.getItem('table-order')) {
|
||||
saveColumnOrder(initialColumnOrder);
|
||||
}
|
||||
}, [saveColumnOrder]);
|
||||
|
||||
const handleOnDragEnd = (event: DragEndEvent) => {
|
||||
const { delta, active, over } = event;
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { useFullscreen } from '@mantine/hooks';
|
||||
import { IoContract } from '@react-icons/all-files/io5/IoContract';
|
||||
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
|
||||
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
|
||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||
import { Playback, ProjectData } from 'ontime-types';
|
||||
|
||||
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
|
||||
@@ -28,10 +24,7 @@ interface CuesheetTableHeaderProps {
|
||||
|
||||
export default function CuesheetTableHeader({ handleExport, featureData }: CuesheetTableHeaderProps) {
|
||||
const followSelected = useCuesheetSettings((state) => state.followSelected);
|
||||
const showSettings = useCuesheetSettings((state) => state.showSettings);
|
||||
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
|
||||
const toggleFollow = useCuesheetSettings((state) => state.toggleFollow);
|
||||
const { fullscreen, toggle } = useFullscreen();
|
||||
const { data: project } = useProjectData();
|
||||
|
||||
const exportProject = () => {
|
||||
@@ -66,19 +59,6 @@ export default function CuesheetTableHeader({ handleExport, featureData }: Cuesh
|
||||
<IoLocate />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Toggle settings'>
|
||||
<span
|
||||
onClick={() => toggleSettings()}
|
||||
className={cx([style.actionIcon, showSettings ? style.enabled : null])}
|
||||
>
|
||||
<IoSettingsOutline />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Toggle Fullscreen'>
|
||||
<span onClick={() => toggle()} className={style.actionIcon}>
|
||||
{fullscreen ? <IoContract /> : <IoExpand />}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Export rundown'>
|
||||
<span className={style.actionText} onClick={exportProject}>
|
||||
Export CSV
|
||||
|
||||
@@ -23,12 +23,14 @@ interface CuesheetTableSettingsProps {
|
||||
function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
|
||||
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
|
||||
const {
|
||||
followSelected,
|
||||
toggleFollow,
|
||||
showPrevious,
|
||||
toggleDelayVisibility,
|
||||
togglePreviousVisibility,
|
||||
showDelayBlock,
|
||||
showDelayedTimes,
|
||||
toggleDelayedTimes,
|
||||
togglePreviousVisibility,
|
||||
toggleDelayVisibility,
|
||||
} = useCuesheetSettings();
|
||||
|
||||
return (
|
||||
@@ -53,6 +55,10 @@ function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
|
||||
</div>
|
||||
<div className={style.sectionTitle}>Table Options</div>
|
||||
<div className={style.options}>
|
||||
<label className={style.option}>
|
||||
<Switch variant='ontime' size='sm' isChecked={followSelected} onChange={() => toggleFollow()} />
|
||||
Follow selected event
|
||||
</label>
|
||||
<label className={style.option}>
|
||||
<Switch variant='ontime' size='sm' isChecked={showPrevious} onChange={() => togglePreviousVisibility()} />
|
||||
Show past events
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
@use './EditorMixin' as editor;
|
||||
|
||||
$menu-width: 2.75rem;
|
||||
$min-playback-width: 27rem;
|
||||
$max-playback-width: 35rem;
|
||||
$panel-gap: 0.5rem;
|
||||
@@ -17,11 +16,11 @@ $panel-gap: 0.5rem;
|
||||
padding: 1rem 0.5rem;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: $menu-width auto;
|
||||
grid-template-columns: auto;
|
||||
grid-template-rows: 3rem 1fr;
|
||||
grid-template-areas:
|
||||
'menu overview'
|
||||
'menu main';
|
||||
'overview'
|
||||
'main';
|
||||
gap: $panel-gap;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { lazy, useEffect } from 'react';
|
||||
import { lazy, useCallback, useEffect } from 'react';
|
||||
import { IconButton, useDisclosure } from '@chakra-ui/react';
|
||||
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||
|
||||
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
|
||||
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
|
||||
import useElectronEvent from '../../common/hooks/useElectronEvent';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import AppSettings from '../app-settings/AppSettings';
|
||||
import { SettingsOptionId } from '../app-settings/settingsStore';
|
||||
import useAppSettingsNavigation from '../app-settings/useAppSettingsNavigation';
|
||||
import MenuBar from '../menu/MenuBar';
|
||||
import Overview from '../overview/Overview';
|
||||
import { EditorOverview } from '../overview/Overview';
|
||||
|
||||
import styles from './Editor.module.scss';
|
||||
|
||||
@@ -14,27 +17,71 @@ const TimerControl = lazy(() => import('../control/playback/TimerControlExport')
|
||||
const MessageControl = lazy(() => import('../control/message/MessageControlExport'));
|
||||
|
||||
export default function Editor() {
|
||||
const { isOpen, setLocation, close } = useAppSettingsNavigation();
|
||||
const { isOpen: isSettingsOpen, setLocation, close } = useAppSettingsNavigation();
|
||||
const { isElectron } = useElectronEvent();
|
||||
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const handleSettings = (newTab?: SettingsOptionId) => {
|
||||
if (isOpen) {
|
||||
const toggleSettings = useCallback(() => {
|
||||
if (isSettingsOpen) {
|
||||
close();
|
||||
} else {
|
||||
setLocation(newTab ?? 'project');
|
||||
setLocation('project');
|
||||
}
|
||||
};
|
||||
}, [close, isSettingsOpen, setLocation]);
|
||||
|
||||
// Set window title
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyPress = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
// handle held key
|
||||
if (event.repeat) return;
|
||||
|
||||
// check if the ctrl key is pressed
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
// ctrl + , (settings)
|
||||
if (event.key === ',') {
|
||||
toggleSettings();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
},
|
||||
[toggleSettings],
|
||||
);
|
||||
|
||||
// register ctrl + , to open settings
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Editor';
|
||||
}, []);
|
||||
if (isElectron) {
|
||||
document.addEventListener('keydown', handleKeyPress);
|
||||
}
|
||||
return () => {
|
||||
if (isElectron) {
|
||||
document.removeEventListener('keydown', handleKeyPress);
|
||||
}
|
||||
};
|
||||
}, [handleKeyPress, isElectron]);
|
||||
|
||||
useWindowTitle('Editor');
|
||||
|
||||
return (
|
||||
<div className={styles.mainContainer} data-testid='event-editor'>
|
||||
<ErrorBoundary>
|
||||
<MenuBar openSettings={handleSettings} isSettingsOpen={isOpen} />
|
||||
</ErrorBoundary>
|
||||
{isOpen ? (
|
||||
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
|
||||
<EditorOverview>
|
||||
<IconButton
|
||||
aria-label='Toggle navigation'
|
||||
variant='ontime-subtle-white'
|
||||
size='lg'
|
||||
icon={<IoApps />}
|
||||
onClick={onOpen}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label='Toggle settings'
|
||||
variant='ontime-subtle-white'
|
||||
size='lg'
|
||||
icon={<IoSettingsOutline />}
|
||||
onClick={toggleSettings}
|
||||
/>
|
||||
</EditorOverview>
|
||||
{isSettingsOpen ? (
|
||||
<AppSettings />
|
||||
) : (
|
||||
<div id='panels' className={styles.panelContainer}>
|
||||
@@ -45,7 +92,6 @@ export default function Editor() {
|
||||
<Rundown />
|
||||
</div>
|
||||
)}
|
||||
<Overview />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
.menu {
|
||||
grid-area: menu;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
padding-bottom: 1rem
|
||||
}
|
||||
|
||||
.gap {
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.open {
|
||||
background: $blue-700;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
margin-top: auto;
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { memo, useCallback, useEffect } from 'react';
|
||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||
|
||||
import QuitIconBtn from '../../common/components/buttons/QuitIconBtn';
|
||||
import TooltipActionBtn from '../../common/components/buttons/TooltipActionBtn';
|
||||
import useElectronEvent from '../../common/hooks/useElectronEvent';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
|
||||
import style from './MenuBar.module.scss';
|
||||
|
||||
interface MenuBarProps {
|
||||
openSettings: (newTab?: string) => void;
|
||||
isSettingsOpen: boolean;
|
||||
}
|
||||
|
||||
const buttonStyle = {
|
||||
fontSize: '1.25em',
|
||||
size: 'md',
|
||||
colorScheme: 'white',
|
||||
_hover: {
|
||||
background: 'rgba(255, 255, 255, 0.10)', // $white-10
|
||||
},
|
||||
_active: {
|
||||
background: 'rgba(255, 255, 255, 0.13)', // $white-13
|
||||
},
|
||||
};
|
||||
|
||||
const MenuBar = (props: MenuBarProps) => {
|
||||
const { openSettings, isSettingsOpen } = props;
|
||||
const { isElectron, sendToElectron } = useElectronEvent();
|
||||
|
||||
const sendShutdown = () => {
|
||||
if (isElectron) {
|
||||
sendToElectron('shutdown', 'now');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyPress = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
// handle held key
|
||||
if (event.repeat) return;
|
||||
|
||||
// check if the ctrl key is pressed
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
// ctrl + , (settings)
|
||||
if (event.key === ',') {
|
||||
openSettings();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
},
|
||||
[openSettings],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isElectron) {
|
||||
document.addEventListener('keydown', handleKeyPress);
|
||||
}
|
||||
return () => {
|
||||
if (isElectron) {
|
||||
document.removeEventListener('keydown', handleKeyPress);
|
||||
}
|
||||
};
|
||||
}, [handleKeyPress, isElectron]);
|
||||
|
||||
return (
|
||||
<div className={style.menu}>
|
||||
<QuitIconBtn disabled={!isElectron} clickHandler={sendShutdown} />
|
||||
<TooltipActionBtn
|
||||
{...buttonStyle}
|
||||
className={cx([isSettingsOpen ? style.open : null, style.bottom])}
|
||||
icon={<IoSettingsOutline />}
|
||||
clickHandler={() => openSettings()}
|
||||
tooltip='Application settings'
|
||||
aria-label='Application settings'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(MenuBar);
|
||||
@@ -3,12 +3,12 @@ import { useSearchParams } from 'react-router-dom';
|
||||
import { CustomField, CustomFields, isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types';
|
||||
import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils';
|
||||
|
||||
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
|
||||
import Empty from '../../common/components/state/Empty';
|
||||
import { getOperatorOptions } from '../../common/components/view-params-editor/constants';
|
||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||
import { useOperator } from '../../common/hooks/useSocket';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||
import useProjectData from '../../common/hooks-query/useProjectData';
|
||||
import useRundown from '../../common/hooks-query/useRundown';
|
||||
@@ -57,10 +57,7 @@ export default function Operator() {
|
||||
topOffset: selectedOffset,
|
||||
});
|
||||
|
||||
// Set window title
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Operator';
|
||||
}, []);
|
||||
useWindowTitle('Operator');
|
||||
|
||||
// reset scroll if nothing is selected
|
||||
useEffect(() => {
|
||||
@@ -141,7 +138,6 @@ export default function Operator() {
|
||||
|
||||
return (
|
||||
<div className={style.operatorContainer}>
|
||||
<NavigationMenu />
|
||||
<ViewParamsEditor paramFields={operatorOptions} />
|
||||
{editEvent && <EditModal event={editEvent} onClose={() => setEditEvent(null)} />}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useDisclosure } from '@chakra-ui/react';
|
||||
|
||||
import FloatingNavigation from '../../common/components/navigation-menu/FloatingNavigation';
|
||||
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
|
||||
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
|
||||
|
||||
import Operator from './Operator';
|
||||
|
||||
export default function OperatorExport() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const showEditFormDrawer = useCallback(() => {
|
||||
searchParams.set('edit', 'true');
|
||||
setSearchParams(searchParams);
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const toggleMenu = isOpen ? onClose : onOpen;
|
||||
|
||||
return (
|
||||
<ProtectRoute permission='operator'>
|
||||
<FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
|
||||
<ProductionNavigationMenu isMenuOpen={isOpen} onMenuClose={onClose} />
|
||||
<Operator />
|
||||
</ProtectRoute>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,20 @@
|
||||
.overview {
|
||||
grid-area: overview;
|
||||
font-size: $inner-section-text-size;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1;
|
||||
padding-inline: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: $inner-section-text-size;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
|
||||
@@ -1,38 +1,19 @@
|
||||
import { useMemo } from 'react';
|
||||
import { MaybeNumber } from 'ontime-types';
|
||||
import { dayInMs, millisToString } from 'ontime-utils';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
|
||||
import { useRuntimeOverview, useRuntimePlaybackOverview } from '../../common/hooks/useSocket';
|
||||
import { useRuntimeOverview, useRuntimePlaybackOverview, useTimer } from '../../common/hooks/useSocket';
|
||||
import useProjectData from '../../common/hooks-query/useProjectData';
|
||||
import { enDash, timerPlaceholder } from '../../common/utils/styleUtils';
|
||||
import { enDash } from '../../common/utils/styleUtils';
|
||||
|
||||
import { TimeColumn, TimeRow } from './composite/TimeLayout';
|
||||
import { calculateEndAndDaySpan, formatedTime, getOffsetText } from './overviewUtils';
|
||||
|
||||
import style from './Overview.module.scss';
|
||||
|
||||
/**
|
||||
* Encapsulates the logic for formatting time in overview
|
||||
* @param time
|
||||
* @returns
|
||||
*/
|
||||
function formatedTime(time: MaybeNumber) {
|
||||
return millisToString(time, { fallback: timerPlaceholder });
|
||||
}
|
||||
export const EditorOverview = memo(_EditorOverview);
|
||||
|
||||
function calculateEndAndDaySpan(end: MaybeNumber): [MaybeNumber, number] {
|
||||
let maybeEnd = end;
|
||||
let maybeDaySpan = 0;
|
||||
if (end !== null) {
|
||||
if (end > dayInMs) {
|
||||
maybeEnd = end % dayInMs;
|
||||
maybeDaySpan = Math.floor(end / dayInMs);
|
||||
}
|
||||
}
|
||||
return [maybeEnd, maybeDaySpan];
|
||||
}
|
||||
|
||||
export default function Overview() {
|
||||
function _EditorOverview({ children }: { children: React.ReactNode }) {
|
||||
const { plannedEnd, plannedStart, actualStart, expectedEnd } = useRuntimeOverview();
|
||||
|
||||
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
|
||||
@@ -44,15 +25,58 @@ export default function Overview() {
|
||||
return (
|
||||
<div className={style.overview}>
|
||||
<ErrorBoundary>
|
||||
<TitlesOverview />
|
||||
<div className={style.column}>
|
||||
<TimeRow label='Planned start' value={formatedTime(plannedStart)} className={style.start} />
|
||||
<TimeRow label='Actual start' value={formatedTime(actualStart)} className={style.start} />
|
||||
<div className={style.nav}>{children}</div>
|
||||
<div className={style.info}>
|
||||
<TitlesOverview />
|
||||
<div>
|
||||
<TimeRow label='Planned start' value={formatedTime(plannedStart)} className={style.start} />
|
||||
<TimeRow label='Actual start' value={formatedTime(actualStart)} className={style.start} />
|
||||
</div>
|
||||
<ProgressOverview />
|
||||
<RuntimeOverview />
|
||||
<div>
|
||||
<TimeRow label='Planned end' value={plannedEndText} className={style.end} daySpan={maybePlannedDaySpan} />
|
||||
<TimeRow
|
||||
label='Expected end'
|
||||
value={expectedEndText}
|
||||
className={style.end}
|
||||
daySpan={maybeExpectedDaySpan}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<RuntimeOverview />
|
||||
<div className={style.column}>
|
||||
<TimeRow label='Planned end' value={plannedEndText} className={style.end} daySpan={maybePlannedDaySpan} />
|
||||
<TimeRow label='Expected end' value={expectedEndText} className={style.end} daySpan={maybeExpectedDaySpan} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const CuesheetOverview = memo(_CuesheetOverview);
|
||||
|
||||
function _CuesheetOverview({ children }: { children: React.ReactNode }) {
|
||||
const { plannedEnd, expectedEnd } = useRuntimeOverview();
|
||||
|
||||
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
|
||||
const plannedEndText = formatedTime(maybePlannedEnd);
|
||||
|
||||
const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]);
|
||||
const expectedEndText = formatedTime(maybeExpectedEnd);
|
||||
|
||||
return (
|
||||
<div className={style.overview}>
|
||||
<ErrorBoundary>
|
||||
<div className={style.nav}>{children}</div>
|
||||
<div className={style.info}>
|
||||
<TitlesOverview />
|
||||
<TimerOverview />
|
||||
<RuntimeOverview />
|
||||
<div>
|
||||
<TimeRow label='Planned end' value={plannedEndText} className={style.end} daySpan={maybePlannedDaySpan} />
|
||||
<TimeRow
|
||||
label='Expected end'
|
||||
value={expectedEndText}
|
||||
className={style.end}
|
||||
daySpan={maybeExpectedDaySpan}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
@@ -63,37 +87,39 @@ function TitlesOverview() {
|
||||
const { data } = useProjectData();
|
||||
|
||||
return (
|
||||
<div className={style.titles}>
|
||||
<div>
|
||||
<div className={style.title}>{data.title}</div>
|
||||
<div className={style.description}>{data.description}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getOffsetText(offset: MaybeNumber): string {
|
||||
if (offset === null) {
|
||||
return enDash;
|
||||
}
|
||||
const isAhead = offset <= 0;
|
||||
let offsetText = millisToString(Math.abs(offset), { fallback: enDash });
|
||||
if (offsetText !== enDash) {
|
||||
offsetText = isAhead ? `+${offsetText}` : `${enDash}${offsetText}`;
|
||||
}
|
||||
return offsetText;
|
||||
function TimerOverview() {
|
||||
const {current} = useTimer();
|
||||
|
||||
const display = millisToString(current);
|
||||
|
||||
return <TimeColumn label='Running timer' value={display} />;
|
||||
}
|
||||
|
||||
function RuntimeOverview() {
|
||||
const { clock, numEvents, selectedEventIndex, offset } = useRuntimePlaybackOverview();
|
||||
function ProgressOverview() {
|
||||
const { numEvents, selectedEventIndex } = useRuntimePlaybackOverview();
|
||||
|
||||
const current = selectedEventIndex !== null ? selectedEventIndex + 1 : enDash;
|
||||
const ofTotal = numEvents || enDash;
|
||||
const progressText = numEvents ? `${current} of ${ofTotal}` : '-';
|
||||
|
||||
return <TimeColumn label='Progress' value={progressText} />;
|
||||
}
|
||||
|
||||
function RuntimeOverview() {
|
||||
const { clock, offset } = useRuntimePlaybackOverview();
|
||||
|
||||
const offsetText = getOffsetText(offset);
|
||||
const offsetClasses = offset === null ? undefined : offset > 0 ? style.behind : style.ahead;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TimeColumn label='Progress' value={progressText} />
|
||||
<TimeColumn label='Offset' value={offsetText} className={offsetClasses} />
|
||||
<TimeColumn label='Time now' value={formatedTime(clock)} />
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { MaybeNumber } from 'ontime-types';
|
||||
import { dayInMs, millisToString } from 'ontime-utils';
|
||||
|
||||
import { enDash, timerPlaceholder } from '../../common/utils/styleUtils';
|
||||
|
||||
/**
|
||||
* Encapsulates the logic for formatting time in overview
|
||||
* @param time
|
||||
* @returns
|
||||
*/
|
||||
export function formatedTime(time: MaybeNumber) {
|
||||
return millisToString(time, { fallback: timerPlaceholder });
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates a day span from a number range
|
||||
* @param end
|
||||
* @returns
|
||||
*/
|
||||
export function calculateEndAndDaySpan(end: MaybeNumber): [MaybeNumber, number] {
|
||||
let maybeEnd = end;
|
||||
let maybeDaySpan = 0;
|
||||
if (end !== null) {
|
||||
if (end > dayInMs) {
|
||||
maybeEnd = end % dayInMs;
|
||||
maybeDaySpan = Math.floor(end / dayInMs);
|
||||
}
|
||||
}
|
||||
return [maybeEnd, maybeDaySpan];
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats offset text
|
||||
* @param offset
|
||||
* @returns
|
||||
*/
|
||||
export function getOffsetText(offset: MaybeNumber): string {
|
||||
if (offset === null) {
|
||||
return enDash;
|
||||
}
|
||||
const isAhead = offset <= 0;
|
||||
let offsetText = millisToString(Math.abs(offset), { fallback: enDash });
|
||||
if (offsetText !== enDash) {
|
||||
offsetText = isAhead ? `+${offsetText}` : `${enDash}${offsetText}`;
|
||||
}
|
||||
return offsetText;
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.eventContainer {
|
||||
margin-top: 1em;
|
||||
.rundownContainer {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-right: 4px; // size of scrollbar
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import {
|
||||
isOntimeBlock,
|
||||
isOntimeDelay,
|
||||
isOntimeEvent,
|
||||
MaybeNumber,
|
||||
Playback,
|
||||
RundownCached,
|
||||
SupportedEvent,
|
||||
} from 'ontime-types';
|
||||
import { isOntimeEvent, MaybeNumber, Playback, RundownCached, SupportedEvent } from 'ontime-types';
|
||||
import { getFirstNormal, getNextNormal, getPreviousNormal } from 'ontime-utils';
|
||||
|
||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||
@@ -39,7 +31,6 @@ export default function Rundown({ data }: RundownProps) {
|
||||
const eventSettings = useEditorSettings((state) => state.eventSettings);
|
||||
const defaultPublic = eventSettings.defaultPublic;
|
||||
const linkPrevious = eventSettings.linkPrevious;
|
||||
const showQuickEntry = eventSettings.showQuickEntry;
|
||||
|
||||
// cursor
|
||||
const { cursor, mode: appMode, setCursor } = useAppMode();
|
||||
@@ -219,8 +210,10 @@ export default function Rundown({ data }: RundownProps) {
|
||||
// all events before the current selected are in the past
|
||||
let isPast = Boolean(featureData?.selectedEventId);
|
||||
|
||||
const isEditMode = appMode === AppMode.Edit;
|
||||
|
||||
return (
|
||||
<div className={style.eventContainer} ref={scrollRef} data-testid='rundown'>
|
||||
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
|
||||
<DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}>
|
||||
<SortableContext items={statefulEntries} strategy={verticalListSortingStrategy}>
|
||||
<div className={style.list}>
|
||||
@@ -248,6 +241,7 @@ export default function Rundown({ data }: RundownProps) {
|
||||
thisId = eventId;
|
||||
}
|
||||
}
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === order.length - 1;
|
||||
const isLoaded = featureData?.selectedEventId === event.id;
|
||||
const isNext = featureData?.nextEventId === event.id;
|
||||
@@ -258,6 +252,7 @@ export default function Rundown({ data }: RundownProps) {
|
||||
|
||||
return (
|
||||
<Fragment key={event.id}>
|
||||
{isEditMode && (hasCursor || isFirst) && <QuickAddBlock previousEventId={previousEventId} />}
|
||||
<div className={style.entryWrapper} data-testid={`entry-${eventIndex}`}>
|
||||
{isOntimeEvent(event) && <div className={style.entryIndex}>{eventIndex}</div>}
|
||||
<div className={style.entry} key={event.id} ref={hasCursor ? cursorRef : undefined}>
|
||||
@@ -277,14 +272,7 @@ export default function Rundown({ data }: RundownProps) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{((showQuickEntry && hasCursor) || isLast) && (
|
||||
<QuickAddBlock
|
||||
showKbd={hasCursor}
|
||||
previousEventId={event.id}
|
||||
disableAddDelay={isOntimeDelay(event)}
|
||||
disableAddBlock={isOntimeBlock(event)}
|
||||
/>
|
||||
)}
|
||||
{isEditMode && (hasCursor || isLast) && <QuickAddBlock previousEventId={event.id} />}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -13,7 +13,18 @@ import DelayBlock from './delay-block/DelayBlock';
|
||||
import EventBlock from './event-block/EventBlock';
|
||||
import { useEventSelection } from './useEventSelection';
|
||||
|
||||
export type EventItemActions = 'set-cursor' | 'event' | 'delay' | 'block' | 'delete' | 'clone' | 'update' | 'swap';
|
||||
export type EventItemActions =
|
||||
| 'set-cursor'
|
||||
| 'event'
|
||||
| 'event-before'
|
||||
| 'delay'
|
||||
| 'delay-before'
|
||||
| 'block'
|
||||
| 'block-before'
|
||||
| 'delete'
|
||||
| 'clone'
|
||||
| 'update'
|
||||
| 'swap';
|
||||
|
||||
interface RundownEntryProps {
|
||||
type: SupportedEvent;
|
||||
@@ -83,12 +94,27 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
};
|
||||
return addEvent(newEvent, options);
|
||||
}
|
||||
case 'event-before': {
|
||||
const newEvent = { type: SupportedEvent.Event };
|
||||
const options = {
|
||||
after: previousEventId,
|
||||
defaultPublic,
|
||||
linkPrevious,
|
||||
};
|
||||
return addEvent(newEvent, options);
|
||||
}
|
||||
case 'delay': {
|
||||
return addEvent({ type: SupportedEvent.Delay }, { after: data.id });
|
||||
}
|
||||
case 'delay-before': {
|
||||
return addEvent({ type: SupportedEvent.Delay }, { after: previousEventId });
|
||||
}
|
||||
case 'block': {
|
||||
return addEvent({ type: SupportedEvent.Block }, { after: data.id });
|
||||
}
|
||||
case 'block-before': {
|
||||
return addEvent({ type: SupportedEvent.Block }, { after: previousEventId });
|
||||
}
|
||||
case 'swap': {
|
||||
const { value } = payload as FieldValue;
|
||||
return swapEvents({ from: value as string, to: data.id });
|
||||
@@ -163,9 +189,9 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
/>
|
||||
);
|
||||
} else if (data.type === SupportedEvent.Block) {
|
||||
return <BlockBlock data={data} hasCursor={hasCursor} actionHandler={actionHandler} />;
|
||||
return <BlockBlock data={data} hasCursor={hasCursor} onDelete={() => actionHandler('delete')} />;
|
||||
} else if (data.type === SupportedEvent.Delay) {
|
||||
return <DelayBlock data={data} hasCursor={hasCursor} actionHandler={actionHandler} />;
|
||||
return <DelayBlock data={data} hasCursor={hasCursor} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,32 +1,24 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { IconButton } from '@chakra-ui/react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
|
||||
import { OntimeBlock, OntimeEvent } from 'ontime-types';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
import { OntimeBlock } from 'ontime-types';
|
||||
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import EditableBlockTitle from '../common/EditableBlockTitle';
|
||||
import BlockActionMenu from '../event-block/composite/BlockActionMenu';
|
||||
import type { EventItemActions } from '../RundownEntry';
|
||||
|
||||
import style from './BlockBlock.module.scss';
|
||||
|
||||
interface BlockBlockProps {
|
||||
data: OntimeBlock;
|
||||
hasCursor: boolean;
|
||||
actionHandler: (
|
||||
action: EventItemActions,
|
||||
payload?:
|
||||
| number
|
||||
| {
|
||||
field: keyof Omit<OntimeEvent, 'duration'> | 'durationOverride';
|
||||
value: unknown;
|
||||
},
|
||||
) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export default function BlockBlock(props: BlockBlockProps) {
|
||||
const { data, hasCursor, actionHandler } = props;
|
||||
const { data, hasCursor, onDelete } = props;
|
||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||
|
||||
const {
|
||||
@@ -45,12 +37,6 @@ export default function BlockBlock(props: BlockBlockProps) {
|
||||
transition,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (hasCursor) {
|
||||
handleRef?.current?.focus();
|
||||
}
|
||||
}, [hasCursor]);
|
||||
|
||||
const blockClasses = cx([style.block, hasCursor ? style.hasCursor : null]);
|
||||
|
||||
return (
|
||||
@@ -59,7 +45,14 @@ export default function BlockBlock(props: BlockBlockProps) {
|
||||
<IoReorderTwo />
|
||||
</span>
|
||||
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' />
|
||||
<BlockActionMenu className={style.actionMenu} enableDelete actionHandler={actionHandler} />
|
||||
<IconButton
|
||||
aria-label='Delete'
|
||||
size='sm'
|
||||
icon={<IoTrash />}
|
||||
variant='ontime-subtle'
|
||||
color='#FA5656'
|
||||
onClick={onDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,35 +2,24 @@ import { useEffect, useRef } from 'react';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
||||
import { IoCheckmarkDone } from '@react-icons/all-files/io5/IoCheckmarkDone';
|
||||
import { IoClose } from '@react-icons/all-files/io5/IoClose';
|
||||
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
|
||||
import { OntimeDelay, OntimeEvent } from 'ontime-types';
|
||||
import { OntimeDelay } from 'ontime-types';
|
||||
|
||||
import DelayInput from '../../../common/components/input/delay-input/DelayInput';
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import BlockActionMenu from '../event-block/composite/BlockActionMenu';
|
||||
import type { EventItemActions } from '../RundownEntry';
|
||||
|
||||
import style from './DelayBlock.module.scss';
|
||||
|
||||
interface DelayBlockProps {
|
||||
data: OntimeDelay;
|
||||
hasCursor: boolean;
|
||||
actionHandler: (
|
||||
action: EventItemActions,
|
||||
payload?:
|
||||
| number
|
||||
| {
|
||||
field: keyof Omit<OntimeEvent, 'duration'> | 'durationOverride';
|
||||
value: unknown;
|
||||
},
|
||||
) => void;
|
||||
}
|
||||
|
||||
export default function DelayBlock(props: DelayBlockProps) {
|
||||
const { data, hasCursor, actionHandler } = props;
|
||||
const { data, hasCursor } = props;
|
||||
const { applyDelay, deleteEvent } = useEventAction();
|
||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||
|
||||
@@ -73,13 +62,12 @@ export default function DelayBlock(props: DelayBlockProps) {
|
||||
</span>
|
||||
<DelayInput eventId={data.id} duration={data.duration} />
|
||||
<div className={style.actionButtons}>
|
||||
<Button onClick={applyDelayHandler} size='sm' leftIcon={<IoCheckmark />} variant='ontime-subtle-white'>
|
||||
Apply
|
||||
<Button onClick={applyDelayHandler} size='sm' leftIcon={<IoCheckmarkDone />} variant='ontime-ghosted-white'>
|
||||
Make permanent
|
||||
</Button>
|
||||
<Button onClick={cancelDelayHandler} size='sm' leftIcon={<IoClose />} variant='ontime-subtle-white'>
|
||||
<Button onClick={cancelDelayHandler} size='sm' leftIcon={<IoClose />} variant='ontime-ghosted-white'>
|
||||
Cancel
|
||||
</Button>
|
||||
<BlockActionMenu enableDelete actionHandler={actionHandler} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ $skip-opacity: 0.2;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'binder ... ... ...'
|
||||
'binder pb-actions times actions'
|
||||
'binder pb-actions times ...'
|
||||
'binder pb-actions title title'
|
||||
'binder pb-actions estatus estatus'
|
||||
'binder ... ... ...';
|
||||
@@ -55,7 +55,6 @@ $skip-opacity: 0.2;
|
||||
outline: 1px solid $block-cursor-color;
|
||||
}
|
||||
|
||||
/* we stop the eventActions from having opacity to fix issue with dropdown drawing order */
|
||||
&.past:not(.skip) {
|
||||
.timerNote,
|
||||
.statusElements,
|
||||
@@ -153,12 +152,6 @@ $skip-opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
.eventActions {
|
||||
grid-area: actions;
|
||||
height: 100%;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.progressBg {
|
||||
grid-area: progb;
|
||||
border-radius: 1px;
|
||||
|
||||
@@ -2,16 +2,16 @@ import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { IoCopyOutline } from '@react-icons/all-files/io5/IoCopyOutline';
|
||||
import { IoDuplicateOutline } from '@react-icons/all-files/io5/IoDuplicateOutline';
|
||||
import { IoPeople } from '@react-icons/all-files/io5/IoPeople';
|
||||
import { IoPeopleOutline } from '@react-icons/all-files/io5/IoPeopleOutline';
|
||||
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
|
||||
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
import { EndAction, MaybeNumber, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||
|
||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
import { useAppMode } from '../../../common/stores/appModeStore';
|
||||
import copyToClipboard from '../../../common/utils/copyToClipboard';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import type { EventItemActions } from '../RundownEntry';
|
||||
import { useEventIdSwapping } from '../useEventIdSwapping';
|
||||
@@ -96,31 +96,25 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
selectedEvents.size > 1
|
||||
? [
|
||||
{
|
||||
label: 'Visiblity',
|
||||
group: [
|
||||
{
|
||||
label: 'Make public',
|
||||
icon: IoPeople,
|
||||
onClick: () =>
|
||||
actionHandler('update', {
|
||||
field: 'isPublic',
|
||||
value: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Make private',
|
||||
icon: IoPeopleOutline,
|
||||
onClick: () =>
|
||||
actionHandler('update', {
|
||||
field: 'isPublic',
|
||||
value: false,
|
||||
}),
|
||||
},
|
||||
],
|
||||
label: 'Make public',
|
||||
icon: IoPeople,
|
||||
onClick: () =>
|
||||
actionHandler('update', {
|
||||
field: 'isPublic',
|
||||
value: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Make private',
|
||||
icon: IoPeopleOutline,
|
||||
onClick: () =>
|
||||
actionHandler('update', {
|
||||
field: 'isPublic',
|
||||
value: false,
|
||||
}),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{ label: `Copy ID: ${eventId}`, icon: IoCopyOutline, onClick: () => copyToClipboard(eventId) },
|
||||
{
|
||||
label: 'Toggle public',
|
||||
icon: IoPeopleOutline,
|
||||
@@ -145,6 +139,8 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
},
|
||||
isDisabled: selectedEventId == null || selectedEventId === eventId,
|
||||
},
|
||||
{ withDivider: true, label: 'Clone', icon: IoDuplicateOutline, onClick: () => actionHandler('clone') },
|
||||
{ withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
|
||||
],
|
||||
);
|
||||
|
||||
@@ -281,7 +277,6 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
loaded={loaded}
|
||||
playback={playback}
|
||||
isRolling={isRolling}
|
||||
actionHandler={actionHandler}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -14,10 +14,8 @@ import { EndAction, MaybeString, Playback, TimerType, TimeStrategy } from 'ontim
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
import EditableBlockTitle from '../common/EditableBlockTitle';
|
||||
import { EventItemActions } from '../RundownEntry';
|
||||
import TimeInputFlow from '../time-input-flow/TimeInputFlow';
|
||||
|
||||
import BlockActionMenu from './composite/BlockActionMenu';
|
||||
import EventBlockPlayback from './composite/EventBlockPlayback';
|
||||
import EventBlockProgressBar from './composite/EventBlockProgressBar';
|
||||
|
||||
@@ -46,7 +44,6 @@ interface EventBlockInnerProps {
|
||||
loaded: boolean;
|
||||
playback?: Playback;
|
||||
isRolling: boolean;
|
||||
actionHandler: (action: EventItemActions, payload?: any) => void;
|
||||
}
|
||||
|
||||
const EventBlockInner = (props: EventBlockInnerProps) => {
|
||||
@@ -68,7 +65,6 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
|
||||
loaded,
|
||||
playback,
|
||||
isRolling,
|
||||
actionHandler,
|
||||
} = props;
|
||||
|
||||
const [renderInner, setRenderInner] = useState(false);
|
||||
@@ -139,9 +135,6 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.eventActions}>
|
||||
<BlockActionMenu showClone enableDelete={!loaded} actionHandler={actionHandler} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
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 {
|
||||
enableDelete?: boolean;
|
||||
showClone?: boolean;
|
||||
actionHandler: (action: EventItemActions, payload?: any) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function BlockActionMenu(props: BlockActionMenuProps) {
|
||||
const { 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-ghosted-white'
|
||||
size='sm'
|
||||
className={className}
|
||||
/>
|
||||
</Tooltip>
|
||||
<MenuList>
|
||||
<MenuItem icon={<IoAdd />} onClick={handleAddEvent}>
|
||||
Add Event after
|
||||
</MenuItem>
|
||||
<MenuItem icon={<IoTimerOutline />} onClick={handleAddDelay}>
|
||||
Add Delay after
|
||||
</MenuItem>
|
||||
<MenuItem icon={<IoRemoveCircleOutline />} onClick={handleAddBlock}>
|
||||
Add Block after
|
||||
</MenuItem>
|
||||
{showClone && (
|
||||
<MenuItem icon={<IoDuplicateOutline />} onClick={handleClone}>
|
||||
Clone event
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuDivider />
|
||||
<MenuItem icon={<IoTrashBinSharp />} onClick={handleDelete} isDisabled={!enableDelete} color='#D20300'>
|
||||
Delete
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,13 @@
|
||||
.quickAdd {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0.25rem 0;
|
||||
font-size: $inner-section-text-size;
|
||||
padding: 0 0.75rem;
|
||||
gap: 1rem;
|
||||
|
||||
margin: 0.25rem 0;
|
||||
font-size: calc(1rem - 3px);
|
||||
padding-inline: calc(2em + 0.5rem) 0.75rem;
|
||||
}
|
||||
|
||||
.btnRow {
|
||||
justify-self: center;
|
||||
display: flex;
|
||||
gap: max(0.5rem, 2rem);
|
||||
|
||||
.quickBtn {
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.keyboard {
|
||||
margin-left: 0.5rem;
|
||||
padding: 0 0.25rem;
|
||||
color: $label-gray;
|
||||
border-radius: 2px;
|
||||
background-color: $black-10
|
||||
}
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
white-space: nowrap;
|
||||
.quickBtn {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@@ -1,33 +1,27 @@
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
import { Button, Checkbox, Tooltip } from '@chakra-ui/react';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { SupportedEvent } from 'ontime-types';
|
||||
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import { useEditorSettings } from '../../../common/stores/editorSettings';
|
||||
import { useEmitLog } from '../../../common/stores/logger';
|
||||
import { deviceAlt } from '../../../common/utils/deviceUtils';
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
|
||||
import style from './QuickAddBlock.module.scss';
|
||||
|
||||
interface QuickAddBlockProps {
|
||||
showKbd: boolean;
|
||||
previousEventId: string;
|
||||
disableAddDelay?: boolean;
|
||||
disableAddBlock: boolean;
|
||||
previousEventId?: string;
|
||||
}
|
||||
|
||||
const QuickAddBlock = (props: QuickAddBlockProps) => {
|
||||
const { showKbd, previousEventId, disableAddDelay = true, disableAddBlock } = props;
|
||||
export default memo(QuickAddBlock);
|
||||
|
||||
function QuickAddBlock(props: QuickAddBlockProps) {
|
||||
const { previousEventId } = props;
|
||||
const { addEvent } = useEventAction();
|
||||
const { emitError } = useEmitLog();
|
||||
|
||||
const doLinkPrevious = useRef<HTMLInputElement | null>(null);
|
||||
const doPublic = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const { defaultPublic, linkPrevious } = useEditorSettings((state) => state.eventSettings);
|
||||
|
||||
const handleCreateEvent = useCallback(
|
||||
(eventType: SupportedEvent) => {
|
||||
switch (eventType) {
|
||||
@@ -72,56 +66,33 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
|
||||
|
||||
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}
|
||||
data-testid='quick-add-event'
|
||||
leftIcon={<IoAdd />}
|
||||
>
|
||||
Event {showKbd && <span className={style.keyboard}>{`${deviceAlt} + 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}
|
||||
data-testid='quick-add-delay'
|
||||
leftIcon={<IoAdd />}
|
||||
>
|
||||
Delay {showKbd && <span className={style.keyboard}>{`${deviceAlt} + 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}
|
||||
data-testid='quick-add-block'
|
||||
leftIcon={<IoAdd />}
|
||||
>
|
||||
Block {showKbd && <span className={style.keyboard}>{`${deviceAlt} + B`}</span>}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={style.options}>
|
||||
<Checkbox ref={doLinkPrevious} size='sm' variant='ontime-ondark' defaultChecked={linkPrevious}>
|
||||
Link to previous
|
||||
</Checkbox>
|
||||
<Checkbox ref={doPublic} size='sm' variant='ontime-ondark' defaultChecked={defaultPublic}>
|
||||
Event is public
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleCreateEvent(SupportedEvent.Event)}
|
||||
size='xs'
|
||||
variant='ontime-subtle-white'
|
||||
className={style.quickBtn}
|
||||
leftIcon={<IoAdd />}
|
||||
>
|
||||
Event
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleCreateEvent(SupportedEvent.Delay)}
|
||||
size='xs'
|
||||
variant='ontime-subtle-white'
|
||||
className={style.quickBtn}
|
||||
leftIcon={<IoAdd />}
|
||||
>
|
||||
Delay
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleCreateEvent(SupportedEvent.Block)}
|
||||
size='xs'
|
||||
variant='ontime-subtle-white'
|
||||
className={style.quickBtn}
|
||||
leftIcon={<IoAdd />}
|
||||
>
|
||||
Block
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(QuickAddBlock);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
.header {
|
||||
padding-left: 2rem;
|
||||
padding-right: 1rem;
|
||||
padding-inline: calc(2rem - 6px + 0.5rem) calc(1rem + 2px);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { Button, ButtonGroup, MenuButton } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { IoOptions } from '@react-icons/all-files/io5/IoOptions';
|
||||
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
|
||||
import { IoSnowOutline } from '@react-icons/all-files/io5/IoSnowOutline';
|
||||
import { Button, ButtonGroup } from '@chakra-ui/react';
|
||||
|
||||
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
|
||||
import { AppMode, useAppMode } from '../../../common/stores/appModeStore';
|
||||
|
||||
import RundownMenu from './RundownMenu';
|
||||
@@ -16,42 +11,18 @@ export default function RundownHeader() {
|
||||
const setAppMode = useAppMode((state) => state.setMode);
|
||||
const setRunMode = () => setAppMode(AppMode.Run);
|
||||
const setEditMode = () => setAppMode(AppMode.Edit);
|
||||
const setFreezeMode = () => setAppMode(AppMode.Freeze);
|
||||
|
||||
return (
|
||||
<div className={style.header}>
|
||||
<ButtonGroup isAttached>
|
||||
<TooltipActionBtn
|
||||
variant={appMode === AppMode.Freeze ? 'ontime-filled' : 'ontime-outlined'}
|
||||
size='sm'
|
||||
icon={<IoSnowOutline />}
|
||||
clickHandler={setFreezeMode}
|
||||
tooltip='Freeze rundown'
|
||||
aria-label='Freeze rundown'
|
||||
isDisabled
|
||||
/>
|
||||
<TooltipActionBtn
|
||||
variant={appMode === AppMode.Run ? 'ontime-filled' : 'ontime-outlined'}
|
||||
size='sm'
|
||||
icon={<IoPlay />}
|
||||
clickHandler={setRunMode}
|
||||
tooltip='Run mode'
|
||||
aria-label='Run mode'
|
||||
/>
|
||||
<TooltipActionBtn
|
||||
variant={appMode === AppMode.Edit ? 'ontime-filled' : 'ontime-outlined'}
|
||||
size='sm'
|
||||
icon={<IoOptions />}
|
||||
clickHandler={setEditMode}
|
||||
tooltip='Edit mode'
|
||||
aria-label='Edit mode'
|
||||
/>
|
||||
<Button size='sm' variant={appMode === AppMode.Run ? 'ontime-filled' : 'ontime-subtle'} onClick={setRunMode}>
|
||||
Run
|
||||
</Button>
|
||||
<Button size='sm' variant={appMode === AppMode.Edit ? 'ontime-filled' : 'ontime-subtle'} onClick={setEditMode}>
|
||||
Edit
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<RundownMenu>
|
||||
<MenuButton size='sm' as={Button} rightIcon={<IoAdd />} aria-label='Rundown menu' variant='ontime-outlined'>
|
||||
Rundown
|
||||
</MenuButton>
|
||||
</RundownMenu>
|
||||
<RundownMenu />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,16 @@
|
||||
import { memo, ReactNode, useCallback } from 'react';
|
||||
import { Menu, MenuDivider, MenuItem, MenuList } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { IoRemoveCircleOutline } from '@react-icons/all-files/io5/IoRemoveCircleOutline';
|
||||
import { IoTimerOutline } from '@react-icons/all-files/io5/IoTimerOutline';
|
||||
import { IoTrashOutline } from '@react-icons/all-files/io5/IoTrashOutline';
|
||||
import { SupportedEvent } from 'ontime-types';
|
||||
import { useCallback } from 'react';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import { useAppMode } from '../../../common/stores/appModeStore';
|
||||
import { useEventSelection } from '../useEventSelection';
|
||||
|
||||
const RundownMenu = ({ children }: { children: ReactNode }) => {
|
||||
export default function RundownMenu() {
|
||||
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
|
||||
const setCursor = useAppMode((state) => state.setCursor);
|
||||
const { addEvent, deleteAllEvents } = useEventAction();
|
||||
|
||||
const newEvent = useCallback(() => {
|
||||
addEvent({ type: SupportedEvent.Event });
|
||||
}, [addEvent]);
|
||||
|
||||
const newBlock = useCallback(() => {
|
||||
addEvent({ type: SupportedEvent.Block });
|
||||
}, [addEvent]);
|
||||
|
||||
const newDelay = useCallback(() => {
|
||||
addEvent({ type: SupportedEvent.Delay });
|
||||
}, [addEvent]);
|
||||
const appMode = useAppMode((state) => state.mode);
|
||||
const { deleteAllEvents } = useEventAction();
|
||||
|
||||
const deleteAll = useCallback(() => {
|
||||
deleteAllEvents();
|
||||
@@ -34,25 +19,15 @@ const RundownMenu = ({ children }: { children: ReactNode }) => {
|
||||
}, [clearSelectedEvents, deleteAllEvents, setCursor]);
|
||||
|
||||
return (
|
||||
<Menu isLazy lazyBehavior='unmount' variant='ontime-on-dark' placement='right-start'>
|
||||
{children}
|
||||
<MenuList>
|
||||
<MenuItem icon={<IoAdd />} onClick={newEvent}>
|
||||
Add event at start
|
||||
</MenuItem>
|
||||
<MenuItem icon={<IoTimerOutline />} onClick={newDelay}>
|
||||
Add delay at start
|
||||
</MenuItem>
|
||||
<MenuItem icon={<IoRemoveCircleOutline />} onClick={newBlock}>
|
||||
Add block at start
|
||||
</MenuItem>
|
||||
<MenuDivider />
|
||||
<MenuItem icon={<IoTrashOutline />} onClick={deleteAll} color='#D20300'>
|
||||
Delete all events
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='ontime-outlined'
|
||||
leftIcon={<IoTrash />}
|
||||
onClick={deleteAll}
|
||||
color='#FA5656'
|
||||
isDisabled={appMode === 'run'}
|
||||
>
|
||||
Clear rundown
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(RundownMenu);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from 'ontime-types';
|
||||
import { useStore } from 'zustand';
|
||||
|
||||
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
|
||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||
import useProjectData from '../../common/hooks-query/useProjectData';
|
||||
import { useFlatRundown } from '../../common/hooks-query/useRundown';
|
||||
@@ -93,29 +94,32 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
backstageEvents={rundownData}
|
||||
customFields={customFields}
|
||||
eventNext={eventNext}
|
||||
eventNow={eventNow}
|
||||
events={publicEvents}
|
||||
external={message.external}
|
||||
general={project}
|
||||
isMirrored={isMirrored}
|
||||
lower={message.lower}
|
||||
nextId={nextId}
|
||||
onAir={onAir}
|
||||
pres={message.timer}
|
||||
publ={message.public}
|
||||
publicEventNext={publicEventNext}
|
||||
publicEventNow={publicEventNow}
|
||||
publicSelectedId={publicSelectedId}
|
||||
selectedId={selectedId}
|
||||
settings={settings}
|
||||
time={TimeManagerType}
|
||||
viewSettings={viewSettings}
|
||||
/>
|
||||
<>
|
||||
<ViewNavigationMenu />
|
||||
<Component
|
||||
{...props}
|
||||
backstageEvents={rundownData}
|
||||
customFields={customFields}
|
||||
eventNext={eventNext}
|
||||
eventNow={eventNow}
|
||||
events={publicEvents}
|
||||
external={message.external}
|
||||
general={project}
|
||||
isMirrored={isMirrored}
|
||||
lower={message.lower}
|
||||
nextId={nextId}
|
||||
onAir={onAir}
|
||||
pres={message.timer}
|
||||
publ={message.public}
|
||||
publicEventNext={publicEventNext}
|
||||
publicEventNow={publicEventNow}
|
||||
publicSelectedId={publicSelectedId}
|
||||
selectedId={selectedId}
|
||||
settings={settings}
|
||||
time={TimeManagerType}
|
||||
viewSettings={viewSettings}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { CustomFields, Message, OntimeEvent, ProjectData, Settings, SupportedEve
|
||||
import { millisToString, removeLeadingZero } from 'ontime-utils';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/constants';
|
||||
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
||||
import ProgressBar from '../../../common/components/progress-bar/ProgressBar';
|
||||
import Schedule from '../../../common/components/schedule/Schedule';
|
||||
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
||||
@@ -15,7 +14,9 @@ import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||
import { getBackstageOptions } from '../../../common/components/view-params-editor/constants';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||
import { timerPlaceholderMin } from '../../../common/utils/styleUtils';
|
||||
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
import { titleVariants } from '../common/animation';
|
||||
@@ -58,10 +59,7 @@ export default function Backstage(props: BackstageProps) {
|
||||
const [blinkClass, setBlinkClass] = useState(false);
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// Set window title
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Backstage Screen';
|
||||
}, []);
|
||||
useWindowTitle('Backstage');
|
||||
|
||||
// blink on change
|
||||
useEffect(() => {
|
||||
@@ -93,7 +91,7 @@ export default function Backstage(props: BackstageProps) {
|
||||
const secondaryTextNext = getPropertyValue(eventNext, secondarySource);
|
||||
const secondaryTextNow = getPropertyValue(eventNow, secondarySource);
|
||||
|
||||
let stageTimer = millisToString(time.current, { fallback: '- - : - -' });
|
||||
let stageTimer = millisToString(time.current, { fallback: timerPlaceholderMin });
|
||||
stageTimer = removeLeadingZero(stageTimer);
|
||||
|
||||
const totalTime = (time.duration ?? 0) + (time.addedTime ?? 0);
|
||||
@@ -102,7 +100,6 @@ export default function Backstage(props: BackstageProps) {
|
||||
|
||||
return (
|
||||
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
|
||||
<NavigationMenu />
|
||||
<ViewParamsEditor paramFields={backstageOptions} />
|
||||
<div className='project-header'>
|
||||
{general.title}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Settings, ViewSettings } from 'ontime-types';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/constants';
|
||||
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
||||
import { getClockOptions } from '../../../common/components/view-params-editor/constants';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||
import { OverridableOptions } from '../../../common/models/View.types';
|
||||
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
|
||||
@@ -26,9 +25,7 @@ export default function Clock(props: ClockProps) {
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Clock';
|
||||
}, []);
|
||||
useWindowTitle('Clock');
|
||||
|
||||
// defer rendering until we load stylesheets
|
||||
if (!shouldRender) {
|
||||
@@ -133,7 +130,6 @@ export default function Clock(props: ClockProps) {
|
||||
}}
|
||||
data-testid='clock-view'
|
||||
>
|
||||
<NavigationMenu />
|
||||
<ViewParamsEditor paramFields={clockOptions} />
|
||||
<SuperscriptTime
|
||||
time={clock}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { MaybeString, OntimeEvent, TimerType } from 'ontime-types';
|
||||
import { MaybeNumber, MaybeString, OntimeEvent, TimerType } from 'ontime-types';
|
||||
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, millisToString, removeLeadingZero, removeSeconds } from 'ontime-utils';
|
||||
|
||||
import type { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||
import { timerPlaceholder, timerPlaceholderMin } from '../../../common/utils/styleUtils';
|
||||
|
||||
type TimerTypeParams = Pick<ViewExtendedTimer, 'timerType' | 'current' | 'elapsed' | 'clock'>;
|
||||
|
||||
@@ -57,3 +59,43 @@ export function getPropertyValue(event: OntimeEvent | null, property: MaybeStrin
|
||||
export function capitaliseFirstLetter(string: string) {
|
||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||
}
|
||||
|
||||
type FormattingOptions = {
|
||||
removeSeconds: boolean;
|
||||
removeLeadingZero: boolean;
|
||||
};
|
||||
|
||||
export function getFormattedTimer(
|
||||
timer: MaybeNumber,
|
||||
timerType: TimerType,
|
||||
localisedMinutes: string,
|
||||
options: FormattingOptions,
|
||||
): string {
|
||||
if (timer == null) {
|
||||
return options.removeSeconds ? timerPlaceholderMin : timerPlaceholder;
|
||||
}
|
||||
|
||||
let timeToParse = timer;
|
||||
|
||||
if (options.removeSeconds) {
|
||||
const timerIsTimeOfDay = timerType === TimerType.Clock;
|
||||
const isNegative = timeToParse < -MILLIS_PER_SECOND && !timerIsTimeOfDay && timerType !== TimerType.CountUp;
|
||||
if (isNegative) {
|
||||
// in negative numbers, we need to round down
|
||||
timeToParse -= MILLIS_PER_MINUTE;
|
||||
}
|
||||
}
|
||||
|
||||
let display = millisToString(timeToParse);
|
||||
display = removeLeadingZero(display);
|
||||
|
||||
if (options.removeSeconds) {
|
||||
display = formatDisplayWithMinutes(display, localisedMinutes);
|
||||
}
|
||||
return display;
|
||||
}
|
||||
|
||||
function formatDisplayWithMinutes(display: string, localisedMinutes: string): string {
|
||||
display = removeSeconds(display);
|
||||
return display.length < 3 ? `${display} ${localisedMinutes}` : display;
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import { OntimeEvent, OntimeRundownEntry, Playback, Settings, SupportedEvent, Vi
|
||||
import { millisToString, removeLeadingZero } from 'ontime-utils';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/constants';
|
||||
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
||||
import { getCountdownOptions } from '../../../common/components/view-params-editor/constants';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
@@ -38,9 +38,7 @@ export default function Countdown(props: CountdownProps) {
|
||||
const [runningMessage, setRunningMessage] = useState<TimerMessage>(TimerMessage.unhandled);
|
||||
const [delay, setDelay] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Countdown';
|
||||
}, []);
|
||||
useWindowTitle('Countdown');
|
||||
|
||||
// eg. http://localhost:4001/countdown?eventId=ei0us
|
||||
// Check for user options
|
||||
@@ -110,7 +108,6 @@ export default function Countdown(props: CountdownProps) {
|
||||
|
||||
return (
|
||||
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
|
||||
<NavigationMenu />
|
||||
<ViewParamsEditor paramFields={timeOption} />
|
||||
{follow === null ? (
|
||||
<CountdownSelect events={backstageEvents} />
|
||||
|
||||
@@ -3,10 +3,10 @@ import { useSearchParams } from 'react-router-dom';
|
||||
import { CustomFields, Message, OntimeEvent, ViewSettings } from 'ontime-types';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/constants';
|
||||
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
||||
import { getLowerThirdOptions } from '../../../common/components/view-params-editor/constants';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||
import { getPropertyValue } from '../common/viewUtils';
|
||||
|
||||
import './LowerThird.scss';
|
||||
@@ -142,10 +142,7 @@ export default function LowerThird(props: LowerProps) {
|
||||
const [playState, setPlayState] = useState<'pre' | 'in' | 'out'>('pre');
|
||||
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
|
||||
// set window title
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Lower Third';
|
||||
}, []);
|
||||
useWindowTitle('Lower Third');
|
||||
|
||||
const trigger = useMemo(() => {
|
||||
if (options.trigger === TriggerType.Event) {
|
||||
@@ -191,7 +188,6 @@ export default function LowerThird(props: LowerProps) {
|
||||
|
||||
return (
|
||||
<div className='lower-third' style={{ backgroundColor: `#${options.key}` }}>
|
||||
<NavigationMenu />
|
||||
<ViewParamsEditor paramFields={getLowerThirdOptions(customFields)} />
|
||||
<div
|
||||
className={`container container--${playState}`}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
|
||||
import { MILLIS_PER_SECOND, millisToString, removeLeadingZero, removeSeconds } from 'ontime-utils';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/constants';
|
||||
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
||||
import { MINIMAL_TIMER_OPTIONS } from '../../../common/components/view-params-editor/constants';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||
import { OverridableOptions } from '../../../common/models/View.types';
|
||||
import { timerPlaceholder } from '../../../common/utils/styleUtils';
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
import { getTimerByType, isStringBoolean } from '../common/viewUtils';
|
||||
import { getFormattedTimer, getTimerByType, isStringBoolean } from '../common/viewUtils';
|
||||
|
||||
import './MinimalTimer.scss';
|
||||
|
||||
@@ -29,9 +26,7 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Minimal Timer';
|
||||
}, []);
|
||||
useWindowTitle('Minimal Timer');
|
||||
|
||||
// defer rendering until we load stylesheets
|
||||
if (!shouldRender) {
|
||||
@@ -151,22 +146,11 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
||||
if (!timerIsTimeOfDay && showProgress && showDanger) timerColor = viewSettings.dangerColor;
|
||||
|
||||
const stageTimer = getTimerByType(viewSettings.freezeEnd, time);
|
||||
let display = millisToString(stageTimer, { fallback: timerPlaceholder });
|
||||
if (stageTimer !== null) {
|
||||
if (hideTimerSeconds) {
|
||||
display = removeSeconds(display);
|
||||
}
|
||||
display = removeLeadingZero(display);
|
||||
// last unit rounds up in negative timers
|
||||
const isNegative =
|
||||
(stageTimer ?? 0 < MILLIS_PER_SECOND) && !timerIsTimeOfDay && time.timerType !== TimerType.CountUp;
|
||||
if (isNegative && display === '0') {
|
||||
display = '-1';
|
||||
}
|
||||
if (display.length < 3) {
|
||||
display = `${display} ${getLocalizedString('common.minutes')}`;
|
||||
}
|
||||
}
|
||||
const display = getFormattedTimer(stageTimer, time.timerType, getLocalizedString('common.minutes'), {
|
||||
removeSeconds: userOptions.hideTimerSeconds,
|
||||
removeLeadingZero: true,
|
||||
});
|
||||
|
||||
const stageTimerCharacters = display.replace('/:/g', '').length;
|
||||
|
||||
const timerFontSize = (89 / (stageTimerCharacters - 1)) * (userOptions.size || 1);
|
||||
@@ -186,7 +170,6 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
||||
}}
|
||||
data-testid='minimal-timer'
|
||||
>
|
||||
<NavigationMenu />
|
||||
<ViewParamsEditor paramFields={MINIMAL_TIMER_OPTIONS} />
|
||||
{!hideMessagesOverlay && (
|
||||
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user