refactor: migrate UI components and remove chakra

migrate pin page
migrate copy tag
migrate pin input
migrate dropdown menus
migrate radio buttons
migrate switch
migrate select
migrate textarea
migrate colour picker
migrate select
migrate context menu
migrate viewparams
remove chakra
This commit is contained in:
Carlos Valente
2025-07-08 20:50:49 +02:00
committed by Carlos Valente
parent 4d359445a7
commit e1e8410ba4
100 changed files with 1222 additions and 1990 deletions
-5
View File
@@ -5,13 +5,10 @@
"type": "module",
"dependencies": {
"@base-ui-components/react": "1.0.0-beta.1",
"@chakra-ui/react": "^2.7.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/is-prop-valid": "^1.3.1",
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^8.1.2",
"@sentry/react": "^8.43.0",
@@ -22,7 +19,6 @@
"autosize": "^6.0.1",
"axios": "^1.9.0",
"csv-stringify": "^6.4.5",
"framer-motion": "^10.10.0",
"prismjs": "^1.29.0",
"react": "^18.3.1",
"react-colorful": "^5.6.1",
@@ -80,7 +76,6 @@
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^8.0.0",
"happy-dom": "^16.7.2",
"ontime-types": "workspace:*",
"ontime-utils": "workspace:*",
"prettier": "catalog:",
+18 -22
View File
@@ -1,6 +1,5 @@
import { BrowserRouter } from 'react-router-dom';
import { Tooltip } from '@base-ui-components/react/tooltip';
import { ChakraProvider } from '@chakra-ui/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
@@ -9,7 +8,6 @@ import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverla
import { AppContextProvider } from './common/context/AppContext';
import { ontimeQueryClient } from './common/queryClient';
import { connectSocket } from './common/utils/socket';
import theme from './theme/theme';
import { TranslationProvider } from './translation/TranslationProvider';
import AppRouter from './AppRouter';
import { baseURI } from './externals';
@@ -18,28 +16,26 @@ connectSocket();
function App() {
return (
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
<QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider>
<Tooltip.Provider>
<BrowserRouter basename={baseURI}>
<div className='App'>
<ErrorBoundary>
<TranslationProvider>
<IdentifyOverlay />
<AppRouter />
</TranslationProvider>
</ErrorBoundary>
<ReactQueryDevtools initialIsOpen={false} />
</div>
<QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider>
<Tooltip.Provider>
<BrowserRouter basename={baseURI}>
<div className='App'>
<ErrorBoundary>
<div id='identify-portal' />
<TranslationProvider>
<IdentifyOverlay />
<AppRouter />
</TranslationProvider>
</ErrorBoundary>
</BrowserRouter>
</Tooltip.Provider>
</AppContextProvider>
</QueryClientProvider>
</ChakraProvider>
<ReactQueryDevtools initialIsOpen={false} />
</div>
<ErrorBoundary>
<div id='identify-portal' />
</ErrorBoundary>
</BrowserRouter>
</Tooltip.Provider>
</AppContextProvider>
</QueryClientProvider>
);
}
@@ -1,8 +0,0 @@
.contextMenuButton {
position: absolute;
}
.contextMenuBackdrop {
position: fixed;
inset: 0;
}
@@ -1,99 +0,0 @@
// logic (with some modifications) culled from:
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
import { ReactElement } from 'react';
import { IconType } from 'react-icons';
import { Menu, MenuButton, MenuGroup, MenuList } from '@chakra-ui/react';
import { create } from 'zustand';
import { ContextMenuOption } from './ContextMenuOption';
import style from './ContextMenu.module.scss';
type ContextMenuCoords = {
x: number;
y: number;
};
export type OptionWithoutGroup = {
label: string;
isDisabled?: boolean;
icon: IconType;
onClick: () => void;
withDivider?: boolean;
};
type OptionWithGroup = {
label: string;
group: Omit<OptionWithoutGroup, 'isGroup'>[];
};
export type Option = OptionWithoutGroup | OptionWithGroup;
const isOptionWithGroup = (option: Option): option is OptionWithGroup => 'group' in option;
type ContextMenuStore = {
coords: ContextMenuCoords;
options: Option[];
isOpen: boolean;
setContextMenu: (coords: ContextMenuCoords, options: Option[]) => void;
setIsOpen: (newIsOpen: boolean) => void;
};
export const useContextMenuStore = create<ContextMenuStore>((set) => ({
coords: { x: 0, y: 0 },
options: [],
isOpen: false,
setContextMenu: (coords, options) => set(() => ({ coords, options, isOpen: true })),
setIsOpen: (newIsOpen) => set(() => ({ isOpen: newIsOpen })),
}));
interface ContextMenuProps {
// ReactElement type required due to early `return` (line 51) returning {children}
children: ReactElement;
}
export const ContextMenu = ({ children }: ContextMenuProps) => {
const { coords, options, isOpen, setIsOpen } = useContextMenuStore();
const onClose = () => {
return setIsOpen(false);
};
if (!isOpen) {
return children;
}
return (
<>
{children}
<div className={style.contextMenuBackdrop} />
<Menu isOpen size='sm' gutter={0} onClose={onClose} isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
<MenuButton
className={style.contextMenuButton}
aria-hidden
w={1}
h={1}
style={{
position: 'fixed',
left: coords.x,
top: coords.y,
}}
/>
<MenuList>
{options.map((option) =>
isOptionWithGroup(option) ? (
<MenuGroup key={option.label} title={option.label}>
{option.group.map((groupOption) => (
<ContextMenuOption key={groupOption.label} {...groupOption} />
))}
</MenuGroup>
) : (
<ContextMenuOption key={option.label} {...option} />
),
)}
</MenuList>
</Menu>
</>
);
};
@@ -1,12 +0,0 @@
import { MenuDivider, MenuItem } from '@chakra-ui/react';
import { OptionWithoutGroup } from './ContextMenu';
export const ContextMenuOption = ({ label, onClick, isDisabled, icon: Icon, withDivider }: OptionWithoutGroup) => (
<>
{withDivider && <MenuDivider />}
<MenuItem icon={<Icon style={{ fontSize: '1rem' }} />} onClick={onClick} isDisabled={isDisabled}>
{label}
</MenuItem>
</>
);
@@ -0,0 +1,40 @@
.copytag {
display: flex;
align-items: center;
}
.action {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.label {
border-radius: 3px 0 0 3px;
border: 1px solid $gray-1200;
background-color: $white-3;
padding-inline: 0.5rem 1rem;
line-height: 1em;
display: flex;
align-items: center;
color: $label-gray;
}
.small {
height: 1.5rem;
font-size: calc(1rem - 3px);
}
.medium {
height: 2rem;
font-size: calc(1rem - 2px);
}
.large {
height: 2.5rem;
font-weight: 600;
}
.copy {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
@@ -1,22 +1,28 @@
import { PropsWithChildren, useState } from 'react';
import { IoCheckmark } from 'react-icons/io5';
import { IoCopy } from 'react-icons/io5';
import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { Size } from '../../models/Util.type';
import copyToClipboard from '../../utils/copyToClipboard';
import { cx } from '../../utils/styleUtils';
import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton';
import style from './CopyTag.module.scss';
interface CopyTagProps {
copyValue: string;
label: string;
size?: Size;
disabled?: boolean;
onClick?: () => void;
size?: 'small' | 'medium' | 'large';
}
export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
const { copyValue, label, size = 'xs', disabled, children, onClick } = props;
export default function CopyTag({
copyValue,
disabled,
size = 'medium',
children,
onClick,
}: PropsWithChildren<CopyTagProps>) {
const [copied, setCopied] = useState(false);
const handleClick = () => {
@@ -28,20 +34,24 @@ export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
};
return (
<Tooltip label={label} openDelay={tooltipDelayFast}>
<ButtonGroup size={size} isAttached>
<Button variant='ontime-subtle' tabIndex={-1} onClick={onClick} isDisabled={disabled}>
<div className={style.copytag}>
{onClick !== undefined ? (
<Button className={style.action} size={size} tabIndex={-1} onClick={onClick} disabled={disabled}>
{children}
</Button>
<IconButton
aria-label={label}
icon={copied ? <IoCheckmark /> : <IoCopy />}
variant='ontime-filled'
tabIndex={-1}
onClick={handleClick}
isDisabled={disabled}
/>
</ButtonGroup>
</Tooltip>
) : (
<div className={cx([style.label, style[size]])}>{children}</div>
)}
<IconButton
className={style.copy}
variant='primary'
size={size}
tabIndex={-1}
onClick={handleClick}
disabled={disabled}
>
{copied ? <IoCheckmark /> : <IoCopy />}
</IconButton>
</div>
);
}
@@ -3,7 +3,6 @@
top: 10%;
left: 50%;
z-index: $zindex-dialog;
transform: translateX(-50%);
padding-inline: 1rem;
@@ -19,7 +18,6 @@
.backdrop {
position: fixed;
inset: 0;
z-index: $zindex-backdrop;
background-color: $backdrop-color;
transition: opacity 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
@@ -1,4 +1,5 @@
import { PropsWithChildren, ReactNode } from 'react';
import { PropsWithChildren } from 'react';
import { IconType } from 'react-icons';
import { Menu as BaseMenu } from '@base-ui-components/react/menu';
import style from './DropdownMenu.module.scss';
@@ -7,13 +8,15 @@ type DropdownMenuItemDivider = { type: 'divider' };
type DropdownMenuItem = {
type: 'item';
label: string;
icon?: ReactNode;
icon?: IconType;
disabled?: boolean;
onClick: () => void;
};
export type DropdownMenuOption = DropdownMenuItemDivider | DropdownMenuItem;
interface DropdownMenuProps extends BaseMenu.Trigger.Props {
items: Array<DropdownMenuItemDivider | DropdownMenuItem>;
items: DropdownMenuOption[];
}
export function DropdownMenu({ items, children, ...triggerProps }: PropsWithChildren<DropdownMenuProps>) {
@@ -29,7 +32,8 @@ export function DropdownMenu({ items, children, ...triggerProps }: PropsWithChil
}
return (
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
{item.icon} {item.label}
{item.icon && <item.icon />}
{item.label}
</BaseMenu.Item>
);
})}
@@ -55,12 +59,9 @@ export function PositionedDropdownMenu({ items, isOpen, position, onClose }: Pos
if (!open) onClose();
}}
>
<BaseMenu.Trigger
style={{ position: 'absolute', left: position.x, top: position.y, pointerEvents: 'none' }}
aria-hidden
/>
<BaseMenu.Trigger style={{ position: 'fixed', left: position.x, top: position.y }} aria-hidden />
<BaseMenu.Portal>
<BaseMenu.Positioner className={style.positioner} align='start' sideOffset={8}>
<BaseMenu.Positioner className={style.positioner} align='start' sideOffset={8} alignOffset={8}>
<BaseMenu.Popup className={style.popup}>
{items.map((item, index) => {
if (item.type === 'divider') {
@@ -68,7 +69,8 @@ export function PositionedDropdownMenu({ items, isOpen, position, onClose }: Pos
}
return (
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
{item.icon} {item.label}
{item.icon && <item.icon />}
{item.label}
</BaseMenu.Item>
);
})}
@@ -15,7 +15,6 @@
display: grid;
place-content: center;
text-align: center;
z-index: $zindex-modal;
cursor: pointer;
}
@@ -1,28 +0,0 @@
import { RefObject, useEffect } from 'react';
import { Textarea, TextareaProps } from '@chakra-ui/react';
// @ts-expect-error no types from library
import autosize from 'autosize/dist/autosize';
export const AutoTextArea = (props: TextareaProps & { inputref: RefObject<unknown> }) => {
const { value, inputref } = props;
useEffect(() => {
const node = inputref.current;
autosize(inputref.current);
return () => {
autosize.destroy(node);
};
}, [inputref, value]);
return (
<Textarea
overflow='hidden'
w='100%'
ref={inputref}
resize='none'
transition='height none'
variant='ontime-transparent'
{...props}
/>
);
};
@@ -0,0 +1,26 @@
import { RefObject, useEffect } from 'react';
// @ts-expect-error no types from library
import autosize from 'autosize/dist/autosize';
import Textarea, { type TextareaProps } from '../textarea/Textarea';
interface AutoTextAreaProps extends TextareaProps {
inputref: RefObject<HTMLTextAreaElement>;
}
/**
* A textarea that automatically resizes based on its content
*/
export function AutoTextarea({ value, inputref, ...textAreaProps }: AutoTextAreaProps) {
// when the value changes, we use the ref to reapply autosize
useEffect(() => {
const node = inputref.current;
autosize(inputref.current);
return () => {
autosize.destroy(node);
};
}, [inputref, value]);
return <Textarea ref={inputref} value={value} {...textAreaProps} />;
}
@@ -1,9 +1,8 @@
import { useCallback } from 'react';
import { useController, UseControllerProps } from 'react-hook-form';
import { IoEyedrop } from 'react-icons/io5';
import { useDebouncedCallback } from '@mantine/hooks';
import { ViewSettings } from 'ontime-types';
import { debounce } from '../../../utils/debounce';
import { cx, getAccessibleColour } from '../../../utils/styleUtils';
import PopoverPicker from '../popover-picker/PopoverPicker';
@@ -19,12 +18,9 @@ interface SwatchPickerProps {
export default function SwatchPicker(props: SwatchPickerProps) {
const { color, onChange, isSelected, alwaysDisplayColor } = props;
const debouncedOnChange = useCallback(
debounce((newValue: string) => {
onChange(newValue);
}, 500),
[onChange],
);
const debouncedOnChange = useDebouncedCallback((newValue: string) => {
onChange(newValue);
}, 100);
const displayColor = alwaysDisplayColor || isSelected ? color : '';
const { color: iconColor } = getAccessibleColour(displayColor);
@@ -0,0 +1,62 @@
.radioGroup {
color: $gray-900;
font-size: calc(1rem - 3px);
color: $label-gray;
}
.item {
display: flex;
align-items: center;
gap: 0.5rem;
line-height: 1.2em;
&:has([data-checked]) {
color: $ui-white;
}
}
.radio {
box-sizing: border-box;
display: flex;
width: 0.75rem;
height: 0.75rem;
align-items: center;
justify-content: center;
border-radius: 100%;
outline: 0;
border: none;
&[data-unchecked] {
background-color: $gray-1200;
}
&[data-checked] {
background-color: $gray-1200;
&:hover {
border-color: $gray-1000;
}
}
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: 2px;
}
}
.indicator {
display: grid;
place-content: center;
&[data-unchecked] {
display: none;
}
&::before {
content: '';
border-radius: 100%;
width: 0.5em;
height: 0.5em;
background-color: $blue-500;
}
}
@@ -0,0 +1,35 @@
import { Radio } from '@base-ui-components/react/radio';
import { RadioGroup as BaseRadioGroup } from '@base-ui-components/react/radio-group';
import style from './BlockRadio.module.scss';
interface BlockRadioProps<T extends string | number | boolean> extends Omit<BaseRadioGroup.Props, 'onValueChange'> {
items: {
value: T;
label: string;
}[];
onValueChange?: (value: T) => void;
}
export default function BlockRadio<T extends string | number | boolean>({
items,
onValueChange,
...elementProps
}: BlockRadioProps<T>) {
return (
<BaseRadioGroup
onValueChange={(value) => onValueChange?.(value as T)}
className={style.radioGroup}
{...elementProps}
>
{items.map((item) => (
<label className={style.item} key={item.value.toString()}>
<Radio.Root value={item.value.toString()} className={style.radio}>
<Radio.Indicator className={style.indicator} />
</Radio.Root>
{item.label}
</label>
))}
</BaseRadioGroup>
);
}
@@ -1,22 +1,12 @@
$input-font-size: 15px;
.delayInput {
display: flex;
gap: $element-spacing;
align-items: center;
font-size: $text-body-size;
.inputField {
font-size: $input-font-size;
letter-spacing: 0.5px;
max-width: 7em;
padding-left: 16px;
color: $ontime-delay-text
}
}
.delayOptions {
display: flex;
flex-direction: column;
.inputField {
text-align: center;
letter-spacing: 1px;
max-width: 7em;
color: $ontime-delay-text
}
@@ -1,8 +1,10 @@
import { KeyboardEvent, useEffect, useRef, useState } from 'react';
import { Input, Radio, RadioGroup } from '@chakra-ui/react';
import { millisToString, parseUserTime } from 'ontime-utils';
import { useEntryActions } from '../../../hooks/useEntryAction';
import Input from '../input/Input';
import BlockRadio from './BlockRadio';
import style from './DelayInput.module.scss';
@@ -105,13 +107,10 @@ export default function DelayInput(props: DelayInputProps) {
return (
<div className={style.delayInput}>
<Input
size='sm'
ref={inputRef}
data-testid='delay-input'
className={style.inputField}
type='text'
placeholder='-'
variant='ontime-filled'
onFocus={handleFocus}
onChange={(event) => setValue(event.target.value)}
onBlur={(event) => validateAndSubmit(event.target.value)}
@@ -119,16 +118,14 @@ export default function DelayInput(props: DelayInputProps) {
value={value}
maxLength={9}
/>
<RadioGroup
className={style.delayOptions}
onChange={handleSlipChange}
<BlockRadio
onValueChange={handleSlipChange}
value={checkedOption}
variant='ontime-block'
size='sm'
>
<Radio value='add'>Add time</Radio>
<Radio value='subtract'>Subtract time</Radio>
</RadioGroup>
items={[
{ value: 'add', label: 'Add time' },
{ value: 'subtract', label: 'Subtract time' },
]}
/>
</div>
);
}
@@ -1,20 +1,36 @@
// styles from Input.module.scss
.input {
color: $gray-200;
border: 1px solid transparent;
border-radius: 0 0 8px 8px;
background-color: $gray-1200;
padding: 0 0.5rem;
width: 100%;
margin-top: 0.25rem;
box-sizing: border-box;
font-size: 1rem;
font-weight: 400;
color: $gray-200;
border-radius: $component-border-radius-md;
background-color: $gray-1200;
border: 1px solid transparent;
height: 2rem;
padding-inline: 0.5em;
outline: none;
&:hover {
&:hover:not(:disabled) {
background-color: $gray-1100;
}
&:focus {
&:focus:not(:read-only) {
background-color: $gray-1000;
color: $gray-50;
border: 1px solid $blue-500;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
&::placeholder {
color: $gray-500;
letter-spacing: 0;
}
}
@@ -1,6 +1,8 @@
import { PropsWithChildren } from 'react';
import { HexAlphaColorPicker, HexColorInput } from 'react-colorful';
import { Popover, PopoverContent, PopoverTrigger } from '@chakra-ui/react';
import { Popover } from '@base-ui-components/react/popover';
import PopoverContents from '../../popover/Popover';
import style from './PopoverPicker.module.scss';
@@ -9,15 +11,14 @@ interface PopoverPickerProps {
onChange: (color: string) => void;
}
export default function PopoverPicker(props: PropsWithChildren<PopoverPickerProps>) {
const { color, onChange, children } = props;
export default function PopoverPicker({ color, onChange, children }: PropsWithChildren<PopoverPickerProps>) {
return (
<Popover>
<PopoverTrigger>{children}</PopoverTrigger>
<PopoverContent className={style.small} style={{ borderRadius: '9px', width: 'auto' }}>
<Popover.Root>
<Popover.Trigger>{children}</Popover.Trigger>
<PopoverContents>
<HexAlphaColorPicker color={color} onChange={onChange} />
<HexColorInput color={color} onChange={onChange} className={style.input} prefixed />
</PopoverContent>
</Popover>
</PopoverContents>
</Popover.Root>
);
}
@@ -11,7 +11,7 @@ interface UseReactiveTextInputReturn {
export default function useReactiveTextInput(
initialText: string,
submitCallback: (newValue: string) => void,
ref: RefObject<HTMLInputElement>,
ref: RefObject<HTMLInputElement | HTMLTextAreaElement>,
options?: {
submitOnEnter?: boolean;
submitOnCtrlEnter?: boolean;
@@ -101,25 +101,31 @@ export default function useReactiveTextInput(
];
if (options?.submitOnEnter) {
hotKeys.push(['Enter', () => {
isKeyboardSubmitting.current = true;
handleSubmit(text);
// clear flag after blur has been processed
setTimeout(() => {
isKeyboardSubmitting.current = false;
}, 0);
}]);
hotKeys.push([
'Enter',
() => {
isKeyboardSubmitting.current = true;
handleSubmit(text);
// clear flag after blur has been processed
setTimeout(() => {
isKeyboardSubmitting.current = false;
}, 0);
},
]);
}
if (options?.submitOnCtrlEnter) {
hotKeys.push(['mod + Enter', () => {
isKeyboardSubmitting.current = true;
handleSubmit(text);
// clear flag after blur has been processed
setTimeout(() => {
isKeyboardSubmitting.current = false;
}, 0);
}]);
hotKeys.push([
'mod + Enter',
() => {
isKeyboardSubmitting.current = true;
handleSubmit(text);
// clear flag after blur has been processed
setTimeout(() => {
isKeyboardSubmitting.current = false;
}, 0);
},
]);
}
const hotKeyHandler = getHotkeyHandler(hotKeys);
@@ -1,7 +1,6 @@
@use '../../../../theme/viewerDefs' as *;
.textarea {
box-sizing: border-box;
min-height: 2rem;
display: block;
font-size: 1rem;
@@ -5,7 +5,6 @@
.backdrop {
position: fixed;
inset: 0;
z-index: $zindex-backdrop;
background-color: $backdrop-color;
transition: opacity 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
@@ -21,7 +20,6 @@
top: 0;
left: 0;
bottom: 0;
z-index: $zindex-dialog;
width: 22rem;
height: 100vh;
@@ -30,12 +30,7 @@ export default function OtherAddresses({ currentLocation }: OtherAddressesProps)
const address = linkToOtherHost(nif.address, currentLocation);
return (
<CopyTag
key={nif.name}
copyValue={address}
onClick={() => openLink(address)}
label='Copy IP or navigate to address'
>
<CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)}>
{nif.address} <IoArrowUp className={style.goIcon} />
</CopyTag>
);
@@ -0,0 +1,50 @@
.container {
width: 100%;
height: 100%;
padding-top: 25vh;
background: $ui-black;
color: $ontime-color;
font-size: 3rem;
text-align: center;
}
.pin {
display: flex;
align-items: center;
justify-content: center;
input {
font-size: 4rem;
height: 4rem;
width: 4em;
text-align: center;
letter-spacing: 0.25em;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
button {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
height: 4rem;
width: 4rem;
font-size: 4rem;
}
}
.pinFailed {
input {
animation: redFlash 1.5s ease;
}
}
@keyframes redFlash {
from {
background: $red-500;
}
to {
background: rgba($red-500, 0);
}
}
@@ -1,71 +1,55 @@
import { useCallback, useEffect, useState } from 'react';
import { PropsWithChildren, useState } from 'react';
import { IoCheckmark } from 'react-icons/io5';
import { IconButton, PinInput, PinInputField } from '@chakra-ui/react';
import style from './ProtectRoute.module.scss';
import { cx } from '../../utils/styleUtils';
import IconButton from '../buttons/IconButton';
import Input from '../input/input/Input';
import style from './PinPage.module.scss';
interface PinPageProps {
permission: 'editor' | 'operator';
handleValidation: (pin: string) => boolean;
}
export default function PinPage(props: PinPageProps) {
const { permission, handleValidation } = props;
export default function PinPage({ permission, handleValidation }: PropsWithChildren<PinPageProps>) {
const [pin, setPin] = useState('');
const [failed, setFailed] = useState(false);
const validate = useCallback(() => {
const validate = () => {
const isValid = handleValidation(pin);
if (!isValid) {
setFailed(true);
setPin('');
}
}, [handleValidation, pin]);
};
useEffect(() => {
const handleKeyPress = (event: KeyboardEvent) => {
if (event.repeat) return;
if (event.key === 'Enter') {
validate();
}
};
document.addEventListener('keydown', handleKeyPress);
return () => {
document.removeEventListener('keydown', handleKeyPress);
};
}, [validate]);
const handleInputChange = (value: string) => {
setPin(value);
if (failed) setFailed(false);
};
return (
<div className={style.container}>
{`Ontime ${permission}`}
<div className={failed ? style.pin__failed : style.pin}>
<PinInput
type='alphanumeric'
size='lg'
mask
autoFocus
<form
onSubmit={(event) => {
event.preventDefault();
validate();
}}
className={cx([style.pin, failed && style.pinFailed])}
>
<Input
type='password'
maxLength={4}
height='large'
value={pin}
onChange={(value) => {
setFailed(false);
setPin(value);
}}
>
<PinInputField />
<PinInputField />
<PinInputField />
<PinInputField />
</PinInput>
<IconButton
variant='ontime-filled'
aria-label='Enter'
size='lg'
isRound
icon={<IoCheckmark />}
onClick={validate}
onChange={(e) => handleInputChange(e.target.value)}
/>
</div>
<IconButton type='submit' variant='primary' aria-label='Enter'>
<IoCheckmark />
</IconButton>
</form>
</div>
);
}
@@ -1,49 +0,0 @@
.container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 25vh;
background: $ui-black;
color: $ontime-color;
font-weight: 200;
font-size: 3rem;
}
.pin,
.pin__failed {
display: flex;
gap: 0.125em;
padding-block: 0.5em;
input {
border-radius: 99px;
border-color: $gray-500;
&:hover {
border-color: $blue-500;
}
}
button {
margin-left: 1em;
}
}
.pin__failed {
input {
animation: colourFade 1.5s ease;
}
}
@keyframes colourFade {
from {
background: $red-500;
}
to {
background: rgba($red-500, 0);
}
}
@@ -0,0 +1,74 @@
.radioGroup {
display: flex;
gap: 0.25rem;
color: $gray-900;
font-size: calc(1rem - 2px);
color: $ui-white;
}
.horizontal {
align-items: center;
flex-direction: row;
gap: 1rem;
}
.vertical {
align-items: start;
flex-direction: column;
}
.item {
display: flex;
align-items: center;
gap: 0.5rem;
&:has([data-checked]) {
color: $ui-white;
}
}
.radio {
box-sizing: border-box;
display: flex;
width: 1rem;
height: 1rem;
align-items: center;
justify-content: center;
border-radius: 100%;
outline: 0;
border: none;
&[data-unchecked] {
border: 1px solid $gray-1200;
background-color: $gray-1200;
}
&[data-checked] {
background-color: $ui-white;
&:hover {
border-color: $gray-1000;
}
}
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: 2px;
}
}
.indicator {
display: grid;
place-content: center;
&[data-unchecked] {
display: none;
}
&::before {
content: '';
border-radius: 100%;
width: 0.5rem;
height: 0.5rem;
background-color: $gray-1200;
}
}
@@ -0,0 +1,40 @@
import { Radio } from '@base-ui-components/react/radio';
import { RadioGroup as BaseRadioGroup } from '@base-ui-components/react/radio-group';
import { cx } from '../../utils/styleUtils';
import style from './RadioGroup.module.scss';
interface RadioGroupProps<T extends string | number | boolean> extends Omit<BaseRadioGroup.Props, 'onValueChange'> {
items: {
value: T;
label: string;
}[];
onValueChange?: (value: T) => void;
orientation?: 'horizontal' | 'vertical';
}
export default function RadioGroup<T extends string | number | boolean>({
items,
className,
orientation = 'vertical',
onValueChange,
...elementProps
}: RadioGroupProps<T>) {
return (
<BaseRadioGroup
onValueChange={(value) => onValueChange?.(value as T)}
className={cx([style.radioGroup, style[orientation], className])}
{...elementProps}
>
{items.map((item) => (
<label className={style.item} key={item.value.toString()}>
<Radio.Root value={item.value.toString()} className={style.radio}>
<Radio.Indicator className={style.indicator} />
</Radio.Root>
{item.label}
</label>
))}
</BaseRadioGroup>
);
}
@@ -38,10 +38,18 @@
outline: 2px solid $blue-500;
outline-offset: 2px;
}
}
&.fluid {
width: 100%;
}
.medium {
height: 2rem;
}
.large {
height: 2.5rem;
}
.fluid {
width: 100%;
}
.selectIcon {
@@ -6,20 +6,22 @@ import { cx } from '../../utils/styleUtils';
import styles from './Select.module.scss';
export type SelectOption<T = string> = {
value: T;
label: string;
disabled?: boolean;
};
interface SelectProps<T> extends Omit<BaseSelect.Root.Props<T>, 'items'> {
// overload items to not allow undefined values
options: {
value: T;
label: string;
disabled?: boolean;
}[];
options: SelectOption<T>[];
fluid?: boolean;
size?: 'medium' | 'large';
}
export default function Select<T>({ options, fluid, ...selectRootProps }: SelectProps<T>) {
export default function Select<T>({ options, fluid, size = 'medium', ...selectRootProps }: SelectProps<T>) {
return (
<BaseSelect.Root items={options} {...selectRootProps}>
<BaseSelect.Trigger className={cx([styles.select, fluid && styles.fluid])}>
<BaseSelect.Trigger className={cx([styles.select, styles[size], fluid && styles.fluid])}>
<BaseSelect.Value />
<BaseSelect.Icon className={styles.selectIcon}>
<LuChevronsUpDown />
@@ -19,7 +19,8 @@
}
&:focus {
border-color: $blue-500;
outline: 2px solid $blue-500;
outline-offset: 2px;
}
}
@@ -0,0 +1,42 @@
.inline {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
// styles from subtle button
.toggleSelect {
display: flex;
align-items: center;
gap: 0.25rem;
padding-inline: 0.5rem;
height: 2.5rem;
background: $gray-1050;
color: $ui-white;
line-height: 1em;
border-radius: $component-border-radius-md;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&::after {
content: '';
margin-left: 0.25rem;
width: 0.75em;
height: 0.75em;
background: var(--user-bg);
border-radius: 50%;
}
}
.empty {
color: $ui-white;
}
@@ -1,25 +1,17 @@
import { useState } from 'react';
import { IoChevronDown } from 'react-icons/io5';
import { useSearchParams } from 'react-router-dom';
import {
Button,
Input,
InputGroup,
InputLeftElement,
Menu,
MenuButton,
MenuItemOption,
MenuList,
MenuOptionGroup,
Select,
Switch,
} from '@chakra-ui/react';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import Checkbox from '../checkbox/Checkbox';
import Input from '../input/input/Input';
import Select from '../select/Select';
import Switch from '../switch/Switch';
import InlineColourPicker from './InlineColourPicker';
import { ParamField } from './viewParams.types';
import style from './ParamInput.module.scss';
interface ParamInputProps {
paramField: ParamField;
}
@@ -39,20 +31,11 @@ export default function ParamInput({ paramField }: ParamInputProps) {
const optionFromParams = searchParams.get(id);
const defaultOptionValue = optionFromParams || defaultValue;
return (
<Select
placeholder={defaultValue ? undefined : 'Select an option'}
variant='ontime'
name={id}
defaultValue={defaultOptionValue}
>
{Object.entries(paramField.values).map(([key, value]) => (
<option key={key} value={key}>
{value}
</option>
))}
</Select>
);
if (paramField.values.length === 0) {
return <span className={style.empty}>No options available</span>;
}
return <Select size='large' name={id} defaultValue={defaultOptionValue} options={paramField.values} />;
}
if (type === 'multi-option') {
@@ -61,27 +44,22 @@ export default function ParamInput({ paramField }: ParamInputProps) {
if (type === 'boolean') {
const defaultCheckedValue = isStringBoolean(searchParams.get(id)) || defaultValue;
// checked value should be 'true', so it can be captured by the form event
return <Switch variant='ontime' name={id} defaultChecked={defaultCheckedValue} value='true' />;
return <Switch size='large' name={id} defaultChecked={defaultCheckedValue} />;
}
if (type === 'number') {
const { prefix, placeholder } = paramField;
const { placeholder } = paramField;
const defaultNumberValue = searchParams.get(id) ?? defaultValue;
return (
<InputGroup variant='ontime-filled'>
{prefix && <InputLeftElement pointerEvents='none'>{prefix}</InputLeftElement>}
<Input
type='number'
step='any'
variant='ontime-filled'
name={id}
defaultValue={defaultNumberValue}
placeholder={placeholder}
/>
</InputGroup>
<Input
height='large'
type='number'
step='any'
name={id}
defaultValue={defaultNumberValue}
placeholder={placeholder}
/>
);
}
@@ -92,52 +70,56 @@ export default function ParamInput({ paramField }: ParamInputProps) {
}
const defaultStringValue = searchParams.get(id) ?? defaultValue;
const { prefix, placeholder } = paramField;
const { placeholder } = paramField;
return (
<InputGroup variant='ontime-filled'>
{prefix && <InputLeftElement pointerEvents='none'>{prefix}</InputLeftElement>}
<Input name={id} defaultValue={defaultStringValue} placeholder={placeholder} />
</InputGroup>
);
return <Input height='large' name={id} defaultValue={defaultStringValue} placeholder={placeholder} />;
}
interface EditFormMultiOptionProps {
paramField: ParamField & { type: 'multi-option' };
}
function MultiOption(props: EditFormMultiOptionProps) {
function MultiOption({ paramField }: EditFormMultiOptionProps) {
const [searchParams] = useSearchParams();
const { paramField } = props;
const { id, defaultValue } = paramField;
const { id, values, defaultValue = [''] } = paramField;
const optionFromParams = searchParams.getAll(id);
const [paramState, setParamState] = useState<string[]>(optionFromParams || defaultValue || ['']);
const [paramState, setParamState] = useState<string[]>(optionFromParams || defaultValue);
const toggleValue = (value: string, checked: boolean) => {
if (checked) {
setParamState((prev) => [...prev, value]);
} else {
setParamState((prev) => prev.filter((v) => v !== value));
}
};
if (values.length === 0) {
return <span className={style.empty}>No options available</span>;
}
return (
<>
<input name={id} hidden readOnly value={paramState} />
<Menu isLazy closeOnSelect={false} variant='ontime-on-dark'>
<MenuButton as={Button} variant='ontime-subtle-white' position='relative' width='fit-content' fontWeight={400}>
{paramField.title} <IoChevronDown style={{ display: 'inline' }} />
</MenuButton>
<MenuList overflow='auto' maxHeight='200px'>
<MenuOptionGroup
type='checkbox'
value={paramState}
onChange={(value) => setParamState(Array.isArray(value) ? value : [value])}
>
{Object.values(paramField.values).map((option) => {
const { value, label, colour } = option;
return (
<MenuItemOption value={value} key={value} style={{ borderRight: `8px solid ${colour}` }}>
{label}
</MenuItemOption>
);
})}
</MenuOptionGroup>
</MenuList>
</Menu>
<input name={id} hidden readOnly value={paramState.join(',')} />
<div className={style.inline}>
{values.map((option) => {
return (
<label
key={option.value}
className={style.toggleSelect}
style={{
'--user-bg': option.colour,
}}
>
<Checkbox
checked={paramState.includes(option.value)}
onCheckedChange={(checked) => toggleValue(option.value, checked as boolean)}
/>
{option.label}
</label>
);
})}
</div>
</>
);
}
@@ -11,7 +11,6 @@
.backdrop {
position: fixed;
inset: 0;
z-index: $zindex-backdrop;
background-color: $backdrop-color;
transition: opacity 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
@@ -27,14 +26,13 @@
top: 0;
right: 0;
bottom: 0;
z-index: $zindex-dialog;
width: 40rem;
height: 100vh;
display: flex;
flex-direction: column;
padding: 1rem 1.5rem;
padding-block: 1rem 1.5rem;
background-color: $gray-1250;
color: $ui-white;
@@ -58,6 +56,7 @@
}
.header {
padding-inline: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
@@ -69,12 +68,14 @@
.body {
flex: 1;
padding-inline: 1rem;
padding-bottom: 10vh;
overflow-y: auto;
}
.footer {
display: flex;
padding-inline: 1rem;
gap: 1rem;
align-items: center;
justify-content: flex-end;
@@ -1,5 +1,6 @@
import { FormEvent, memo } from 'react';
import { IoClose } from 'react-icons/io5';
import { useSearchParams } from 'react-router-dom';
import { Dialog } from '@base-ui-components/react/dialog';
import useViewSettings from '../../hooks-query/useViewSettings';
@@ -21,6 +22,7 @@ interface EditFormDrawerProps {
export default memo(ViewParamsEditor);
function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const [_, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings();
const { isOpen, close } = useViewParamsEditorStore();
@@ -29,8 +31,7 @@ function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
};
const resetParams = () => {
window.history.pushState(null, '', window.location.pathname);
close();
setSearchParams();
};
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
@@ -38,9 +39,9 @@ function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
const url = new URL(window.location.href);
url.search = newSearchParams.toString();
window.history.pushState(null, '', url);
console.log('New search params:', newParamsObject, newSearchParams.toString());
setSearchParams(newSearchParams);
};
return (
@@ -5,32 +5,32 @@ import { OptionTitle } from '../constants';
import type { ViewOption } from '../viewParams.types';
import { getURLSearchParamsFromObj, makeOptionsFromCustomFields } from '../viewParams.utils';
describe('makeOptionsFromCustomFields', () => {
describe('makeOptionsFromCustomFields()', () => {
const testCustomFields: CustomFields = {
field1: { label: 'Field 1', colour: 'red', type: 'string' },
field2: { label: 'Field 2', colour: 'blue', type: 'string' },
};
it('creates a record of keys for the given custom fields', () => {
it('creates an array of options to use in a select', () => {
const result = makeOptionsFromCustomFields(testCustomFields);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
});
expect(result).toStrictEqual([
{ value: 'custom-field1', label: 'Custom: Field 1' },
{ value: 'custom-field2', label: 'Custom: Field 2' },
]);
});
it('appends additional data', () => {
const additionalData = {
test1: 'test1',
test2: 'test2',
};
const additionalData = [
{ value: 'test1', label: 'Test 1' },
{ value: 'test2', label: 'Test 2' },
];
const result = makeOptionsFromCustomFields(testCustomFields, additionalData);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
test1: 'test1',
test2: 'test2',
});
expect(result).toStrictEqual([
{ value: 'custom-field1', label: 'Custom: Field 1' },
{ value: 'custom-field2', label: 'Custom: Field 2' },
{ value: 'test1', label: 'Test 1' },
{ value: 'test2', label: 'Test 2' },
]);
});
it('filtersImageTypes', () => {
@@ -40,14 +40,14 @@ describe('makeOptionsFromCustomFields', () => {
};
const result = makeOptionsFromCustomFields(customFieldsWIthImage);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
});
expect(result).toStrictEqual([
{ value: 'custom-field1', label: 'Custom: Field 1' },
{ value: 'custom-field2', label: 'Custom: Field 2' },
]);
});
});
describe('getURLSearchParamsFromObj', () => {
describe('getURLSearchParamsFromObj()', () => {
// Mock view options for testing
const mockViewOptions: ViewOption[] = [
{
@@ -73,7 +73,10 @@ describe('getURLSearchParamsFromObj', () => {
title: 'Multi Select',
description: 'A multi-select field',
type: 'option',
values: { value1: 'Value 1', value2: 'Value 2' },
values: [
{ value: 'value1', label: 'Value 1' },
{ value: 'value2', label: 'Value 2' },
],
defaultValue: '',
},
],
@@ -201,4 +204,36 @@ describe('getURLSearchParamsFromObj', () => {
// Should only include unique values while maintaining order
expect(result.getAll('sub')).toEqual(['value1', 'value2', 'value3']);
});
it('converts on-off from toggle to boolean', () => {
const mockOptionsWithBooleans: ViewOption[] = [
{
title: OptionTitle.StyleOverride,
options: [
{
id: 'bool1',
title: 'bool1',
description: 'Bool1',
type: 'boolean',
defaultValue: true,
},
{
id: 'bool2',
title: 'bool2',
description: 'Bool2',
type: 'boolean',
defaultValue: false,
},
],
},
];
const params = {
bool1: 'off',
bool2: 'on',
};
const result = getURLSearchParamsFromObj(params, mockOptionsWithBooleans);
console.log('Result:', result.toString());
expect(result.get('bool1')).toBe('false');
expect(result.get('bool2')).toBe('true');
});
});
@@ -8,20 +8,19 @@ type BaseField = {
type OptionsField = {
type: 'option';
values: Record<string, string>;
values: { value: string; label: string }[];
defaultValue?: string;
};
type MultiselectOption = { value: string; label: string; colour: string };
export type MultiselectOptions = Record<string, MultiselectOption>;
export type MultiselectOption = { value: string; label: string; colour: string };
type MultiOptionsField = {
type: 'multi-option';
values: MultiselectOptions;
values: MultiselectOption[];
defaultValue?: string;
};
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
type StringField = { type: 'string'; defaultValue?: string; placeholder?: string };
type NumberField = { type: 'number'; defaultValue?: number; placeholder?: string };
type BooleanField = { type: 'boolean'; defaultValue: boolean };
type ColourField = { type: 'colour'; defaultValue: string; placeholder?: string };
type PersistedField = { type: 'persist'; defaultValue?: string[]; values: string[] };
@@ -1,6 +1,8 @@
import type { CustomFields } from 'ontime-types';
import type { MultiselectOptions, ViewOption } from './viewParams.types';
import type { SelectOption } from '../select/Select';
import type { MultiselectOption, ViewOption } from './viewParams.types';
/**
* Creates a list of custom fields for a select
@@ -8,32 +10,46 @@ import type { MultiselectOptions, ViewOption } from './viewParams.types';
*/
export function makeOptionsFromCustomFields(
customFields: CustomFields,
additionalOptions: Readonly<Record<string, string>> = {},
additionalOptions: SelectOption[] = [],
filterImageType = true,
): Record<string, string> {
const options = { ...additionalOptions };
): SelectOption[] {
const options: SelectOption[] = [];
// Add custom fields first
for (const [key, value] of Object.entries(customFields)) {
if (filterImageType && value.type === 'image') {
continue;
}
options[`custom-${key}`] = `Custom: ${value.label}`;
options.push({
value: `custom-${key}`,
label: `Custom: ${value.label}`,
});
}
return options;
return options.concat(additionalOptions);
}
/**
* Creates data for a multiselect component from custom fields
* Filters out image type custom fields
*/
export function makeCustomFieldSelectOptions(customFields: CustomFields, filterImageType = true): MultiselectOptions {
const options: MultiselectOptions = {};
export function makeCustomFieldSelectOptions(customFields: CustomFields, filterImageType = true): MultiselectOption[] {
const options: MultiselectOption[] = [];
// Add custom fields first
for (const [key, value] of Object.entries(customFields)) {
if (filterImageType && value.type === 'image') {
continue;
}
options[key] = { value: key, label: value.label, colour: value.colour };
options.push({
value: key,
label: value.label,
colour: value.colour || 'transparent',
});
}
return options;
}
@@ -52,17 +68,22 @@ function sanitiseColour(colour: string) {
type FieldMetadata = {
defaultValues: Record<string, string>;
colorFields: Set<string>;
booleanFields: Set<string>;
isPersistedField: Set<string>;
persistedValues: Record<string, string[]>;
};
/**
* Utility collects metadata about fields from view options
* - where are the default values
* - which fields are colours
* - which fields are persisted
*/
function collectFieldMetadata(paramFields: ViewOption[]): FieldMetadata {
const metadata: FieldMetadata = {
defaultValues: {},
colorFields: new Set(),
booleanFields: new Set(),
isPersistedField: new Set(),
persistedValues: {},
};
@@ -80,6 +101,8 @@ function collectFieldMetadata(paramFields: ViewOption[]): FieldMetadata {
if (option.type === 'colour') {
metadata.colorFields.add(option.id);
} else if (option.type === 'boolean') {
metadata.booleanFields.add(option.id);
}
});
});
@@ -132,7 +155,16 @@ export function getURLSearchParamsFromObj(paramsObj: ViewParamsObj, paramFields:
// Process and add new values
value.split(',').forEach((v) => {
const processedValue = metadata.colorFields.has(id) ? sanitiseColour(v) : v;
// some field types need extra processing
const processedValue = (() => {
if (metadata.colorFields.has(id)) {
return sanitiseColour(v);
}
if (metadata.booleanFields.has(id)) {
return v === 'on' ? 'true' : 'false';
}
return v;
})();
if (metadata.isPersistedField.has(id) || metadata.defaultValues[id] !== processedValue) {
addUniqueParam(id, processedValue);
}
@@ -1,9 +1,10 @@
import { MouseEvent } from 'react';
import { Option, useContextMenuStore } from '../components/context-menu/ContextMenu';
import { useContextMenuStore } from '../../features/rundown/rundown-context-menu/RundownContextMenu';
import { DropdownMenuOption } from '../components/dropdown-menu/DropdownMenu';
export const useContextMenu = <T extends HTMLElement>(options: Option[]) => {
const { setContextMenu } = useContextMenuStore();
export const useContextMenu = <T extends HTMLElement>(options: DropdownMenuOption[]) => {
const setContextMenu = useContextMenuStore((state) => state.setContextMenu);
const localCreateContextMenu = (contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => {
// prevent browser default context menu from showing up
@@ -150,9 +150,9 @@ $inner-padding: 1rem;
}
.divider {
border: none;
border-top: 1px solid $white-10;
margin: 1rem -2rem;
z-index: $zindex-floating;
}
.overlay {
@@ -13,11 +13,6 @@
gap: 0.5rem;
}
.matchRadio {
display: flex;
gap: 1rem;
}
.ruleSection {
display: flex;
flex-direction: column;
@@ -1,7 +1,6 @@
import { useEffect, useMemo } from 'react';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoTrash } from 'react-icons/io5';
import { Radio, RadioGroup, Select } from '@chakra-ui/react';
import {
Automation,
AutomationDTO,
@@ -20,6 +19,8 @@ import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import RadioGroup from '../../../../common/components/radio-group/RadioGroup';
import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
@@ -214,15 +215,14 @@ export default function AutomationForm(props: AutomationFormProps) {
<div className={style.ruleSection}>
<label>
Trigger outputs if
<Controller
name='filterRule'
control={control}
render={({ field }) => (
<RadioGroup {...field} size='sm' className={style.matchRadio} variant='ontime'>
<Radio value='all'>All filters pass</Radio>
<Radio value='any'>Any filter passes</Radio>
</RadioGroup>
)}
<RadioGroup
orientation='horizontal'
value={watch('filterRule')}
onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })}
items={[
{ value: 'all', label: 'All filters pass' },
{ value: 'any', label: 'Any filter passes' },
]}
/>
</label>
{fieldFilters.map((field, index) => {
@@ -232,43 +232,29 @@ export default function AutomationForm(props: AutomationFormProps) {
<label>
Runtime data source
<Select
{...register(`filters.${index}.field`, { required: { value: true, message: 'Required field' } })}
size='sm'
variant='ontime'
>
<option selected hidden disabled value=''>
Event field
</option>
{fieldList.map(({ value, label }, localIndex) => {
const key = `filters.${index}.field.${localIndex}`;
return (
<option key={key} value={value}>
{label}
</option>
);
})}
</Select>
value={watch(`filters.${index}.field`)}
onValueChange={(value) => setValue(`filters.${index}.field`, value, { shouldDirty: true })}
options={fieldList.map(({ value, label }) => ({ value, label }))}
aria-label='Event field'
/>
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
</label>
<label>
Matching condition
<Select
{...register(`filters.${index}.operator`, { required: { value: true, message: 'Required field' } })}
size='sm'
variant='ontime'
>
<option selected hidden disabled value=''>
Operator
</option>
<option value='equals'>equals</option>
<option value='not_equals'>not equals</option>
<option value='contains'>contains</option>
{/*
We dont currently offer a data source where these operators would make sense
<option value='greater_than'>greater than</option>
<option value='less_than'>less than</option>
*/}
</Select>
value={watch(`filters.${index}.operator`)}
onValueChange={(value) =>
setValue(`filters.${index}.operator`, value as 'equals' | 'not_equals' | 'contains', {
shouldDirty: true,
})
}
options={[
{ value: 'equals', label: 'equals' },
{ value: 'not_equals', label: 'not equals' },
{ value: 'contains', label: 'contains' },
]}
aria-label='Operator'
/>
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
</label>
<label>
@@ -1,5 +1,4 @@
import { Controller, useForm } from 'react-hook-form';
import { Switch } from '@chakra-ui/react';
import { useForm } from 'react-hook-form';
import { editAutomationSettings } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
@@ -7,6 +6,7 @@ import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import Switch from '../../../../common/components/switch/Switch';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOnlyNumbers } from '../../../../common/utils/regex';
import { isOntimeCloud } from '../../../../externals';
@@ -24,11 +24,12 @@ export default function AutomationSettingsForm(props: AutomationSettingsProps) {
const { enabledAutomations, enabledOscIn, oscPortIn } = props;
const {
control,
handleSubmit,
reset,
register,
setError,
watch,
setValue,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<AutomationSettingsProps>({
mode: 'onChange',
@@ -103,12 +104,10 @@ export default function AutomationSettingsForm(props: AutomationSettingsProps) {
description='Allow Ontime to send messages on lifecycle triggers'
error={errors.enabledAutomations?.message}
/>
<Controller
control={control}
name='enabledAutomations'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
<Switch
size='large'
checked={watch('enabledAutomations')}
onCheckedChange={(value: boolean) => setValue('enabledAutomations', value, { shouldDirty: true })}
/>
</Panel.ListItem>
</Panel.ListGroup>
@@ -123,12 +122,10 @@ export default function AutomationSettingsForm(props: AutomationSettingsProps) {
description='Allow control of Ontime through OSC'
error={errors.enabledOscIn?.message}
/>
<Controller
control={control}
name='enabledOscIn'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
<Switch
size='large'
checked={watch('enabledOscIn')}
onCheckedChange={(value: boolean) => setValue('enabledOscIn', value, { shouldDirty: true })}
/>
</Panel.ListItem>
<Panel.ListItem>
@@ -1,12 +1,12 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Select } from '@chakra-ui/react';
import { NormalisedAutomation, TimerLifeCycle, TriggerDTO } from 'ontime-types';
import { addTrigger, editTrigger } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import Select from '../../../../common/components/select/Select';
import { preventEscape } from '../../../../common/utils/keyEvent';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -29,6 +29,8 @@ export default function TriggerForm(props: TriggerFormProps) {
register,
setFocus,
setError,
watch,
setValue,
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<TriggerDTO>({
defaultValues: {
@@ -44,8 +46,7 @@ export default function TriggerForm(props: TriggerFormProps) {
// give initial focus to the title field
useEffect(() => {
setFocus('title');
// eslint-disable-next-line react-hooks/exhaustive-deps -- focus on mount
}, []);
}, [setFocus]);
const onSubmit = async (values: TriggerDTO) => {
// if we were passed an ID we are editing a Trigger
@@ -97,33 +98,21 @@ export default function TriggerForm(props: TriggerFormProps) {
<label>
Lifecycle trigger
<Select
size='sm'
variant='ontime'
defaultValue={initialTrigger}
{...register('trigger', { required: { value: true, message: 'Required field' } })}
>
{cycles.map((cycle) => (
<option key={cycle.id} value={cycle.value}>
{cycle.label}
</option>
))}
</Select>
value={watch('trigger')}
onValueChange={(value) => setValue('trigger', value as TimerLifeCycle, { shouldDirty: true })}
options={cycles.map((cycle) => ({ value: cycle.value, label: cycle.label }))}
aria-label='Lifecycle trigger'
/>
<Panel.Error>{errors.trigger?.message}</Panel.Error>
</label>
<label>
Automation title
<Select
size='sm'
variant='ontime'
defaultValue={initialAutomationId}
{...register('automationId', { required: { value: true, message: 'Required field' } })}
>
{automationSelect.map((automation) => (
<option key={automation.value} value={automation.value}>
{automation.label}
</option>
))}
</Select>
value={watch('automationId')}
onValueChange={(value) => setValue('automationId', value, { shouldDirty: true })}
options={automationSelect}
aria-label='Automation title'
/>
<Panel.Error>{errors.automationId?.message}</Panel.Error>
</label>
<Panel.InlineElements align='end'>
@@ -107,11 +107,17 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, isLockedToV
title='Lock navigation'
description='Prevent showing navigation (will only work for non production URLs)'
/>
<Switch name='lock' checked={watch('lock')} onCheckedChange={(checked) => setValue('lock', checked)} />
<Switch
size='large'
name='lock'
checked={watch('lock')}
onCheckedChange={(checked) => setValue('lock', checked)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Authenticate' description='Whether the URL should be pre-authenticated' />
<Switch
size='large'
name='authenticate'
checked={watch('authenticate')}
onCheckedChange={(checked) => setValue('authenticate', checked)}
@@ -1,7 +1,6 @@
import { useEffect } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoOpenOutline, IoTrash } from 'react-icons/io5';
import { Switch } from '@chakra-ui/react';
import { URLPreset } from 'ontime-types';
import { postUrlPresets } from '../../../../common/api/urlPresets';
@@ -11,6 +10,7 @@ import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import Switch from '../../../../common/components/switch/Switch';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
import { preventEscape } from '../../../../common/utils/keyEvent';
@@ -34,6 +34,8 @@ export default function UrlPresetsForm() {
register,
reset,
setError,
watch,
setValue,
formState: { isSubmitting, isDirty, isValid, errors },
} = useForm<FormData>({
mode: 'onChange',
@@ -166,8 +168,11 @@ export default function UrlPresetsForm() {
<tr key={preset.id}>
<td className={style.fit}>
<Switch
{...register(`data.${index}.enabled`)}
variant='ontime'
size='large'
checked={watch(`data.${index}.enabled`)}
onCheckedChange={(value: boolean) =>
setValue(`data.${index}.enabled`, value, { shouldDirty: true })
}
data-testid={`field__enable_${index}`}
/>
</td>
@@ -1,8 +1,9 @@
import { Select, Switch } from '@chakra-ui/react';
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
import { parseUserTime } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import Select from '../../../../common/components/select/Select';
import Switch from '../../../../common/components/switch/Switch';
import { editorSettingsDefaults, useEditorSettings } from '../../../../common/stores/editorSettings';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -41,12 +42,7 @@ export default function RundownDefaultSettings() {
title='Link previous'
description='Whether the start time of new events should be linked to the previous event end time'
/>
<Switch
variant='ontime'
size='lg'
defaultChecked={linkPrevious}
onChange={(event) => setLinkPrevious(event.target.checked)}
/>
<Switch size='large' checked={linkPrevious} onCheckedChange={setLinkPrevious} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
@@ -54,15 +50,13 @@ export default function RundownDefaultSettings() {
description='Which time should be maintained when event schedule is recalculated'
/>
<Select
variant='ontime'
size='sm'
width='auto'
value={defaultTimeStrategy}
onChange={(event) => setTimeStrategy(event.target.value as TimeStrategy)}
>
<option value={TimeStrategy.LockDuration}>Duration</option>
<option value={TimeStrategy.LockEnd}>End Time</option>
</Select>
onValueChange={(value) => setTimeStrategy(value as TimeStrategy)}
options={[
{ value: TimeStrategy.LockDuration, label: 'Duration' },
{ value: TimeStrategy.LockEnd, label: 'End Time' },
]}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.ListGroup>
@@ -78,31 +72,27 @@ export default function RundownDefaultSettings() {
<Panel.ListItem>
<Panel.Field title='Timer type' description='Default type of timer for new events' />
<Select
variant='ontime'
size='sm'
width='auto'
value={defaultTimerType}
onChange={(event) => setDefaultTimerType(event.target.value as TimerType)}
>
<option value={TimerType.CountDown}>Count down</option>
<option value={TimerType.CountUp}>Count up</option>
<option value={TimerType.Clock}>Clock</option>
<option value={TimerType.None}>None</option>
</Select>
onValueChange={(value) => setDefaultTimerType(value as TimerType)}
options={[
{ value: TimerType.CountDown, label: 'Count down' },
{ value: TimerType.CountUp, label: 'Count up' },
{ value: TimerType.Clock, label: 'Clock' },
{ value: TimerType.None, label: 'None' },
]}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='End Action' description='Default end action for new events' />
<Select
variant='ontime'
size='sm'
width='auto'
value={defaultEndAction}
onChange={(event) => setDefaultEndAction(event.target.value as EndAction)}
>
<option value={EndAction.None}>None</option>
<option value={EndAction.LoadNext}>Load next</option>
<option value={EndAction.PlayNext}>Play next</option>
</Select>
onValueChange={(value) => setDefaultEndAction(value as EndAction)}
options={[
{ value: EndAction.None, label: 'None' },
{ value: EndAction.LoadNext, label: 'Load next' },
{ value: EndAction.PlayNext, label: 'Play next' },
]}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.ListGroup>
@@ -56,7 +56,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
</td>
<td className={style.halfWidth}>{label}</td>
<td className={style.fullWidth}>
<CopyTag label='Copy key to use in integrations' copyValue={fieldKey}>
<CopyTag size='small' copyValue={fieldKey}>
{fieldKey}
</CopyTag>
</td>
@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { Radio, RadioGroup } from '@chakra-ui/react';
import { useForm } from 'react-hook-form';
import { CustomField } from 'ontime-types';
import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
@@ -9,6 +8,7 @@ import Button from '../../../../../common/components/buttons/Button';
import Info from '../../../../../common/components/info/Info';
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
import Input from '../../../../../common/components/input/input/Input';
import RadioGroup from '../../../../../common/components/radio-group/RadioGroup';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { preventEscape } from '../../../../../common/utils/keyEvent';
import * as Panel from '../../../panel-utils/PanelUtils';
@@ -33,13 +33,13 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
const [_, setColour] = useState(initialColour || '');
const {
control,
handleSubmit,
register,
setFocus,
setError,
setValue,
getValues,
watch,
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<CustomFieldFormData>({
defaultValues: { type: 'string', label: initialLabel || '', colour: initialColour || '' },
@@ -90,17 +90,15 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
</Info>
<div>
<Panel.Description>Type</Panel.Description>
<Controller
name='type'
control={control}
render={({ field }) => (
<RadioGroup {...field} size='sm' isDisabled={isEditMode} variant='ontime'>
<Panel.InlineElements relation='component'>
<Radio value='string'>Text</Radio>
<Radio value='image'>Image</Radio>
</Panel.InlineElements>
</RadioGroup>
)}
<RadioGroup
orientation='horizontal'
disabled={isEditMode}
onValueChange={(value) => setValue('type', value, { shouldDirty: true })}
value={watch('type')}
items={[
{ value: 'string', label: 'Text' },
{ value: 'image', label: 'Image' },
]}
/>
</div>
<div className={style.twoCols}>
@@ -138,7 +136,7 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
<Button variant='ghosted' onClick={onCancel}>
Cancel
</Button>
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
@@ -174,7 +174,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
<Panel.ListGroup>
<Panel.InlineElements>
{isAuthenticating && <span>Authenticating...</span>}
<CopyTag copyValue={authKey ?? ''} label='Google Auth Key' disabled={!canAuthenticate} size='sm'>
<CopyTag copyValue={authKey ?? ''} disabled={!canAuthenticate}>
{authKey ? authKey : 'Upload files to generate Auth Key'}
</CopyTag>
<Button onClick={handleAuthenticate} disabled={!canAuthenticate}>
@@ -1,12 +1,12 @@
import { useEffect, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoTrash } from 'react-icons/io5';
import { Select } from '@chakra-ui/react';
import { ImportMap, isAlphanumericWithSpace } from 'ontime-utils';
import Button from '../../../../../../common/components/buttons/Button';
import IconButton from '../../../../../../common/components/buttons/IconButton';
import Input from '../../../../../../common/components/input/input/Input';
import Select from '../../../../../../common/components/select/Select';
import Tooltip from '../../../../../../common/components/tooltip/Tooltip';
import * as Panel from '../../../../panel-utils/PanelUtils';
import useGoogleSheet from '../useGoogleSheet';
@@ -33,6 +33,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
handleSubmit,
register,
setValue,
watch,
formState: { errors, isValid },
} = useForm<NamedImportMap>({
mode: 'onChange',
@@ -149,19 +150,13 @@ export default function ImportMapForm(props: ImportMapFormProps) {
<td>{label}</td>
<td>
<Select
variant='ontime'
id={importName as string}
size='sm'
{...register(label as keyof NamedImportMap)}
>
{worksheetNames?.map((name) => {
return (
<option key={name} value={name}>
{name}
</option>
);
})}
</Select>
value={watch(label as keyof NamedImportMap) as string}
onValueChange={(value: string) =>
setValue(label as keyof NamedImportMap, value, { shouldDirty: true })
}
options={worksheetNames?.map((name) => ({ value: name, label: name })) || []}
/>
</td>
<td className={style.singleActionCell} />
</tr>
@@ -21,12 +21,7 @@ export default function InfoNif() {
const address = linkToOtherHost(nif.address);
return (
<CopyTag
key={nif.name}
copyValue={address}
onClick={() => handleClick(address)}
label='Copy IP or navigate to address'
>
<CopyTag key={nif.name} copyValue={address} onClick={() => handleClick(address)}>
{`${nif.name} - ${nif.address}`} <IoArrowUp className={style.goIcon} />
</CopyTag>
);
@@ -10,3 +10,8 @@
width: 50%;
white-space: nowrap;
}
.copiable {
cursor: text ;
user-select: text;
}
@@ -61,8 +61,8 @@ export default function ClientList() {
<Panel.Table>
<thead>
<tr>
<td className={style.halfWidth}>Client Name</td>
<td className={style.fullWidth}>Path</td>
<td style={{ width: '20%' }}>Client Name</td>
<td>Path</td>
<td />
</tr>
</thead>
@@ -76,7 +76,7 @@ export default function ClientList() {
{isCurrent && <Tag>SELF</Tag>}
{name}
</Panel.InlineElements>
<td>{path}</td>
<td className={style.copiable}>{path}</td>
<Panel.InlineElements relation='inner'>
<Button
size='small'
@@ -1,6 +1,12 @@
import { useState } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5';
import { IconButton, Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react';
import {
IoCopyOutline,
IoDocumentOutline,
IoDownloadOutline,
IoEllipsisHorizontal,
IoPencilOutline,
IoTrash,
} from 'react-icons/io5';
import {
deleteProject,
@@ -11,6 +17,8 @@ import {
renameProject,
} from '../../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
import IconButton from '../../../../common/components/buttons/IconButton';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -184,31 +192,33 @@ function ActionMenu(props: ActionMenuProps) {
};
return (
<Menu variant='ontime-on-dark' size='sm'>
<MenuButton
as={IconButton}
aria-label='Options'
icon={<IoEllipsisHorizontal />}
color='#e2e2e2' // $gray-200
variant='ontime-ghosted'
size='sm'
isDisabled={isDisabled}
/>
<MenuList>
<MenuItem onClick={() => onLoad(filename)} isDisabled={current}>
Load
</MenuItem>
<MenuItem onClick={() => onMerge(filename)} isDisabled={current}>
Partial Load
</MenuItem>
<MenuItem onClick={handleRename}>Rename</MenuItem>
<MenuItem onClick={handleDuplicate}>Duplicate</MenuItem>
<MenuItem onClick={handleDownload}>Download</MenuItem>
<MenuItem onClick={handleExportCSV}>Export CSV Rundown</MenuItem>
<MenuItem isDisabled={current} onClick={() => onDelete(filename)}>
Delete
</MenuItem>
</MenuList>
</Menu>
<DropdownMenu
render={<IconButton variant='ghosted-white' />}
disabled={isDisabled}
items={[
{
type: 'item',
icon: IoDownloadOutline,
label: 'Load',
onClick: () => onLoad(filename),
disabled: current,
},
{
type: 'item',
icon: IoDownloadOutline,
label: 'Partial Load',
onClick: () => onMerge(filename),
disabled: current,
},
{ type: 'item', icon: IoPencilOutline, label: 'Rename', onClick: handleRename },
{ type: 'item', icon: IoCopyOutline, label: 'Duplicate', onClick: handleDuplicate },
{ type: 'item', icon: IoDocumentOutline, label: 'Download', onClick: handleDownload },
{ type: 'item', icon: IoDocumentOutline, label: 'Export CSV Rundown', onClick: handleExportCSV },
{ type: 'divider' },
{ type: 'item', icon: IoTrash, label: 'Delete', onClick: () => onDelete(filename), disabled: current },
]}
>
<IoEllipsisHorizontal />
</DropdownMenu>
);
}
@@ -1,12 +1,12 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { Switch } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { PROJECT_DATA } from '../../../../common/api/constants';
import { getDb, patchData } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Switch from '../../../../common/components/switch/Switch';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -34,7 +34,8 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
const {
handleSubmit,
register,
watch,
setValue,
formState: { isSubmitting, isValid, isDirty },
} = useForm<ProjectMergeFormValues>({
defaultValues: {
@@ -92,23 +93,43 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
<br /> This process is irreversible.
</Panel.Description>
<label>
<Switch variant='ontime' {...register('project')} />
<Switch
size='large'
checked={watch('project')}
onCheckedChange={(value: boolean) => setValue('project', value, { shouldDirty: true })}
/>
Project data
</label>
<label>
<Switch variant='ontime' {...register('rundown')} />
<Switch
size='large'
checked={watch('rundown')}
onCheckedChange={(value: boolean) => setValue('rundown', value, { shouldDirty: true })}
/>
Rundown + Custom Fields
</label>
<label>
<Switch variant='ontime' {...register('viewSettings')} />
<Switch
size='large'
checked={watch('viewSettings')}
onCheckedChange={(value: boolean) => setValue('viewSettings', value, { shouldDirty: true })}
/>
View Settings
</label>
<label>
<Switch variant='ontime' {...register('urlPresets')} />
<Switch
size='large'
checked={watch('urlPresets')}
onCheckedChange={(value: boolean) => setValue('urlPresets', value, { shouldDirty: true })}
/>
URL Presets
</label>
<label>
<Switch variant='ontime' {...register('automation')} />
<Switch
size='large'
checked={watch('automation')}
onCheckedChange={(value: boolean) => setValue('automation', value, { shouldDirty: true })}
/>
Automation Settings
</label>
</Panel.Section>
@@ -119,7 +119,7 @@ export default function GeneralSettings() {
description='Protect the editor view with a pin code'
error={errors.editorKey?.message}
/>
<GeneralPinInput register={register} formName='editorKey' isDisabled={disableInputs} />
<GeneralPinInput register={register} formName='editorKey' disabled={disableInputs} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
@@ -127,7 +127,7 @@ export default function GeneralSettings() {
description='Protect the operator and cuesheet views with a pin code'
error={errors.operatorKey?.message}
/>
<GeneralPinInput register={register} formName='operatorKey' isDisabled={disableInputs} />
<GeneralPinInput register={register} formName='operatorKey' disabled={disableInputs} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
@@ -1,6 +1,5 @@
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { Switch } from '@chakra-ui/react';
import { useForm } from 'react-hook-form';
import { useDisclosure } from '@mantine/hooks';
import { ViewSettings as ViewSettingsType } from 'ontime-types';
@@ -10,6 +9,7 @@ import Info from '../../../../common/components/info/Info';
import { SwatchPickerRHF } from '../../../../common/components/input/colour-input/SwatchPicker';
import Input from '../../../../common/components/input/input/Input';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import Switch from '../../../../common/components/switch/Switch';
import useViewSettings from '../../../../common/hooks-query/useViewSettings';
import { preventEscape } from '../../../../common/utils/keyEvent';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -28,6 +28,8 @@ export default function ViewSettings() {
setError,
register,
reset,
setValue,
watch,
formState: { isSubmitting, isDirty, errors },
} = useForm<ViewSettingsType>({
defaultValues: data,
@@ -96,12 +98,10 @@ export default function ViewSettings() {
title='Override CSS styles'
description='Enables overriding view styles with custom stylesheet'
/>
<Controller
control={control}
name='overrideStyles'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
<Switch
size='large'
checked={watch('overrideStyles')}
onCheckedChange={(value: boolean) => setValue('overrideStyles', value, { shouldDirty: true })}
/>
<Button onClick={codeEditorHandler.open} disabled={isSubmitting}>
Edit CSS override
@@ -128,12 +128,10 @@ export default function ViewSettings() {
title='Freeze timer on end'
description='When a timer hits 00:00:00, it freezes instead of going negative. It invalidates the End Message.'
/>
<Controller
control={control}
name='freezeEnd'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
<Switch
size='large'
checked={watch('freezeEnd')}
onCheckedChange={(value: boolean) => setValue('freezeEnd', value, { shouldDirty: true })}
/>
</Panel.ListItem>
<Panel.ListItem>
@@ -0,0 +1,9 @@
.container {
display: flex;
align-items: center;
gap: 0.25rem;
input {
width: 5em;
}
}
@@ -1,24 +1,26 @@
import { PropsWithChildren, useState } from 'react';
import { UseFormRegister } from 'react-hook-form';
import { IoEyeOutline } from 'react-icons/io5';
import { IconButton, Input, InputGroup, InputRightElement } from '@chakra-ui/react';
import { Settings } from 'ontime-types';
import IconButton from '../../../../../common/components/buttons/IconButton';
import Input from '../../../../../common/components/input/input/Input';
import { isAlphanumeric } from '../../../../../common/utils/regex';
import style from './GeneralPinInput.module.scss';
interface GeneralPinInputProps {
register: UseFormRegister<Settings>;
formName: keyof Settings;
isDisabled?: boolean;
disabled?: boolean;
}
export default function GeneralPinInput(props: PropsWithChildren<GeneralPinInputProps>) {
const { register, formName, isDisabled } = props;
export default function GeneralPinInput({ register, formName, disabled }: PropsWithChildren<GeneralPinInputProps>) {
const [isVisible, setVisible] = useState(false);
return (
<InputGroup size='sm' width='100px'>
<div className={style.container}>
<Input
variant='ontime-filled'
type={isVisible ? 'text' : 'password'}
maxLength={4}
{...register(formName, {
@@ -28,18 +30,16 @@ export default function GeneralPinInput(props: PropsWithChildren<GeneralPinInput
},
})}
placeholder='-'
isDisabled={isDisabled}
disabled={disabled}
/>
<InputRightElement>
<IconButton
onMouseDown={() => setVisible(true)}
onMouseUp={() => setVisible(false)}
size='sm'
variant='ontime-ghosted'
icon={<IoEyeOutline />}
aria-label='Show pin code'
/>
</InputRightElement>
</InputGroup>
<IconButton
onMouseDown={() => setVisible(true)}
onMouseUp={() => setVisible(false)}
variant='ghosted'
aria-label='Show pin code'
>
<IoEyeOutline />
</IconButton>
</div>
);
}
@@ -75,7 +75,8 @@ export default function QuickStart({ isOpen, onClose }: QuickStartProps) {
error={errors.settings?.timeFormat?.message}
/>
<Select
{...register('settings.timeFormat')}
value={watch('settings.timeFormat')}
onValueChange={(value: '12' | '24') => setValue('settings.timeFormat', value, { shouldDirty: true })}
defaultValue='24'
options={[
{ value: '12', label: '12 hours 11:00:10 PM' },
@@ -90,7 +91,8 @@ export default function QuickStart({ isOpen, onClose }: QuickStartProps) {
error={errors.settings?.language?.message}
/>
<Select
{...register('settings.language')}
value={watch('settings.language')}
onValueChange={(value: string) => setValue('settings.language', value, { shouldDirty: true })}
defaultValue='en'
options={[
{ value: 'en', label: 'English' },
@@ -132,6 +134,7 @@ export default function QuickStart({ isOpen, onClose }: QuickStartProps) {
description='When a timer hits 00:00:00, it freezes instead of going negative. It invalidates the End Message.'
/>
<Switch
size='large'
name='viewSettings.freezeEnd'
checked={watch('viewSettings.freezeEnd')}
onCheckedChange={(checked) => setValue('viewSettings.freezeEnd', checked)}
@@ -2,16 +2,6 @@ $button-bg-gray: $gray-1050;
$button-color-white: $gray-50;
@mixin tap-factory($theme-color) {
font-family: $ontime-font-family;
font-size: calc(1rem + 2px);
border-radius: $component-border-radius-md;
width: 100%;
transition-property: color, background-color;
transition-duration: $transition-time-feedback;
display: grid;
place-content: center;
letter-spacing: 0.5px;
background-color: $button-bg-gray;
color: $theme-color;
@@ -43,6 +33,18 @@ $button-color-white: $gray-50;
}
}
.tapButton {
font-family: $ontime-font-family;
font-size: calc(1rem + 2px);
border-radius: $component-border-radius-md;
width: 100%;
transition-property: color, background-color;
transition-duration: $transition-time-feedback;
display: grid;
place-content: center;
letter-spacing: 0.5px;
}
.tapButton.neutral {
@include tap-factory($gray-50);
@@ -86,7 +88,7 @@ $button-color-white: $gray-50;
.tapButton.tight {
padding-inline: 0.5rem;
width: fit-content
width: fit-content;
}
.tapButton.fill {
@@ -17,10 +17,15 @@ interface TapButtonProps {
const TapButton = forwardRef((props: PropsWithChildren<TapButtonProps>, ref: ForwardedRef<HTMLButtonElement>) => {
const { children, disabled, onClick, theme = 'neutral', aspect = 'normal', active, className } = props;
const classes = cx([style.tapButton, className, style[theme], style[aspect], active ? style.active : null]);
return (
<button className={classes} disabled={disabled} type='button' onClick={onClick} ref={ref}>
<button
className={cx([style.tapButton, className, style[theme], style[aspect], active && style.active])}
disabled={disabled}
type='button'
onClick={onClick}
ref={ref}
>
{children}
</button>
);
@@ -7,6 +7,12 @@ $info-hover: $section-white;
user-select: text;
min-height: 20vh;
max-height: 40vh;
background-color: $gray-1350;
padding: 0.5rem;
}
.apart {
margin-left: auto;
}
.logEntry {
+3 -2
View File
@@ -1,4 +1,5 @@
import { useCallback, useState } from 'react';
import { IoClose } from 'react-icons/io5';
import { LogOrigin } from 'ontime-types';
import Button from '../../common/components/buttons/Button';
@@ -105,8 +106,8 @@ export default function Log() {
>
{LogOrigin.Tx}
</Button>
<Button variant='subtle-destructive' size='small' onClick={clearLogs}>
Clear
<Button variant='subtle-destructive' size='small' onClick={clearLogs} className={style.apart}>
<IoClose /> Clear
</Button>
</Panel.InlineElements>
<ul className={style.log}>
@@ -3,7 +3,6 @@
top: 10%;
left: 50%;
z-index: $zindex-dialog;
transform: translateX(-50%);
padding-inline: 1rem;
@@ -78,7 +78,6 @@ export default function EditModal(props: EditModalProps) {
defaultValue={field.value}
data-field={field.id}
disabled={loading}
resize='none'
rows={5}
/>
</Fragment>
@@ -12,7 +12,10 @@ import {
import { isStringBoolean } from '../viewers/common/viewUtils';
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
const fieldOptions = makeOptionsFromCustomFields(customFields, [
{ value: 'title', label: 'Title' },
{ value: 'note', label: 'Note' },
]);
const customFieldSelect = makeCustomFieldSelectOptions(customFields);
return [
@@ -1,7 +1,6 @@
import { memo } from 'react';
import { useSessionStorage } from '@mantine/hooks';
import { ContextMenu } from '../../common/components/context-menu/ContextMenu';
import { Corner } from '../../common/components/editor-utils/EditorUtils';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
@@ -13,6 +12,7 @@ import { AppMode, sessionKeys } from '../../ontimeConfig';
import RundownEntryEditor from './entry-editor/RundownEntryEditor';
import FinderPlacement from './placements/FinderPlacement';
import { RundownContextMenu } from './rundown-context-menu/RundownContextMenu';
import RundownWrapper from './RundownWrapper';
import style from './RundownExport.module.scss';
@@ -39,9 +39,9 @@ function RundownExport() {
<ViewNavigationMenu suppressSettings />
<div className={style.content}>
<ErrorBoundary>
<ContextMenu>
<RundownContextMenu>
<RundownWrapper isSmallDevice />
</ContextMenu>
</RundownContextMenu>
</ErrorBoundary>
</div>
</div>
@@ -59,9 +59,9 @@ function RundownExport() {
<div className={style.list}>
<ErrorBoundary>
<Corner onClick={(event) => handleLinks('rundown', event)} />
<ContextMenu>
<RundownContextMenu>
<RundownWrapper />
</ContextMenu>
</RundownContextMenu>
</ErrorBoundary>
</div>
{!hideSideBar && (
@@ -16,12 +16,8 @@ function EventEditorFooter({ id, cue }: EventEditorFooterProps) {
return (
<div className={style.footer}>
<CopyTag copyValue={loadById} label='OSC trigger by ID'>
{loadById}
</CopyTag>
<CopyTag copyValue={loadByCue} label='OSC trigger by cue'>
{loadByCue}
</CopyTag>
<CopyTag copyValue={loadById}>{loadById}</CopyTag>
<CopyTag copyValue={loadByCue}>{loadByCue}</CopyTag>
</div>
);
}
@@ -1,7 +1,7 @@
import { type CSSProperties, useCallback, useRef } from 'react';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import { AutoTextArea } from '../../../../common/components/input/auto-text-area/AutoTextArea';
import { AutoTextarea } from '../../../../common/components/input/auto-textarea/AutoTextarea';
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
import { EventEditorUpdateFields } from '../EventEditor';
@@ -22,7 +22,7 @@ export default function EventTextArea({
style: givenStyles,
submitHandler,
}: CountedTextAreaProps) {
const ref = useRef<HTMLInputElement | null>(null);
const ref = useRef<HTMLTextAreaElement | null>(null);
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
@@ -34,14 +34,12 @@ export default function EventTextArea({
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
{label}
</Editor.Label>
<AutoTextArea
<AutoTextarea
id={field}
inputref={ref}
rows={1}
size='sm'
resize='none'
variant='ontime-filled'
data-testid='input-textarea'
fluid
value={value}
onChange={onChange}
onBlur={onBlur}
@@ -67,10 +67,10 @@ function QuickAddInline({ previousEventId, parentBlock }: QuickAddInlineProps) {
<div className={style.quickAdd} data-testid='quick-add-inline'>
<DropdownMenu
items={[
{ type: 'item', icon: <IoAdd />, label: 'Add Event', onClick: addEvent },
{ type: 'item', icon: <IoAdd />, label: 'Add Delay', onClick: addDelay },
{ type: 'item', icon: <IoAdd />, label: 'Add Milestone', onClick: addMilestone },
{ type: 'item', icon: <IoAdd />, label: 'Add Group', onClick: addBlock, disabled: parentBlock !== null },
{ type: 'item', icon: IoAdd, label: 'Add Event', onClick: addEvent },
{ type: 'item', icon: IoAdd, label: 'Add Delay', onClick: addDelay },
{ type: 'item', icon: IoAdd, label: 'Add Milestone', onClick: addMilestone },
{ type: 'item', icon: IoAdd, label: 'Add Group', onClick: addBlock, disabled: parentBlock !== null },
]}
render={<IconButton size='small' variant='primary' className={style.addButton} />}
>
@@ -36,21 +36,26 @@ export default function RundownBlock({ data, hasCursor, collapsed, onCollapse }:
const [onContextMenu] = useContextMenu<HTMLDivElement>([
{
type: 'item',
label: 'Clone Group',
icon: IoDuplicateOutline,
onClick: () => clone(data.id),
},
{
type: 'item',
label: 'Ungroup',
icon: IoFolderOpenOutline,
onClick: () => ungroup(data.id),
isDisabled: data.entries.length === 0,
disabled: data.entries.length === 0,
},
{ type: 'divider' },
{
type: 'item',
label: 'Delete Group',
icon: IoTrash,
onClick: () => deleteEntry([data.id]),
withDivider: true,
disabled: true,
},
]);
@@ -0,0 +1,44 @@
import type { PropsWithChildren } from 'react';
import { create } from 'zustand';
import { DropdownMenuOption, PositionedDropdownMenu } from '../../../common/components/dropdown-menu/DropdownMenu';
type Position = {
x: number;
y: number;
};
type ContextMenuStore = {
position: Position;
options: DropdownMenuOption[];
isOpen: boolean;
setContextMenu: (position: Position, options: DropdownMenuOption[]) => void;
setIsOpen: (newIsOpen: boolean) => void;
};
export const useContextMenuStore = create<ContextMenuStore>((set) => ({
position: { x: 0, y: 0 },
options: [],
isOpen: false,
setContextMenu: (position, options) => set(() => ({ position, options, isOpen: true })),
setIsOpen: (newIsOpen) => set(() => ({ isOpen: newIsOpen })),
}));
export function RundownContextMenu({ children }: PropsWithChildren) {
const { position, options, isOpen, setIsOpen } = useContextMenuStore();
const onClose = () => {
return setIsOpen(false);
};
if (!isOpen) {
return children;
}
return (
<>
{children}
<PositionedDropdownMenu isOpen position={position} onClose={onClose} items={options} />
</>
);
}
@@ -107,6 +107,7 @@ export default function RundownEvent({
selectedEvents.size > 1
? [
{
type: 'item',
label: 'Link to previous',
icon: IoLink,
onClick: () =>
@@ -116,6 +117,8 @@ export default function RundownEvent({
}),
},
{
type: 'item',
label: 'Unlink from previous',
icon: IoUnlink,
onClick: () =>
@@ -124,11 +127,15 @@ export default function RundownEvent({
value: null,
}),
},
{ withDivider: true, label: 'Group', icon: IoFolder, onClick: () => actionHandler('group') },
{ withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
{ type: 'divider' },
{ type: 'item', label: 'Group', icon: IoFolder, onClick: () => actionHandler('group') },
{ type: 'divider' },
{ type: 'item', label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
]
: [
{
type: 'item',
label: 'Toggle link to previous',
icon: IoLink,
onClick: () =>
@@ -137,23 +144,34 @@ export default function RundownEvent({
value: linkStart,
}),
},
{ type: 'divider' },
{
type: 'item',
label: 'Add to swap',
icon: IoAdd,
onClick: () => setSelectedEventId(eventId),
withDivider: true,
},
{
type: 'item',
label: `Swap this event with ${selectedEventId ?? ''}`,
icon: IoSwapVertical,
onClick: () => {
actionHandler('swap', { field: 'id', value: selectedEventId });
clearSelectedEventId();
},
isDisabled: selectedEventId == null || selectedEventId === eventId,
disabled: selectedEventId == null || selectedEventId === eventId,
},
{ withDivider: false, label: 'Clone', icon: IoDuplicateOutline, onClick: () => actionHandler('clone') },
{ withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
{
type: 'item',
label: 'Clone',
icon: IoDuplicateOutline,
onClick: () => actionHandler('clone'),
},
{ type: 'divider' },
{ type: 'item', label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
],
);
@@ -48,7 +48,11 @@ export const getClockOptions = (timeFormat: string): ViewOption[] => [
title: 'Align Horizontal',
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
values: [
{ value: 'start', label: 'Start' },
{ value: 'center', label: 'Center' },
{ value: 'end', label: 'End' },
],
defaultValue: 'center',
},
{
@@ -63,7 +67,11 @@ export const getClockOptions = (timeFormat: string): ViewOption[] => [
title: 'Align Vertical',
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
values: [
{ value: 'start', label: 'Start' },
{ value: 'center', label: 'Center' },
{ value: 'end', label: 'End' },
],
defaultValue: 'center',
},
{
@@ -8,16 +8,16 @@ import { makeOptionsFromCustomFields } from '../../../common/components/view-par
import safeParseNumber from '../../../common/utils/safeParseNumber';
export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] => {
const topSourceOptions = makeOptionsFromCustomFields(customFields, {
title: 'Title',
note: 'Note',
});
const topSourceOptions = makeOptionsFromCustomFields(customFields, [
{ value: 'title', label: 'Title' },
{ value: 'note', label: 'Note' },
]);
const bottomSourceOptions = makeOptionsFromCustomFields(customFields, {
title: 'Title',
note: 'Note',
none: 'None',
});
const bottomSourceOptions = makeOptionsFromCustomFields(customFields, [
{ value: 'title', label: 'Title' },
{ value: 'note', label: 'Note' },
{ value: 'none', label: 'None' },
]);
return [
{
@@ -99,10 +99,9 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] =
{
id: 'width',
title: 'Minimum Width',
description: 'Minimum Width of the element',
description: 'Minimum Width of the element (percentage)',
type: 'number',
prefix: '%',
placeholder: '45 (default)',
placeholder: '45 (default %)',
},
{
id: 'key',
@@ -68,7 +68,11 @@ export const MINIMAL_TIMER_OPTIONS: ViewOption[] = [
title: 'Align Horizontal',
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
values: [
{ value: 'start', label: 'Start' },
{ value: 'center', label: 'Center' },
{ value: 'end', label: 'End' },
],
defaultValue: 'center',
},
{
@@ -83,7 +87,11 @@ export const MINIMAL_TIMER_OPTIONS: ViewOption[] = [
title: 'Align Vertical',
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
values: [
{ value: 'start', label: 'Start' },
{ value: 'center', label: 'Center' },
{ value: 'end', label: 'End' },
],
defaultValue: 'center',
},
{
+59 -17
View File
@@ -43,24 +43,29 @@ $track-color: $white-1;
$thumb-color: $gray-1100;
$thumb-color-hover: $gray-900;
* {
/* Apply a natural box layout model to all elements */
html {
box-sizing: border-box;
}
*,
*::before,
*::after {
box-sizing: inherit;
margin: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
scrollbar-color: $thumb-color $track-color;
scrollbar-width: thin;
}
// reset box-sizing
*,
*:before,
*:after {
box-sizing: inherit;
}
body, html {
body,
html {
font-size: 15px;
font-family: $ontime-font-family;
background-color: var(--background-color-override, $ui-black);
-webkit-font-smoothing: antialiased;
line-height: 1.5;
}
body,
@@ -76,6 +81,51 @@ html,
isolation: isolate;
}
// reset button styles
button {
all: unset;
box-sizing: border-box;
}
img,
picture,
video,
canvas,
svg {
display: block;
max-width: 100%;
}
input,
button,
textarea,
select {
font: inherit;
}
p,
h1,
h2,
h3,
h4,
h5,
h6 {
overflow-wrap: break-word;
}
table {
word-break: break-word;
}
// remove buttons in number inputs
/* WebKit and Blink */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type='number'] {
-moz-appearance: textfield;
}
/* smaller root size for MacOS laptops */
@media (max-width: 1600px) {
body,
@@ -85,14 +135,6 @@ html,
}
}
/**
* workaround for chakra
* https://github.com/chakra-ui/chakra-ui/issues/417
*/
option {
color: initial;
}
/* width */
::-webkit-scrollbar {
width: 6px;
-20
View File
@@ -1,20 +0,0 @@
export const ontimeAlertOnDark = {
container: {
fontSize: 'calc(1rem - 1px)',
backgroundColor: '#202020', // $gray-1200
color: '#e2e2e2', // $gray-200
borderRadius: '3px',
},
icon: {
alignSelf: 'start',
color: '#578AF4', // $blue-500
},
};
export const ontimeDialog = {
container: {
backgroundColor: '#1a1a1a', // $gray-1300
color: '#e2e2e2', // $gray-200
borderRadius: '3px',
},
};
-78
View File
@@ -1,78 +0,0 @@
export const ontimeButtonFilled = {
background: '#2B5ABC', // $blue-700
color: '#fff', // pure-white
border: '1px solid #2B5ABC', // $blue-700
_hover: {
backgroundColor: '#0A43B9', // $blue-800
border: '1px solid #0A43B9', // $blue-800
_disabled: {
background: '#2B5ABC', // $blue-700
},
},
_active: {
backgroundColor: '#0036A6', // blue-900
borderColor: '#002A90', // blue-1000
},
};
export const ontimeButtonOutlined = {
backgroundColor: '#2d2d2d', // $gray-1100
color: '#e2e2e2', // $blue-400
border: '1px solid rgba(255, 255, 255, 0.10)', // white-10
_hover: {
backgroundColor: '#404040', // $gray-1000
_disabled: {
backgroundColor: '#2d2d2d', // $gray-1100
},
},
_active: {
backgroundColor: '#2d2d2d', // $gray-1100
borderColor: '#202020', // $gray-1250
},
};
export const ontimeButtonSubtle = {
backgroundColor: '#303030', // $gray-1050
color: '#779BE7', // $blue-400
border: '1px solid transparent',
_hover: {
background: '#404040', // $gray-1000
_disabled: {
backgroundColor: '#303030', // $gray-1050
},
},
_active: {
backgroundColor: '#2d2d2d', // $gray-1100
borderColor: '#202020', // $gray-1250
},
};
// TODO: revise colours
export const ontimeButtonGhostedWhite = {
...ontimeButtonSubtle,
backgroundColor: 'transparent',
color: 'white',
_hover: {
background: '#404040', // $gray-1000
},
_active: {
background: '#2d2d2d', // $gray-1100
},
};
export const ontimeButtonGhosted = {
...ontimeButtonSubtle,
backgroundColor: 'transparent',
_hover: {
background: '#404040', // $gray-1000
_disabled: {
backgroundColor: 'transparent',
},
},
};
export const ontimeButtonSubtleWhite = {
...ontimeButtonSubtle,
color: '#f6f6f6', // $gray-50
fontWeight: 600,
};
-33
View File
@@ -1,33 +0,0 @@
export const ontimeCheckboxOnDark = {
control: {
border: '1px',
borderColor: '#2d2d2d', // $gray-1100
backgroundColor: '#2d2d2d', // $gray-1100
_disabled: {
color: 'white',
borderColor: '#2d2d2d', // $gray-1100
backgroundColor: '#2d2d2d', // $gray-1100
opacity: 0.6,
},
_checked: {
borderColor: '#578AF4', // $blue-500
backgroundColor: '#578AF4', // $blue-500
_disabled: {
color: 'white',
borderColor: '#578AF4', // $blue-500
backgroundColor: '#578AF4', // $blue-500
opacity: 0.6,
},
},
_focus: {
boxShadow: 'none',
},
},
label: {
fontWeight: '200',
color: '#9d9d9d', // $gray-500
_checked: {
color: '#cfcfcf', // $gray-300
},
},
};
-22
View File
@@ -1,22 +0,0 @@
export const ontimeDrawer = {
header: {
color: '#fefefe', // $gray-50
backgroundColor: '#202020', // $gray-1250
},
body: {
display: 'flex',
flexDirection: 'column',
color: '#fefefe', // $gray-50
backgroundColor: '#202020', // $gray-1250
},
footer: {
backgroundColor: '#202020', // $gray-1250
},
closeButton: {
color: '#fefefe', // $gray-50
_hover: {
color: '#303030', // $gray-1050
backgroundColor: '#fefefe', // $gray-50
},
},
};
-14
View File
@@ -1,14 +0,0 @@
export const ontimeEditable = {
input: {
borderRadius: '3px',
width: '100%',
_focus: {
border: '1px solid #578AF4', // $blue-500
boxShadow: 'none',
},
},
preview: {
width: '100%',
border: '1px solid transparent', // $blue-500
},
};
-27
View File
@@ -1,27 +0,0 @@
export const ontimeMenuOnDark = {
list: {
fontSize: 'calc(1rem - 2px)',
borderRadius: '3px',
borderColor: 'rgba(255, 255, 255, 0.1)',
color: '#ececec', // $gray-1030
backgroundColor: '#202020', // $gray-1250
zIndex: 100,
},
item: {
backgroundColor: 'transparent',
paddingBlock: '0.5rem',
_hover: {
backgroundColor: 'rgba(0, 0, 0, 0.2)',
_disabled: {
backgroundColor: 'transparent',
},
},
_disabled: {
color: '#b1b1b1', // $gray-400
},
},
divider: {
borderColor: 'rgba(255, 255, 255, 0.07)',
opacity: 1,
},
};
-33
View File
@@ -1,33 +0,0 @@
export const ontimeModal = {
header: {
fontWeight: 400,
letterSpacing: '0.3px',
padding: '1rem 1.5rem',
fontSize: '1.25rem',
color: '#fefefe', // $gray-50
},
dialog: {
borderRadius: '3px',
padding: 0,
minHeight: 'min(200px, 10vh)',
backgroundColor: '#202020', // $gray-1250
color: '#fefefe', // $gray-50
border: '1px solid #2d2d2d', // $gray-1100
},
body: {
padding: '1rem',
fontSize: 'calc(1rem - 2px)',
display: 'flex',
flexDirection: 'column',
gap: '1rem',
},
closeButton: {
color: '#fefefe', // $gray-50
},
footer: {
padding: '1rem',
display: 'flex',
alignItems: 'left',
gap: '0.5rem',
},
};
-48
View File
@@ -1,48 +0,0 @@
export const ontimeRadio = {
control: {
borderColor: '#262626', // $gray-1200
backgroundColor: '#262626', // $gray-1200
_checked: {
borderColor: '#262626', // $gray-1200
color: '#f6f6f6', // $ui-white
backgroundColor: '#f6f6f6', // $ui-white
},
},
label: {
color: '#9d9d9d', // $gray-500, same as placeholder value
_checked: {
color: '#f6f6f6', // $gray-200
},
_hover: {
color: '#e2e2e2', // $gray-200
},
},
};
export const ontimeBlockRadio = {
control: {
borderColor: '#262626', // $gray-1200
backgroundColor: '#262626', // $gray-1200
_checked: {
borderColor: '#262626', // $gray-1200
color: '#578AF4', // $blue-500
backgroundColor: '#578AF4', // $blue-500
},
_hover: {
color: '#578AF4', // $blue-500
backgroundColor: '#578AF4', // $blue-500
outline: 'none',
},
},
label: {
fontSize: '0.7em',
letterSpacing: '0.3px',
color: '#9d9d9d', // $gray-500
_checked: {
color: '#cfcfcf', // $gray-300
},
_hover: {
color: '#e2e2e2', // $gray-200
},
},
};
-25
View File
@@ -1,25 +0,0 @@
export const ontimeSelect = {
field: {
color: '#e2e2e2', // $gray-200
borderRadius: '3px',
fontWeight: '400',
background: '#262626', // $gray-1100
border: '1px solid transparent',
_hover: {
background: '#404040', // $gray-1000
},
_focus: {
background: '#404040', // $gray-1000
color: '#f6f6f6', // $gray-50
border: '1px solid #578AF4', // $blue-500
},
_disabled: {
_hover: {
background: '#262626', // $gray-1100
},
},
},
icon: {
color: '#e2e2e2', // $gray-200
},
};
-12
View File
@@ -1,12 +0,0 @@
export const ontimeSwitch = {
track: {
background: '#2d2d2d', // $gray-1100
border: '1px solid transparent',
_checked: {
background: '#2B5ABC', // $blue-700
},
_focus: {
border: '1px solid #578AF4', // $blue-500
},
},
};
-19
View File
@@ -1,19 +0,0 @@
export const ontimeTab = {
tab: {
fontWeight: 600,
borderBottom: '2px solid transparent',
color: '#9d9d9d', // $gray-500
marginBottom: '-2px',
_selected: {
color: '#101010', // $ui-black
border: 'none',
borderBottom: '2px solid #779BE7', // $blue-400
},
},
tablist: {
borderBottom: '2px solid #ececec', // $gray-100
},
tabpanel: {
padding: 0,
},
};
-61
View File
@@ -1,61 +0,0 @@
const commonStyles = {
fontWeight: '400',
backgroundColor: '#262626', // $gray-1200
color: '#e2e2e2', // $gray-200
border: '1px solid transparent',
borderRadius: '3px',
_hover: {
backgroundColor: '#2d2d2d', // $gray-1100
},
_focus: {
backgroundColor: '#2d2d2d', // $gray-1000
color: '#f6f6f6', // $gray-50
border: '1px solid #578AF4', // $blue-500
},
_placeholder: { color: '#9d9d9d' }, // $gray-500
_disabled: {
_hover: {
backgroundColor: '#262626', // $gray-1200
},
},
};
export const ontimeInputFilled = {
field: {
...commonStyles,
},
};
export const ontimeInputGhosted = {
field: {
...commonStyles,
backgroundColor: 'transparent',
color: '#f6f6f6', // $gray-50
_hover: {
backgroundColor: 'transparent',
border: '1px solid #2B5ABC', // $blue-500
},
},
};
export const ontimeInputTransparent = {
field: {
...commonStyles,
backgroundColor: 'transparent',
_hover: {
backgroundColor: '#2d2d2d', // $gray-1100
},
},
};
export const ontimeTextAreaFilled = {
...commonStyles,
};
export const ontimeTextAreaTransparent = {
...commonStyles,
backgroundColor: 'transparent',
_hover: {
backgroundColor: '#2d2d2d', // $gray-1100
},
};
-12
View File
@@ -1,12 +0,0 @@
export const ontimeTooltip = {
_light: {
backgroundColor: '#2d2d2d', // $gray-1100
color: '#ececec', // $gray-100
padding: '2px 8px',
},
_dark: {
backgroundColor: '#2d2d2d', // $gray-1100
color: '#ececec', // $gray-100
padding: '2px 8px',
},
};
-146
View File
@@ -1,146 +0,0 @@
import { extendTheme } from '@chakra-ui/react';
import { ontimeAlertOnDark, ontimeDialog } from './OntimeAlert';
import {
ontimeButtonFilled,
ontimeButtonGhosted,
ontimeButtonGhostedWhite,
ontimeButtonOutlined,
ontimeButtonSubtle,
ontimeButtonSubtleWhite,
} from './ontimeButton';
import { ontimeCheckboxOnDark } from './ontimeCheckbox';
import { ontimeDrawer } from './ontimeDrawer';
import { ontimeEditable } from './ontimeEditable';
import { ontimeMenuOnDark } from './ontimeMenu';
import { ontimeModal } from './ontimeModal';
import { ontimeBlockRadio, ontimeRadio } from './ontimeRadio';
import { ontimeSelect } from './ontimeSelect';
import { ontimeSwitch } from './ontimeSwitch';
import { ontimeTab } from './ontimeTab';
import {
ontimeInputFilled,
ontimeInputGhosted,
ontimeInputTransparent,
ontimeTextAreaFilled,
ontimeTextAreaTransparent,
} from './ontimeTextInputs';
import { ontimeTooltip } from './ontimeTooltip';
const theme = extendTheme({
initialColorMode: 'dark',
useSystemColorMode: false,
components: {
Alert: {
variants: {
'ontime-on-dark-info': { ...ontimeAlertOnDark },
},
},
AlertDialog: {
variants: {
ontime: { ...ontimeDialog },
},
},
Button: {
baseStyle: {
letterSpacing: '0.3px',
fontWeight: '400',
borderRadius: '3px',
},
variants: {
'ontime-filled': { ...ontimeButtonFilled },
'ontime-outlined': { ...ontimeButtonOutlined },
'ontime-subtle': { ...ontimeButtonSubtle },
'ontime-ghosted': { ...ontimeButtonGhosted },
'ontime-ghosted-white': { ...ontimeButtonGhostedWhite },
'ontime-subtle-white': { ...ontimeButtonSubtleWhite },
},
},
Checkbox: {
variants: {
'ontime-ondark': { ...ontimeCheckboxOnDark },
},
},
Drawer: {
variants: {
ontime: { ...ontimeDrawer },
},
},
Editable: {
variants: {
ontime: { ...ontimeEditable },
},
},
Input: {
baseStyle: {
borderRadius: '3px',
border: '1px',
},
variants: {
'ontime-filled': { ...ontimeInputFilled },
'ontime-ghosted': { ...ontimeInputGhosted },
'ontime-transparent': { ...ontimeInputTransparent },
},
},
Kbd: {
baseStyle: {
borderRadius: '2px',
border: 'none',
background: '#262626', // $gray-1200
padding: '0.125rem 0.5rem',
color: '#f6f6f6', // $ui-white
fontWeight: 400,
boxShadow: '0px 0px 3px 0px rgba(0,0,0,0.4)',
fontSize: 'calc(1rem - 2px)',
},
},
Menu: {
variants: {
'ontime-on-dark': { ...ontimeMenuOnDark },
},
},
Modal: {
baseStyle: {
background: 'rgba(0, 0, 0, 0.5)',
},
variants: {
ontime: { ...ontimeModal },
},
},
Radio: {
variants: {
ontime: { ...ontimeRadio },
'ontime-block': { ...ontimeBlockRadio },
},
},
Tabs: {
variants: {
ontime: { ...ontimeTab },
},
},
Textarea: {
baseStyle: {
borderRadius: '3px',
},
variants: {
'ontime-filled': { ...ontimeTextAreaFilled },
'ontime-transparent': { ...ontimeTextAreaTransparent },
},
},
Tooltip: {
baseStyle: { ...ontimeTooltip },
},
Switch: {
variants: {
ontime: { ...ontimeSwitch },
},
},
Select: {
variants: {
ontime: { ...ontimeSelect },
},
},
},
});
export default theme;
@@ -9,7 +9,7 @@ import { makeOptionsFromCustomFields } from '../../common/components/view-params
import { scheduleOptions } from '../common/schedule/schedule.options';
export const getBackstageOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
const secondaryOptions = makeOptionsFromCustomFields(customFields, [{ value: 'note', label: 'Note' }]);
return [
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
@@ -13,7 +13,7 @@ export const getCountdownOptions = (
customFields: CustomFields,
persistedSubscriptions: EntryId[],
): ViewOption[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
const secondaryOptions = makeOptionsFromCustomFields(customFields, [{ value: 'note', label: 'Note' }]);
return [
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
@@ -1,6 +1,6 @@
import { memo, useCallback, useRef } from 'react';
import { AutoTextArea } from '../../../../common/components/input/auto-text-area/AutoTextArea';
import { AutoTextarea } from '../../../../common/components/input/auto-textarea/AutoTextarea';
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
interface MultiLineCellProps {
@@ -11,7 +11,7 @@ interface MultiLineCellProps {
export default memo(MultiLineCell);
function MultiLineCell({ initialValue, handleUpdate }: MultiLineCellProps) {
const ref = useRef<HTMLInputElement | null>(null);
const ref = useRef<HTMLTextAreaElement | null>(null);
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
@@ -20,18 +20,11 @@ function MultiLineCell({ initialValue, handleUpdate }: MultiLineCellProps) {
});
return (
<AutoTextArea
<AutoTextarea
inputref={ref}
variant='ghosted'
fluid
rows={1}
size='sm'
style={{
minHeight: '2rem',
padding: '0',
paddingTop: '0.25rem',
fontSize: '1rem',
}}
transition='none'
variant='ontime-transparent'
value={value}
onChange={onChange}
onBlur={onBlur}
@@ -24,46 +24,46 @@ function CuesheetTableMenu() {
isOpen
onClose={closeMenu}
items={[
{ type: 'item', label: 'Edit...', onClick: () => showModal(entryId), icon: <IoOptions /> },
{ type: 'item', label: 'Edit...', onClick: () => showModal(entryId), icon: IoOptions },
{ type: 'divider' },
{
type: 'item',
label: 'Add event above',
onClick: () => addEntry({ type: SupportedEntry.Event, parent: parentId }, { before: entryId }),
icon: <IoAdd />,
icon: IoAdd,
},
{
type: 'item',
label: 'Add event below',
onClick: () => addEntry({ type: SupportedEntry.Event, parent: parentId }, { after: entryId }),
icon: <IoAdd />,
icon: IoAdd,
},
{
type: 'item',
label: 'Clone event',
onClick: () => clone(entryId),
icon: <IoDuplicateOutline />,
icon: IoDuplicateOutline,
},
{ type: 'divider' },
{
type: 'item',
label: 'Move up',
onClick: () => move(entryId, 'up'),
icon: <IoArrowUp />,
icon: IoArrowUp,
disabled: entryIndex < 1,
},
{
type: 'item',
label: 'Move down',
onClick: () => move(entryId, 'down'),
icon: <IoArrowDown />,
icon: IoArrowDown,
},
{ type: 'divider' },
{
type: 'item',
label: 'Delete',
onClick: () => deleteEntry([entryId]),
icon: <IoTrash />,
icon: IoTrash,
},
]}
position={position}
+15 -8
View File
@@ -3,6 +3,7 @@ import { useSearchParams } from 'react-router-dom';
import { CustomFields, OntimeEvent, TimerType } from 'ontime-types';
import { validateTimerType } from 'ontime-utils';
import type { SelectOption } from '../../common/components/select/Select';
import {
getTimeOption,
hideTimerSeconds,
@@ -14,16 +15,22 @@ import { makeOptionsFromCustomFields } from '../../common/components/view-params
import { isStringBoolean } from '../../features/viewers/common/viewUtils';
// manually match the properties of TimerType excluding the None
const timerDisplayOptions = {
'no-overrides': 'No Overrides',
'count-up': 'Count up',
'count-down': 'Count down',
clock: 'Clock',
};
const timerDisplayOptions: SelectOption[] = [
{ value: 'no-overrides', label: 'No Overrides' },
{ value: TimerType.CountUp, label: 'Count Up' },
{ value: TimerType.CountDown, label: 'Count Down' },
{ value: TimerType.Clock, label: 'Clock' },
];
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const mainOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
const secondaryOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
const mainOptions = makeOptionsFromCustomFields(customFields, [
{ value: 'title', label: 'Title' },
{ value: 'note', label: 'Note' },
]);
const secondaryOptions = makeOptionsFromCustomFields(customFields, [
{ value: 'title', label: 'Title' },
{ value: 'note', label: 'Note' },
]);
return [
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
@@ -18,8 +18,8 @@ test('sheet file upload', async ({ page }) => {
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(fileToUpload);
await page.locator('[id="event\\ schedule"]').selectOption('Sheet2');
await page.locator('[id="event\\ schedule"]').selectOption('test');
await page.getByRole('row', { name: 'Worksheet' }).getByRole('combobox').click();
await page.getByRole('option', { name: 'test' }).click();
await page.getByRole('button', { name: 'Import preview' }).click();
await page.getByRole('button', { name: 'Apply' }).click();
+5 -575
View File
File diff suppressed because it is too large Load Diff