Compare commits

..

4 Commits

Author SHA1 Message Date
google-labs-jules[bot] 67ebd12a94 feat: Integrate Lexical editor for custom fields in cuesheet
- Added lexical and @lexical/react dependencies.
- Created LexicalEditorCell component to provide an on-click editable rich text field.
- Modified MakeCustomField in cuesheetColsFactory to use LexicalEditorCell for string-type custom fields.
- Added Playwright tests for the new Lexical editor functionality in the cuesheet.

Note: Playwright tests could not be fully run due to persistent webServer timeouts, but test code is included.
2025-07-11 13:18:40 +00:00
Carlos Valente e963e183db fix: entry actions in cuesheet
fix: insert entry before
fix: avoid double submit on enter
2025-07-01 07:20:37 +02:00
Carlos Valente ac4257ece9 refactor: cuesheet design review
fix: parsing of custom fields for blocks
refactor: extract cuesheet settings
refactor: cuesheet actions
refactor: improve cuesheet performance on resizing
2025-07-01 07:20:37 +02:00
Carlos Valente 8e111512d8 refactor: align property names between block and event 2025-06-29 08:02:43 +02:00
166 changed files with 5145 additions and 4878 deletions
+5 -3
View File
@@ -4,7 +4,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@base-ui-components/react": "1.0.0-beta.1", "@base-ui-components/react": "1.0.0-beta.0",
"@chakra-ui/react": "^2.7.0", "@chakra-ui/react": "^2.7.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@@ -13,7 +13,7 @@
"@emotion/react": "^11.10.6", "@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6", "@emotion/styled": "^11.10.6",
"@fontsource/open-sans": "^5.0.28", "@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^8.1.2", "@mantine/hooks": "^7.17.2",
"@sentry/react": "^8.43.0", "@sentry/react": "^8.43.0",
"@table-nav/react": "^0.0.7", "@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.62.7", "@tanstack/react-query": "^5.62.7",
@@ -34,7 +34,9 @@
"react-router-dom": "^6.3.0", "react-router-dom": "^6.3.0",
"react-simple-code-editor": "^0.14.1", "react-simple-code-editor": "^0.14.1",
"web-vitals": "^3.1.1", "web-vitals": "^3.1.1",
"zustand": "^5.0.3" "zustand": "^5.0.3",
"lexical": "^0.17.0",
"@lexical/react": "^0.17.0"
}, },
"scripts": { "scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js", "addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
-1
View File
@@ -8,7 +8,6 @@ export const AUTOMATION = ['automation'];
export const CUSTOM_FIELDS = ['customFields']; export const CUSTOM_FIELDS = ['customFields'];
export const PROJECT_DATA = ['project']; export const PROJECT_DATA = ['project'];
export const PROJECT_LIST = ['projectList']; export const PROJECT_LIST = ['projectList'];
export const PROJECT_RUNDOWNS = ['projectRundowns'];
export const RUNDOWN = ['rundown']; export const RUNDOWN = ['rundown'];
export const RUNTIME = ['runtimeStore']; export const RUNTIME = ['runtimeStore'];
export const URL_PRESETS = ['urlpresets']; export const URL_PRESETS = ['urlpresets'];
+10 -17
View File
@@ -1,45 +1,38 @@
import axios from 'axios'; import axios from 'axios';
import { CustomField, CustomFieldKey } from 'ontime-types'; // Removed CustomFields import { CustomField, CustomFieldKey, CustomFields } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
// Define CustomFieldWithKey for client-side usage
export type CustomFieldWithKey = CustomField & { key: CustomFieldKey };
const customFieldsPath = `${apiEntryUrl}/custom-fields`; const customFieldsPath = `${apiEntryUrl}/custom-fields`;
/** /**
* Requests list of known custom fields, sorted by order * Requests list of known custom fields
*/ */
export async function getCustomFields(): Promise<CustomFieldWithKey[]> { export async function getCustomFields(): Promise<CustomFields> {
const res = await axios.get<CustomFieldWithKey[]>(customFieldsPath); const res = await axios.get(customFieldsPath);
return res.data; return res.data;
} }
/** /**
* Sets list of known custom fields * Sets list of known custom fields
* Returns the updated list, sorted by order
*/ */
export async function postCustomField(newField: CustomField): Promise<CustomFieldWithKey[]> { export async function postCustomField(newField: CustomField): Promise<CustomFields> {
const res = await axios.post<CustomFieldWithKey[]>(customFieldsPath, { ...newField }); const res = await axios.post(customFieldsPath, { ...newField });
return res.data; return res.data;
} }
/** /**
* Edits single custom field * Edits single custom field
* Returns the updated list, sorted by order
*/ */
export async function editCustomField(key: CustomFieldKey, newField: Partial<CustomField>): Promise<CustomFieldWithKey[]> { export async function editCustomField(key: CustomFieldKey, newField: CustomField): Promise<CustomFields> {
// Ensure newField can include 'order' by using Partial<CustomField> const res = await axios.put(`${customFieldsPath}/${key}`, { ...newField });
const res = await axios.put<CustomFieldWithKey[]>(`${customFieldsPath}/${key}`, { ...newField });
return res.data; return res.data;
} }
/** /**
* Deletes single custom field * Deletes single custom field
* Returns the updated list, sorted by order
*/ */
export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFieldWithKey[]> { export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFields> {
const res = await axios.delete<CustomFieldWithKey[]>(`${customFieldsPath}/${key}`); const res = await axios.delete(`${customFieldsPath}/${key}`);
return res.data; return res.data;
} }
@@ -1,7 +1,6 @@
.subtle { .subtle {
background: $gray-1050; background: $gray-1050;
color: $blue-400; color: $blue-400;
line-height: 1em;
&:hover:not(:disabled):not(:active) { &:hover:not(:disabled):not(:active) {
background: $gray-1000; background: $gray-1000;
@@ -82,25 +81,6 @@
} }
.ghosted { .ghosted {
background: transparent;
color: $blue-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-white {
background: transparent; background: transparent;
color: $ui-white; color: $ui-white;
@@ -117,24 +97,4 @@
&:disabled { &:disabled {
opacity: $opacity-disabled; opacity: $opacity-disabled;
} }
} }
.ghosted-destructive {
background: transparent;
color: $red-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
@@ -2,7 +2,6 @@
@import './BaseButtonStyles.module.scss'; @import './BaseButtonStyles.module.scss';
.baseButton { .baseButton {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -26,36 +25,6 @@
outline: 2px solid $blue-500; outline: 2px solid $blue-500;
outline-offset: 2px; outline-offset: 2px;
} }
&.loading {
cursor: wait;
.content {
opacity: 0;
}
}
}
.content {
display: flex;
align-items: center;
gap: inherit;
}
.loadingOverlay {
position: absolute;
display: grid;
place-content: center;
}
.spinner {
animation: spin 1s linear infinite;
stroke-dasharray: 8;
}
@keyframes spin {
100% {
transform: rotate(360deg);
}
} }
.small { .small {
@@ -1,52 +1,29 @@
import { ButtonHTMLAttributes, forwardRef } from 'react'; import { ButtonHTMLAttributes, forwardRef } from 'react';
import { IoEllipseOutline } from 'react-icons/io5';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import style from './Button.module.scss'; import style from './Button.module.scss';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: variant?: 'primary' | 'subtle' | 'subtle-white' | 'destructive' | 'subtle-destructive' | 'ghosted';
| 'primary'
| 'subtle'
| 'subtle-white'
| 'destructive'
| 'subtle-destructive'
| 'ghosted'
| 'ghosted-white'
| 'ghosted-destructive';
size?: 'small' | 'medium' | 'large' | 'xlarge'; size?: 'small' | 'medium' | 'large' | 'xlarge';
fluid?: boolean; fluid?: boolean;
loading?: boolean;
} }
const Button = forwardRef<HTMLButtonElement, ButtonProps>( const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
({ className, children, variant = 'subtle', size = 'medium', fluid, loading, ...buttonProps }, ref) => { const { className, children, variant = 'subtle', size = 'medium', fluid, ...buttonProps } = props;
return (
<button return (
ref={ref} <button
className={cx([ ref={ref}
style.baseButton, className={cx([style.baseButton, style[variant], style[size], fluid && style.fluid, className])}
style[variant], type='button'
style[size], {...buttonProps}
fluid && style.fluid, >
loading && style.loading, {children}
className, </button>
])} );
type='button' });
disabled={loading || buttonProps.disabled}
{...buttonProps}
>
<span className={style.content}>{children}</span>
{loading && (
<div className={style.loadingOverlay}>
<IoEllipseOutline className={style.spinner} />
</div>
)}
</button>
);
},
);
Button.displayName = 'Button'; Button.displayName = 'Button';
@@ -5,15 +5,7 @@ import { cx } from '../../utils/styleUtils';
import style from './IconButton.module.scss'; import style from './IconButton.module.scss';
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: variant?: 'primary' | 'subtle' | 'subtle-white' | 'destructive' | 'subtle-destructive' | 'ghosted';
| 'primary'
| 'subtle'
| 'subtle-white'
| 'destructive'
| 'subtle-destructive'
| 'ghosted'
| 'ghosted-white'
| 'ghosted-destructive';
size?: 'small' | 'medium' | 'large' | 'xlarge'; size?: 'small' | 'medium' | 'large' | 'xlarge';
} }
@@ -1,15 +1,22 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoArrowForward } from 'react-icons/io5'; import { IoArrowForward } from 'react-icons/io5';
import {
IconButton,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
Select,
} from '@chakra-ui/react';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import { setClientRemote } from '../../hooks/useSocket'; import { setClientRemote } from '../../hooks/useSocket';
import useUrlPresets from '../../hooks-query/useUrlPresets'; import useUrlPresets from '../../hooks-query/useUrlPresets';
import Button from '../buttons/Button';
import Info from '../info/Info'; import Info from '../info/Info';
import Input from '../input/input/Input';
import AppLink from '../link/app-link/AppLink'; import AppLink from '../link/app-link/AppLink';
import Modal from '../modal/Modal';
import Select from '../select/Select';
import style from './RedirectClientModal.module.scss'; import style from './RedirectClientModal.module.scss';
@@ -22,7 +29,8 @@ interface RedirectClientModalProps {
onClose: () => void; onClose: () => void;
} }
export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onClose }: RedirectClientModalProps) { export function RedirectClientModal(props: RedirectClientModalProps) {
const { id, isOpen, name, currentPath, origin, onClose } = props;
const { data } = useUrlPresets(); const { data } = useUrlPresets();
const [path, setPath] = useState(currentPath); const [path, setPath] = useState(currentPath);
const [selected, setSelected] = useState('/'); const [selected, setSelected] = useState('/');
@@ -39,26 +47,13 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
const enabledPresets = data.filter((preset) => preset.enabled); const enabledPresets = data.filter((preset) => preset.enabled);
const viewOptions = [
...navigatorConstants.map((view) => ({
value: `/${view.url}`,
label: view.label,
})),
...enabledPresets.map((preset) => ({
value: preset.pathAndParams,
label: `Preset: ${preset.alias}`,
})),
];
return ( return (
<Modal <Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
isOpen={isOpen} <ModalOverlay />
onClose={onClose} <ModalContent maxWidth='max(480px, 35vw)'>
showCloseButton <ModalHeader>Redirect: {name}</ModalHeader>
showBackdrop <ModalCloseButton />
title={`Redirect: ${name}`} <ModalBody>
bodyElements={
<>
<Info> <Info>
Remotely redirect the client to a different URL. <br /> Remotely redirect the client to a different URL. <br />
Either by selecting a URL Preset or entering a custom path. Either by selecting a URL Preset or entering a custom path.
@@ -70,21 +65,36 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
<span className={style.label}>Select View or URL Preset</span> <span className={style.label}>Select View or URL Preset</span>
<div className={style.textEntry}> <div className={style.textEntry}>
<Select <Select
fluid size='md'
options={viewOptions} variant='ontime'
defaultValue={viewOptions[0].value} isDisabled={enabledPresets.length === 0}
onValueChange={(value) => setSelected(value)} onChange={(event) => setSelected(event.target.value)}
disabled={enabledPresets.length === 0} >
/> <option value='/'>Select view or preset</option>
<Button {navigatorConstants.map((view) => {
variant='primary' return (
<option key={view.url} value={`/${view.url}`}>
{view.label}
</option>
);
})}
{enabledPresets.map((preset) => {
return (
<option key={preset.pathAndParams} value={preset.pathAndParams}>
{`Preset: ${preset.alias}`}
</option>
);
})}
</Select>
<IconButton
variant='ontime-filled'
size='md'
aria-label='Redirect to preset' aria-label='Redirect to preset'
className={style.redirect} className={style.redirect}
disabled={enabledPresets.length === 0 || selected === '/'} icon={<IoArrowForward />}
isDisabled={enabledPresets.length === 0 || selected === '/'}
onClick={() => handleRedirect(selected)} onClick={() => handleRedirect(selected)}
> />
Redirect <IoArrowForward />
</Button>
</div> </div>
</div> </div>
<div className={style.inlineEntry}> <div className={style.inlineEntry}>
@@ -92,24 +102,25 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
<label className={style.textEntry}> <label className={style.textEntry}>
{origin} {origin}
<Input <Input
variant='ontime-filled'
size='md'
placeholder='eg. /minimal?key=0000ffff' placeholder='eg. /minimal?key=0000ffff'
fluid
value={path} value={path}
onChange={(event) => setPath(event.target.value)} onChange={(event) => setPath(event.target.value)}
/> />
</label> </label>
<Button <IconButton
variant='primary' variant='ontime-filled'
size='md'
aria-label='Redirect' aria-label='Redirect'
disabled={path === currentPath || path === ''} isDisabled={path === currentPath || path === ''}
className={style.redirect} className={style.redirect}
icon={<IoArrowForward />}
onClick={() => handleRedirect(path)} onClick={() => handleRedirect(path)}
> />
Redirect <IoArrowForward />
</Button>
</div> </div>
</> </ModalBody>
} </ModalContent>
/> </Modal>
); );
} }
@@ -13,7 +13,7 @@
color: $ui-white; color: $ui-white;
border-radius: 3px; border-radius: 3px;
box-shadow: $box-shadow-l1; box-shadow: $box-shadow-l1;
border: 1px solid $gray-1100; border: 1px solid $gray-1200;
} }
.backdrop { .backdrop {
@@ -7,29 +7,15 @@
border-radius: 3px; border-radius: 3px;
font-size: $text-body-size; font-size: $text-body-size;
padding: 1rem; padding: 1rem;
color: $gray-200;
.content {
color: $gray-200;
}
svg { svg {
min-width: 1.5rem; min-width: 1.5rem;
align-self: start; align-self: start;
font-size: 1.5rem; font-size: 1.5rem;
}
}
.info {
svg {
color: $info-blue; color: $info-blue;
} }
} }
.warning {
svg {
color: $orange-500;
}
}
.error {
svg {
color: $red-500;
}
}
@@ -1,5 +1,5 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { IoAlertCircle, IoWarning } from 'react-icons/io5'; import { IoAlertCircle } from 'react-icons/io5';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -7,15 +7,14 @@ import style from './Info.module.scss';
interface InfoProps { interface InfoProps {
className?: string; className?: string;
type?: 'info' | 'warning' | 'error';
} }
export default function Info({ className, type = 'info', children }: PropsWithChildren<InfoProps>) { export default function Info(props: PropsWithChildren<InfoProps>) {
const { className, children } = props;
return ( return (
<div className={cx([style.infoLabel, style[type], className])}> <div className={cx([style.infoLabel, className])}>
{type === 'info' && <IoAlertCircle />} <IoAlertCircle />
{type === 'warning' && <IoWarning />}
{type === 'error' && <IoWarning />}
<div>{children}</div> <div>{children}</div>
</div> </div>
); );
@@ -1,47 +0,0 @@
@use '../../../../theme/viewerDefs' as *;
.textarea {
box-sizing: border-box;
display: block;
font-size: 1rem;
font-weight: 400;
color: $gray-200;
border-radius: $component-border-radius-md;
border: 1px solid transparent;
padding-inline: 0.5em;
outline: none;
&:hover:not(:disabled) {
background-color: $gray-1100;
}
&:focus:not(:read-only) {
background-color: $gray-1000;
border: 1px solid $blue-500;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
&::placeholder {
color: $gray-500;
letter-spacing: 0;
}
}
.subtle {
background-color: $gray-1200;
}
.ghosted {
background-color: transparent;
padding: 0;
}
.fluid {
width: 100%;
}
@@ -1,31 +0,0 @@
import { forwardRef, TextareaHTMLAttributes } from 'react';
import { cx } from '../../../utils/styleUtils';
import style from './Textarea.module.scss';
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
variant?: 'subtle' | 'ghosted';
fluid?: boolean;
resize?: 'none' | 'both' | 'horizontal' | 'vertical';
}
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function TextArea(
{ className, variant = 'subtle', fluid, rows = 5, resize = 'none', style: customStyle, ...textareaProps },
ref,
) {
return (
<textarea
ref={ref}
autoCorrect='off'
autoComplete='off'
spellCheck='false'
rows={rows}
style={{ ...customStyle, resize }}
className={cx([style.textarea, style[variant], fluid && style.fluid, className])}
{...textareaProps}
/>
);
});
export default Textarea;
@@ -1,24 +1,26 @@
.modal { .modal {
position: fixed; position: fixed;
top: 10vh; top: 50%;
left: 50%; left: 50%;
transform: translateX(-50%);
z-index: $zindex-dialog;
transform: translate(-50%, -50%);
padding-inline: 1rem; padding-inline: 1rem;
min-width: min(680px, 90vw); min-width: min(680px, 90vw);
min-height: min(200px, 10vh); min-height: min(200px, 10vh);
max-width: min(680px, 90vw);
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
border-radius: 3px; border-radius: 3px;
box-shadow: $box-shadow-l1; box-shadow: $box-shadow-l1;
border: 1px solid $gray-1100; border: 1px solid $gray-1200;
} }
.backdrop { .backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: $zindex-backdrop;
background-color: $backdrop-color; background-color: $backdrop-color;
transition: opacity 300ms cubic-bezier(0.45, 1.005, 0, 1.005); transition: opacity 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
@@ -43,7 +45,7 @@
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
max-height: 60vh; max-height: min(80vh, 600px);
overflow-y: auto; overflow-y: auto;
} }
@@ -8,7 +8,7 @@ import style from './Modal.module.scss';
interface ModalProps { interface ModalProps {
isOpen: boolean; isOpen: boolean;
title?: string; title: string;
showCloseButton?: boolean; showCloseButton?: boolean;
showBackdrop?: boolean; showBackdrop?: boolean;
bodyElements: ReactNode; bodyElements: ReactNode;
@@ -32,7 +32,7 @@
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
border-right: 1px solid $gray-1100; border-right: 1px solid $gray-1200;
&[data-open] { &[data-open] {
transform: translateX(0%); transform: translateX(0%);
@@ -1,5 +1,6 @@
import { memo } from 'react'; import { memo } from 'react';
import { useDisclosure, useHotkeys } from '@mantine/hooks'; import { useDisclosure } from '@chakra-ui/react';
import { useHotkeys } from '@mantine/hooks';
import FloatingNavigation from './floating-navigation/FloatingNavigation'; import FloatingNavigation from './floating-navigation/FloatingNavigation';
import ViewLockedIcon from './view-locked-icon/ViewLockedIcon'; import ViewLockedIcon from './view-locked-icon/ViewLockedIcon';
@@ -13,15 +14,17 @@ interface ViewNavigationMenuProps {
export default memo(ViewNavigationMenu); export default memo(ViewNavigationMenu);
function ViewNavigationMenu({ isLockable, supressSettings }: ViewNavigationMenuProps) { function ViewNavigationMenu({ isLockable, supressSettings }: ViewNavigationMenuProps) {
const [isMenuOpen, menuHandler] = useDisclosure(); const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable }); const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable });
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
useHotkeys([ useHotkeys([
[ [
'Space', 'Space',
() => { () => {
if (isViewLocked) return; if (isViewLocked) return;
menuHandler.toggle(); toggleMenu();
}, },
{ preventDefault: true }, { preventDefault: true },
], ],
@@ -42,10 +45,10 @@ function ViewNavigationMenu({ isLockable, supressSettings }: ViewNavigationMenuP
return ( return (
<> <>
<FloatingNavigation <FloatingNavigation
toggleMenu={menuHandler.toggle} toggleMenu={toggleMenu}
toggleSettings={supressSettings ? undefined : () => showEditFormDrawer()} toggleSettings={supressSettings ? undefined : () => showEditFormDrawer()}
/> />
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} /> <NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />
</> </>
); );
} }
@@ -6,9 +6,9 @@ import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
export default function EditorNavigation() { export default function EditorNavigation() {
const navigate = useNavigate(); const navigate = useNavigate();
const isSmallDevice = useIsSmallDevice(); const isSmallDevide = useIsSmallDevice();
if (!isSmallDevice) { if (!isSmallDevide) {
return ( return (
<NavigationMenuItem active={location.pathname === '/editor'} onClick={() => navigate('/editor')}> <NavigationMenuItem active={location.pathname === '/editor'} onClick={() => navigate('/editor')}>
<IoLockClosedOutline /> <IoLockClosedOutline />
@@ -0,0 +1,40 @@
import { IoPause, IoPlay, IoStop } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react';
import { Playback } from 'ontime-types';
import { tooltipDelayFast } from '../../../ontimeConfig';
interface PlaybackIconProps {
state: Playback;
skipTooltip?: boolean;
className?: string;
}
export default function PlaybackIcon(props: PlaybackIconProps) {
const { state, skipTooltip, className } = props;
// if timer is Pause or Armed
let label = 'Timer Paused';
let Icon = IoPause;
if (state === Playback.Roll) {
label = 'Timer Rolling';
Icon = IoPlay;
} else if (state === Playback.Play) {
label = 'Timer Playing';
Icon = IoPlay;
} else if (state === Playback.Stop) {
label = 'Timer Stopped';
Icon = IoStop;
}
if (skipTooltip) {
return <Icon className={className} />;
}
return (
<Tooltip openDelay={tooltipDelayFast} label={label} shouldWrapChildren>
<Icon className={className} />
</Tooltip>
);
}
@@ -17,11 +17,11 @@
font-size: calc(1rem - 2px); font-size: calc(1rem - 2px);
white-space: nowrap; white-space: nowrap;
&:hover:not([data-disabled]) { &:hover:not(:disabled) {
background-color: $gray-1100; background-color: $gray-1100;
} }
&:active:not([data-disabled]) { &:active {
background-color: $gray-1000; background-color: $gray-1000;
} }
@@ -29,14 +29,10 @@
background-color: $gray-1000; background-color: $gray-1000;
} }
&[data-disabled] { &:disabled {
opacity: 0.4; opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
} }
&.fluid {
width: 100%;
}
} }
.selectIcon { .selectIcon {
@@ -2,24 +2,31 @@ import { IoCheckmark } from 'react-icons/io5';
import { LuChevronsUpDown } from 'react-icons/lu'; import { LuChevronsUpDown } from 'react-icons/lu';
import { Select as BaseSelect } from '@base-ui-components/react/select'; import { Select as BaseSelect } from '@base-ui-components/react/select';
import { cx } from '../../utils/styleUtils';
import styles from './Select.module.scss'; import styles from './Select.module.scss';
interface SelectProps<T> extends Omit<BaseSelect.Root.Props<T>, 'items'> { interface SelectProps<T extends string | null = string> {
// overload items to not allow undefined values defaultValue?: T;
options: { options: {
value: T; value: NonNullable<T>;
label: string; label: string;
disabled?: boolean; // exposed to allow creating a non-selectable option
}[]; }[];
fluid?: boolean; placeholder?: string;
value?: T;
onChange?: (value: NonNullable<T>) => void;
} }
export default function Select<T>({ options, fluid, ...selectRootProps }: SelectProps<T>) { export default function Select<T extends string | null = string>({
defaultValue,
options,
placeholder,
value,
onChange,
}: SelectProps<T>) {
return ( return (
<BaseSelect.Root items={options} {...selectRootProps}> <BaseSelect.Root defaultValue={defaultValue} onValueChange={onChange} value={value}>
<BaseSelect.Trigger className={cx([styles.select, fluid && styles.fluid])}> <BaseSelect.Trigger className={styles.select}>
<BaseSelect.Value /> <BaseSelect.Value placeholder={placeholder} />
<BaseSelect.Icon className={styles.selectIcon}> <BaseSelect.Icon className={styles.selectIcon}>
<LuChevronsUpDown /> <LuChevronsUpDown />
</BaseSelect.Icon> </BaseSelect.Icon>
@@ -28,14 +35,16 @@ export default function Select<T>({ options, fluid, ...selectRootProps }: Select
<BaseSelect.Positioner side='bottom' align='start'> <BaseSelect.Positioner side='bottom' align='start'>
<BaseSelect.ScrollUpArrow className={styles.scrollArrow} /> <BaseSelect.ScrollUpArrow className={styles.scrollArrow} />
<BaseSelect.Popup className={styles.popup}> <BaseSelect.Popup className={styles.popup}>
{options.map(({ label, value }) => ( {options.map((option) => {
<BaseSelect.Item key={String(value)} className={styles.item} value={value}> return (
<BaseSelect.ItemIndicator className={styles.itemIndicator}> <BaseSelect.Item key={option.value} className={styles.item} value={option.value}>
<IoCheckmark className={styles.itemIndicatorIcon} /> <BaseSelect.ItemIndicator className={styles.itemIndicator}>
</BaseSelect.ItemIndicator> <IoCheckmark className={styles.itemIndicatorIcon} />
<BaseSelect.ItemText className={styles.itemLabel}>{label}</BaseSelect.ItemText> </BaseSelect.ItemIndicator>
</BaseSelect.Item> <BaseSelect.ItemText className={styles.itemLabel}>{option.label}</BaseSelect.ItemText>
))} </BaseSelect.Item>
);
})}
</BaseSelect.Popup> </BaseSelect.Popup>
<BaseSelect.ScrollDownArrow className={styles.scrollArrow} /> <BaseSelect.ScrollDownArrow className={styles.scrollArrow} />
</BaseSelect.Positioner> </BaseSelect.Positioner>
@@ -38,7 +38,7 @@
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
border-left: 1px solid $gray-1100; border-left: 1px solid $gray-1200;
&[data-open] { &[data-open] {
transform: translateX(0%); transform: translateX(0%);
@@ -1,17 +1,17 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
// CustomFields record type is no longer used here import { CustomFields } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CUSTOM_FIELDS } from '../api/constants'; import { CUSTOM_FIELDS } from '../api/constants';
import { getCustomFields, CustomFieldWithKey } from '../api/customFields'; // Import CustomFieldWithKey import { getCustomFields } from '../api/customFields';
const placeholder: CustomFieldWithKey[] = []; // Placeholder is now an empty array const placeholder: CustomFields = {};
export default function useCustomFields() { export default function useCustomFields() {
// Explicitly type the useQuery hook const { data, status, isFetching, isError, refetch } = useQuery({
const { data, status, isFetching, isError, refetch } = useQuery<CustomFieldWithKey[], Error>({
queryKey: CUSTOM_FIELDS, queryKey: CUSTOM_FIELDS,
queryFn: getCustomFields, queryFn: getCustomFields,
placeholderData: (previousData, _previousQuery) => previousData ?? placeholder, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
@@ -1,22 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { ProjectRundownsList } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_RUNDOWNS } from '../api/constants';
import { fetchProjectRundownList } from '../api/rundown';
/**
* Project rundowns
*/
export function useProjectRundowns() {
const { data, status, isError, refetch, isFetching } = useQuery<ProjectRundownsList>({
queryKey: PROJECT_RUNDOWNS,
queryFn: fetchProjectRundownList,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? { loaded: '', rundowns: [] }, status, isError, refetch, isFetching };
}
@@ -4,11 +4,7 @@ import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { URL_PRESETS } from '../api/constants'; import { URL_PRESETS } from '../api/constants';
import { getUrlPresets } from '../api/urlPresets'; import { getUrlPresets } from '../api/urlPresets';
interface FetchProps { export default function useUrlPresets() {
skip?: boolean;
}
export default function useUrlPresets({ skip = false }: FetchProps = {}) {
const { data, status, isError, refetch } = useQuery({ const { data, status, isError, refetch } = useQuery({
queryKey: URL_PRESETS, queryKey: URL_PRESETS,
queryFn: getUrlPresets, queryFn: getUrlPresets,
@@ -17,7 +13,6 @@ export default function useUrlPresets({ skip = false }: FetchProps = {}) {
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always', networkMode: 'always',
enabled: !skip,
}); });
return { data: data ?? [], status, isError, refetch }; return { data: data ?? [], status, isError, refetch };
+59 -69
View File
@@ -74,7 +74,7 @@ export const useEntryActions = () => {
* Calls mutation to add new entry * Calls mutation to add new entry
* @private * @private
*/ */
const { mutateAsync: addEntryMutation } = useMutation({ const _addEntryMutation = useMutation({
// TODO(v4): optimistic create entry // TODO(v4): optimistic create entry
mutationFn: postAddEntry, mutationFn: postAddEntry,
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
@@ -143,13 +143,13 @@ export const useEntryActions = () => {
} }
try { try {
await addEntryMutation(newEntry); await _addEntryMutation.mutateAsync(newEntry);
} catch (error) { } catch (error) {
logAxiosError('Failed adding event', error); logAxiosError('Failed adding event', error);
} }
}, },
[ [
addEntryMutation, _addEntryMutation,
defaultDangerTime, defaultDangerTime,
defaultDuration, defaultDuration,
defaultEndAction, defaultEndAction,
@@ -165,7 +165,7 @@ export const useEntryActions = () => {
* Calls mutation to clone a selection * Calls mutation to clone a selection
* @private * @private
*/ */
const { mutateAsync: cloneEntryMutation } = useMutation({ const _cloneMutation = useMutation({
mutationFn: postCloneEntry, mutationFn: postCloneEntry,
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
}); });
@@ -176,19 +176,19 @@ export const useEntryActions = () => {
const clone = useCallback( const clone = useCallback(
async (entryId: EntryId) => { async (entryId: EntryId) => {
try { try {
await cloneEntryMutation(entryId); await _cloneMutation.mutateAsync(entryId);
} catch (error) { } catch (error) {
logAxiosError('Error cloning entry', error); logAxiosError('Error cloning entry', error);
} }
}, },
[cloneEntryMutation], [_cloneMutation],
); );
/** /**
* Calls mutation to update existing entry * Calls mutation to update existing entry
* @private * @private
*/ */
const { mutateAsync: updateEntryMutation } = useMutation({ const _updateEntryMutation = useMutation({
mutationFn: putEditEntry, mutationFn: putEditEntry,
// we optimistically update here // we optimistically update here
onMutate: async (newEvent) => { onMutate: async (newEvent) => {
@@ -234,12 +234,12 @@ export const useEntryActions = () => {
const updateEntry = useCallback( const updateEntry = useCallback(
async (event: Partial<OntimeEntry>) => { async (event: Partial<OntimeEntry>) => {
try { try {
await updateEntryMutation(event); await _updateEntryMutation.mutateAsync(event);
} catch (error) { } catch (error) {
logAxiosError('Error updating event', error); logAxiosError('Error updating event', error);
} }
}, },
[updateEntryMutation], [_updateEntryMutation],
); );
const updateCustomField = useCallback( const updateCustomField = useCallback(
@@ -287,7 +287,7 @@ export const useEntryActions = () => {
} }
try { try {
await updateEntryMutation(newEvent); await _updateEntryMutation.mutateAsync(newEvent);
} catch (error) { } catch (error) {
logAxiosError('Error updating event', error); logAxiosError('Error updating event', error);
} }
@@ -339,14 +339,14 @@ export const useEntryActions = () => {
return previousEnd; return previousEnd;
} }
}, },
[updateEntryMutation, queryClient], [_updateEntryMutation, queryClient],
); );
/** /**
* Calls mutation to edit multiple events * Calls mutation to edit multiple events
* @private * @private
*/ */
const { mutateAsync: batchUpdateEventsMutation } = useMutation({ const _batchUpdateEventsMutation = useMutation({
mutationFn: putBatchEditEvents, mutationFn: putBatchEditEvents,
onMutate: async ({ ids, data }) => { onMutate: async ({ ids, data }) => {
// cancel ongoing queries // cancel ongoing queries
@@ -405,19 +405,19 @@ export const useEntryActions = () => {
const batchUpdateEvents = useCallback( const batchUpdateEvents = useCallback(
async (data: Partial<OntimeEvent>, eventIds: string[]) => { async (data: Partial<OntimeEvent>, eventIds: string[]) => {
try { try {
await batchUpdateEventsMutation({ ids: eventIds, data }); await _batchUpdateEventsMutation.mutateAsync({ ids: eventIds, data });
} catch (error) { } catch (error) {
logAxiosError('Error updating events', error); logAxiosError('Error updating events', error);
} }
}, },
[batchUpdateEventsMutation], [_batchUpdateEventsMutation],
); );
/** /**
* Calls mutation to delete an entry * Calls mutation to delete an entry
* @private * @private
*/ */
const { mutateAsync: deleteEntryMutation } = useMutation({ const _deleteEntryMutation = useMutation({
mutationFn: deleteEntries, mutationFn: deleteEntries,
// we optimistically update here // we optimistically update here
onMutate: async (entryIds: EntryId[]) => { onMutate: async (entryIds: EntryId[]) => {
@@ -462,19 +462,19 @@ export const useEntryActions = () => {
const deleteEntry = useCallback( const deleteEntry = useCallback(
async (entryIds: EntryId[]) => { async (entryIds: EntryId[]) => {
try { try {
await deleteEntryMutation(entryIds); await _deleteEntryMutation.mutateAsync(entryIds);
} catch (error) { } catch (error) {
logAxiosError('Error deleting event', error); logAxiosError('Error deleting event', error);
} }
}, },
[deleteEntryMutation], [_deleteEntryMutation],
); );
/** /**
* Calls mutation to delete all events * Calls mutation to delete all events
* @private * @private
*/ */
const { mutateAsync: deleteAllEntriesMutation } = useMutation({ const _deleteAllEntriesMutation = useMutation({
mutationFn: requestDeleteAll, mutationFn: requestDeleteAll,
// we optimistically update here // we optimistically update here
onMutate: async () => { onMutate: async () => {
@@ -514,17 +514,17 @@ export const useEntryActions = () => {
*/ */
const deleteAllEntries = useCallback(async () => { const deleteAllEntries = useCallback(async () => {
try { try {
await deleteAllEntriesMutation(); await _deleteAllEntriesMutation.mutateAsync();
} catch (error) { } catch (error) {
logAxiosError('Error deleting events', error); logAxiosError('Error deleting events', error);
} }
}, [deleteAllEntriesMutation]); }, [_deleteAllEntriesMutation]);
/** /**
* Calls mutation to apply a delay * Calls mutation to apply a delay
* @private * @private
*/ */
const { mutateAsync: applyDelayMutation } = useMutation({ const _applyDelayMutation = useMutation({
mutationFn: requestApplyDelay, mutationFn: requestApplyDelay,
onSuccess: (response) => { onSuccess: (response) => {
if (!response.data) return; if (!response.data) return;
@@ -551,19 +551,19 @@ export const useEntryActions = () => {
const applyDelay = useCallback( const applyDelay = useCallback(
async (delayEventId: EntryId) => { async (delayEventId: EntryId) => {
try { try {
await applyDelayMutation(delayEventId); await _applyDelayMutation.mutateAsync(delayEventId);
} catch (error) { } catch (error) {
logAxiosError('Error applying delay', error); logAxiosError('Error applying delay', error);
} }
}, },
[applyDelayMutation], [_applyDelayMutation],
); );
/** /**
* Calls mutation to dissolve a block * Calls mutation to dissolve a block
* @private * @private
*/ */
const { mutateAsync: ungroupMutation } = useMutation({ const _ungroupMutation = useMutation({
mutationFn: requestUngroup, mutationFn: requestUngroup,
onSuccess: (response) => { onSuccess: (response) => {
if (!response.data) return; if (!response.data) return;
@@ -587,19 +587,19 @@ export const useEntryActions = () => {
const ungroup = useCallback( const ungroup = useCallback(
async (blockId: EntryId) => { async (blockId: EntryId) => {
try { try {
await ungroupMutation(blockId); await _ungroupMutation.mutateAsync(blockId);
} catch (error) { } catch (error) {
logAxiosError('Error dissolving block', error); logAxiosError('Error dissolving block', error);
} }
}, },
[ungroupMutation], [_ungroupMutation],
); );
/** /**
* Calls mutation to create a block with a selection * Calls mutation to create a block with a selection
* @private * @private
*/ */
const { mutateAsync: groupEntriesMutation } = useMutation({ const _groupEntriesMutation = useMutation({
mutationFn: requestGroupEntries, mutationFn: requestGroupEntries,
onSuccess: (response) => { onSuccess: (response) => {
if (!response.data) return; if (!response.data) return;
@@ -623,19 +623,19 @@ export const useEntryActions = () => {
const groupEntries = useCallback( const groupEntries = useCallback(
async (entryIds: EntryId[]) => { async (entryIds: EntryId[]) => {
try { try {
await groupEntriesMutation(entryIds); await _groupEntriesMutation.mutateAsync(entryIds);
} catch (error) { } catch (error) {
logAxiosError('Error grouping entries', error); logAxiosError('Error grouping entries', error);
} }
}, },
[groupEntriesMutation], [_groupEntriesMutation],
); );
/** /**
* Calls mutation to reorder an entry * Calls mutation to reorder an entry
* @private * @private
*/ */
const { mutateAsync: reorderEntryMutation } = useMutation({ const _reorderEntryMutation = useMutation({
mutationFn: patchReorderEntry, mutationFn: patchReorderEntry,
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
@@ -644,40 +644,6 @@ export const useEntryActions = () => {
}, },
}); });
/**
* Reorders a given entry one step up or down in the timeline
*/
const move = useCallback(
async (entryId: EntryId, direction: 'up' | 'down') => {
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!rundown) {
return;
}
const { destinationId, order } =
direction === 'up'
? moveUp(entryId, rundown.flatOrder, rundown.entries)
: moveDown(entryId, rundown.flatOrder, rundown.entries);
if (!destinationId) {
return; // noop
}
try {
const reorderObject: ReorderEntry = {
entryId,
destinationId,
order,
};
await reorderEntryMutation(reorderObject);
} catch (error) {
logAxiosError('Error re-ordering event', error);
}
// the rundown needs to know whether we moved into a block
return rundown.entries[destinationId]?.type === 'block' ? destinationId : undefined;
},
[queryClient, reorderEntryMutation],
);
/** /**
* Reorders a given entry * Reorders a given entry
*/ */
@@ -689,19 +655,43 @@ export const useEntryActions = () => {
destinationId, destinationId,
order, order,
}; };
await reorderEntryMutation(reorderObject); await _reorderEntryMutation.mutateAsync(reorderObject);
} catch (error) { } catch (error) {
logAxiosError('Error re-ordering event', error); logAxiosError('Error re-ordering event', error);
} }
}, },
[reorderEntryMutation], [_reorderEntryMutation],
); );
const move = useCallback(async (entryId: EntryId, direction: 'up' | 'down') => {
const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!cachedRundown?.order) {
return;
}
const { destinationId, order } =
direction === 'up'
? moveUp(entryId, cachedRundown.order, cachedRundown.entries)
: moveDown(entryId, cachedRundown.order, cachedRundown.entries);
if (destinationId) {
try {
const reorderObject: ReorderEntry = {
entryId,
destinationId,
order: order as 'before' | 'after' | 'insert',
};
await _reorderEntryMutation.mutateAsync(reorderObject);
} catch (error) {
logAxiosError('Error re-ordering event', error);
}
}
}, []);
/** /**
* Calls mutation to swap events * Calls mutation to swap events
* @private * @private
*/ */
const { mutateAsync: swapEventsMutation } = useMutation({ const _swapEvents = useMutation({
mutationFn: requestEventSwap, mutationFn: requestEventSwap,
// we optimistically update here // we optimistically update here
onMutate: async ({ from, to }) => { onMutate: async ({ from, to }) => {
@@ -755,12 +745,12 @@ export const useEntryActions = () => {
const swapEvents = useCallback( const swapEvents = useCallback(
async ({ from, to }: SwapEntry) => { async ({ from, to }: SwapEntry) => {
try { try {
await swapEventsMutation({ from, to }); await _swapEvents.mutateAsync({ from, to });
} catch (error) { } catch (error) {
logAxiosError('Error re-ordering event', error); logAxiosError('Error re-ordering event', error);
} }
}, },
[swapEventsMutation], [_swapEvents],
); );
return { return {
@@ -1,6 +1,4 @@
import { MutableRefObject, useCallback, useEffect, useRef } from 'react'; import { MutableRefObject, useCallback, useEffect } from 'react';
import { useSelectedEventId } from './useSocket';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>( function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: MutableRefObject<ComponentRef>, componentRef: MutableRefObject<ComponentRef>,
@@ -18,23 +16,6 @@ function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends H
scrollRef.current.scrollTo({ top, behavior: 'smooth' }); scrollRef.current.scrollTo({ top, behavior: 'smooth' });
} }
function snapToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: MutableRefObject<ComponentRef>,
scrollRef: MutableRefObject<ScrollRef>,
topOffset: number,
) {
if (!componentRef.current || !scrollRef.current) {
return;
}
const componentRect = componentRef.current.getBoundingClientRect();
const scrollRect = scrollRef.current.getBoundingClientRect();
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - topOffset;
// maintain current x scroll position
scrollRef.current.scrollTo(scrollRef.current.scrollLeft, top);
}
interface UseFollowComponentProps { interface UseFollowComponentProps {
followRef: MutableRefObject<HTMLElement | null>; followRef: MutableRefObject<HTMLElement | null>;
scrollRef: MutableRefObject<HTMLElement | null>; scrollRef: MutableRefObject<HTMLElement | null>;
@@ -81,32 +62,3 @@ export default function useFollowComponent(props: UseFollowComponentProps) {
return scrollToRefComponent; return scrollToRefComponent;
} }
export function useFollowSelected(doFollow: boolean, topOffset = 100) {
const selectedEvenId = useSelectedEventId();
const selectedRef = useRef<HTMLTableRowElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!doFollow) {
return;
}
if (selectedEvenId && selectedRef.current && scrollRef.current) {
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
snapToComponent(
{ current: selectedRef.current } as MutableRefObject<HTMLElement>,
{ current: scrollRef.current } as MutableRefObject<HTMLElement>,
topOffset,
);
});
}
}, [doFollow, selectedEvenId, topOffset]);
return {
selectedRef,
scrollRef,
};
}
@@ -1,7 +1,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useOs, useViewportSize } from '@mantine/hooks'; import { useOs, useViewportSize } from '@mantine/hooks';
export function useIsMobileDevice(): boolean { export function useIsMobile(): boolean {
const { width } = useViewportSize(); const { width } = useViewportSize();
const os = useOs(); const os = useOs();
@@ -1,8 +0,0 @@
import { useMemo } from 'react';
import { useViewportSize } from '@mantine/hooks';
export function useIsMobileScreen(): boolean {
const { width } = useViewportSize();
return useMemo(() => width < 800, [width]);
}
@@ -4,12 +4,12 @@ import { useKeyDown } from '../../common/hooks/useKeyDown';
import AboutPanel from './panel/about-panel/AboutPanel'; import AboutPanel from './panel/about-panel/AboutPanel';
import AutomationPanel from './panel/automations-panel/AutomationPanel'; import AutomationPanel from './panel/automations-panel/AutomationPanel';
import FeaturePanel from './panel/feature-panel/FeaturePanel'; import FeatureSettingsPanel from './panel/feature-settings-panel/FeatureSettingsPanel';
import ManagePanel from './panel/manage-panel/ManagePanel'; import GeneralPanel from './panel/general-panel/GeneralPanel';
import NetworkLogPanel from './panel/network-panel/NetworkLogPanel'; import NetworkLogPanel from './panel/network-panel/NetworkLogPanel';
import ProjectPanel from './panel/project-panel/ProjectPanel'; import ProjectPanel from './panel/project-panel/ProjectPanel';
import SettingsPanel from './panel/settings-panel/SettingsPanel';
import ShutdownPanel from './panel/shutdown-panel/ShutdownPanel'; import ShutdownPanel from './panel/shutdown-panel/ShutdownPanel';
import SourcesPanel from './panel/sources-panel/SourcesPanel';
import PanelContent from './panel-content/PanelContent'; import PanelContent from './panel-content/PanelContent';
import PanelList from './panel-list/PanelList'; import PanelList from './panel-list/PanelList';
import useAppSettingsNavigation from './useAppSettingsNavigation'; import useAppSettingsNavigation from './useAppSettingsNavigation';
@@ -25,11 +25,11 @@ export default function AppSettings() {
<ErrorBoundary> <ErrorBoundary>
<PanelList selectedPanel={panel} location={location} /> <PanelList selectedPanel={panel} location={location} />
<PanelContent onClose={close}> <PanelContent onClose={close}>
{panel === 'settings' && <SettingsPanel location={location} />}
{panel === 'project' && <ProjectPanel location={location} setLocation={setLocation} />} {panel === 'project' && <ProjectPanel location={location} setLocation={setLocation} />}
{panel === 'manage' && <ManagePanel location={location} />} {panel === 'general' && <GeneralPanel location={location} />}
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
{panel === 'sources' && <SourcesPanel />}
{panel === 'automation' && <AutomationPanel location={location} />} {panel === 'automation' && <AutomationPanel location={location} />}
{panel === 'sharing' && <FeaturePanel location={location} />}
{panel === 'network' && <NetworkLogPanel location={location} />} {panel === 'network' && <NetworkLogPanel location={location} />}
{panel === 'about' && <AboutPanel />} {panel === 'about' && <AboutPanel />}
{panel === 'shutdown' && <ShutdownPanel />} {panel === 'shutdown' && <ShutdownPanel />}
@@ -1,7 +1,6 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { IoClose } from 'react-icons/io5'; import { IoClose } from 'react-icons/io5';
import { Button } from '@chakra-ui/react';
import Button from '../../../common/components/buttons/Button';
import style from './PanelContent.module.scss'; import style from './PanelContent.module.scss';
@@ -9,12 +8,14 @@ interface PanelContentProps {
onClose: () => void; onClose: () => void;
} }
export default function PanelContent({ onClose, children }: PropsWithChildren<PanelContentProps>) { export default function PanelContent(props: PropsWithChildren<PanelContentProps>) {
const { onClose, children } = props;
return ( return (
<div className={style.contentWrapper}> <div className={style.contentWrapper}>
<div className={style.corner}> <div className={style.corner}>
<Button size='large' onClick={onClose}> <Button onClick={onClose} aria-label='close' rightIcon={<IoClose />} variant='ontime-subtle'>
Close settings <IoClose /> Close settings
</Button> </Button>
</div> </div>
<div className={style.content}>{children}</div> <div className={style.content}>{children}</div>
@@ -68,12 +68,12 @@ function PanelListItem(props: PanelListItemProps) {
> >
{panel.label} {panel.label}
</li> </li>
{panel.secondary?.map((secondary, index) => { {panel.secondary?.map((secondary) => {
const id = secondary.id.split('__')[1]; const id = secondary.id.split('__')[1];
const secondaryClasses = cx([style.secondary, isSelected && location === id ? style.active : null]); const secondaryClasses = cx([style.secondary, isSelected && location === id ? style.active : null]);
return ( return (
<li <li
key={secondary.id + index} key={secondary.id}
onClick={() => setLocation(secondary.id as SettingsOptionId)} onClick={() => setLocation(secondary.id as SettingsOptionId)}
onKeyDown={(event) => { onKeyDown={(event) => {
isKeyEnter(event) && setLocation(secondary.id as SettingsOptionId); isKeyEnter(event) && setLocation(secondary.id as SettingsOptionId);
@@ -188,7 +188,6 @@ $inner-padding: 1rem;
button { button {
margin-top: 1rem; margin-top: 1rem;
margin-inline: auto;
} }
} }
@@ -1,7 +1,7 @@
import { HTMLAttributes, PropsWithChildren, ReactNode } from 'react'; import { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
import { IoAdd } from 'react-icons/io5'; import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react';
import Button from '../../../common/components/buttons/Button';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import style from './PanelUtils.module.scss'; import style from './PanelUtils.module.scss';
@@ -68,8 +68,14 @@ export function TableEmpty({ label, handleClick }: { label?: string; handleClick
<td colSpan={99}> <td colSpan={99}>
<div>{label ?? 'No data yet'}</div> <div>{label ?? 'No data yet'}</div>
{handleClick && ( {handleClick && (
<Button onClick={handleClick} disabled={!handleClick} variant='primary'> <Button
New <IoAdd /> onClick={handleClick}
isDisabled={!handleClick}
variant='ontime-filled'
rightIcon={<IoAdd />}
size='sm'
>
New
</Button> </Button>
)} )}
</td> </td>
@@ -1,7 +1,7 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { Controller, useFieldArray, useForm } from 'react-hook-form'; import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoTrash } from 'react-icons/io5'; import { IoAdd, IoTrash } from 'react-icons/io5';
import { Radio, RadioGroup, Select } from '@chakra-ui/react'; import { Button, IconButton, Input, Radio, RadioGroup, Select } from '@chakra-ui/react';
import { import {
Automation, Automation,
AutomationDTO, AutomationDTO,
@@ -15,10 +15,7 @@ import {
import { addAutomation, editAutomation, testOutput } from '../../../../common/api/automation'; import { addAutomation, editAutomation, testOutput } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info'; 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 ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
@@ -201,8 +198,10 @@ export default function AutomationForm(props: AutomationFormProps) {
Title Title
<Input <Input
{...register('title', { required: { value: true, message: 'Required field' } })} {...register('title', { required: { value: true, message: 'Required field' } })}
fluid variant='ontime-filled'
size='sm'
placeholder='Load preset' placeholder='Load preset'
autoComplete='off'
/> />
</label> </label>
<Panel.Error>{errors.title?.message}</Panel.Error> <Panel.Error>{errors.title?.message}</Panel.Error>
@@ -273,22 +272,43 @@ export default function AutomationForm(props: AutomationFormProps) {
</label> </label>
<label> <label>
Value to match Value to match
<Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' /> <Input
{...register(`filters.${index}.value`)}
variant='ontime-filled'
size='sm'
placeholder='<empty / no value>'
autoComplete='off'
/>
</label> </label>
<div> <div>
<span>&nbsp;</span> <span>&nbsp;</span>
<div> <div>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeFilter(index)}> <IconButton
<IoTrash /> aria-label='Delete'
</IconButton> icon={<IoTrash />}
variant='ontime-ghosted'
size='sm'
color='#FA5656' // $red-500
onClick={() => removeFilter(index)}
isDisabled={false}
isLoading={false}
/>
</div> </div>
</div> </div>
</div> </div>
); );
})} })}
<div> <div>
<Button type='submit' onClick={handleAddNewFilter}> <Button
Add filter <IoAdd /> variant='ontime-subtle'
size='sm'
type='submit'
rightIcon={<IoAdd />}
onClick={handleAddNewFilter}
isDisabled={false}
isLoading={false}
>
Add filter
</Button> </Button>
</div> </div>
</div> </div>
@@ -322,8 +342,10 @@ export default function AutomationForm(props: AutomationFormProps) {
{...register(`outputs.${index}.targetIP`, { {...register(`outputs.${index}.targetIP`, {
required: { value: true, message: 'Required field' }, required: { value: true, message: 'Required field' },
})} })}
fluid variant='ontime-filled'
size='sm'
placeholder='127.0.0.1' placeholder='127.0.0.1'
autoComplete='off'
/> />
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error> <Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error>
</label> </label>
@@ -336,32 +358,51 @@ export default function AutomationForm(props: AutomationFormProps) {
max: { value: 65535, message: 'Port must be within range 1024 - 65535' }, max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' }, min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
})} })}
fluid variant='ontime-filled'
size='sm'
type='number' type='number'
maxLength={5} maxLength={5}
placeholder='8000' placeholder='8000'
autoComplete='off'
/> />
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error> <Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
</label> </label>
<label> <label>
Address Address
<Input {...register(`outputs.${index}.address`)} fluid placeholder='/cue/start' /> <Input
{...register(`outputs.${index}.address`)}
variant='ontime-filled'
size='sm'
placeholder='/cue/start'
autoComplete='off'
/>
<Panel.Error>{rowErrors?.address?.message}</Panel.Error> <Panel.Error>{rowErrors?.address?.message}</Panel.Error>
</label> </label>
<label> <label>
Arguments Arguments
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} placeholder='1' /> <TemplateInput
{...register(`outputs.${index}.args`)}
value={output.args}
variant='ontime-filled'
size='sm'
placeholder='1'
/>
<Panel.Error>{rowErrors?.args?.message}</Panel.Error> <Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label> </label>
<div> <div>
<span>&nbsp;</span> <span>&nbsp;</span>
<Panel.InlineElements relation='inner'> <Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestOSCOutput(index)}> <Button size='sm' variant='ontime-ghosted-white' onClick={() => handleTestOSCOutput(index)}>
Test Test
</Button> </Button>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}> <IconButton
<IoTrash /> aria-label='Delete'
</IconButton> icon={<IoTrash />}
variant='ontime-ghosted'
size='sm'
onClick={() => removeOutput(index)}
color='#FA5656' // $red-500
/>
</Panel.InlineElements> </Panel.InlineElements>
</div> </div>
</div> </div>
@@ -388,20 +429,27 @@ export default function AutomationForm(props: AutomationFormProps) {
message: 'HTTP messages should target http:// or https://', message: 'HTTP messages should target http:// or https://',
}, },
})} })}
fluid variant='ontime-filled'
size='sm'
placeholder='http://127.0.0.1/start/1' placeholder='http://127.0.0.1/start/1'
autoComplete='off'
/> />
<Panel.Error>{rowErrors?.url?.message}</Panel.Error> <Panel.Error>{rowErrors?.url?.message}</Panel.Error>
</label> </label>
<div> <div>
<span>&nbsp;</span> <span>&nbsp;</span>
<Panel.InlineElements relation='inner'> <Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestHTTPOutput(index)}> <Button size='sm' variant='ontime-ghosted-white' onClick={() => handleTestHTTPOutput(index)}>
Test Test
</Button> </Button>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}> <IconButton
<IoTrash /> aria-label='Delete'
</IconButton> icon={<IoTrash />}
variant='ontime-ghosted'
size='sm'
onClick={() => removeOutput(index)}
color='#FA5656' // $red-500
/>
</Panel.InlineElements> </Panel.InlineElements>
</div> </div>
</div> </div>
@@ -431,12 +479,17 @@ export default function AutomationForm(props: AutomationFormProps) {
> >
<span>&nbsp;</span> <span>&nbsp;</span>
<Panel.InlineElements relation='inner'> <Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestOntimeAction(index)}> <Button size='sm' variant='ontime-ghosted-white' onClick={() => handleTestOntimeAction(index)}>
Test Test
</Button> </Button>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}> <IconButton
<IoTrash /> aria-label='Delete'
</IconButton> icon={<IoTrash />}
variant='ontime-ghosted'
size='sm'
onClick={() => removeOutput(index)}
color='#FA5656' // $red-500
/>
</Panel.InlineElements> </Panel.InlineElements>
</OntimeActionForm> </OntimeActionForm>
</div> </div>
@@ -447,22 +500,24 @@ export default function AutomationForm(props: AutomationFormProps) {
return null; return null;
})} })}
<Panel.InlineElements relation='inner'> <Panel.InlineElements relation='inner'>
<Button onClick={handleAddNewOSCOutput}> <Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleAddNewOSCOutput}>
OSC <IoAdd /> OSC
</Button> </Button>
<Button onClick={handleAddNewHTTPOutput}> <Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleAddNewHTTPOutput}>
HTTP <IoAdd /> HTTP
</Button> </Button>
<Button onClick={handleAddnewOntimeAction}> <Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleAddnewOntimeAction}>
Ontime action <IoAdd /> Ontime action
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
</div> </div>
<Panel.InlineElements align='end'> <Panel.InlineElements align='end'>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>} {errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button onClick={onClose}>Cancel</Button> <Button variant='ontime-subtle' size='sm' onClick={onClose}>
<Button variant='primary' type='submit' disabled={!canSubmit} loading={isSubmitting}> Cancel
</Button>
<Button variant='ontime-filled' size='sm' type='submit' isDisabled={!canSubmit} isLoading={isSubmitting}>
Save Save
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
@@ -1,11 +1,9 @@
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { Switch } from '@chakra-ui/react'; import { Button, Input, Switch } from '@chakra-ui/react';
import { editAutomationSettings } from '../../../../common/api/automation'; import { editAutomationSettings } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info'; 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 ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import { preventEscape } from '../../../../common/utils/keyEvent'; import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOnlyNumbers } from '../../../../common/utils/regex'; import { isOnlyNumbers } from '../../../../common/utils/regex';
@@ -59,15 +57,16 @@ export default function AutomationSettingsForm(props: AutomationSettingsProps) {
<Panel.SubHeader> <Panel.SubHeader>
Automation settings Automation settings
<Panel.InlineElements> <Panel.InlineElements>
<Button variant='ghosted' onClick={onReset} disabled={!canSubmit}> <Button variant='ontime-ghosted' size='sm' onClick={onReset} isDisabled={!canSubmit}>
Revert to saved Revert to saved
</Button> </Button>
<Button <Button
variant='primary' variant='ontime-filled'
size='sm'
type='submit' type='submit'
form='automation-settings-form' form='automation-settings-form'
disabled={!canSubmit} isDisabled={!canSubmit}
loading={isSubmitting} isLoading={isSubmitting}
> >
Save Save
</Button> </Button>
@@ -140,10 +139,13 @@ export default function AutomationSettingsForm(props: AutomationSettingsProps) {
<Input <Input
id='oscPortIn' id='oscPortIn'
placeholder='8888' placeholder='8888'
width='5rem'
maxLength={5} maxLength={5}
style={{ textAlign: 'right', width: '5rem' }} size='sm'
textAlign='right'
variant='ontime-filled'
type='number' type='number'
fluid autoComplete='off'
{...register('oscPortIn', { {...register('oscPortIn', {
required: { value: true, message: 'Required field' }, required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' }, max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
@@ -1,11 +1,10 @@
import { Fragment, useState } from 'react'; import { Fragment, useState } from 'react';
import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5'; import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5';
import { Button, IconButton } from '@chakra-ui/react';
import { AutomationDTO, NormalisedAutomation } from 'ontime-types'; import { AutomationDTO, NormalisedAutomation } from 'ontime-types';
import { deleteAutomation } from '../../../../common/api/automation'; import { deleteAutomation } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -47,11 +46,14 @@ export default function AutomationsList(props: AutomationsListProps) {
<Panel.SubHeader> <Panel.SubHeader>
Manage automations Manage automations
<Button <Button
variant='ontime-subtle'
rightIcon={<IoAdd />}
size='sm'
type='submit' type='submit'
disabled={Boolean(automationFormData)} isDisabled={Boolean(automationFormData)}
onClick={() => setAutomationFormData(automationPlaceholder)} onClick={() => setAutomationFormData(automationPlaceholder)}
> >
New <IoAdd /> New
</Button> </Button>
</Panel.SubHeader> </Panel.SubHeader>
@@ -92,19 +94,21 @@ export default function AutomationsList(props: AutomationsListProps) {
<td>{automations[automationId].outputs.length}</td> <td>{automations[automationId].outputs.length}</td>
<Panel.InlineElements align='end' relation='inner' as='td'> <Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton <IconButton
variant='ghosted-white' size='sm'
variant='ontime-ghosted'
color='#e2e2e2' // $gray-200
icon={<IoPencil />}
aria-label='Edit entry' aria-label='Edit entry'
onClick={() => setAutomationFormData(automations[automationId])} onClick={() => setAutomationFormData(automations[automationId])}
> />
<IoPencil />
</IconButton>
<IconButton <IconButton
variant='ghosted-destructive' size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry' aria-label='Delete entry'
onClick={() => handleDelete(automationId)} onClick={() => handleDelete(automationId)}
> />
<IoTrash />
</IconButton>
</Panel.InlineElements> </Panel.InlineElements>
</tr> </tr>
{deleteError && ( {deleteError && (
@@ -1,9 +1,8 @@
import { PropsWithChildren, useState } from 'react'; import { PropsWithChildren, useState } from 'react';
import { UseFormRegister, UseFormSetValue } from 'react-hook-form'; import { UseFormRegister, UseFormSetValue } from 'react-hook-form';
import { Select } from '@chakra-ui/react'; import { Input, Select } from '@chakra-ui/react';
import { AutomationDTO, OntimeAction } from 'ontime-types'; import { AutomationDTO, OntimeAction } from 'ontime-types';
import Input from '../../../../common/components/input/input/Input';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -68,8 +67,10 @@ export default function OntimeActionForm(props: PropsWithChildren<OntimeActionFo
{...register(`outputs.${index}.time`, { {...register(`outputs.${index}.time`, {
required: { value: true, message: 'Required field' }, required: { value: true, message: 'Required field' },
})} })}
fluid variant='ontime-filled'
size='sm'
placeholder='eg: 10m5s' placeholder='eg: 10m5s'
autoComplete='off'
/> />
<Panel.Error>{rowErrors?.time?.message}</Panel.Error> <Panel.Error>{rowErrors?.time?.message}</Panel.Error>
</label> </label>
@@ -79,7 +80,13 @@ export default function OntimeActionForm(props: PropsWithChildren<OntimeActionFo
<> <>
<label> <label>
Text (leave empty for no change) Text (leave empty for no change)
<Input {...register(`outputs.${index}.text`)} fluid placeholder='eg: Timer is finished' /> <Input
{...register(`outputs.${index}.text`)}
variant='ontime-filled'
size='sm'
placeholder='eg: Timer is finished'
autoComplete='off'
/>
<Panel.Error>{rowErrors?.text?.message}</Panel.Error> <Panel.Error>{rowErrors?.text?.message}</Panel.Error>
</label> </label>
<label> <label>
@@ -1,12 +1,10 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { Select } from '@chakra-ui/react'; import { Button, Input, Select } from '@chakra-ui/react';
import { NormalisedAutomation, TimerLifeCycle, TriggerDTO } from 'ontime-types'; import { NormalisedAutomation, TimerLifeCycle, TriggerDTO } from 'ontime-types';
import { addTrigger, editTrigger } from '../../../../common/api/automation'; import { addTrigger, editTrigger } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import { preventEscape } from '../../../../common/utils/keyEvent'; import { preventEscape } from '../../../../common/utils/keyEvent';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -89,7 +87,9 @@ export default function TriggerForm(props: TriggerFormProps) {
Title Title
<Input <Input
{...register('title', { required: { value: true, message: 'Required field' } })} {...register('title', { required: { value: true, message: 'Required field' } })}
fluid size='sm'
variant='ontime-filled'
autoComplete='off'
defaultValue={initialTitle} defaultValue={initialTitle}
/> />
<Panel.Error>{errors.title?.message}</Panel.Error> <Panel.Error>{errors.title?.message}</Panel.Error>
@@ -127,10 +127,10 @@ export default function TriggerForm(props: TriggerFormProps) {
<Panel.Error>{errors.automationId?.message}</Panel.Error> <Panel.Error>{errors.automationId?.message}</Panel.Error>
</label> </label>
<Panel.InlineElements align='end'> <Panel.InlineElements align='end'>
<Button disabled={isSubmitting} onClick={onCancel}> <Button size='sm' variant='ontime-subtle' isDisabled={isSubmitting} onClick={onCancel}>
Cancel Cancel
</Button> </Button>
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}> <Button type='submit' size='sm' variant='ontime-filled' isDisabled={!canSubmit} isLoading={isSubmitting}>
Save Save
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
@@ -1,10 +1,10 @@
import { Fragment, useMemo, useState } from 'react'; import { Fragment, useMemo, useState } from 'react';
import { IoAdd } from 'react-icons/io5'; import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react';
import { NormalisedAutomation, Trigger } from 'ontime-types'; import { NormalisedAutomation, Trigger } from 'ontime-types';
import { deleteTrigger } from '../../../../common/api/automation'; import { deleteTrigger } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -47,8 +47,17 @@ export default function TriggersList(props: TriggersListProps) {
<Panel.Card> <Panel.Card>
<Panel.SubHeader> <Panel.SubHeader>
Manage triggers Manage triggers
<Button type='submit' form='trigger-form' disabled={!canAdd} loading={false} onClick={() => setShowForm(true)}> <Button
New <IoAdd /> variant='ontime-subtle'
rightIcon={<IoAdd />}
size='sm'
type='submit'
form='trigger-form'
isDisabled={!canAdd}
isLoading={false}
onClick={() => setShowForm(true)}
>
New
</Button> </Button>
</Panel.SubHeader> </Panel.SubHeader>
<Panel.Divider /> <Panel.Divider />
@@ -1,8 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoPencil, IoTrash, IoWarningOutline } from 'react-icons/io5'; import { IoPencil, IoTrash, IoWarningOutline } from 'react-icons/io5';
import { IconButton } from '@chakra-ui/react';
import { NormalisedAutomation, TimerLifeCycle } from 'ontime-types'; import { NormalisedAutomation, TimerLifeCycle } from 'ontime-types';
import IconButton from '../../../../common/components/buttons/IconButton';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -62,12 +62,22 @@ export default function TriggersListItem(props: TriggersListItemProps) {
<Tag>{automations?.[automationId]?.title}</Tag> <Tag>{automations?.[automationId]?.title}</Tag>
</td> </td>
<Panel.InlineElements align='end' relation='inner' as='td'> <Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={() => setIsEditing(true)}> <IconButton
<IoPencil /> size='sm'
</IconButton> variant='ontime-ghosted'
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={handleDelete}> color='#e2e2e2' // $gray-200
<IoTrash /> icon={<IoPencil />}
</IconButton> aria-label='Edit entry'
onClick={() => setIsEditing(true)}
/>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={handleDelete}
/>
</Panel.InlineElements> </Panel.InlineElements>
</tr> </tr>
); );
@@ -1,7 +1,7 @@
import { forwardRef, useMemo, useState } from 'react'; import { forwardRef, useMemo, useState } from 'react';
import { type InputProps, Input } from '@chakra-ui/react';
import { mergeRefs, useClickOutside } from '@mantine/hooks'; import { mergeRefs, useClickOutside } from '@mantine/hooks';
import Input, { type InputProps } from '../../../../../common/components/input/input/Input';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { makeAutoCompleteList, matchRemaining, selectFromLastTemplate } from './templateInput.utils'; import { makeAutoCompleteList, matchRemaining, selectFromLastTemplate } from './templateInput.utils';
@@ -53,7 +53,7 @@ const TemplateInput = forwardRef(function TemplateInput(props: TemplateInputProp
return ( return (
<div className={style.wrapper} ref={mergeRefs(localRef, ref)}> <div className={style.wrapper} ref={mergeRefs(localRef, ref)}>
<Input value={inputValue} {...rest} onChange={handleInputChange} fluid /> <Input value={inputValue} {...rest} onChange={handleInputChange} autoComplete='off' autoCorrect='off' />
{showSuggestions && suggestions.length > 0 && ( {showSuggestions && suggestions.length > 0 && (
<ul className={style.suggestions}> <ul className={style.suggestions}>
{suggestions.map((suggestion) => ( {suggestions.map((suggestion) => (
@@ -1,4 +1,4 @@
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import ClientList from './ClientList'; import ClientList from './ClientList';
@@ -1,34 +1,32 @@
import { useState } from 'react'; import { useState } from 'react';
import { useDisclosure } from '@mantine/hooks'; import { Badge, Button, useDisclosure } from '@chakra-ui/react';
import { Client } from 'ontime-types'; import { Client } from 'ontime-types';
import Button from '../../../../../common/components/buttons/Button'; import { RedirectClientModal } from '../../../../common/components/client-modal/RedirectClientModal';
import { RedirectClientModal } from '../../../../../common/components/client-modal/RedirectClientModal'; import { RenameClientModal } from '../../../../common/components/client-modal/RenameClientModal';
import { RenameClientModal } from '../../../../../common/components/client-modal/RenameClientModal'; import { setClientRemote } from '../../../../common/hooks/useSocket';
import Tag from '../../../../../common/components/tag/Tag'; import { useClientStore } from '../../../../common/stores/clientStore';
import { setClientRemote } from '../../../../../common/hooks/useSocket'; import * as Panel from '../../panel-utils/PanelUtils';
import { useClientStore } from '../../../../../common/stores/clientStore';
import * as Panel from '../../../panel-utils/PanelUtils';
import style from './ClientControlPanel.module.scss'; import style from './ClientControlPanel.module.scss';
export default function ClientList() { export default function ClientList() {
const id = useClientStore((store) => store.id); const id = useClientStore((store) => store.id);
const clients = useClientStore((store) => store.clients); const clients = useClientStore((store) => store.clients);
const [isOpenRedirect, redirectHandler] = useDisclosure(); const { isOpen: isOpenRedirect, onOpen: onOpenRedirect, onClose: onCloseRedirect } = useDisclosure();
const [isOpenRename, renameHandler] = useDisclosure(); const { isOpen: isOpenRename, onOpen: onOpenRename, onClose: onCloseRename } = useDisclosure();
const { setIdentify } = setClientRemote; const { setIdentify } = setClientRemote;
const [targetId, setTargetId] = useState(''); const [targetId, setTargetId] = useState('');
const openRename = (targetId: string) => { const openRename = (targetId: string) => {
setTargetId(targetId); setTargetId(targetId);
renameHandler.open(); onOpenRename();
}; };
const openRedirect = (targetId: string) => { const openRedirect = (targetId: string) => {
setTargetId(targetId); setTargetId(targetId);
redirectHandler.open(); onOpenRedirect();
}; };
const ontimeClients = Object.entries(clients).filter(([_, { type }]) => type === 'ontime'); const ontimeClients = Object.entries(clients).filter(([_, { type }]) => type === 'ontime');
@@ -45,16 +43,11 @@ export default function ClientList() {
origin={targetClient.origin} origin={targetClient.origin}
currentPath={targetClient.path} currentPath={targetClient.path}
isOpen={isOpenRedirect} isOpen={isOpenRedirect}
onClose={redirectHandler.close} onClose={onCloseRedirect}
/> />
)} )}
{isOpenRename && ( {isOpenRename && (
<RenameClientModal <RenameClientModal id={targetId} name={targetClient?.name} isOpen={isOpenRename} onClose={onCloseRename} />
id={targetId}
name={targetClient?.name}
isOpen={isOpenRename}
onClose={renameHandler.close}
/>
)} )}
<Panel.Section> <Panel.Section>
<Panel.Title>Ontime Clients ({ontimeClients.length})</Panel.Title> <Panel.Title>Ontime Clients ({ontimeClients.length})</Panel.Title>
@@ -73,16 +66,20 @@ export default function ClientList() {
return ( return (
<tr key={key}> <tr key={key}>
<Panel.InlineElements relation='inner' as='td'> <Panel.InlineElements relation='inner' as='td'>
{isCurrent && <Tag>SELF</Tag>} {isCurrent && (
<Badge variant='outline' colorScheme='yellow' size='xs'>
self
</Badge>
)}
{name} {name}
</Panel.InlineElements> </Panel.InlineElements>
<td>{path}</td> <td>{path}</td>
<Panel.InlineElements relation='inner'> <Panel.InlineElements relation='inner'>
<Button <Button
size='small' size='xs'
className={`${identify ? style.blink : ''}`} className={`${identify ? style.blink : ''}`}
disabled={isCurrent} isDisabled={isCurrent}
variant={identify ? 'primary' : 'subtle'} variant={identify ? 'ontime-filled' : 'ontime-subtle'}
data-testid={isCurrent ? '' : 'not-self-identify'} data-testid={isCurrent ? '' : 'not-self-identify'}
onClick={() => { onClick={() => {
setIdentify({ target: key, identify: !identify }); setIdentify({ target: key, identify: !identify });
@@ -91,7 +88,8 @@ export default function ClientList() {
Identify Identify
</Button> </Button>
<Button <Button
size='small' size='xs'
variant='ontime-subtle'
data-testid={isCurrent ? '' : 'not-self-rename'} data-testid={isCurrent ? '' : 'not-self-rename'}
onClick={() => openRename(key)} onClick={() => openRename(key)}
> >
@@ -99,8 +97,9 @@ export default function ClientList() {
</Button> </Button>
<Button <Button
size='small' size='xs'
disabled={isCurrent} variant='ontime-subtle'
isDisabled={isCurrent}
data-testid={isCurrent ? '' : 'not-self-redirect'} data-testid={isCurrent ? '' : 'not-self-redirect'}
onClick={() => openRedirect(key)} onClick={() => openRedirect(key)}
> >
@@ -1,11 +0,0 @@
.fit {
width: fit-content;
}
.aliasConstrain {
min-width: 12em;
}
.fullWidth {
width: 100%;
}
@@ -1,42 +0,0 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { isOntimeCloud } from '../../../../externals';
import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import InfoNif from '../network-panel/NetworkInterfaces';
import GenerateLinkFormExport from './GenerateLinkFormExport';
import ReportSettings from './ReportSettings';
import UrlPresetsForm from './UrlPresetsForm';
export default function FeaturePanel({ location }: PanelBaseProps) {
const presetsRef = useScrollIntoView<HTMLDivElement>('presets', location);
const linkRef = useScrollIntoView<HTMLDivElement>('link', location);
const reportRef = useScrollIntoView<HTMLDivElement>('report', location);
return (
<>
<Panel.Header>Sharing and reporting</Panel.Header>
<div ref={presetsRef}>
<UrlPresetsForm />
</div>
<div ref={linkRef}>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Share Ontime Link</Panel.SubHeader>
<Panel.Divider />
{!isOntimeCloud && (
<>
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
<InfoNif />
</>
)}
<GenerateLinkFormExport />
</Panel.Card>
</Panel.Section>
</div>
<div ref={reportRef}>
<ReportSettings />
</div>
</>
);
}
@@ -1,42 +0,0 @@
import { useMemo } from 'react';
import useInfo from '../../../../common/hooks-query/useInfo';
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
import GenerateLinkForm from './GenerateLinkForm';
interface GenerateLinkFormExportProps {
lockedPath?: { value: string; label: string };
}
export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormExportProps) {
const { data: infoData } = useInfo();
const { data: urlPresetData } = useUrlPresets({ skip: lockedPath === undefined });
const hostOptions = useMemo(
() =>
infoData.networkInterfaces.map((nif) => ({
value: nif.address,
label: `${nif.name} - ${nif.address}`,
})),
[infoData.networkInterfaces],
);
const pathOptions = useMemo(() => {
if (lockedPath) {
return [{ value: lockedPath.value, label: lockedPath.label }];
}
return [
{ value: 'timer', label: 'Timer' },
{ value: 'cuesheet', label: 'Cuesheet' },
{ value: 'op', label: 'Operator' },
{ value: '', label: 'Companion' },
...urlPresetData.map((preset) => ({
value: preset.alias,
label: `Preset: ${preset.alias}`,
})),
];
}, [lockedPath, urlPresetData]);
return <GenerateLinkForm hostOptions={hostOptions} pathOptions={pathOptions} isLockedToView={Boolean(lockedPath)} />;
}
@@ -14,12 +14,16 @@
gap: 1rem; gap: 1rem;
} }
.fit {
width: fit-content;
}
.aliasConstrain {
min-width: 12em;
}
.twoCols { .twoCols {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 1rem; gap: 1rem;
} }
.current {
background-color: $blue-1100;
}
@@ -0,0 +1,30 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import CustomFields from './custom-fields/CustomFields';
import ReportSettings from './ReportSettings';
import UrlPresetsForm from './UrlPresetsForm';
export default function FeatureSettingsPanel({ location }: PanelBaseProps) {
const customFieldsRef = useScrollIntoView<HTMLDivElement>('custom', location);
const urlPresetsRef = useScrollIntoView<HTMLDivElement>('urlpresets', location);
const reportRef = useScrollIntoView<HTMLDivElement>('report', location);
return (
<>
<Panel.Header>Feature Settings</Panel.Header>
<div ref={customFieldsRef}>
<CustomFields />
</div>
<div ref={urlPresetsRef}>
<UrlPresetsForm />
</div>
<div ref={reportRef}>
<ReportSettings />
</div>
</>
);
}
@@ -1,9 +1,9 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { IoTrashBin } from 'react-icons/io5'; import { IoTrashBin } from 'react-icons/io5';
import { Button } from '@chakra-ui/react';
import { deleteAllReport } from '../../../../common/api/report'; import { deleteAllReport } from '../../../../common/api/report';
import { createBlob, downloadBlob } from '../../../../common/api/utils'; import { createBlob, downloadBlob } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import useReport from '../../../../common/hooks-query/useReport'; import useReport from '../../../../common/hooks-query/useReport';
import useRundown from '../../../../common/hooks-query/useRundown'; import useRundown from '../../../../common/hooks-query/useRundown';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
@@ -41,12 +41,23 @@ export default function ReportSettings() {
<Panel.Title> <Panel.Title>
Manage report Manage report
<Panel.InlineElements> <Panel.InlineElements>
<Button onClick={() => downloadCSV(combinedReport)} disabled={combinedReport.length === 0}> <Button
<IoTrashBin /> variant='ontime-subtle'
leftIcon={<IoTrashBin />}
size='sm'
onClick={() => downloadCSV(combinedReport)}
isDisabled={combinedReport.length === 0}
>
Export CSV Export CSV
</Button> </Button>
<Button variant='subtle-destructive' onClick={clearReport} disabled={combinedReport.length === 0}> <Button
<IoTrashBin /> variant='ontime-subtle'
leftIcon={<IoTrashBin />}
size='sm'
color='#FA5656'
onClick={clearReport}
isDisabled={combinedReport.length === 0}
>
Clear All Clear All
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
@@ -1,16 +1,13 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoOpenOutline, IoTrash } from 'react-icons/io5'; import { IoAdd, IoOpenOutline, IoTrash } from 'react-icons/io5';
import { Switch } from '@chakra-ui/react'; import { Button, IconButton, Input, Switch } from '@chakra-ui/react';
import { URLPreset } from 'ontime-types'; import { URLPreset } from 'ontime-types';
import { postUrlPresets } from '../../../../common/api/urlPresets'; import { postUrlPresets } from '../../../../common/api/urlPresets';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import TooltipActionBtn from '../../../../common/components/buttons/TooltipActionBtn'; import TooltipActionBtn from '../../../../common/components/buttons/TooltipActionBtn';
import Info from '../../../../common/components/info/Info'; 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 ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets'; import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
import { preventEscape } from '../../../../common/utils/keyEvent'; import { preventEscape } from '../../../../common/utils/keyEvent';
@@ -18,7 +15,7 @@ import { handleLinks } from '../../../../common/utils/linkUtils';
import { validateUrlPresetPath } from '../../../../common/utils/urlPresets'; import { validateUrlPresetPath } from '../../../../common/utils/urlPresets';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import style from './FeaturePanel.module.scss'; import style from './FeatureSettings.module.scss';
const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/'; const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/';
@@ -101,10 +98,10 @@ export default function UrlPresetsForm() {
<Panel.SubHeader> <Panel.SubHeader>
URL presets URL presets
<Panel.InlineElements> <Panel.InlineElements>
<Button variant='ghosted' onClick={onReset} disabled={!canSubmit}> <Button variant='ontime-ghosted' size='sm' onClick={onReset} isDisabled={!canSubmit}>
Revert to saved Revert to saved
</Button> </Button>
<Button variant='primary' type='submit' disabled={!canSubmit} loading={isSubmitting}> <Button variant='ontime-filled' size='sm' type='submit' isDisabled={!canSubmit} isLoading={isSubmitting}>
Save Save
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
@@ -137,8 +134,8 @@ export default function UrlPresetsForm() {
<Panel.Loader isLoading={isLoading} /> <Panel.Loader isLoading={isLoading} />
<Panel.Title> <Panel.Title>
Manage presets Manage presets
<Button onClick={addNew}> <Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={addNew}>
New <IoAdd /> New
</Button> </Button>
</Panel.Title> </Panel.Title>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>} {errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
@@ -176,9 +173,11 @@ export default function UrlPresetsForm() {
{...register(`data.${index}.alias`, { {...register(`data.${index}.alias`, {
required: { value: true, message: 'Required field' }, required: { value: true, message: 'Required field' },
})} })}
fluid size='sm'
variant='ontime-filled'
placeholder='URL Preset' placeholder='URL Preset'
data-testid={`field__alias_${index}`} data-testid={`field__alias_${index}`}
autoComplete='off'
/> />
<Panel.Error>{maybeAliasError}</Panel.Error> <Panel.Error>{maybeAliasError}</Panel.Error>
</td> </td>
@@ -187,9 +186,11 @@ export default function UrlPresetsForm() {
{...register(`data.${index}.pathAndParams`, { {...register(`data.${index}.pathAndParams`, {
required: { value: true, message: 'Required field' }, required: { value: true, message: 'Required field' },
})} })}
fluid size='sm'
variant='ontime-filled'
placeholder='URL (portion after ontime Port)' placeholder='URL (portion after ontime Port)'
data-testid={`field__url_${index}`} data-testid={`field__url_${index}`}
autoComplete='off'
/> />
<Panel.Error>{maybeUrlError}</Panel.Error> <Panel.Error>{maybeUrlError}</Panel.Error>
</td> </td>
@@ -206,13 +207,14 @@ export default function UrlPresetsForm() {
data-testid={`field__test_${index}`} data-testid={`field__test_${index}`}
/> />
<IconButton <IconButton
size='sm'
onClick={() => remove(index)} onClick={() => remove(index)}
variant='ghosted-destructive' variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry' aria-label='Delete entry'
data-testid={`field__delete_${index}`} data-testid={`field__delete_${index}`}
> />
<IoTrash />
</IconButton>
</Panel.InlineElements> </Panel.InlineElements>
</tr> </tr>
); );
@@ -1,8 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoArrowDown, IoArrowUp, IoPencil, IoTrash } from 'react-icons/io5'; import { IoPencil, IoTrash } from 'react-icons/io5';
import { IconButton } from '@chakra-ui/react';
import { CustomField, CustomFieldKey } from 'ontime-types'; import { CustomField, CustomFieldKey } from 'ontime-types';
import IconButton from '../../../../../common/components/buttons/IconButton';
import CopyTag from '../../../../../common/components/copy-tag/CopyTag'; import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
import Swatch from '../../../../../common/components/input/colour-input/Swatch'; import Swatch from '../../../../../common/components/input/colour-input/Swatch';
import Tag from '../../../../../common/components/tag/Tag'; import Tag from '../../../../../common/components/tag/Tag';
@@ -10,26 +10,22 @@ import * as Panel from '../../../panel-utils/PanelUtils';
import CustomFieldForm from './CustomFieldForm'; import CustomFieldForm from './CustomFieldForm';
import style from '../ManagePanel.module.scss'; import style from '../FeatureSettings.module.scss';
interface CustomFieldEntryProps { interface CustomFieldEntryProps {
colour: string; colour: string;
label: string; label: string;
fieldKey: string; fieldKey: string;
type: 'string' | 'image'; type: 'string' | 'image';
order?: number; // Add order onEdit: (key: CustomFieldKey, patch: CustomField) => Promise<void>;
onEdit: (key: CustomFieldKey, patch: Partial<CustomField>) => Promise<void>; // Patch can be partial for order updates
onDelete: (key: CustomFieldKey) => Promise<void>; onDelete: (key: CustomFieldKey) => Promise<void>;
isFirst: boolean;
isLast: boolean;
onMove: (direction: 'up' | 'down') => void; // Changed from Promise<void> to void
} }
export default function CustomFieldEntry(props: CustomFieldEntryProps) { export default function CustomFieldEntry(props: CustomFieldEntryProps) {
const { colour, label, fieldKey, type, onEdit, onDelete, isFirst, isLast, onMove } = props; const { colour, label, fieldKey, type, onEdit, onDelete } = props;
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const handleEdit = async (patch: CustomField) => { // This patch comes from CustomFieldForm, so it's a full CustomField const handleEdit = async (patch: CustomField) => {
await onEdit(fieldKey, patch); await onEdit(fieldKey, patch);
setIsEditing(false); setIsEditing(false);
}; };
@@ -65,18 +61,22 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
</CopyTag> </CopyTag>
</td> </td>
<Panel.InlineElements relation='inner' as='td'> <Panel.InlineElements relation='inner' as='td'>
<IconButton variant='ghosted-white' aria-label='Move field up' onClick={() => onMove('up')} disabled={isFirst}> <IconButton
<IoArrowUp /> size='sm'
</IconButton> variant='ontime-ghosted'
<IconButton variant='ghosted-white' aria-label='Move field down' onClick={() => onMove('down')} disabled={isLast}> color='#e2e2e2' // $gray-200
<IoArrowDown /> icon={<IoPencil />}
</IconButton> aria-label='Edit entry'
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={() => setIsEditing(true)}> onClick={() => setIsEditing(true)}
<IoPencil /> />
</IconButton> <IconButton
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={() => onDelete(fieldKey)}> size='sm'
<IoTrash /> variant='ontime-ghosted'
</IconButton> color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={() => onDelete(fieldKey)}
/>
</Panel.InlineElements> </Panel.InlineElements>
</tr> </tr>
); );
@@ -1,19 +1,17 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { Radio, RadioGroup } from '@chakra-ui/react'; import { Button, Input, Radio, RadioGroup } from '@chakra-ui/react';
import { CustomField } from 'ontime-types'; import { CustomField } from 'ontime-types';
import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils'; import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
import { maybeAxiosError } from '../../../../../common/api/utils'; import { maybeAxiosError } from '../../../../../common/api/utils';
import Button from '../../../../../common/components/buttons/Button';
import Info from '../../../../../common/components/info/Info'; import Info from '../../../../../common/components/info/Info';
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect'; import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
import Input from '../../../../../common/components/input/input/Input';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { preventEscape } from '../../../../../common/utils/keyEvent'; import { preventEscape } from '../../../../../common/utils/keyEvent';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import style from '../ManagePanel.module.scss'; import style from '../FeatureSettings.module.scss';
interface CustomFieldsFormProps { interface CustomFieldsFormProps {
onSubmit: (field: CustomField) => Promise<void>; onSubmit: (field: CustomField) => Promise<void>;
@@ -120,13 +118,15 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
return true; return true;
}, },
})} })}
fluid size='sm'
variant='ontime-filled'
autoComplete='off'
/> />
</div> </div>
<div> <div>
<Panel.Description>Key (use in Integrations and API)</Panel.Description> <Panel.Description>Key (use in Integrations and API)</Panel.Description>
<Input {...register('key')} readOnly fluid /> <Input {...register('key')} disabled size='sm' variant='ontime-filled' autoComplete='off' />
</div> </div>
</div> </div>
<div> <div>
@@ -135,10 +135,10 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
</div> </div>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>} {errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.InlineElements relation='inner' align='end'> <Panel.InlineElements relation='inner' align='end'>
<Button variant='ghosted' onClick={onCancel}> <Button size='sm' variant='ontime-ghosted' onClick={onCancel}>
Cancel Cancel
</Button> </Button>
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}> <Button size='sm' type='submit' variant='ontime-filled' isDisabled={!canSubmit} isLoading={isSubmitting}>
Save Save
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
@@ -0,0 +1,103 @@
import { useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react';
import { CustomField, CustomFieldKey } from 'ontime-types';
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
import Info from '../../../../../common/components/info/Info';
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { customFieldsDocsUrl } from '../../../../../externals';
import * as Panel from '../../../panel-utils/PanelUtils';
import CustomFieldEntry from './CustomFieldEntry';
import CustomFieldForm from './CustomFieldForm';
export default function CustomFields() {
const { data, refetch } = useCustomFields();
const [isAdding, setIsAdding] = useState(false);
const handleInitiateCreate = () => {
setIsAdding(true);
};
const handleCancel = () => {
setIsAdding(false);
};
const handleCreate = async (customField: CustomField) => {
await postCustomField(customField);
refetch();
setIsAdding(false);
};
const handleEditField = async (key: CustomFieldKey, customField: CustomField) => {
await editCustomField(key, customField);
refetch();
};
const handleDelete = async (key: CustomFieldKey) => {
try {
await deleteCustomField(key);
refetch();
} catch (_error) {
/** we do not handle errors here */
}
};
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>
Custom fields
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleInitiateCreate}>
New
</Button>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
<Info>
Custom fields allow for additional information to be added to an event.
<br />
<br />
This data is not used by Ontime, but provides place for cueing or department specific information (eg.
light, sound, camera).
<br />
<br />
Custom fields can be used width the Integrations feature using the generated key.
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
</Info>
</Panel.Section>
<Panel.Section>
{isAdding && <CustomFieldForm onSubmit={handleCreate} onCancel={handleCancel} />}
<Panel.Table>
<thead>
<tr>
<th>Colour</th>
<th>Type</th>
<th>Name</th>
<th>Key (used in Integrations)</th>
<th />
</tr>
</thead>
<tbody>
{Object.entries(data).map(([key, { colour, label, type }]) => {
return (
<CustomFieldEntry
key={key}
fieldKey={key}
colour={colour}
label={label}
type={type}
onEdit={handleEditField}
onDelete={handleDelete}
/>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -0,0 +1,28 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import EditorSettingsForm from '../interface-panel/EditorSettingsForm';
import GeneralPanelForm from './GeneralPanelForm';
import ViewSettingsForm from './ViewSettingsForm';
export default function GeneralPanel({ location }: PanelBaseProps) {
const generalRef = useScrollIntoView<HTMLDivElement>('settings', location);
const editorRef = useScrollIntoView<HTMLDivElement>('editor', location);
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
return (
<>
<Panel.Header>App Settings</Panel.Header>
<div ref={generalRef}>
<GeneralPanelForm />
</div>
<div ref={editorRef}>
<EditorSettingsForm />
</div>
<div ref={viewRef}>
<ViewSettingsForm />
</div>
</>
);
}
@@ -1,21 +1,19 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { Select } from '@chakra-ui/react'; import { Button, Input, Select } from '@chakra-ui/react';
import { Settings } from 'ontime-types'; import { Settings } from 'ontime-types';
import { postSettings } from '../../../../common/api/settings'; import { postSettings } from '../../../../common/api/settings';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import useSettings from '../../../../common/hooks-query/useSettings'; import useSettings from '../../../../common/hooks-query/useSettings';
import { preventEscape } from '../../../../common/utils/keyEvent'; import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOnlyNumbers } from '../../../../common/utils/regex'; import { isOnlyNumbers } from '../../../../common/utils/regex';
import { isOntimeCloud } from '../../../../externals'; import { isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import GeneralPinInput from './composite/GeneralPinInput'; import GeneralPinInput from './GeneralPinInput';
export default function GeneralSettings() { export default function GeneralPanelForm() {
const { data, status, refetch } = useSettings(); const { data, status, refetch } = useSettings();
const { const {
handleSubmit, handleSubmit,
@@ -71,10 +69,17 @@ export default function GeneralSettings() {
<Panel.SubHeader> <Panel.SubHeader>
General settings General settings
<Panel.InlineElements> <Panel.InlineElements>
<Button disabled={!isDirty || isSubmitting} variant='ghosted' onClick={onReset}> <Button isDisabled={!isDirty || isSubmitting} variant='ontime-ghosted' size='sm' onClick={onReset}>
Revert to saved Revert to saved
</Button> </Button>
<Button type='submit' form='app-settings' loading={isSubmitting} disabled={disableSubmit} variant='primary'> <Button
type='submit'
form='app-settings'
isLoading={isSubmitting}
isDisabled={disableSubmit}
variant='ontime-filled'
size='sm'
>
Save Save
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
@@ -96,10 +101,12 @@ export default function GeneralSettings() {
/> />
<Input <Input
id='serverPort' id='serverPort'
size='sm'
type='number' type='number'
variant='ontime-filled'
maxLength={5} maxLength={5}
style={{ width: '75px' }} width='75px'
disabled={isOntimeCloud} isDisabled={isOntimeCloud}
{...register('serverPort', { {...register('serverPort', {
required: { value: true, message: 'Required field' }, required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' }, max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
@@ -4,7 +4,7 @@ import { IoEyeOutline } from 'react-icons/io5';
import { IconButton, Input, InputGroup, InputRightElement } from '@chakra-ui/react'; import { IconButton, Input, InputGroup, InputRightElement } from '@chakra-ui/react';
import { Settings } from 'ontime-types'; import { Settings } from 'ontime-types';
import { isAlphanumeric } from '../../../../../common/utils/regex'; import { isAlphanumeric } from '../../../../common/utils/regex';
interface GeneralPinInputProps { interface GeneralPinInputProps {
register: UseFormRegister<Settings>; register: UseFormRegister<Settings>;
@@ -8,8 +8,5 @@
} }
.column { .column {
width: 100%;
display: flex;
gap: 1rem;
flex-direction: column; flex-direction: column;
} }
@@ -1,10 +1,18 @@
import { lazy, useEffect, useRef, useState } from 'react'; import { lazy, useEffect, useRef, useState } from 'react';
import {
Button,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react';
import { getCSSContents, postCSSContents, restoreCSSContents } from '../../../../../common/api/assets'; import { getCSSContents, postCSSContents, restoreCSSContents } from '../../../../common/api/assets';
import Button from '../../../../../common/components/buttons/Button'; import Info from '../../../../common/components/info/Info';
import Info from '../../../../../common/components/info/Info'; import * as Panel from '../../panel-utils/PanelUtils';
import Modal from '../../../../../common/components/modal/Modal';
import * as Panel from '../../../panel-utils/PanelUtils';
import style from './StyleEditorModal.module.scss'; import style from './StyleEditorModal.module.scss';
@@ -19,7 +27,9 @@ interface CSSRef {
getCss: () => string; getCss: () => string;
} }
export default function CodeEditorModal({ isOpen, onClose }: CodeEditorModalProps) { export default function CodeEditorModal(props: CodeEditorModalProps) {
const { isOpen, onClose } = props;
const [css, setCSS] = useState(''); const [css, setCSS] = useState('');
const [isDirty, setIsDirty] = useState(false); const [isDirty, setIsDirty] = useState(false);
const [saveLoading, setSaveLoading] = useState(false); const [saveLoading, setSaveLoading] = useState(false);
@@ -74,43 +84,46 @@ export default function CodeEditorModal({ isOpen, onClose }: CodeEditorModalProp
}, [isOpen]); }, [isOpen]);
return ( return (
<Modal <Modal isOpen={isOpen} onClose={onClose} variant='ontime' isCentered>
title='Edit CSS override' <ModalOverlay />
isOpen={isOpen} <ModalContent maxWidth='max(800px, 40vw)'>
onClose={onClose} <ModalHeader>Edit CSS override</ModalHeader>
showCloseButton <ModalCloseButton />
showBackdrop <ModalBody>
bodyElements={ <CodeEditor ref={cssRef} initialValue={css} language='css' isDirty={isDirty} setIsDirty={setIsDirty} />
<CodeEditor ref={cssRef} initialValue={css} language='css' isDirty={isDirty} setIsDirty={setIsDirty} /> </ModalBody>
}
footerElements={ <ModalFooter className={style.column}>
<div className={style.column}>
<Info>Invalid CSS will be refused by the browser</Info> <Info>Invalid CSS will be refused by the browser</Info>
{error && <Panel.Error className={style.right}>{`Error: ${error}`}</Panel.Error>} {error && <Panel.Error className={style.right}>{`Error: ${error}`}</Panel.Error>}
<Panel.InlineElements align='apart' className={style.editorActions}> <Panel.InlineElements align='apart' className={style.editorActions}>
<Button variant='ghosted' size='large' onClick={handleRestore} disabled={saveLoading || resetLoading}> <Button
variant='ontime-ghosted'
onClick={handleRestore}
isDisabled={saveLoading || resetLoading}
isLoading={resetLoading}
>
Reset to example Reset to example
</Button> </Button>
<Panel.InlineElements> <Panel.InlineElements>
<Button variant='ghosted' size='large' onClick={clear}> <Button variant='ontime-ghosted' onClick={clear}>
Clear Clear
</Button> </Button>
<Button size='large' onClick={onClose}> <Button variant='ontime-subtle' onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button <Button
variant='primary' variant='ontime-filled'
size='large'
onClick={handleSave} onClick={handleSave}
disabled={saveLoading || resetLoading || !isDirty} isDisabled={saveLoading || resetLoading || !isDirty}
loading={saveLoading} isLoading={saveLoading}
> >
Save changes Save changes
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
</Panel.InlineElements> </Panel.InlineElements>
</div> </ModalFooter>
} </ModalContent>
/> </Modal>
); );
} }
@@ -1,26 +1,26 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { Switch } from '@chakra-ui/react'; import { Button, Input, Switch, useDisclosure } from '@chakra-ui/react';
import { useDisclosure } from '@mantine/hooks'; import { ViewSettings } from 'ontime-types';
import { ViewSettings as ViewSettingsType } from 'ontime-types';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
import { SwatchPickerRHF } from '../../../../common/components/input/colour-input/SwatchPicker'; 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 ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import useInfo from '../../../../common/hooks-query/useInfo';
import useViewSettings from '../../../../common/hooks-query/useViewSettings'; import useViewSettings from '../../../../common/hooks-query/useViewSettings';
import { preventEscape } from '../../../../common/utils/keyEvent'; import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import CodeEditorModal from './composite/StyleEditorModal'; import CodeEditorModal from './StyleEditorModal';
const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/'; const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/';
export default function ViewSettings() { export default function ViewSettingsForm() {
const { data, isPending, mutateAsync } = useViewSettings(); const { data, isPending, mutateAsync } = useViewSettings();
const [isCodeEditorOpen, codeEditorHandler] = useDisclosure(); const { data: info, status: infoStatus } = useInfo();
const { isOpen: isCodeEditorOpen, onOpen: onCodeEditorOpen, onClose: onCodeEditorClose } = useDisclosure();
const { const {
control, control,
@@ -29,7 +29,7 @@ export default function ViewSettings() {
register, register,
reset, reset,
formState: { isSubmitting, isDirty, errors }, formState: { isSubmitting, isDirty, errors },
} = useForm<ViewSettingsType>({ } = useForm<ViewSettings>({
defaultValues: data, defaultValues: data,
values: data, values: data,
resetOptions: { resetOptions: {
@@ -44,7 +44,7 @@ export default function ViewSettings() {
} }
}, [data, reset]); }, [data, reset]);
const onSubmit = async (formData: ViewSettingsType) => { const onSubmit = async (formData: ViewSettings) => {
try { try {
mutateAsync(formData); mutateAsync(formData);
} catch (error) { } catch (error) {
@@ -61,6 +61,8 @@ export default function ViewSettings() {
return null; return null;
} }
const isLoading = isPending || infoStatus === 'pending';
return ( return (
<Panel.Section <Panel.Section
as='form' as='form'
@@ -72,25 +74,33 @@ export default function ViewSettings() {
<Panel.SubHeader> <Panel.SubHeader>
View settings View settings
<Panel.InlineElements> <Panel.InlineElements>
<Button disabled={!isDirty} variant='ghosted' onClick={onReset}> <Button isDisabled={!isDirty} variant='ontime-ghosted' size='sm' onClick={onReset}>
Revert to saved Revert to saved
</Button> </Button>
<Button type='submit' loading={isSubmitting} disabled={!isDirty} variant='primary'> <Button type='submit' isLoading={isSubmitting} isDisabled={!isDirty} variant='ontime-filled' size='sm'>
Save Save
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
</Panel.SubHeader> </Panel.SubHeader>
<Panel.Divider /> <Panel.Divider />
<Info> <Info>
You can customise the styles applied to Ontime views by providing overriding CSS rules. You can the Ontime views or customise its styles by modifying the provided CSS file.
<br /> <br />
{!isOntimeCloud && (
<>
<br />
The loaded CSS file is in the user directory at{' '}
<Panel.BlockQuote>{`${info.publicDir}/user/styles/override.css`}</Panel.BlockQuote>
<br />
</>
)}
<ExternalLink href={cssOverrideDocsUrl}>See the docs</ExternalLink> <ExternalLink href={cssOverrideDocsUrl}>See the docs</ExternalLink>
</Info> </Info>
<Panel.Section> <Panel.Section>
<Panel.Loader isLoading={isPending} /> <Panel.Loader isLoading={isLoading} />
<Panel.Error>{errors.root?.message}</Panel.Error> <Panel.Error>{errors.root?.message}</Panel.Error>
<Panel.ListGroup> <Panel.ListGroup>
<CodeEditorModal isOpen={isCodeEditorOpen} onClose={codeEditorHandler.close} /> <CodeEditorModal isOpen={isCodeEditorOpen} onClose={onCodeEditorClose} />
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title='Override CSS styles' title='Override CSS styles'
@@ -103,7 +113,13 @@ export default function ViewSettings() {
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} /> <Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)} )}
/> />
<Button onClick={codeEditorHandler.open} disabled={isSubmitting}> <Button
onClick={onCodeEditorOpen}
variant='ontime-subtle'
size='sm'
isDisabled={isSubmitting}
width='fit-content'
>
Edit CSS override Edit CSS override
</Button> </Button>
</Panel.ListItem> </Panel.ListItem>
@@ -142,8 +158,11 @@ export default function ViewSettings() {
description='Message for negative timers; applies only if the timer isn`t frozen on End. If no message is provided, it continues into negative time' description='Message for negative timers; applies only if the timer isn`t frozen on End. If no message is provided, it continues into negative time'
/> />
<Input <Input
size='sm'
autoComplete='off'
variant='ontime-filled'
maxLength={150} maxLength={150}
style={{ width: '275px' }} width='275px'
placeholder='Shown when timer reaches end' placeholder='Shown when timer reaches end'
{...register('endMessage')} {...register('endMessage')}
/> />
@@ -6,7 +6,7 @@ import TimeInput from '../../../../common/components/input/time-input/TimeInput'
import { editorSettingsDefaults, useEditorSettings } from '../../../../common/stores/editorSettings'; import { editorSettingsDefaults, useEditorSettings } from '../../../../common/stores/editorSettings';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
export default function RundownDefaultSettings() { export default function EditorSettingsForm() {
const { const {
defaultDuration, defaultDuration,
linkPrevious, linkPrevious,
@@ -31,10 +31,10 @@ export default function RundownDefaultSettings() {
return ( return (
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
<Panel.SubHeader>Rundown defaults</Panel.SubHeader> <Panel.SubHeader>Editor settings</Panel.SubHeader>
<Panel.Divider /> <Panel.Divider />
<Panel.Section> <Panel.Section>
<Panel.Title>Default settings for new events</Panel.Title> <Panel.Title>Rundown defaults for new events</Panel.Title>
<Panel.ListGroup> <Panel.ListGroup>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
@@ -126,6 +126,44 @@ export default function RundownDefaultSettings() {
</Panel.ListItem> </Panel.ListItem>
</Panel.ListGroup> </Panel.ListGroup>
</Panel.Section> </Panel.Section>
<Panel.Section>
<Panel.Title>Run mode</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Show quick entry'
description='Whether the quick entry buttons show above / under selected event'
/>
<Switch variant='ontime' size='lg' defaultChecked={false} isDisabled />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Follow playback'
description='Whether view automatically follows the event being played'
/>
<Switch variant='ontime' size='lg' defaultChecked isDisabled />
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
<Panel.Section>
<Panel.Title>Edit mode</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Show quick entry'
description='Whether the quick entry buttons show above / under selected event'
/>
<Switch variant='ontime' size='lg' defaultChecked isDisabled />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Follow playback'
description='Whether view automatically follows the event being played'
/>
<Switch variant='ontime' size='lg' defaultChecked={false} isDisabled />
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
</Panel.Card> </Panel.Card>
</Panel.Section> </Panel.Section>
); );
@@ -1,209 +0,0 @@
import { useState, useEffect } from 'react'; // Import useEffect
import { IoAdd } from 'react-icons/io5';
import { CustomField, CustomFieldKey } from 'ontime-types';
// Import CustomFieldWithKey
import { deleteCustomField, editCustomField, postCustomField, CustomFieldWithKey } from '../../../../common/api/customFields';
import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
import { customFieldsDocsUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import CustomFieldEntry from './composite/CustomFieldEntry';
import CustomFieldForm from './composite/CustomFieldForm';
export default function CustomFieldSettings() {
const { data, refetch } = useCustomFields(); // data is CustomFieldWithKey[]
const [isAdding, setIsAdding] = useState(false);
const [displayedFields, setDisplayedFields] = useState<CustomFieldWithKey[]>([]);
const [orderChanged, setOrderChanged] = useState(false); // To track if order has changed for enabling Save button
const [isSavingOrder, setIsSavingOrder] = useState(false); // For Save Order button loading state
useEffect(() => {
// Initialize displayedFields with fetched data, ensuring a fresh copy for local manipulation
// And sort it initially, as the backend already provides it sorted, but this ensures consistency.
setDisplayedFields(data ? [...data].sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity)) : []);
// When data is refetched from server (e.g. after save), reset orderChanged flag
setOrderChanged(false);
}, [data]);
const handleSaveOrder = async () => {
setIsSavingOrder(true);
const updatePromises = [];
// Create a map of original orders for quick lookup
const originalFieldsMap = new Map(data.map(f => [f.key, f]));
for (let i = 0; i < displayedFields.length; i++) {
const currentField = displayedFields[i];
const originalField = originalFieldsMap.get(currentField.key);
// The new order is its current index in the displayedFields array
const newOrder = i;
// Check if the effective order has changed, or if it's a new field without an original order yet (though create handles initial order)
// or if the field itself is new and not in originalFieldsMap (less likely here as it should have been created)
if (!originalField || originalField.order !== newOrder) {
// Only update if the order property is actually different
// We need to ensure the object passed to editCustomField has all required CustomField props
// and the order. The 'key' is passed as the first argument to editCustomField.
const fieldToSave: Partial<CustomField> = {
label: currentField.label,
type: currentField.type,
colour: currentField.colour,
order: newOrder,
};
updatePromises.push(editCustomField(currentField.key, fieldToSave));
}
}
try {
await Promise.all(updatePromises);
setOrderChanged(false);
refetch(); // Refetch data from the server to confirm and get fresh state
} catch (error) {
console.error("Error saving custom field order:", error);
// Potentially show an error message to the user
} finally {
setIsSavingOrder(false);
}
};
const handleInitiateCreate = () => {
setIsAdding(true);
};
const handleCancel = () => {
setIsAdding(false);
};
const handleCreate = async (customField: CustomField) => {
await postCustomField(customField);
refetch();
setIsAdding(false);
};
const handleEditField = async (key: CustomFieldKey, customField: CustomField) => {
await editCustomField(key, customField);
refetch();
};
const handleDelete = async (key: CustomFieldKey) => {
try {
await deleteCustomField(key);
refetch();
} catch (_error) {
/** we do not handle errors here */
}
};
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>
Custom fields
<Panel.InlineElements>
<Button onClick={handleSaveOrder} variant='primary' disabled={!orderChanged || isSavingOrder} loading={isSavingOrder}>
Save Order
</Button>
<Button onClick={handleInitiateCreate} disabled={isSavingOrder}>
New <IoAdd />
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
<Info>
Custom fields allow for additional information to be added to an event.
<br />
<br />
This data can be used in the Automation feature by using the generated key.
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
</Info>
</Panel.Section>
<Panel.Section>
{isAdding && <CustomFieldForm onSubmit={handleCreate} onCancel={handleCancel} />}
<Panel.Table>
<thead>
<tr>
<th>Colour</th>
<th>Type</th>
<th>Name</th>
<th>Key (used in Integrations)</th>
<th />
</tr>
</thead>
<tbody>
{displayedFields.map((field, index) => { // Iterate over displayedFields
return (
<CustomFieldEntry
key={field.key}
fieldKey={field.key}
colour={field.colour}
label={field.label}
type={field.type}
order={field.order} // Pass order
onEdit={handleEditField}
onDelete={handleDelete}
// For reordering
isFirst={index === 0}
isLast={index === displayedFields.length - 1} // Use displayedFields.length
onMove={(direction: 'up' | 'down') => {
const newFields = [...displayedFields];
const fieldToMove = newFields[index];
let neighborIndex = -1;
if (direction === 'up' && index > 0) {
neighborIndex = index - 1;
} else if (direction === 'down' && index < newFields.length - 1) {
neighborIndex = index + 1;
}
if (neighborIndex !== -1) {
const neighborField = newFields[neighborIndex];
// Swap order properties
const tempOrder = fieldToMove.order;
fieldToMove.order = neighborField.order;
neighborField.order = tempOrder;
// Actual swap in the array for immediate UI feedback before potential sort
newFields[index] = neighborField;
newFields[neighborIndex] = fieldToMove;
// Sort by the new order values to ensure dense packing if orders were sparse or undefined
// This also handles cases where initial orders might not be perfectly sequential.
newFields.sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity));
// If orders became non-dense or undefined, re-assign dense orders
// This ensures that when we save, we send a clean, sequential order.
let orderIsDirty = false;
newFields.forEach((f, i) => {
if(f.order !== i) {
f.order = i;
orderIsDirty = true;
}
});
// If we had to re-assign orders, sort again just in case (though should be sorted)
if(orderIsDirty) {
newFields.sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity));
}
setDisplayedFields(newFields);
setOrderChanged(true);
}
}}
/>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -1,33 +0,0 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import SourcesPanel from './sources-panel/SourcesPanel';
import CustomFieldSettings from './CustomFields';
import ManageRundowns from './ManageRundowns';
import RundownDefaultSettings from './RundownDefaultSettings';
export default function ManagePanel({ location }: PanelBaseProps) {
const defaultsRef = useScrollIntoView<HTMLDivElement>('defaults', location);
const customRef = useScrollIntoView<HTMLDivElement>('custom', location);
const rundownsRef = useScrollIntoView<HTMLDivElement>('rundowns', location);
const sheetsRef = useScrollIntoView<HTMLDivElement>('sheets', location);
return (
<>
<Panel.Header>Project data</Panel.Header>
<div ref={defaultsRef}>
<RundownDefaultSettings />
</div>
<div ref={customRef}>
<CustomFieldSettings />
</div>
<div ref={rundownsRef}>
<ManageRundowns />
</div>
<div ref={sheetsRef}>
<SourcesPanel />
</div>
</>
);
}
@@ -1,111 +0,0 @@
import { IoAdd } from 'react-icons/io5';
import { useDisclosure } from '@mantine/hooks';
import Button from '../../../../common/components/buttons/Button';
import Dialog from '../../../../common/components/dialog/Dialog';
import { useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './ManagePanel.module.scss';
export default function ManageRundowns() {
const { data } = useProjectRundowns();
const [deleteOpen, deleteHandlers] = useDisclosure();
const [loadOpen, loadHandlers] = useDisclosure();
return (
<>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>
Manage project rundowns
<Panel.InlineElements>
<Button onClick={() => undefined} disabled>
New <IoAdd />
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Table>
<thead>
<tr>
<th># Entries</th>
<th style={{ width: '100%' }}>Title</th>
<th />
</tr>
</thead>
<tbody>
{data.rundowns.map((rundown) => {
const isLoaded = data.loaded === rundown.id;
return (
<tr key={rundown.id} className={cx([isLoaded && style.current])}>
<td>{rundown.numEntries}</td>
<td>{`${rundown.title}${isLoaded && ' (loaded)'}`}</td>
<Panel.InlineElements as='td'>
<Button size='small' onClick={() => loadHandlers.open()} disabled={isLoaded}>
Load
</Button>
<Button
size='small'
variant='subtle-destructive'
onClick={() => deleteHandlers.open()}
disabled={isLoaded}
>
Delete
</Button>
</Panel.InlineElements>
</tr>
);
})}
</tbody>
</Panel.Table>
</Panel.Card>
</Panel.Section>
<Dialog
isOpen={deleteOpen}
onClose={deleteHandlers.close}
title='Load rundown'
showBackdrop
showCloseButton
bodyElements={
<>
You will lose all data in your rundown. <br /> Are you sure?
</>
}
footerElements={
<>
<Button size='large' onClick={deleteHandlers.close}>
Cancel
</Button>
<Button variant='destructive' size='large' onClick={() => undefined}>
Delete rundown
</Button>
</>
}
/>
<Dialog
isOpen={loadOpen}
onClose={loadHandlers.close}
title='Delete rundown'
showBackdrop
showCloseButton
bodyElements={
<>
The current playback will be stopped. <br /> Are you sure?
</>
}
footerElements={
<>
<Button size='large' onClick={loadHandlers.close}>
Cancel
</Button>
<Button variant='primary' size='large' onClick={() => undefined}>
Load rundown
</Button>
</>
}
/>
</>
);
}
@@ -12,8 +12,3 @@
color: $label-gray; color: $label-gray;
user-select: text; user-select: text;
} }
.copiableLink {
user-select: text;
color: $ui-white;
}
@@ -1,13 +1,13 @@
import { useState } from 'react'; import { useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import QRCode from 'react-qr-code'; import QRCode from 'react-qr-code';
import { Button, Select, Switch } from '@chakra-ui/react';
import { generateUrl } from '../../../../common/api/session'; import { generateUrl } from '../../../../common/api/session';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
import Select from '../../../../common/components/select/Select'; import useInfo from '../../../../common/hooks-query/useInfo';
import Switch from '../../../../common/components/switch/Switch'; import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
import copyToClipboard from '../../../../common/utils/copyToClipboard'; import copyToClipboard from '../../../../common/utils/copyToClipboard';
import { preventEscape } from '../../../../common/utils/keyEvent'; import { preventEscape } from '../../../../common/utils/keyEvent';
import { linkToOtherHost } from '../../../../common/utils/linkUtils'; import { linkToOtherHost } from '../../../../common/utils/linkUtils';
@@ -16,12 +16,6 @@ import * as Panel from '../../panel-utils/PanelUtils';
import style from './GenerateLinkForm.module.scss'; import style from './GenerateLinkForm.module.scss';
interface GenerateLinkFormProps {
hostOptions: { value: string; label: string }[];
pathOptions: { value: string; label: string }[];
isLockedToView?: boolean;
}
interface GenerateLinkFormOptions { interface GenerateLinkFormOptions {
baseUrl: string; baseUrl: string;
path: string; path: string;
@@ -31,21 +25,22 @@ interface GenerateLinkFormOptions {
type GenerateLinkState = 'pending' | 'loading' | 'success' | 'error'; type GenerateLinkState = 'pending' | 'loading' | 'success' | 'error';
export default function GenerateLinkForm({ hostOptions, pathOptions, isLockedToView }: GenerateLinkFormProps) { export default function GenerateLinkForm() {
const { data: infoData } = useInfo();
const { data: urlPresetData } = useUrlPresets();
const [formState, setFormState] = useState<GenerateLinkState>('pending'); const [formState, setFormState] = useState<GenerateLinkState>('pending');
const [url, setUrl] = useState(serverURL); const [url, setUrl] = useState(serverURL);
const { const {
handleSubmit, handleSubmit,
register,
setError, setError,
watch,
setValue,
formState: { errors }, formState: { errors },
} = useForm<GenerateLinkFormOptions>({ } = useForm<GenerateLinkFormOptions>({
mode: 'onChange', mode: 'onChange',
defaultValues: { defaultValues: {
baseUrl: currentHostName, baseUrl: currentHostName,
path: isLockedToView ? pathOptions[0].value : 'timer', path: '',
lock: false, lock: false,
authenticate: false, authenticate: false,
}, },
@@ -75,64 +70,75 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, isLockedToV
return ( return (
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} onKeyDown={(event) => preventEscape(event)}> <Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} onKeyDown={(event) => preventEscape(event)}>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>} {errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
{!isLockedToView ? ( <Info>
<Info> <Panel.Paragraph>
<Panel.Paragraph> You can generate a link to share with your team or to use in automation (such as companion).
You can generate a link to share with your team or to use in automation (such as companion). </Panel.Paragraph>
</Panel.Paragraph> </Info>
</Info>
) : (
<Info>
<Panel.Paragraph>You can generate a link to share with your team</Panel.Paragraph>
</Info>
)}
<Panel.ListGroup> <Panel.ListGroup>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title='Host IP' title='Host IP'
description={`Which IP address will be used${isOntimeCloud ? ' (not applicable in Ontime Cloud)' : ''}`} description={`Which IP address will be used${isOntimeCloud ? ' (not applicable in Ontime Cloud)' : ''}`}
/> />
<Select <Select variant='ontime' isDisabled={isOntimeCloud} size='sm' {...register('baseUrl')}>
disabled={isOntimeCloud} {infoData.networkInterfaces.map((nif) => {
options={hostOptions} return (
value={watch('baseUrl')} <option key={nif.name} value={nif.address}>
onValueChange={(value) => setValue('baseUrl', value)} {`${nif.name} - ${nif.address}`}
/> </option>
);
})}
</Select>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='URL Preset'
description='Which preset will the link point to (will default to /timer if none is given)'
/>
<Select variant='ontime' size='sm' {...register('path')}>
<option key='timer' value='timer'>
Timer
</option>
<option key='companion' value=''>
Companion
</option>
{urlPresetData.map((preset) => {
return (
<option key={preset.alias} value={preset.alias}>
{`Preset: ${preset.alias}`}
</option>
);
})}
</Select>
</Panel.ListItem> </Panel.ListItem>
{isLockedToView ? (
<input type='hidden' value={watch('path')} />
) : (
<Panel.ListItem>
<Panel.Field title='Ontime view' description='Which view or preset will the link point to' />
<Select options={pathOptions} value={watch('path')} onValueChange={(value) => setValue('path', value)} />
</Panel.ListItem>
)}
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title='Lock navigation' title='Lock navigation'
description='Prevent showing navigation (will only work for non production URLs)' description='Prevent showing navigation (will only work for non production URLs)'
/> />
<Switch name='lock' checked={watch('lock')} onCheckedChange={(checked) => setValue('lock', checked)} /> <Switch variant='ontime' size='lg' {...register('lock')} />
</Panel.ListItem> </Panel.ListItem>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field title='Authenticate' description='Whether the URL should be pre-authenticated' /> <Panel.Field title='Authenticate' description='Whether the URL should be pre-authenticated' />
<Switch <Switch variant='ontime' size='lg' {...register('authenticate')} />
name='authenticate'
checked={watch('authenticate')}
onCheckedChange={(checked) => setValue('authenticate', checked)}
/>
</Panel.ListItem> </Panel.ListItem>
</Panel.ListGroup> </Panel.ListGroup>
<Panel.ListGroup> <Panel.ListGroup>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field title='Generate link' description='Fill form and generate link and QR code' /> <Panel.Field title='Generate link' description='Fill form and generate link and QR code' />
<Button variant='primary' loading={formState === 'loading'} type='submit' style={{ alignSelf: 'end' }}> <Button
variant='ontime-filled'
size='sm'
isLoading={formState === 'loading'}
type='submit'
style={{ alignSelf: 'end' }}
>
{formState === 'success' ? 'Link copied to clipboard!' : 'Update share link'} {formState === 'success' ? 'Link copied to clipboard!' : 'Update share link'}
</Button> </Button>
<div className={style.column}> <div className={style.column}>
<QRCode size={172} value={url} className={style.qrCode} /> <QRCode size={172} value={url} className={style.qrCode} />
<div className={style.copiableLink}>{url}</div> <div>{url}</div>
</div> </div>
</Panel.ListItem> </Panel.ListItem>
</Panel.ListGroup> </Panel.ListGroup>
@@ -1,7 +1,7 @@
import { MouseEvent } from 'react'; import { MouseEvent } from 'react';
import { IoArrowUp } from 'react-icons/io5'; import { IoArrowUp } from 'react-icons/io5';
import { Button } from '@chakra-ui/react';
import Button from '../../../../common/components/buttons/Button';
import { handleLinks } from '../../../../common/utils/linkUtils'; import { handleLinks } from '../../../../common/utils/linkUtils';
import Log from '../../../log/Log'; import Log from '../../../log/Log';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -18,8 +18,13 @@ export default function LogExport() {
<Panel.Card> <Panel.Card>
<Panel.SubHeader> <Panel.SubHeader>
Event log Event log
<Button onClick={extract}> <Button
Extract <IoArrowUp className={style.iconRotate} /> variant='ontime-subtle'
size='sm'
rightIcon={<IoArrowUp className={style.iconRotate} />}
onClick={extract}
>
Extract
</Button> </Button>
</Panel.SubHeader> </Panel.SubHeader>
<Panel.Divider /> <Panel.Divider />
@@ -4,14 +4,17 @@ import { MessageTag } from 'ontime-types';
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { usePing } from '../../../../common/hooks/useSocket'; import { usePing } from '../../../../common/hooks/useSocket';
import { sendSocket } from '../../../../common/utils/socket'; import { sendSocket } from '../../../../common/utils/socket';
import { isDockerImage } from '../../../../externals'; import { isDockerImage, isOntimeCloud } from '../../../../externals';
import type { PanelBaseProps } from '../../panel-list/PanelList'; import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import ClientControlPanel from '../client-control-panel/ClientControlPanel';
import ClientControlPanel from './client-control/ClientControlPanel'; import GenerateLinkForm from './GenerateLinkForm';
import InfoNif from './NetworkInterfaces';
import LogExport from './NetworkLogExport'; import LogExport from './NetworkLogExport';
export default function NetworkLogPanel({ location }: PanelBaseProps) { export default function NetworkLogPanel({ location }: PanelBaseProps) {
const linkRef = useScrollIntoView<HTMLDivElement>('link', location);
const clientsRef = useScrollIntoView<HTMLDivElement>('clients', location); const clientsRef = useScrollIntoView<HTMLDivElement>('clients', location);
const logRef = useScrollIntoView<HTMLDivElement>('log', location); const logRef = useScrollIntoView<HTMLDivElement>('log', location);
@@ -23,6 +26,21 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
<OntimeCloudStats /> <OntimeCloudStats />
</Panel.Section> </Panel.Section>
)} )}
<div ref={linkRef}>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Share Ontime Link</Panel.SubHeader>
<Panel.Divider />
{!isOntimeCloud && (
<>
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
<InfoNif />
</>
)}
<GenerateLinkForm />
</Panel.Card>
</Panel.Section>
</div>
<div ref={logRef}> <div ref={logRef}>
<LogExport /> <LogExport />
</div> </div>
@@ -1,10 +1,10 @@
import { ChangeEvent, useRef, useState } from 'react'; import { ChangeEvent, useRef, useState } from 'react';
import { IoAdd } from 'react-icons/io5'; import { IoAdd } from 'react-icons/io5';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { Button, Input } from '@chakra-ui/react';
import { uploadProjectFile } from '../../../../common/api/db'; import { uploadProjectFile } from '../../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils'; import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import { validateProjectFile } from '../../../../common/utils/uploadUtils'; import { validateProjectFile } from '../../../../common/utils/uploadUtils';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -57,7 +57,7 @@ export default function ManageProjects() {
return ( return (
<Panel.Section> <Panel.Section>
<input <Input
ref={fileInputRef} ref={fileInputRef}
style={{ display: 'none' }} style={{ display: 'none' }}
type='file' type='file'
@@ -70,14 +70,22 @@ export default function ManageProjects() {
Manage projects Manage projects
<Panel.InlineElements> <Panel.InlineElements>
<Button <Button
variant='ontime-subtle'
onClick={handleSelectFile} onClick={handleSelectFile}
disabled={Boolean(loading) || isCreatingProject} size='sm'
loading={loading === 'import'} isDisabled={Boolean(loading) || isCreatingProject}
isLoading={loading === 'import'}
> >
Import Import
</Button> </Button>
<Button onClick={handleToggleCreate} disabled={Boolean(loading) || isCreatingProject}> <Button
New <IoAdd /> variant='ontime-subtle'
onClick={handleToggleCreate}
size='sm'
isDisabled={Boolean(loading) || isCreatingProject}
rightIcon={<IoAdd />}
>
New
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
</Panel.SubHeader> </Panel.SubHeader>
@@ -1,14 +1,12 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoTrash } from 'react-icons/io5'; import { IoTrash } from 'react-icons/io5';
import { Button, Input, Textarea } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { PROJECT_LIST } from '../../../../common/api/constants'; import { PROJECT_LIST } from '../../../../common/api/constants';
import { createProject } from '../../../../common/api/db'; import { createProject } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import Textarea from '../../../../common/components/input/textarea/Textarea';
import { preventEscape } from '../../../../common/utils/keyEvent'; import { preventEscape } from '../../../../common/utils/keyEvent';
import { documentationUrl } from '../../../../externals'; import { documentationUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -88,10 +86,10 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
<Panel.Title> <Panel.Title>
Create new project Create new project
<Panel.InlineElements> <Panel.InlineElements>
<Button onClick={onClose} variant='ghosted' disabled={isSubmitting}> <Button onClick={onClose} variant='ontime-ghosted' size='sm' isDisabled={isSubmitting}>
Cancel Cancel
</Button> </Button>
<Button disabled={!isValid} type='submit' loading={isSubmitting} variant='primary'> <Button isDisabled={!isValid} type='submit' isLoading={isSubmitting} variant='ontime-filled' size='sm'>
Create project Create project
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
@@ -100,31 +98,53 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
<Panel.Section className={style.innerColumn}> <Panel.Section className={style.innerColumn}>
<label> <label>
Project title Project title
<Input fluid maxLength={50} placeholder='Your project name' {...register('title')} /> <Input
variant='ontime-filled'
size='sm'
maxLength={50}
placeholder='Your project name'
autoComplete='off'
{...register('title')}
/>
</label> </label>
<label> <label>
Project description Project description
<Input fluid maxLength={100} placeholder='Euro Love, Malmö 2024' {...register('description')} /> <Input
variant='ontime-filled'
size='sm'
maxLength={100}
placeholder='Euro Love, Malmö 2024'
autoComplete='off'
{...register('description')}
/>
</label> </label>
<label> <label>
Backstage info Backstage info
<Textarea <Textarea
fluid variant='ontime-filled'
size='sm'
maxLength={150} maxLength={150}
placeholder='Wi-Fi password: 1234' placeholder='Wi-Fi password: 1234'
resize='vertical' autoComplete='off'
resize='none'
{...register('backstageInfo')} {...register('backstageInfo')}
/> />
</label> </label>
<label> <label>
Backstage QR code Url Backstage QR code Url
<Input fluid placeholder={documentationUrl} {...register('backstageUrl')} /> <Input
variant='ontime-filled'
size='sm'
placeholder={documentationUrl}
autoComplete='off'
{...register('backstageUrl')}
/>
</label> </label>
<Panel.Section> <Panel.Section>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field title='Custom data' description='Add custom data for your project' /> <Panel.Field title='Custom data' description='Add custom data for your project' />
<Button onClick={handleAddCustom}> <Button variant='ontime-subtle' onClick={handleAddCustom}>
Add <IoAdd /> +
</Button> </Button>
</Panel.ListItem> </Panel.ListItem>
{fields.map((field, idx) => ( {fields.map((field, idx) => (
@@ -132,13 +152,25 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
<Panel.Paragraph>{idx + 1}.</Panel.Paragraph> <Panel.Paragraph>{idx + 1}.</Panel.Paragraph>
<label> <label>
Title Title
<Input placeholder={field.title} {...register(`custom.${idx}.title` as const)} /> <Input
variant='ontime-filled'
size='sm'
placeholder={field.title}
autoComplete='off'
{...register(`custom.${idx}.title` as const)}
/>
</label> </label>
<label> <label>
Value Value
<Input placeholder={field.value} autoComplete='off' {...register(`custom.${idx}.value` as const)} /> <Input
variant='ontime-filled'
size='sm'
placeholder={field.value}
autoComplete='off'
{...register(`custom.${idx}.value` as const)}
/>
</label> </label>
<Button variant='ghosted' onClick={() => remove(idx)}> <Button variant='ontime-ghosted' onClick={() => remove(idx)}>
<IoTrash /> <IoTrash />
</Button> </Button>
</div> </div>
@@ -1,21 +1,19 @@
import { ChangeEvent, useEffect, useRef } from 'react'; import { ChangeEvent, useEffect, useRef } from 'react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoDownloadOutline, IoTrash } from 'react-icons/io5'; import { IoAdd, IoDownloadOutline, IoTrash } from 'react-icons/io5';
import { Button, Input, Textarea } from '@chakra-ui/react';
import { type ProjectData } from 'ontime-types'; import { type ProjectData } from 'ontime-types';
import { projectLogoPath } from '../../../../common/api/constants'; import { projectLogoPath } from '../../../../common/api/constants';
import { postProjectData, uploadProjectLogo } from '../../../../common/api/project'; import { postProjectData, uploadProjectLogo } from '../../../../common/api/project';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import Textarea from '../../../../common/components/input/textarea/Textarea';
import useProjectData from '../../../../common/hooks-query/useProjectData'; import useProjectData from '../../../../common/hooks-query/useProjectData';
import { preventEscape } from '../../../../common/utils/keyEvent'; import { preventEscape } from '../../../../common/utils/keyEvent';
import { validateLogo } from '../../../../common/utils/uploadUtils'; import { validateLogo } from '../../../../common/utils/uploadUtils';
import { documentationUrl } from '../../../../externals'; import { documentationUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import style from './SettingsPanel.module.scss'; import style from './ProjectPanel.module.scss';
export default function ProjectData() { export default function ProjectData() {
const { data, status, refetch } = useProjectData(); const { data, status, refetch } = useProjectData();
@@ -114,10 +112,16 @@ export default function ProjectData() {
<Panel.SubHeader> <Panel.SubHeader>
Project data Project data
<Panel.InlineElements> <Panel.InlineElements>
<Button onClick={onReset} disabled={isSubmitting || !isDirty}> <Button variant='ontime-ghosted' size='sm' onClick={onReset} isDisabled={isSubmitting || !isDirty}>
Revert to saved Revert to saved
</Button> </Button>
<Button variant='primary' type='submit' disabled={!isDirty || !isValid} loading={isSubmitting}> <Button
variant='ontime-filled'
size='sm'
type='submit'
isDisabled={!isDirty || !isValid}
isLoading={isSubmitting}
>
Save Save
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
@@ -128,16 +132,20 @@ export default function ProjectData() {
<label> <label>
Project title Project title
<Input <Input
fluid variant='ontime-filled'
size='sm'
maxLength={50} maxLength={50}
placeholder='Project title is shown in production views' placeholder='Project title is shown in production views'
autoComplete='off'
{...register('title')} {...register('title')}
/> />
</label> </label>
<Panel.Section style={{ marginTop: 0 }}> <Panel.Section style={{ marginTop: 0 }}>
<label> <label>
Project logo Project logo
<input <Input
variant='ontime-filled'
size='sm'
type='file' type='file'
style={{ display: 'none' }} style={{ display: 'none' }}
accept='image/*' accept='image/*'
@@ -153,17 +161,25 @@ export default function ProjectData() {
<> <>
<img src={`${projectLogoPath}/${watch('projectLogo')}`} /> <img src={`${projectLogoPath}/${watch('projectLogo')}`} />
<Button <Button
variant='subtle-destructive' size='sm'
disabled={isSubmitting || !watch('projectLogo')} variant='ontime-filled'
isDisabled={isSubmitting || !watch('projectLogo')}
leftIcon={<IoTrash />}
onClick={handleDeleteLogo} onClick={handleDeleteLogo}
type='button'
> >
<IoTrash />
Delete Delete
</Button> </Button>
</> </>
) : ( ) : (
<Button disabled={isSubmitting} onClick={handleClickUpload} type='button'> <Button
<IoDownloadOutline /> variant='ontime-filled'
size='sm'
isDisabled={isSubmitting}
leftIcon={<IoDownloadOutline />}
onClick={handleClickUpload}
type='button'
>
Upload logo Upload logo
</Button> </Button>
)} )}
@@ -171,30 +187,44 @@ export default function ProjectData() {
</Panel.Card> </Panel.Card>
</label> </label>
</Panel.Section> </Panel.Section>
<label> <label>
Project description Project description
<Input fluid maxLength={100} placeholder='Euro Love, Malmö 2024' {...register('description')} /> <Input
variant='ontime-filled'
size='sm'
maxLength={100}
placeholder='Euro Love, Malmö 2024'
autoComplete='off'
{...register('description')}
/>
</label> </label>
<label> <label>
Backstage info Backstage info
<Textarea <Textarea
fluid variant='ontime-filled'
size='sm'
maxLength={150} maxLength={150}
placeholder='Wi-Fi password: 1234' placeholder='Wi-Fi password: 1234'
resize='vertical' autoComplete='off'
resize='none'
{...register('backstageInfo')} {...register('backstageInfo')}
/> />
</label> </label>
<label> <label>
Backstage QR code URL Backstage QR code URL
<Input fluid placeholder={documentationUrl} {...register('backstageUrl')} /> <Input
variant='ontime-filled'
size='sm'
placeholder={documentationUrl}
autoComplete='off'
{...register('backstageUrl')}
/>
</label> </label>
<Panel.Section style={{ marginTop: 0 }}> <Panel.Section style={{ marginTop: 0 }}>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field title='Custom data' description='' /> <Panel.Field title='Custom data' description='' />
<Button onClick={handleAddCustom}> <Button leftIcon={<IoAdd />} size='sm' variant='ontime-subtle' onClick={handleAddCustom}>
Add <IoAdd /> Add
</Button> </Button>
</Panel.ListItem> </Panel.ListItem>
{fields.length > 0 && {fields.length > 0 &&
@@ -207,31 +237,41 @@ export default function ProjectData() {
| undefined; | undefined;
return ( return (
<div key={field.id} className={style.customDataItem}> <div key={field.id} className={style.customDataItem}>
<div className={style.titleRow}> <div>
<label> <div className={style.titleRow}>
Title <label>
<Input Title
fluid <Input
defaultValue={field.title} variant='ontime-filled'
placeholder='Title of your custom data' size='sm'
{...register(`custom.${idx}.title`, { defaultValue={field.title}
required: { value: true, message: 'Field cannot be empty' }, placeholder='Title of your custom data'
})} autoComplete='off'
/> {...register(`custom.${idx}.title`, {
</label> required: { value: true, message: 'Field cannot be empty' },
<Button variant='subtle-destructive' onClick={() => remove(idx)}> })}
<IoTrash /> />
Delete Entry </label>
</Button> <Button
size='sm'
variant='ontime-subtle'
color='#FA5656' // $red-500
onClick={() => remove(idx)}
leftIcon={<IoTrash />}
>
Delete Entry
</Button>
</div>
{rowErrors?.title?.message && <Panel.Error>{rowErrors.title.message}</Panel.Error>}
</div> </div>
{rowErrors?.title?.message && <Panel.Error>{rowErrors.title.message}</Panel.Error>}
<label> <label>
Value Value
<Textarea <Textarea
fluid variant='ontime-filled'
rows={3} resize='none'
resize='vertical' size='sm'
defaultValue={field.value} defaultValue={field.value}
autoComplete='off'
placeholder='Text of your custom data' placeholder='Text of your custom data'
{...register(`custom.${idx}.value`, { {...register(`custom.${idx}.value`, {
required: { value: true, message: 'Field cannot be empty' }, required: { value: true, message: 'Field cannot be empty' },
@@ -1,8 +1,7 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { Button, Input } from '@chakra-ui/react';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import { preventEscape } from '../../../../common/utils/keyEvent'; import { preventEscape } from '../../../../common/utils/keyEvent';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -46,16 +45,21 @@ export default function ProjectForm({ action, filename, onSubmit, onCancel }: Pr
<Input <Input
className={style.formInput} className={style.formInput}
id='filename' id='filename'
size='sm'
type='text'
variant='ontime-filled'
placeholder='Enter new name' placeholder='Enter new name'
autoComplete='off'
{...register('filename', { required: true })} {...register('filename', { required: true })}
/> />
<Panel.InlineElements relation='inner'> <Panel.InlineElements relation='inner'>
<Button onClick={onCancel} variant='ghosted' disabled={isSubmitting}> <Button onClick={onCancel} size='sm' variant='ontime-ghosted' disabled={isSubmitting}>
Cancel Cancel
</Button> </Button>
<Button <Button
variant='primary' size='sm'
disabled={!isDirty || !isValid || isSubmitting} variant='ontime-filled'
isDisabled={!isDirty || !isValid || isSubmitting}
type='submit' type='submit'
className={style.saveButton} className={style.saveButton}
> >
@@ -1,6 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import Info from '../../../../common/components/info/Info';
import { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList'; import { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -9,7 +8,7 @@ import ProjectListItem, { EditMode } from './ProjectListItem';
import style from './ProjectPanel.module.scss'; import style from './ProjectPanel.module.scss';
export default function ProjectList() { export default function ProjectList() {
const { data, refetch, status } = useOrderedProjectList(); const { data, refetch } = useOrderedProjectList();
const [editingMode, setEditingMode] = useState<EditMode | null>(null); const [editingMode, setEditingMode] = useState<EditMode | null>(null);
const [editingFilename, setEditingFilename] = useState<string | null>(null); const [editingFilename, setEditingFilename] = useState<string | null>(null);
@@ -28,47 +27,30 @@ export default function ProjectList() {
await refetch(); await refetch();
}; };
if (status === 'pending') {
return (
<div className={style.empty}>
<Panel.Loader isLoading />
</div>
);
}
const numProjects = data.reorderedProjectFiles.length;
return ( return (
<> <Panel.Table>
{numProjects > 20 && ( <thead>
<Info className={style.warningInfo} type='warning'> <tr>
You have {numProjects} projects. Consider deleting unused projects to improve performance. <th className={style.containCell}>File Name</th>
</Info> <th>Last Used</th>
)} <th />
<Panel.Table> </tr>
<thead> </thead>
<tr> <tbody>
<th className={style.containCell}>File Name</th> {data.reorderedProjectFiles.map((project) => (
<th>Last Used</th> <ProjectListItem
<th /> key={project.filename}
</tr> filename={project.filename}
</thead> updatedAt={project.updatedAt}
<tbody> onToggleEditMode={handleToggleEditMode}
{data.reorderedProjectFiles.map((project) => ( onSubmit={handleClear}
<ProjectListItem onRefetch={handleRefetch}
key={project.filename} editingFilename={editingFilename}
filename={project.filename} editingMode={editingMode}
updatedAt={project.updatedAt} current={project.filename === data.lastLoadedProject}
onToggleEditMode={handleToggleEditMode} />
onSubmit={handleClear} ))}
onRefetch={handleRefetch} </tbody>
editingFilename={editingFilename} </Panel.Table>
editingMode={editingMode}
current={project.filename === data.lastLoadedProject}
/>
))}
</tbody>
</Panel.Table>
</>
); );
} }
@@ -1,12 +1,11 @@
import { useState } from 'react'; import { useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { Switch } from '@chakra-ui/react'; import { Button, Switch } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { PROJECT_DATA } from '../../../../common/api/constants'; import { PROJECT_DATA } from '../../../../common/api/constants';
import { getDb, patchData } from '../../../../common/api/db'; import { getDb, patchData } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -77,10 +76,16 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
<Panel.Title> <Panel.Title>
Merge {`"${fileName}"`} Merge {`"${fileName}"`}
<Panel.InlineElements> <Panel.InlineElements>
<Button onClick={onClose} variant='ghosted' disabled={isSubmitting}> <Button onClick={onClose} variant='ontime-ghosted' size='sm' isDisabled={isSubmitting}>
Cancel Cancel
</Button> </Button>
<Button type='submit' disabled={!isValid || !isDirty} loading={isSubmitting} variant='primary'> <Button
isDisabled={!isValid || !isDirty}
type='submit'
isLoading={isSubmitting}
variant='ontime-filled'
size='sm'
>
Merge Merge
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
@@ -26,10 +26,6 @@
max-width: 400px; max-width: 400px;
} }
.fullWidth {
width: 100%;
}
.innerColumn { .innerColumn {
margin: 0 2rem; margin: 0 2rem;
margin-bottom: 2rem; margin-bottom: 2rem;
@@ -45,11 +41,34 @@
} }
} }
.warningInfo { .uploadLogoCard {
margin-bottom: 1rem; display: flex;
gap: 1rem;
justify-content: center;
align-items: center;
flex-direction: column;
background-color: $gray-1350;
border: 1px solid $white-10;
border-radius: 3px;
img {
max-width: 250px;
height: auto;
}
} }
.empty { .customDataItem {
height: 300px; display: contents;
position: relative; width: 100%;
}
.titleRow{
display: flex;
gap: 1rem;
align-items: end;
label {
flex: 1;
}
}
}
@@ -5,13 +5,15 @@ import QuickStart from '../../quick-start/QuickStart';
import type { SettingsOptionId } from '../../useAppSettingsMenu'; import type { SettingsOptionId } from '../../useAppSettingsMenu';
import ManageProjects from './ManageProjects'; import ManageProjects from './ManageProjects';
import ProjectData from './ProjectData';
interface ProjectPanelProps extends PanelBaseProps { interface ProjectPanelProps extends PanelBaseProps {
setLocation: (location: SettingsOptionId) => void; setLocation: (location: SettingsOptionId) => void;
} }
export default function ProjectPanel({ location, setLocation }: ProjectPanelProps) { export default function ProjectPanel({ location, setLocation }: ProjectPanelProps) {
const manageProjectsRef = useScrollIntoView<HTMLDivElement>('list', location); const projectRef = useScrollIntoView<HTMLDivElement>('data', location);
const manageRef = useScrollIntoView<HTMLDivElement>('manage', location);
const handleQuickClose = () => { const handleQuickClose = () => {
setLocation('project'); setLocation('project');
@@ -21,7 +23,10 @@ export default function ProjectPanel({ location, setLocation }: ProjectPanelProp
<> <>
<Panel.Header>Project</Panel.Header> <Panel.Header>Project</Panel.Header>
<QuickStart isOpen={location === 'create'} onClose={handleQuickClose} /> <QuickStart isOpen={location === 'create'} onClose={handleQuickClose} />
<div ref={manageProjectsRef}> <div ref={projectRef}>
<ProjectData />
</div>
<div ref={manageRef}>
<ManageProjects /> <ManageProjects />
</div> </div>
</> </>
@@ -1,33 +0,0 @@
.uploadLogoCard {
display: flex;
gap: 1rem;
justify-content: center;
align-items: center;
flex-direction: column;
background-color: $gray-1350;
border: 1px solid $white-10;
border-radius: 3px;
img {
max-width: 250px;
height: auto;
}
}
.customDataItem {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.titleRow {
display: flex;
gap: 1rem;
align-items: end;
label {
flex: 1;
}
}
@@ -1,28 +0,0 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import GeneralSettings from './GeneralSettings';
import ProjectData from './ProjectData';
import ViewSettings from './ViewSettings';
export default function SettingsPanel({ location }: PanelBaseProps) {
const dataRef = useScrollIntoView<HTMLDivElement>('data', location);
const generalRef = useScrollIntoView<HTMLDivElement>('general', location);
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
return (
<>
<Panel.Header>Settings</Panel.Header>
<div ref={dataRef}>
<ProjectData />
</div>
<div ref={generalRef}>
<GeneralSettings />
</div>
<div ref={viewRef}>
<ViewSettings />
</div>
</>
);
}
@@ -1,20 +1,27 @@
import { useRef } from 'react'; import { useRef } from 'react';
import { useDisclosure } from '@mantine/hooks'; import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Button,
useDisclosure,
} from '@chakra-ui/react';
import Button from '../../../../common/components/buttons/Button';
import Dialog from '../../../../common/components/dialog/Dialog';
import { useElectronEvent } from '../../../../common/hooks/useElectronEvent'; import { useElectronEvent } from '../../../../common/hooks/useElectronEvent';
import { isLocalhost, isOntimeCloud } from '../../../../externals'; import { isLocalhost, isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
export default function ShutdownPanel() { export default function ShutdownPanel() {
const { isElectron, sendToElectron } = useElectronEvent(); const { isElectron, sendToElectron } = useElectronEvent();
const [isOpen, handler] = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const cancelRef = useRef<HTMLButtonElement | null>(null); const cancelRef = useRef<HTMLButtonElement | null>(null);
const sendShutdown = () => { const sendShutdown = () => {
sendToElectron('shutdown', 'now'); sendToElectron('shutdown', 'now');
handler.close(); onClose();
}; };
const canShutdown = isElectron || isLocalhost; const canShutdown = isElectron || isLocalhost;
@@ -33,33 +40,32 @@ export default function ShutdownPanel() {
The runtime state will be lost, but your project is kept for next time. The runtime state will be lost, but your project is kept for next time.
</Panel.Paragraph> </Panel.Paragraph>
)} )}
<Button variant='destructive' onClick={handler.open} disabled={!(isElectron || isLocalhost)}> <Button colorScheme='red' onClick={onOpen} maxWidth='350px' isDisabled={!(isElectron || isLocalhost)}>
Shutdown ontime Shutdown ontime
</Button> </Button>
{!canShutdown && ( {!canShutdown && (
<Panel.Description>Note: Ontime can only be shutdown from the machine it is running in.</Panel.Description> <Panel.Description>Note: Ontime can only be shutdown from the machine it is running in.</Panel.Description>
)} )}
<Dialog <AlertDialog variant='ontime' isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}>
isOpen={isOpen} <AlertDialogOverlay>
title='Shutdown Ontime' <AlertDialogContent>
showCloseButton <AlertDialogHeader fontSize='lg' fontWeight='bold'>
onClose={handler.close} Ontime Shutdown
bodyElements={ </AlertDialogHeader>
<Panel.Paragraph> <AlertDialogBody>
This will shutdown the Ontime server. <br /> Are you sure? This will shutdown the Ontime server. <br /> Are you sure?
</Panel.Paragraph> </AlertDialogBody>
} <AlertDialogFooter>
footerElements={ <Button ref={cancelRef} onClick={onClose} variant='ontime-ghosted-white'>
<> Cancel
<Button ref={cancelRef} onClick={handler.close} variant='ghosted-white'> </Button>
Cancel <Button colorScheme='red' onClick={sendShutdown} disabled={!canShutdown}>
</Button> Shutdown
<Button variant='destructive' onClick={sendShutdown} disabled={!canShutdown}> </Button>
Shutdown </AlertDialogFooter>
</Button> </AlertDialogContent>
</> </AlertDialogOverlay>
} </AlertDialog>
/>
</Panel.Section> </Panel.Section>
</> </>
); );
@@ -1,5 +1,5 @@
import Info from '../../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink'; import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadsheet-gsheet/'; const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadsheet-gsheet/';
@@ -1,13 +1,12 @@
import { ChangeEvent, useEffect, useState } from 'react'; import { ChangeEvent, useEffect, useState } from 'react';
import { IoCheckmark, IoShieldCheckmarkOutline } from 'react-icons/io5'; import { IoCheckmark, IoShieldCheckmarkOutline } from 'react-icons/io5';
import { Button, Input, Spinner } from '@chakra-ui/react';
import { getWorksheetNames } from '../../../../../common/api/sheets'; import { getWorksheetNames } from '../../../../common/api/sheets';
import { maybeAxiosError } from '../../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../../common/components/buttons/Button'; import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import CopyTag from '../../../../../common/components/copy-tag/CopyTag'; import { openLink } from '../../../../common/utils/linkUtils';
import Input from '../../../../../common/components/input/input/Input'; import * as Panel from '../../panel-utils/PanelUtils';
import { openLink } from '../../../../../common/utils/linkUtils';
import * as Panel from '../../../panel-utils/PanelUtils';
import useGoogleSheet from './useGoogleSheet'; import useGoogleSheet from './useGoogleSheet';
import { useSheetStore } from './useSheetStore'; import { useSheetStore } from './useSheetStore';
@@ -139,33 +138,50 @@ export default function GSheetSetup(props: GSheetSetupProps) {
<Panel.Title> <Panel.Title>
Sync with Google Sheet (experimental) Sync with Google Sheet (experimental)
{isAuthenticated ? ( {isAuthenticated ? (
<Button onClick={handleRevoke} loading={loading === 'cancel'}> <Button variant='ontime-subtle' size='sm' onClick={handleRevoke} isLoading={loading === 'cancel'}>
Revoke Authentication Revoke Authentication
</Button> </Button>
) : ( ) : (
<Button onClick={handleCancelFlow}>Go Back</Button> <Button variant='ontime-subtle' size='sm' onClick={handleCancelFlow}>
Go Back
</Button>
)} )}
</Panel.Title> </Panel.Title>
<Panel.ListGroup> <Panel.ListGroup>
<Panel.Description>Upload Client Secret provided by Google</Panel.Description> <Panel.Description>Upload Client Secret provided by Google</Panel.Description>
<Panel.Error>{authenticationError}</Panel.Error> <Panel.Error>{authenticationError}</Panel.Error>
<Input fluid type='file' onChange={handleClientSecret} accept='.json' disabled={isLoading || canAuthenticate} /> <Input
type='file'
onChange={handleClientSecret}
accept='.json'
size='sm'
variant='ontime-filled'
isDisabled={isLoading || canAuthenticate}
/>
</Panel.ListGroup> </Panel.ListGroup>
<Panel.ListGroup> <Panel.ListGroup>
<Panel.Description>Enter ID of sheet to synchronise</Panel.Description> <Panel.Description>Enter ID of sheet to synchronise</Panel.Description>
<Panel.Error>{undefined}</Panel.Error> <Panel.Error>{undefined}</Panel.Error>
<Input <Input
fluid size='sm'
variant='ontime-filled'
autoComplete='off'
placeholder='Sheet ID' placeholder='Sheet ID'
onChange={(event) => setSheetId(event.target.value)} onChange={(event) => setSheetId(event.target.value)}
disabled={isLoading || canAuthenticate} isDisabled={isLoading || canAuthenticate}
/> />
</Panel.ListGroup> </Panel.ListGroup>
{!canAuthenticate ? ( {!canAuthenticate ? (
<Panel.ListGroup> <Panel.ListGroup>
<Panel.InlineElements> <Panel.InlineElements>
<Button onClick={handleConnect} disabled={!canConnect || isLoading} loading={loading === 'connect'}> <Button
<IoCheckmark /> variant='ontime-subtle'
size='sm'
leftIcon={<IoCheckmark />}
onClick={handleConnect}
isDisabled={!canConnect || isLoading}
isLoading={loading === 'connect'}
>
Connect Connect
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
@@ -173,12 +189,17 @@ export default function GSheetSetup(props: GSheetSetupProps) {
) : ( ) : (
<Panel.ListGroup> <Panel.ListGroup>
<Panel.InlineElements> <Panel.InlineElements>
{isAuthenticating && <span>Authenticating...</span>} {isAuthenticating && <Spinner />}
<CopyTag copyValue={authKey ?? ''} label='Google Auth Key' disabled={!canAuthenticate} size='sm'> <CopyTag copyValue={authKey ?? ''} label='Google Auth Key' disabled={!canAuthenticate} size='sm'>
{authKey ? authKey : 'Upload files to generate Auth Key'} {authKey ? authKey : 'Upload files to generate Auth Key'}
</CopyTag> </CopyTag>
<Button onClick={handleAuthenticate} disabled={!canAuthenticate}> <Button
<IoShieldCheckmarkOutline /> variant='ontime-filled'
size='sm'
leftIcon={<IoShieldCheckmarkOutline />}
onClick={handleAuthenticate}
isDisabled={!canAuthenticate}
>
Authenticate Authenticate
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
@@ -1,8 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { Button } from '@chakra-ui/react';
import { CustomFields, Rundown } from 'ontime-types'; import { CustomFields, Rundown } from 'ontime-types';
import Button from '../../../../../common/components/buttons/Button'; import * as Panel from '../../panel-utils/PanelUtils';
import * as Panel from '../../../panel-utils/PanelUtils';
import PreviewSpreadsheet from './preview/PreviewRundown'; import PreviewSpreadsheet from './preview/PreviewRundown';
import useGoogleSheet from './useGoogleSheet'; import useGoogleSheet from './useGoogleSheet';
@@ -44,10 +44,10 @@ export default function ImportReview(props: ImportReviewProps) {
<Panel.Title> <Panel.Title>
Review Rundown Review Rundown
<Panel.InlineElements> <Panel.InlineElements>
<Button onClick={handleCancel} variant='ghosted' disabled={loading}> <Button onClick={handleCancel} variant='ontime-ghosted' size='sm' isDisabled={loading}>
Cancel Cancel
</Button> </Button>
<Button onClick={applyImport} variant='primary' loading={loading}> <Button onClick={applyImport} variant='ontime-filled' size='sm' isLoading={loading}>
Apply Apply
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
@@ -1,18 +1,17 @@
import { ChangeEvent, useRef, useState } from 'react'; import { ChangeEvent, useRef, useState } from 'react';
import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5'; import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
import { Button, Input } from '@chakra-ui/react';
import { getErrorMessage, ImportMap } from 'ontime-utils'; import { getErrorMessage, ImportMap } from 'ontime-utils';
import { import {
getWorksheetNames as getWorksheetNamesExcel, getWorksheetNames as getWorksheetNamesExcel,
importRundownPreview as importRundownPreviewExcel, importRundownPreview as importRundownPreviewExcel,
upload as uploadExcel, upload as uploadExcel,
} from '../../../../../common/api/excel'; } from '../../../../common/api/excel';
import { getWorksheetNames } from '../../../../../common/api/sheets'; import { getWorksheetNames } from '../../../../common/api/sheets';
import { maybeAxiosError } from '../../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../../common/components/buttons/Button'; import { validateExcelImport } from '../../../../common/utils/uploadUtils';
import * as Editor from '../../../../../common/components/editor-utils/EditorUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { validateExcelImport } from '../../../../../common/utils/uploadUtils';
import * as Panel from '../../../panel-utils/PanelUtils';
import ImportMapForm from './import-map/ImportMapForm'; import ImportMapForm from './import-map/ImportMapForm';
import GSheetInfo from './GSheetInfo'; import GSheetInfo from './GSheetInfo';
@@ -155,75 +154,87 @@ export default function SourcesPanel() {
const showReview = rundown !== null && customFields !== null; const showReview = rundown !== null && customFields !== null;
return ( return (
<Panel.Section> <>
<Panel.Card> <Panel.Header>Data sources</Panel.Header>
<Panel.SubHeader>Synchronise your rundown with an external source</Panel.SubHeader> <Panel.Section>
{error && <Panel.Error>{error}</Panel.Error>} <Panel.Card>
{showInput && ( <Panel.SubHeader>Synchronise your rundown with an external source</Panel.SubHeader>
<> {error && <Panel.Error>{error}</Panel.Error>}
<GSheetInfo /> {showInput && (
<input <>
ref={fileInputRef} <GSheetInfo />
style={{ display: 'none' }} <Input
type='file' ref={fileInputRef}
onChange={handleFile} style={{ display: 'none' }}
accept='.xlsx' type='file'
data-testid='file-input' onChange={handleFile}
/> accept='.xlsx'
<div className={style.uploadSection}> data-testid='file-input'
<div> />
<Button variant='primary' onClick={handleUpload} loading={hasFile === 'loading'}> <div className={style.uploadSection}>
<IoDownloadOutline /> <div>
Import from spreadsheet <Button
</Button> variant='ontime-filled'
<Panel.Description>Accepts .xlsx files</Panel.Description> size='sm'
</div> leftIcon={<IoDownloadOutline />}
<Editor.Separator orientation='vertical' /> onClick={handleUpload}
<div> isLoading={hasFile === 'loading'}
<Button variant='primary' onClick={openGSheetFlow} disabled={hasFile !== 'none'}> >
<IoCloudOutline /> Import from spreadsheet
Synchronise with Google </Button>
</Button> <Panel.Description>Accepts .xlsx files</Panel.Description>
<Panel.Description>Start authentication process</Panel.Description> </div>
<div>
<Button
variant='ontime-filled'
size='sm'
leftIcon={<IoCloudOutline />}
onClick={openGSheetFlow}
isDisabled={hasFile !== 'none'}
>
Synchronise with Google
</Button>
<Panel.Description>Start authentication process</Panel.Description>
</div>
</div> </div>
</>
)}
{showCompleted && (
<div className={style.finishSection}>
{error ? (
<span key='finish__error' className={style.error}>
Import failed
</span>
) : (
<span key='finish__success' className={style.success}>
Import successful
</span>
)}
<Button variant='ontime-filled' size='sm' onClick={resetFlow}>
Return
</Button>
</div> </div>
</> )}
)} {showAuth && <GSheetSetup onCancel={cancelGSheetFlow} />}
{showCompleted && ( {showImportMap && !showReview && (
<div className={style.finishSection}> <ImportMapForm
{error ? ( hasErrors={Boolean(error)}
<span key='finish__error' className={style.error}> isSpreadsheet={isExcelFlow}
Import failed onCancel={cancelImportMap}
</span> onSubmitExport={handleSubmitExport}
) : ( onSubmitImport={handleSubmitImportPreview}
<span key='finish__success' className={style.success}> />
Import successful )}
</span> {showReview && (
)} <ImportReview
<Button variant='primary' onClick={resetFlow}> rundown={rundown}
Return customFields={customFields}
</Button> onFinished={handleFinished}
</div> onCancel={cancelImportMap}
)} />
{showAuth && <GSheetSetup onCancel={cancelGSheetFlow} />} )}
{showImportMap && !showReview && ( </Panel.Card>
<ImportMapForm </Panel.Section>
hasErrors={Boolean(error)} </>
isSpreadsheet={isExcelFlow}
onCancel={cancelImportMap}
onSubmitExport={handleSubmitExport}
onSubmitImport={handleSubmitImportPreview}
/>
)}
{showReview && (
<ImportReview
rundown={rundown}
customFields={customFields}
onFinished={handleFinished}
onCancel={cancelImportMap}
/>
)}
</Panel.Card>
</Panel.Section>
); );
} }
@@ -1,13 +1,10 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoTrash } from 'react-icons/io5'; import { IoAdd, IoTrash } from 'react-icons/io5';
import { Select, Tooltip } from '@chakra-ui/react'; import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react';
import { ImportMap, isAlphanumericWithSpace } from 'ontime-utils'; import { ImportMap, isAlphanumericWithSpace } from 'ontime-utils';
import Button from '../../../../../../common/components/buttons/Button'; import * as Panel from '../../../panel-utils/PanelUtils';
import IconButton from '../../../../../../common/components/buttons/IconButton';
import Input from '../../../../../../common/components/input/input/Input';
import * as Panel from '../../../../panel-utils/PanelUtils';
import useGoogleSheet from '../useGoogleSheet'; import useGoogleSheet from '../useGoogleSheet';
import { useSheetStore } from '../useSheetStore'; import { useSheetStore } from '../useSheetStore';
@@ -98,29 +95,31 @@ export default function ImportMapForm(props: ImportMapFormProps) {
<Panel.InlineElements> <Panel.InlineElements>
{!isSpreadsheet && ( {!isSpreadsheet && (
<Tooltip label='Revoke the google authentication'> <Tooltip label='Revoke the google authentication'>
<Button onClick={handleRevoke} disabled={isLoading}> <Button variant='ontime-subtle' size='sm' onClick={handleRevoke} isDisabled={isLoading}>
Revoke Revoke
</Button> </Button>
</Tooltip> </Tooltip>
)} )}
<Button onClick={onCancel} disabled={isLoading}> <Button variant='ontime-subtle' size='sm' onClick={onCancel} isDisabled={isLoading}>
Cancel Cancel
</Button> </Button>
{!isSpreadsheet && ( {!isSpreadsheet && (
<Button <Button
variant='primary' variant='ontime-filled'
size='sm'
onClick={handleSubmit(handleExport)} onClick={handleSubmit(handleExport)}
disabled={!canSubmitGSheet} isDisabled={!canSubmitGSheet}
loading={loading === 'export'} isLoading={loading === 'export'}
> >
Export Export
</Button> </Button>
)} )}
<Button <Button
variant='primary' variant='ontime-filled'
size='sm'
onClick={handleSubmit(handleImportPreview)} onClick={handleSubmit(handleImportPreview)}
disabled={!canSubmit} isDisabled={!canSubmit}
loading={loading === 'import'} isLoading={loading === 'import'}
> >
Import preview Import preview
</Button> </Button>
@@ -169,7 +168,9 @@ export default function ImportMapForm(props: ImportMapFormProps) {
<td> <td>
<Input <Input
id={importName as string} id={importName as string}
fluid size='sm'
variant='ontime-filled'
autoComplete='off'
maxLength={25} maxLength={25}
defaultValue={importName as string} defaultValue={importName as string}
placeholder='Use default column name' placeholder='Use default column name'
@@ -189,8 +190,10 @@ export default function ImportMapForm(props: ImportMapFormProps) {
<tr key={key}> <tr key={key}>
<td> <td>
<Input <Input
size='sm'
variant='ontime-filled'
autoComplete='off'
maxLength={25} maxLength={25}
fluid
defaultValue={ontimeName} defaultValue={ontimeName}
placeholder='Name of the field as shown in Ontime' placeholder='Name of the field as shown in Ontime'
{...register(`custom.${index}.ontimeName`, { {...register(`custom.${index}.ontimeName`, {
@@ -205,8 +208,10 @@ export default function ImportMapForm(props: ImportMapFormProps) {
</td> </td>
<td> <td>
<Input <Input
size='sm'
variant='ontime-filled'
autoComplete='off'
maxLength={25} maxLength={25}
fluid
defaultValue={importName} defaultValue={importName}
placeholder='Name of the column in the spreadsheet' placeholder='Name of the column in the spreadsheet'
{...register(`custom.${index}.importName`)} {...register(`custom.${index}.importName`)}
@@ -214,12 +219,13 @@ export default function ImportMapForm(props: ImportMapFormProps) {
</td> </td>
<td className={style.singleActionCell}> <td className={style.singleActionCell}>
<IconButton <IconButton
variant='ghosted-destructive' size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry' aria-label='Delete entry'
onClick={() => deleteCustomImport(index)} onClick={() => deleteCustomImport(index)}
> />
<IoTrash />
</IconButton>
</td> </td>
</tr> </tr>
); );
@@ -227,8 +233,8 @@ export default function ImportMapForm(props: ImportMapFormProps) {
<tr> <tr>
<td /> <td />
<Panel.InlineElements as='td' align='end'> <Panel.InlineElements as='td' align='end'>
<Button onClick={addCustomImport}> <Button size='sm' variant='ontime-subtle' rightIcon={<IoAdd />} onClick={addCustomImport}>
Add custom field <IoAdd /> Add custom field
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
<td /> <td />
@@ -3,9 +3,9 @@ import { IoLink } from 'react-icons/io5';
import { CustomFields, isOntimeBlock, isOntimeEvent, Rundown } from 'ontime-types'; import { CustomFields, isOntimeBlock, isOntimeEvent, Rundown } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import Tag from '../../../../../../common/components/tag/Tag'; import Tag from '../../../../../common/components/tag/Tag';
import { getAccessibleColour } from '../../../../../../common/utils/styleUtils'; import { getAccessibleColour } from '../../../../../common/utils/styleUtils';
import * as Panel from '../../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import style from './PreviewRundown.module.scss'; import style from './PreviewRundown.module.scss';
@@ -2,16 +2,16 @@ import { useQueryClient } from '@tanstack/react-query';
import { AuthenticationStatus, CustomFields, ProjectRundowns } from 'ontime-types'; import { AuthenticationStatus, CustomFields, ProjectRundowns } from 'ontime-types';
import { ImportMap } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { CUSTOM_FIELDS, RUNDOWN } from '../../../../../common/api/constants'; import { CUSTOM_FIELDS, RUNDOWN } from '../../../../common/api/constants';
import { patchData } from '../../../../../common/api/db'; import { patchData } from '../../../../common/api/db';
import { import {
previewRundown, previewRundown,
requestConnection, requestConnection,
revokeAuthentication, revokeAuthentication,
uploadRundown, uploadRundown,
verifyAuthenticationStatus, verifyAuthenticationStatus,
} from '../../../../../common/api/sheets'; } from '../../../../common/api/sheets';
import { maybeAxiosError } from '../../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import { useSheetStore } from './useSheetStore'; import { useSheetStore } from './useSheetStore';
@@ -0,0 +1,8 @@
.scrollContainer {
max-height: 70vh;
overflow: auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
@@ -1,35 +1,45 @@
import { useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import {
Button,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Select,
Switch,
} from '@chakra-ui/react';
import { QuickStartData } from 'ontime-types'; import { QuickStartData } from 'ontime-types';
import { parseUserTime } from 'ontime-utils'; import { parseUserTime } from 'ontime-utils';
import { quickProject } from '../../../common/api/db'; import { quickProject } from '../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../common/api/utils'; import { invalidateAllCaches, maybeAxiosError } from '../../../common/api/utils';
import Button from '../../../common/components/buttons/Button';
import Input from '../../../common/components/input/input/Input';
import TimeInput from '../../../common/components/input/time-input/TimeInput'; import TimeInput from '../../../common/components/input/time-input/TimeInput';
import Modal from '../../../common/components/modal/Modal';
import Select from '../../../common/components/select/Select';
import Switch from '../../../common/components/switch/Switch';
import { editorSettingsDefaults, useEditorSettings } from '../../../common/stores/editorSettings'; import { editorSettingsDefaults, useEditorSettings } from '../../../common/stores/editorSettings';
import * as Panel from '../panel-utils/PanelUtils'; import * as Panel from '../panel-utils/PanelUtils';
import { quickStartDefaults } from './quickStart.utils'; import { quickStartDefaults } from './quickStart.utils';
import style from './QuickStart.module.scss';
interface QuickStartProps { interface QuickStartProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
} }
export default function QuickStart({ isOpen, onClose }: QuickStartProps) { export default function QuickStart(props: QuickStartProps) {
const { isOpen, onClose } = props;
const { defaultWarnTime, defaultDangerTime, setDangerTime, setWarnTime } = useEditorSettings(); const { defaultWarnTime, defaultDangerTime, setDangerTime, setWarnTime } = useEditorSettings();
const { const {
control,
handleSubmit, handleSubmit,
register, register,
formState: { errors, isSubmitting, isValid }, formState: { errors, isSubmitting, isValid },
watch,
setError, setError,
setValue,
} = useForm<QuickStartData>({ } = useForm<QuickStartData>({
defaultValues: quickStartDefaults, defaultValues: quickStartDefaults,
values: quickStartDefaults, values: quickStartDefaults,
@@ -55,114 +65,123 @@ export default function QuickStart({ isOpen, onClose }: QuickStartProps) {
const dangerTimeInMs = parseUserTime(defaultDangerTime); const dangerTimeInMs = parseUserTime(defaultDangerTime);
return ( return (
<Modal <Modal isOpen={isOpen} onClose={onClose} closeOnOverlayClick={false} variant='ontime'>
isOpen={isOpen} <ModalOverlay />
onClose={onClose} <ModalCloseButton />
showBackdrop <ModalContent maxWidth='max(640px, 40vw)'>
showCloseButton
title='Create new project...'
bodyElements={
<form onSubmit={handleSubmit(onSubmit)} id='quick-start'> <form onSubmit={handleSubmit(onSubmit)} id='quick-start'>
<Panel.ListGroup> <ModalHeader>Create new project...</ModalHeader>
<Panel.ListItem> <ModalBody className={style.scrollContainer}>
<Panel.Field title='Project title' description='Shown as the title in some views' /> <ModalCloseButton />
<Input maxLength={150} placeholder='Project title' fluid {...register('project.title')} /> <Panel.ListGroup>
</Panel.ListItem> <Panel.ListItem>
<Panel.ListItem> <Panel.Field title='Project title' description='Shown as the title in some views' />
<Panel.Field <Input
title='Time format' variant='ontime-filled'
description='Default time format to show in views 12 /24 hours' size='sm'
error={errors.settings?.timeFormat?.message} maxLength={150}
/> placeholder='Project title'
<Select autoComplete='off'
{...register('settings.timeFormat')} width='20rem'
defaultValue='24' {...register('project.title')}
options={[ />
{ value: '12', label: '12 hours 11:00:10 PM' }, </Panel.ListItem>
{ value: '24', label: '24 hours 23:00:10' }, <Panel.ListItem>
]} <Panel.Field
/> title='Time format'
</Panel.ListItem> description='Default time format to show in views 12 /24 hours'
<Panel.ListItem> error={errors.settings?.timeFormat?.message}
<Panel.Field />
title='Views language' <Select variant='ontime' size='sm' width='auto' isDisabled={false} {...register('settings.timeFormat')}>
description='Language to be displayed in views' <option value='12'>12 hours 11:00:10 PM</option>
error={errors.settings?.language?.message} <option value='24'>24 hours 23:00:10</option>
/> </Select>
<Select </Panel.ListItem>
{...register('settings.language')} <Panel.ListItem>
defaultValue='en' <Panel.Field
options={[ title='Views language'
{ value: 'en', label: 'English' }, description='Language to be displayed in views'
{ value: 'fr', label: 'French' }, error={errors.settings?.language?.message}
{ value: 'de', label: 'German' }, />
{ value: 'hu', label: 'Hungarian' }, <Select variant='ontime' size='sm' width='auto' isDisabled={false} {...register('settings.language')}>
{ value: 'it', label: 'Italian' }, <option value='en'>English</option>
{ value: 'no', label: 'Norwegian' }, <option value='fr'>French</option>
{ value: 'pt', label: 'Portuguese' }, <option value='de'>German</option>
{ value: 'es', label: 'Spanish' }, <option value='hu'>Hungarian</option>
{ value: 'sv', label: 'Swedish' }, <option value='it'>Italian</option>
{ value: 'pl', label: 'Polish' }, <option value='no'>Norwegian</option>
{ value: 'zh', label: 'Chinese (Simplified)' }, <option value='pt'>Portuguese</option>
]} <option value='es'>Spanish</option>
/> <option value='sv'>Swedish</option>
</Panel.ListItem> <option value='pl'>Polish</option>
</Panel.ListGroup> <option value='zh'>Chinese (Simplified)</option>
</Select>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.ListGroup> <Panel.ListGroup>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field title='Warning time' description='Default threshold for warning time in an event' /> <Panel.Field title='Warning time' description='Default threshold for warning time in an event' />
<TimeInput<'warnTime'> <TimeInput<'warnTime'>
name='warnTime' name='warnTime'
submitHandler={(_field, value) => setWarnTime(value)} submitHandler={(_field, value) => setWarnTime(value)}
time={warnTimeInMs} time={warnTimeInMs}
placeholder={editorSettingsDefaults.warnTime} placeholder={editorSettingsDefaults.warnTime}
/> />
</Panel.ListItem> </Panel.ListItem>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field title='Danger time' description='Default threshold for danger time in an event' /> <Panel.Field title='Danger time' description='Default threshold for danger time in an event' />
<TimeInput<'dangerTime'> <TimeInput<'dangerTime'>
name='dangerTime' name='dangerTime'
submitHandler={(_field, value) => setDangerTime(value)} submitHandler={(_field, value) => setDangerTime(value)}
time={dangerTimeInMs} time={dangerTimeInMs}
placeholder={editorSettingsDefaults.dangerTime} placeholder={editorSettingsDefaults.dangerTime}
/> />
</Panel.ListItem> </Panel.ListItem>
</Panel.ListGroup> </Panel.ListGroup>
<Panel.ListGroup> <Panel.ListGroup>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title='Freeze timer on end' title='Freeze timer on end'
description='When a timer hits 00:00:00, it freezes instead of going negative. It invalidates the End Message.' description='When a timer hits 00:00:00, it freezes instead of going negative. It invalidates the End Message.'
/> />
<Switch <Controller
name='viewSettings.freezeEnd' control={control}
checked={watch('viewSettings.freezeEnd')} name='viewSettings.freezeEnd'
onCheckedChange={(checked) => setValue('viewSettings.freezeEnd', checked)} render={({ field: { onChange, value, ref } }) => (
/> <Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
</Panel.ListItem> )}
<Panel.ListItem> />
<Panel.Field </Panel.ListItem>
title='End message' <Panel.ListItem>
description='Message for negative timers; applies only if the timer isn`t frozen on End. If no message is provided, it continues into negative time' <Panel.Field
/> title='End message'
<Input maxLength={150} fluid placeholder='eg: Time is up!' {...register('viewSettings.endMessage')} /> description='Message for negative timers; applies only if the timer isn`t frozen on End. If no message is provided, it continues into negative time'
</Panel.ListItem> />
</Panel.ListGroup> <Input
size='sm'
autoComplete='off'
variant='ontime-filled'
maxLength={150}
width='20rem'
placeholder='Shown when timer reaches end'
{...register('viewSettings.endMessage')}
/>
</Panel.ListItem>
</Panel.ListGroup>
</ModalBody>
<ModalFooter>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button variant='ontime-ghosted' size='md' onClick={onClose} isDisabled={false}>
Cancel
</Button>
<Button variant='ontime-filled' size='md' type='submit' isDisabled={!isValid} isLoading={isSubmitting}>
Create project
</Button>
</ModalFooter>
</form> </form>
} </ModalContent>
footerElements={ </Modal>
<>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button variant='ghosted' onClick={onClose} disabled={false}>
Cancel
</Button>
<Button variant='primary' type='submit' form='quick-start' disabled={!isValid} loading={isSubmitting}>
Create project
</Button>
</>
}
/>
); );
} }
@@ -11,63 +11,60 @@ export type SettingsOption = {
}; };
const staticOptions = [ const staticOptions = [
{
id: 'settings',
label: 'Settings',
secondary: [
{ id: 'settings__data', label: 'Project data' },
{ id: 'settings__general', label: 'General settings' },
{ id: 'settings__view', label: 'View settings' },
],
},
{ {
id: 'project', id: 'project',
label: 'Project', label: 'Project',
split: true,
secondary: [ secondary: [
{ id: 'project__create', label: 'Create...' }, { id: 'project__create', label: 'Create...' },
{ id: 'project__list', label: 'Manage projects' }, { id: 'project__data', label: 'Project data' },
{ id: 'project__manage', label: 'Manage projects' },
], ],
}, },
{ {
id: 'manage', id: 'general',
label: 'Project data', label: 'App Settings',
secondary: [ secondary: [
{ id: 'manage__defaults', label: 'Rundown defaults' }, { id: 'general__settings', label: 'General settings' },
{ id: 'manage__custom', label: 'Custom fields' }, { id: 'general__editor', label: 'Editor settings' },
{ id: 'manage__rundowns', label: 'Manage rundowns' }, { id: 'general__view', label: 'View settings' },
{ id: 'manage__sheets', label: 'Import spreadsheet' },
{ id: 'manage__sheets', label: 'Sync with Google Sheet' },
], ],
}, },
{
id: 'feature_settings',
label: 'Feature Settings',
secondary: [
{ id: 'feature_settings__custom', label: 'Custom fields' },
{ id: 'feature_settings__urlpresets', label: 'URL Presets' },
{ id: 'feature_settings__report', label: 'Report' },
],
},
{
id: 'sources',
label: 'Data Sources',
secondary: [
{ id: 'sources__xlsx', label: 'Import spreadsheet' },
{ id: 'sources__gsheet', label: 'Sync with Google Sheet' },
],
split: true,
},
{ {
id: 'automation', id: 'automation',
label: 'Automation', label: 'Automation',
split: true,
secondary: [ secondary: [
{ id: 'automation__settings', label: 'Automation settings' }, { id: 'automation__settings', label: 'Automation settings' },
{ id: 'automation__automations', label: 'Manage automations' }, { id: 'automation__automations', label: 'Manage automations' },
{ id: 'automation__triggers', label: 'Manage triggers' }, { id: 'automation__triggers', label: 'Manage triggers' },
], ],
}, },
{
id: 'sharing',
label: 'Sharing and reporting',
split: true,
secondary: [
{ id: 'sharing__presets', label: 'URL Presets' },
{
id: 'sharing__link',
label: 'Share link',
},
{ id: 'sharing__report', label: 'Runtime report' },
],
},
{ {
id: 'network', id: 'network',
label: 'Network', label: 'Network',
split: true, split: true,
secondary: [ secondary: [
{
id: 'network__link',
label: 'Share link',
},
{ {
id: 'network__log', id: 'network__log',
label: 'Event log', label: 'Event log',

Some files were not shown because too many files have changed in this diff Show More