mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-04 15:08:01 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69aeb4aabb | |||
| 34ca715fb7 | |||
| b981bae63f |
@@ -1,82 +1,24 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background-color: $gray-1300;
|
||||
border: 1px solid $gray-1100;
|
||||
border-radius: $component-border-radius-md;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.separator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: $gray-500;
|
||||
font-size: $inner-section-text-size;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
border-top: 1px solid $gray-1100;
|
||||
}
|
||||
}
|
||||
|
||||
.textEntry {
|
||||
grid-area: input;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
.inlineEntry {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
grid-template-areas:
|
||||
'label label'
|
||||
'input btn';
|
||||
grid-template-columns: 1fr auto;
|
||||
column-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.redirect {
|
||||
margin-left: auto;
|
||||
grid-area: btn;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: $gray-200;
|
||||
font-size: $text-body-size;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: $gray-500;
|
||||
font-size: $inner-section-text-size;
|
||||
}
|
||||
|
||||
.origin {
|
||||
flex: 0 0 auto;
|
||||
color: $gray-500;
|
||||
font-size: $inner-section-text-size;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: $min-tablet) {
|
||||
.inlineEntry {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.redirect {
|
||||
align-self: flex-end;
|
||||
}
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { IoArrowForward } from 'react-icons/io5';
|
||||
|
||||
import { baseURI } from '../../../externals';
|
||||
import { navigatorConstants } from '../../../viewerConfig';
|
||||
import useUrlPresets from '../../hooks-query/useUrlPresets';
|
||||
import { setClientRemote } from '../../hooks/useSocket';
|
||||
@@ -9,7 +8,7 @@ import Button from '../buttons/Button';
|
||||
import Dialog from '../dialog/Dialog';
|
||||
import Info from '../info/Info';
|
||||
import Input from '../input/input/Input';
|
||||
import ExternalLink from '../link/external-link/ExternalLink';
|
||||
import AppLink from '../link/app-link/AppLink';
|
||||
import Select from '../select/Select';
|
||||
|
||||
import style from './RedirectClientModal.module.scss';
|
||||
@@ -45,7 +44,6 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
||||
};
|
||||
|
||||
const enabledPresets = data.filter((preset) => preset.enabled);
|
||||
const settingsUrl = `${window.location.origin}${baseURI}/?settings=sharing__presets`;
|
||||
|
||||
const viewOptions = [
|
||||
...navigatorConstants.map((view) => ({
|
||||
@@ -68,46 +66,34 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
||||
bodyElements={
|
||||
<>
|
||||
<Info>
|
||||
<Info.Body>
|
||||
Remotely redirect the client to a different URL. Choose a custom path or select a URL Preset.
|
||||
</Info.Body>
|
||||
<Info.Footer>
|
||||
<ExternalLink href={settingsUrl}>Manage URL Presets</ExternalLink>
|
||||
</Info.Footer>
|
||||
Remotely redirect the client to a different URL. <br />
|
||||
Either by entering a custom path or selecting a URL Preset.
|
||||
<br />
|
||||
<br />
|
||||
<AppLink search='settings=sharing__presets'>Manage URL Presets</AppLink>
|
||||
</Info>
|
||||
<section className={style.section}>
|
||||
<div className={style.sectionHeader}>
|
||||
<span className={style.label}>Enter custom path</span>
|
||||
<span className={style.description}>Type the path the client should open.</span>
|
||||
</div>
|
||||
<div className={style.inlineEntry}>
|
||||
<label className={style.textEntry}>
|
||||
<span className={style.origin}>{origin}</span>
|
||||
<Input placeholder='eg. /timer' fluid value={path} onChange={(event) => setPath(event.target.value)} />
|
||||
</label>
|
||||
<Button
|
||||
variant='primary'
|
||||
aria-label='Redirect to custom path'
|
||||
disabled={path === currentPath || path === ''}
|
||||
className={style.redirect}
|
||||
onClick={() => handleRedirect(path)}
|
||||
>
|
||||
Redirect
|
||||
<IoArrowForward />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<div className={style.separator} role='separator'>
|
||||
<span>Or</span>
|
||||
<div className={style.inlineEntry}>
|
||||
<span className={style.label}>Enter custom path</span>
|
||||
<label className={style.textEntry}>
|
||||
{origin}
|
||||
<Input placeholder='eg. /timer' fluid value={path} onChange={(event) => setPath(event.target.value)} />
|
||||
</label>
|
||||
<Button
|
||||
variant='primary'
|
||||
aria-label='Redirect'
|
||||
disabled={path === currentPath || path === ''}
|
||||
className={style.redirect}
|
||||
onClick={() => handleRedirect(path)}
|
||||
>
|
||||
Redirect
|
||||
<IoArrowForward />
|
||||
</Button>
|
||||
</div>
|
||||
<section className={style.section}>
|
||||
<div className={style.sectionHeader}>
|
||||
<span className={style.label}>Select view or URL Preset</span>
|
||||
<span className={style.description}>Choose from the available destinations.</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={style.label}>Select View or URL Preset</span>
|
||||
<div className={style.inlineEntry}>
|
||||
<label className={style.textEntry}>
|
||||
<span className={style.origin}>{origin}</span>
|
||||
{origin}
|
||||
<Select
|
||||
fluid
|
||||
options={viewOptions}
|
||||
@@ -129,7 +115,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
||||
Redirect <IoArrowForward />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
@use '../../../theme/ontimeStyles' as *;
|
||||
|
||||
.form {
|
||||
padding: 0.25rem 0.5rem 0.5rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
color: $label-gray;
|
||||
font-size: $inner-section-text-size;
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import { setClientRemote } from '../../hooks/useSocket';
|
||||
import Dialog from '../dialog/Dialog';
|
||||
import Input from '../input/input/Input';
|
||||
|
||||
import style from './RenameClientModal.module.scss';
|
||||
|
||||
interface RenameClientModalProps {
|
||||
id: string;
|
||||
name?: string;
|
||||
@@ -33,22 +31,9 @@ export function RenameClientModal({ id, name: currentName = '', isOpen, onClose
|
||||
isOpen={isOpen}
|
||||
title={`Rename Client: ${currentName}`}
|
||||
showCloseButton
|
||||
showBackdrop
|
||||
onClose={onClose}
|
||||
bodyElements={
|
||||
<div className={style.form}>
|
||||
<label className={style.label}>
|
||||
Client name
|
||||
<Input
|
||||
height='large'
|
||||
fluid
|
||||
autoComplete='off'
|
||||
placeholder='New name'
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<Input height='large' placeholder='New name' value={name} onChange={(event) => setName(event.target.value)} />
|
||||
}
|
||||
footerElements={
|
||||
<>
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
.dialog {
|
||||
position: fixed;
|
||||
top: 10vh;
|
||||
top: 10%;
|
||||
left: 50%;
|
||||
|
||||
transform: translateX(-50%);
|
||||
|
||||
box-sizing: border-box;
|
||||
width: min(600px, 90vw);
|
||||
max-height: calc(90vh - 1rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-inline: 1rem;
|
||||
min-width: min(600px, 90vw);
|
||||
|
||||
background-color: $gray-1250;
|
||||
color: $ui-white;
|
||||
@@ -47,8 +43,7 @@
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: min(80vh, 600px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -58,5 +53,4 @@
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ import SwatchPicker from './SwatchPicker';
|
||||
import style from './SwatchSelect.module.scss';
|
||||
|
||||
interface ColourInputProps {
|
||||
value: string;
|
||||
/** undefined represents values which do not agree, no swatch is highlighted */
|
||||
value: string | undefined;
|
||||
name: 'colour';
|
||||
handleChange: (newValue: 'colour', name: string) => void;
|
||||
}
|
||||
@@ -44,7 +45,11 @@ export default function SwatchSelect(props: ColourInputProps) {
|
||||
{colours.map((colour) => (
|
||||
<Swatch key={colour} color={colour} onClick={setColour} isSelected={value === colour} />
|
||||
))}
|
||||
<SwatchPicker color={value} onChange={setColour} isSelected={!colours.includes(value)} />
|
||||
<SwatchPicker
|
||||
color={value ?? ''}
|
||||
onChange={setColour}
|
||||
isSelected={value !== undefined && !colours.includes(value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { shouldSubmitValue } from '../useReactiveTextInput';
|
||||
|
||||
describe('shouldSubmitValue()', () => {
|
||||
it('submits a value which differs from the initial value', () => {
|
||||
expect(shouldSubmitValue('new', 'old')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not submit a value which has not changed', () => {
|
||||
expect(shouldSubmitValue('same', 'same')).toBe(false);
|
||||
});
|
||||
|
||||
it('submits an empty value when the initial value is known', () => {
|
||||
// clearing a field which has a value is a valid edit
|
||||
expect(shouldSubmitValue('', 'old')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not submit an empty value when the initial value is unknown', () => {
|
||||
// an unknown value renders as an empty field, submitting it on blur
|
||||
// would overwrite every entry being edited with an empty value
|
||||
expect(shouldSubmitValue('', undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('submits a typed value when the initial value is unknown', () => {
|
||||
expect(shouldSubmitValue('typed', undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('submits an unchanged value when the caller opts in', () => {
|
||||
expect(shouldSubmitValue('same', 'same', true)).toBe(true);
|
||||
expect(shouldSubmitValue('', undefined, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,23 @@
|
||||
import { HotkeyItem, getHotkeyHandler } from '@mantine/hooks';
|
||||
import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Whether a value should be sent to the server
|
||||
* An undefined initial value means that the value is unknown, for example when several
|
||||
* entries are being edited and they do not agree on a value
|
||||
* It is shown as an empty field, so submitting it would overwrite data the user has not edited
|
||||
*/
|
||||
export function shouldSubmitValue(
|
||||
valueToSubmit: string,
|
||||
initialText: string | undefined,
|
||||
allowSubmitSameValue?: boolean,
|
||||
): boolean {
|
||||
if (allowSubmitSameValue) {
|
||||
return true;
|
||||
}
|
||||
return valueToSubmit !== (initialText ?? '');
|
||||
}
|
||||
|
||||
interface UseReactiveTextInputReturn {
|
||||
value: string;
|
||||
onChange: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
|
||||
@@ -9,7 +26,8 @@ interface UseReactiveTextInputReturn {
|
||||
}
|
||||
|
||||
export default function useReactiveTextInput(
|
||||
initialText: string,
|
||||
/** undefined represents an unknown value, the field is shown empty and blurring it submits nothing */
|
||||
initialText: string | undefined,
|
||||
submitCallback: (newValue: string) => void,
|
||||
ref: RefObject<HTMLInputElement | HTMLTextAreaElement | null>,
|
||||
options?: {
|
||||
@@ -22,7 +40,7 @@ export default function useReactiveTextInput(
|
||||
allowKeyboardNavigation?: boolean;
|
||||
},
|
||||
): UseReactiveTextInputReturn {
|
||||
const [text, setText] = useState<string>(initialText);
|
||||
const [text, setText] = useState<string>(initialText ?? '');
|
||||
// track whether we are submitting via a submit key (eg enter) and avoid submitting again on blur
|
||||
const isKeyboardSubmitting = useRef(false);
|
||||
// track escape to prevent the subsequent blur from submitting
|
||||
@@ -57,8 +75,7 @@ export default function useReactiveTextInput(
|
||||
*/
|
||||
const handleSubmit = useCallback(
|
||||
(valueToSubmit: string) => {
|
||||
// No need to update if it hasn't changed
|
||||
if (valueToSubmit === initialText && !options?.allowSubmitSameValue) {
|
||||
if (!shouldSubmitValue(valueToSubmit, initialText, options?.allowSubmitSameValue)) {
|
||||
options?.onCancelUpdate?.();
|
||||
} else {
|
||||
const cleanVal = valueToSubmit.trim();
|
||||
@@ -86,10 +103,10 @@ export default function useReactiveTextInput(
|
||||
const handleEscape = useCallback(() => {
|
||||
isEscaping.current = true;
|
||||
// No need to update if it hasn't changed
|
||||
setText(initialText);
|
||||
setText(initialText ?? '');
|
||||
// force the text to be the initial value
|
||||
if (ref.current) {
|
||||
ref.current.value = initialText;
|
||||
ref.current.value = initialText ?? '';
|
||||
}
|
||||
options?.onCancelUpdate?.();
|
||||
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before blurring
|
||||
|
||||
@@ -41,7 +41,10 @@ export default function TimeInput<T extends string>({
|
||||
* @description Resets input value to given
|
||||
*/
|
||||
const resetValue = useCallback(() => {
|
||||
if (typeof time !== 'number' || isNaN(time)) {
|
||||
if (time === undefined) {
|
||||
// there is no value to show, we leave the field empty so that the placeholder is visible
|
||||
setValue('');
|
||||
} else if (typeof time !== 'number' || isNaN(time)) {
|
||||
setValue('00:00:00');
|
||||
} else if (shouldFormat) {
|
||||
setValue(formatTime(time));
|
||||
|
||||
@@ -21,15 +21,4 @@
|
||||
outline: none;
|
||||
box-shadow: 0 1px 0 0 currentColor;
|
||||
}
|
||||
|
||||
&:hover .icon,
|
||||
&:focus-visible .icon {
|
||||
transform: translateX(0.25rem);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex: 0 0 1.5em;
|
||||
font-size: 1em;
|
||||
transition: transform $transition-time-action;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MouseEvent, ReactNode } from 'react';
|
||||
import { HiArrowLongRight } from 'react-icons/hi2';
|
||||
import { IoOpenOutline } from 'react-icons/io5';
|
||||
|
||||
import { openLink } from '../../../utils/linkUtils';
|
||||
import { cx } from '../../../utils/styleUtils';
|
||||
@@ -10,10 +10,9 @@ interface ExternalLinkProps {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
inline?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ExternalLink({ href, inline, className, children }: ExternalLinkProps) {
|
||||
export default function ExternalLink({ href, inline, children }: ExternalLinkProps) {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
openLink(href);
|
||||
@@ -24,11 +23,10 @@ export default function ExternalLink({ href, inline, className, children }: Exte
|
||||
href='#!'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className={cx([style.link, inline && style.inline, className])}
|
||||
className={cx([style.link, inline && style.inline])}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{children}
|
||||
<HiArrowLongRight className={style.icon} />
|
||||
{children} <IoOpenOutline style={{ fontSize: '1em' }} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,10 @@
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
|
||||
box-sizing: border-box;
|
||||
width: min(880px, 90vw);
|
||||
padding-inline: 1rem;
|
||||
min-width: min(880px, 90vw);
|
||||
min-height: min(200px, 10vh);
|
||||
max-height: calc(90vh - 1rem);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: min(1200px, 90vw);
|
||||
|
||||
background-color: $gray-1250;
|
||||
color: $ui-white;
|
||||
@@ -20,21 +17,14 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.compact {
|
||||
min-width: 0;
|
||||
width: min(680px, 90vw);
|
||||
}
|
||||
|
||||
.small {
|
||||
min-width: 0;
|
||||
width: min(480px, 90vw);
|
||||
}
|
||||
|
||||
.wide {
|
||||
top: 4vh;
|
||||
width: min(1800px, 98vw);
|
||||
height: min(88vh, calc(96vh - 1rem));
|
||||
min-height: 0;
|
||||
min-width: min(1280px, 96vw);
|
||||
max-width: min(1600px, 96vw);
|
||||
height: 88vh;
|
||||
max-height: 88vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
@@ -51,19 +41,15 @@
|
||||
|
||||
.title {
|
||||
padding-block: 1rem;
|
||||
padding-inline: 1rem;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.titleText {
|
||||
min-width: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.body {
|
||||
@@ -71,23 +57,21 @@
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0.5rem 1rem;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
scroll-padding-block: 0.5rem;
|
||||
}
|
||||
|
||||
.wide .body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding-block: 1rem;
|
||||
padding-inline: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ interface ModalProps {
|
||||
title?: string;
|
||||
showCloseButton?: boolean;
|
||||
showBackdrop?: boolean;
|
||||
size?: 'small' | 'compact' | 'default' | 'wide';
|
||||
size?: 'default' | 'wide';
|
||||
bodyElements: ReactNode;
|
||||
footerElements?: ReactNode;
|
||||
onClose: () => void;
|
||||
@@ -38,7 +38,7 @@ export default function Modal({
|
||||
>
|
||||
<BaseDialog.Portal>
|
||||
{showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />}
|
||||
<BaseDialog.Popup aria-label={title} className={cx([style.modal, style[size]])}>
|
||||
<BaseDialog.Popup aria-label={title} className={cx([style.modal, size === 'wide' && style.wide])}>
|
||||
<div className={style.title}>
|
||||
{title ? <BaseDialog.Title className={style.titleText}>{title}</BaseDialog.Title> : <div />}
|
||||
{showCloseButton && (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useDisclosure, useHotkeys } from '@mantine/hooks';
|
||||
import { memo } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { hasCustomParams } from '../../stores/savedViewParams';
|
||||
import { hasCustomParams, useSavedViewParams } from '../../stores/savedViewParams';
|
||||
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
|
||||
import FloatingNavigation from './floating-navigation/FloatingNavigation';
|
||||
import NavigationMenu from './NavigationMenu';
|
||||
@@ -20,7 +20,8 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
|
||||
const [isMenuOpen, menuHandler] = useDisclosure();
|
||||
const { open: showEditFormDrawer } = useViewParamsEditorStore();
|
||||
const [searchParams] = useSearchParams();
|
||||
const hasSavedChanges = hasCustomParams(searchParams);
|
||||
const savedParams = useSavedViewParams((store) => store.params);
|
||||
const hasSavedChanges = hasCustomParams(searchParams) || Object.keys(savedParams).length > 0;
|
||||
|
||||
useHotkeys([
|
||||
[
|
||||
|
||||
@@ -16,13 +16,21 @@ interface SelectProps<T> extends Omit<BaseSelect.Root.Props<T>, 'items'> {
|
||||
options: SelectOption<T>[];
|
||||
fluid?: boolean;
|
||||
size?: 'medium' | 'large';
|
||||
/** shown when the value is null, used to represent values which do not agree */
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export default function Select<T>({ options, fluid, size = 'medium', ...selectRootProps }: SelectProps<T>) {
|
||||
export default function Select<T>({
|
||||
options,
|
||||
fluid,
|
||||
size = 'medium',
|
||||
placeholder,
|
||||
...selectRootProps
|
||||
}: SelectProps<T>) {
|
||||
return (
|
||||
<BaseSelect.Root items={options} {...selectRootProps}>
|
||||
<BaseSelect.Trigger className={cx([styles.select, styles[size], fluid && styles.fluid])}>
|
||||
<BaseSelect.Value />
|
||||
<BaseSelect.Value placeholder={placeholder} />
|
||||
<BaseSelect.Icon className={styles.selectIcon}>
|
||||
<LuChevronsUpDown />
|
||||
</BaseSelect.Icon>
|
||||
|
||||
@@ -1,43 +1,20 @@
|
||||
@use '@/theme/viewerDefs' as *;
|
||||
|
||||
.emptyContainer {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
color: $white-10;
|
||||
|
||||
.empty {
|
||||
display: block;
|
||||
width: min(100%, 14rem);
|
||||
margin: 0 auto -1.5rem;
|
||||
opacity: 0.6;
|
||||
width: min(100%, 24rem);
|
||||
margin-inline: auto;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.text {
|
||||
display: block;
|
||||
margin-inline: auto;
|
||||
font-weight: 400;
|
||||
font-size: clamp(1rem, 1.55vw, 1.5rem);
|
||||
line-height: 1.35;
|
||||
max-width: min(100%, 40rem);
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: $error-red;
|
||||
|
||||
.empty {
|
||||
opacity: 0.35;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.text {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.errorIcon {
|
||||
display: block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
margin: -0.125rem auto 0;
|
||||
font-weight: 600;
|
||||
font-size: 2em;
|
||||
max-width: min(100%, 600px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { IoWarningOutline } from 'react-icons/io5';
|
||||
|
||||
import EmptyImage from '../../../assets/images/empty.svg?react';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
@@ -10,18 +9,12 @@ interface EmptyProps {
|
||||
text?: string;
|
||||
injectedStyles?: CSSProperties;
|
||||
className?: string;
|
||||
variant?: 'error';
|
||||
}
|
||||
|
||||
export default function Empty({ text, className, injectedStyles, variant }: EmptyProps) {
|
||||
export default function Empty({ text, className, injectedStyles }: EmptyProps) {
|
||||
return (
|
||||
<div
|
||||
className={cx([style.emptyContainer, variant === 'error' && style.error, className])}
|
||||
style={injectedStyles}
|
||||
role={variant === 'error' ? 'alert' : undefined}
|
||||
>
|
||||
<div className={cx([style.emptyContainer, className])} style={injectedStyles}>
|
||||
<EmptyImage className={style.empty} />
|
||||
{variant === 'error' && <IoWarningOutline className={style.errorIcon} aria-hidden />}
|
||||
{text && <span className={style.text}>{text}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
.fill {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
|
||||
display: grid;
|
||||
place-items: start center;
|
||||
padding: clamp(4rem, 20dvh, 12rem) 1.5rem 1rem;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import Empty from './Empty';
|
||||
|
||||
import style from './EmptyFill.module.scss';
|
||||
|
||||
interface EmptyFillProps {
|
||||
text?: string;
|
||||
/** placed on the fill wrapper — e.g. to assign a grid-area in a grid parent */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Container-filling empty/loading state for panels and grid/flex cells. */
|
||||
export default function EmptyFill({ text, className }: EmptyFillProps) {
|
||||
return (
|
||||
<div className={cx([style.fill, className])}>
|
||||
<Empty text={text} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
box-sizing: border-box; /* reset */
|
||||
overflow: hidden;
|
||||
width: 100%; /* restrict the page width to viewport */
|
||||
height: 100dvh;
|
||||
height: 100vh;
|
||||
|
||||
font-family: var(--font-family-override, $viewer-font-family);
|
||||
background: var(--background-color-override, $viewer-background-color);
|
||||
@@ -16,6 +16,5 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-block: 5rem;
|
||||
padding-top: 5rem;
|
||||
}
|
||||
|
||||
@@ -7,13 +7,12 @@ import style from './EmptyPage.module.scss';
|
||||
interface EmptyPageProps {
|
||||
text?: string;
|
||||
injectedStyles?: CSSProperties;
|
||||
variant?: 'error';
|
||||
}
|
||||
|
||||
export default function EmptyPage({ text, injectedStyles, variant }: EmptyPageProps) {
|
||||
export default function EmptyPage({ text, injectedStyles }: EmptyPageProps) {
|
||||
return (
|
||||
<div className={style.page}>
|
||||
<Empty text={text} injectedStyles={injectedStyles} variant={variant} />
|
||||
<Empty text={text} injectedStyles={injectedStyles} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,4 +15,9 @@
|
||||
gap: 1rem;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-weight: 600;
|
||||
font-size: 2em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
|
||||
<tbody className={style.emptyContainer}>
|
||||
<tr>
|
||||
<td colSpan={99} className={style.emptyCell}>
|
||||
<Empty text={text} injectedStyles={{ marginTop: '5vh' }} />
|
||||
<Empty injectedStyles={{ marginTop: '5vh' }} />
|
||||
<span className={style.text}>{text}</span>
|
||||
{handleAddNew && (
|
||||
<div className={style.inline}>
|
||||
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
|
||||
|
||||
@@ -29,6 +29,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* values do not agree, we show an empty track instead of a thumb position */
|
||||
.mixed .thumb {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.medium {
|
||||
padding: 2px;
|
||||
--width: calc(2.5rem + 4px);
|
||||
|
||||
@@ -6,11 +6,13 @@ import style from './Switch.module.scss';
|
||||
|
||||
interface SwitchProps extends BaseSwitch.Root.Props {
|
||||
size?: 'medium' | 'large';
|
||||
/** the switch represents several values which do not agree, we hide the thumb */
|
||||
mixed?: boolean;
|
||||
}
|
||||
|
||||
export default function Switch({ size = 'medium', ...switchProps }: SwitchProps) {
|
||||
export default function Switch({ size = 'medium', mixed, ...switchProps }: SwitchProps) {
|
||||
return (
|
||||
<BaseSwitch.Root className={cx([style.switch, style[size]])} {...switchProps}>
|
||||
<BaseSwitch.Root className={cx([style.switch, style[size], mixed && style.mixed])} {...switchProps}>
|
||||
<BaseSwitch.Thumb className={style.thumb} />
|
||||
</BaseSwitch.Root>
|
||||
);
|
||||
|
||||
@@ -11,11 +11,6 @@
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
.active {
|
||||
border: 1px solid $green-700;
|
||||
color: $green-400;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background-color: $orange-1300;
|
||||
color: $orange-300;
|
||||
|
||||
@@ -6,7 +6,7 @@ import style from './Tag.module.scss';
|
||||
|
||||
interface TagProps {
|
||||
className?: string;
|
||||
variant?: 'default' | 'active' | 'warning';
|
||||
variant?: 'default' | 'warning';
|
||||
}
|
||||
|
||||
export default function Tag({ className, variant = 'default', children }: PropsWithChildren<TagProps>) {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { OntimeEvent, OntimeGroup, RundownEntries, SupportedEntry } from 'ontime-types';
|
||||
|
||||
import { applyPatchToEvents, canPredictBatchResult } from '../entryAction.utils';
|
||||
|
||||
function makeEntries(): RundownEntries {
|
||||
return {
|
||||
'1': { type: SupportedEntry.Event, id: '1', title: 'one', custom: { lx: 'a', sound: 'x' } } as OntimeEvent,
|
||||
'2': { type: SupportedEntry.Event, id: '2', title: 'two', custom: { lx: 'b' } } as OntimeEvent,
|
||||
'3': { type: SupportedEntry.Event, id: '3', title: 'three', custom: {} } as OntimeEvent,
|
||||
group: { type: SupportedEntry.Group, id: 'group', title: 'a group', custom: {} } as OntimeGroup,
|
||||
};
|
||||
}
|
||||
|
||||
describe('applyPatchToEvents()', () => {
|
||||
it('applies the patch to the given events only', () => {
|
||||
const patched = applyPatchToEvents(makeEntries(), ['1', '2'], { title: 'patched' });
|
||||
|
||||
expect((patched['1'] as OntimeEvent).title).toBe('patched');
|
||||
expect((patched['2'] as OntimeEvent).title).toBe('patched');
|
||||
expect((patched['3'] as OntimeEvent).title).toBe('three');
|
||||
});
|
||||
|
||||
it('patches custom fields instead of replacing them', () => {
|
||||
const patched = applyPatchToEvents(makeEntries(), ['1', '2'], { custom: { lx: 'new' } });
|
||||
|
||||
// the field which was not part of the patch must survive
|
||||
expect((patched['1'] as OntimeEvent).custom).toStrictEqual({ lx: 'new', sound: 'x' });
|
||||
expect((patched['2'] as OntimeEvent).custom).toStrictEqual({ lx: 'new' });
|
||||
});
|
||||
|
||||
it('keeps the existing custom fields when the patch has none', () => {
|
||||
const patched = applyPatchToEvents(makeEntries(), ['1'], { title: 'patched' });
|
||||
|
||||
expect((patched['1'] as OntimeEvent).custom).toStrictEqual({ lx: 'a', sound: 'x' });
|
||||
});
|
||||
|
||||
it('ignores ids which are not in the rundown', () => {
|
||||
const patched = applyPatchToEvents(makeEntries(), ['1', 'does-not-exist'], { title: 'patched' });
|
||||
|
||||
expect(Object.keys(patched)).toStrictEqual(['1', '2', '3', 'group']);
|
||||
expect((patched['1'] as OntimeEvent).title).toBe('patched');
|
||||
});
|
||||
|
||||
it('ignores entries which are not events', () => {
|
||||
const patched = applyPatchToEvents(makeEntries(), ['group'], { title: 'patched' });
|
||||
|
||||
expect((patched.group as OntimeGroup).title).toBe('a group');
|
||||
});
|
||||
|
||||
it('does not mutate the given entries', () => {
|
||||
const entries = makeEntries();
|
||||
applyPatchToEvents(entries, ['1'], { title: 'patched', custom: { lx: 'new' } });
|
||||
|
||||
expect((entries['1'] as OntimeEvent).title).toBe('one');
|
||||
expect((entries['1'] as OntimeEvent).custom).toStrictEqual({ lx: 'a', sound: 'x' });
|
||||
});
|
||||
|
||||
it('handles repeated ids', () => {
|
||||
const patched = applyPatchToEvents(makeEntries(), ['1', '1'], { title: 'patched' });
|
||||
|
||||
expect((patched['1'] as OntimeEvent).title).toBe('patched');
|
||||
});
|
||||
});
|
||||
|
||||
describe('canPredictBatchResult()', () => {
|
||||
it('allows resolving patches which do not affect the schedule', () => {
|
||||
expect(canPredictBatchResult({ title: 'a title' })).toBe(true);
|
||||
expect(canPredictBatchResult({ colour: 'red', flag: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('defers to the server when the duration changes', () => {
|
||||
// a duration cascades into the start and end of every linked event
|
||||
expect(canPredictBatchResult({ duration: 1000 })).toBe(false);
|
||||
expect(canPredictBatchResult({ title: 'a title', duration: 1000 })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { EntryId, OntimeEvent, RundownEntries, isOntimeEvent } from 'ontime-types';
|
||||
|
||||
/**
|
||||
* Applies a patch to a set of events, used to optimistically resolve a batch edit
|
||||
* Entries which are missing or are not events are left untouched, mirroring the server
|
||||
* @returns a new entries object, the given entries are not mutated
|
||||
*/
|
||||
export function applyPatchToEvents(
|
||||
entries: RundownEntries,
|
||||
ids: EntryId[],
|
||||
patch: Partial<OntimeEvent>,
|
||||
): RundownEntries {
|
||||
const patchedEntries = { ...entries };
|
||||
|
||||
for (const id of new Set(ids)) {
|
||||
const entry = patchedEntries[id];
|
||||
if (!isOntimeEvent(entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
patchedEntries[id] = {
|
||||
...entry,
|
||||
...patch,
|
||||
// custom fields are patched, not replaced
|
||||
custom: patch.custom ? { ...entry.custom, ...patch.custom } : entry.custom,
|
||||
};
|
||||
}
|
||||
|
||||
return patchedEntries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the result of a batch edit can be resolved without the server
|
||||
* Changing the duration cascades through the rundown, so the resulting
|
||||
* schedule is only known once the server has recalculated it
|
||||
*/
|
||||
export function canPredictBatchResult(patch: Partial<OntimeEvent>): boolean {
|
||||
return !('duration' in patch);
|
||||
}
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
} from '../api/rundown';
|
||||
import { logAxiosError } from '../api/utils';
|
||||
import { useEditorSettings } from '../stores/editorSettings';
|
||||
import { applyPatchToEvents, canPredictBatchResult } from './entryAction.utils';
|
||||
|
||||
export type EventOptions = Partial<{
|
||||
// options of any new entries (event / delay / group)
|
||||
@@ -483,21 +484,9 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
||||
// Snapshot the previous value
|
||||
const previousRundown = queryClient.getQueryData<Rundown>(queryKey);
|
||||
|
||||
if (previousRundown) {
|
||||
const eventIds = new Set(data.ids);
|
||||
const newRundown = { ...previousRundown.entries };
|
||||
|
||||
eventIds.forEach((eventId) => {
|
||||
if (Object.hasOwn(newRundown, eventId)) {
|
||||
const event = newRundown[eventId];
|
||||
if (isOntimeEvent(event)) {
|
||||
newRundown[eventId] = {
|
||||
...event,
|
||||
...data,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
// when the result cannot be resolved here we wait for the recalculated rundown from the server
|
||||
if (previousRundown && canPredictBatchResult(data.data)) {
|
||||
const newRundown = applyPatchToEvents(previousRundown.entries, data.ids, data.data);
|
||||
|
||||
queryClient.setQueryData<Rundown>(queryKey, {
|
||||
id: previousRundown.id,
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { matchesSettingsOptionQuery } from '../useAppSettingsMenu';
|
||||
|
||||
describe('matchesSettingsOptionQuery', () => {
|
||||
const option = {
|
||||
id: 'settings__general',
|
||||
label: 'General settings',
|
||||
keywords: ['pin', 'time zone'],
|
||||
};
|
||||
|
||||
it('does not match an empty or whitespace-only query', () => {
|
||||
expect(matchesSettingsOptionQuery(option, '')).toBe(false);
|
||||
expect(matchesSettingsOptionQuery(option, ' ')).toBe(false);
|
||||
});
|
||||
|
||||
it('matches keywords case-insensitively', () => {
|
||||
expect(matchesSettingsOptionQuery(option, 'PIN')).toBe(true);
|
||||
expect(matchesSettingsOptionQuery(option, 'TIME ZONE')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,9 @@
|
||||
$content-max-width: 1280px;
|
||||
.corner {
|
||||
position: fixed;
|
||||
top: 6rem;
|
||||
right: 4rem;
|
||||
z-index: $zindex-floating;
|
||||
}
|
||||
|
||||
.contentWrapper {
|
||||
display: flex;
|
||||
@@ -9,29 +14,9 @@ $content-max-width: 1280px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.corner {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
// width is needed so the button can align to the right of the capped
|
||||
// column, auto margins alone would shrink this to fit and centre it
|
||||
width: 100%;
|
||||
max-width: $content-max-width;
|
||||
margin-inline: auto;
|
||||
padding: 0 1rem 0.5rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.content {
|
||||
box-sizing: border-box;
|
||||
// as with .corner, the width is needed to fill the capped column, otherwise
|
||||
// auto margins shrink this to fit and each panel ends up its own width
|
||||
width: 100%;
|
||||
max-width: $content-max-width;
|
||||
margin: 0 auto 1rem;
|
||||
padding-inline: 1rem;
|
||||
margin: 1rem;
|
||||
overflow-y: auto;
|
||||
flex-grow: 1;
|
||||
// room for the last section to scroll to the top of the viewport
|
||||
padding-bottom: 40vh;
|
||||
padding-bottom: 300px;
|
||||
}
|
||||
|
||||
@@ -12,12 +12,12 @@ interface PanelContentProps {
|
||||
export default function PanelContent({ onClose, children }: PropsWithChildren<PanelContentProps>) {
|
||||
return (
|
||||
<div className={style.contentWrapper}>
|
||||
<div className={style.content}>{children}</div>
|
||||
<div className={style.corner}>
|
||||
<Button size='large' onClick={onClose}>
|
||||
Close settings <IoClose />
|
||||
</Button>
|
||||
</div>
|
||||
<div className={style.content}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
.container {
|
||||
width: min(30vw, 300px);
|
||||
flex: 0 0 min(30vw, 300px);
|
||||
min-width: min(30vw, 300px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tabs,
|
||||
ul {
|
||||
list-style: none;
|
||||
@@ -16,20 +6,22 @@ ul {
|
||||
}
|
||||
|
||||
.tabs {
|
||||
width: min(30vw, 300px);
|
||||
flex: 0 0 min(30vw, 300px);
|
||||
min-width: min(30vw, 300px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.primary,
|
||||
.secondary {
|
||||
padding: 0.25rem 1rem;
|
||||
margin-right: 1rem;
|
||||
border-radius: 2px;
|
||||
|
||||
&:focus-visible {
|
||||
&:focus {
|
||||
background-color: $gray-1000;
|
||||
outline: 2px solid $blue-500;
|
||||
outline-offset: -2px;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@@ -40,21 +32,14 @@ ul {
|
||||
|
||||
.primary {
|
||||
font-size: 1rem;
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-left: 3px solid transparent;
|
||||
|
||||
&.groupActive {
|
||||
color: $blue-400;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: $ui-white;
|
||||
border-left-color: $blue-400;
|
||||
background-color: rgba($blue-500, 0.16);
|
||||
font-weight: 600;
|
||||
color: $blue-400;
|
||||
background-color: $gray-1100;
|
||||
}
|
||||
|
||||
&.highlight {
|
||||
@@ -77,9 +62,6 @@ ul {
|
||||
font-size: $inner-section-text-size;
|
||||
|
||||
&.active {
|
||||
color: $ui-white;
|
||||
border-left-color: $blue-400;
|
||||
background-color: rgba($blue-500, 0.16);
|
||||
font-weight: 600;
|
||||
color: $blue-400;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import { Fragment, useState } from 'react';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||
import { isKeyEnter } from '../../../common/utils/keyEvent';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import * as Panel from '../panel-utils/PanelUtils';
|
||||
import {
|
||||
filterSettingsOptions,
|
||||
matchesSettingsOptionQuery,
|
||||
SettingsOption,
|
||||
SettingsOptionId,
|
||||
useAppSettingsMenu,
|
||||
} from '../useAppSettingsMenu';
|
||||
import { SettingsOption, SettingsOptionId, useAppSettingsMenu } from '../useAppSettingsMenu';
|
||||
import useAppSettingsNavigation from '../useAppSettingsNavigation';
|
||||
import SettingsSearch from './SettingsSearch';
|
||||
|
||||
import style from './PanelList.module.scss';
|
||||
|
||||
@@ -24,57 +16,23 @@ interface PanelListProps extends PanelBaseProps {
|
||||
selectedPanel: string;
|
||||
}
|
||||
|
||||
/** Returns the first matching setting, preferring a matching child over its non-matching group. */
|
||||
function getFirstResultId(results: SettingsOption[], query: string): SettingsOptionId | null {
|
||||
const firstResult = results[0];
|
||||
if (!firstResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (matchesSettingsOptionQuery(firstResult, query.trim().toLowerCase())) {
|
||||
return firstResult.id as SettingsOptionId;
|
||||
}
|
||||
|
||||
return (firstResult.secondary?.[0]?.id as SettingsOptionId | undefined) ?? null;
|
||||
}
|
||||
|
||||
export default function PanelList({ selectedPanel, location }: PanelListProps) {
|
||||
const { options } = useAppSettingsMenu();
|
||||
const { setLocation } = useAppSettingsNavigation();
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const results = filterSettingsOptions(options, query);
|
||||
|
||||
const handleSearchSubmit = () => {
|
||||
const target = getFirstResultId(results, query);
|
||||
if (target) {
|
||||
setLocation(target);
|
||||
setQuery('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.container}>
|
||||
<SettingsSearch query={query} onQueryChange={setQuery} onSubmit={handleSearchSubmit} />
|
||||
|
||||
{results.length === 0 ? (
|
||||
<Panel.EmptyState title='No settings match' description={`Nothing found for "${query.trim()}"`} />
|
||||
) : (
|
||||
<ul className={style.tabs}>
|
||||
{results.map((panel) => {
|
||||
const isSelected = selectedPanel === panel.id;
|
||||
if (panel.highlight) {
|
||||
return (
|
||||
<Tooltip key={panel.id} text={panel.highlight} render={<span />}>
|
||||
<PanelListItem panel={panel} location={location} isSelected={isSelected} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />;
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<ul className={style.tabs}>
|
||||
{options.map((panel) => {
|
||||
const isSelected = selectedPanel === panel.id;
|
||||
if (panel.highlight) {
|
||||
return (
|
||||
<Tooltip key={panel.id} text={panel.highlight} render={<span />}>
|
||||
<PanelListItem panel={panel} location={location} isSelected={isSelected} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />;
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,15 +44,7 @@ interface PanelListItemProps {
|
||||
|
||||
function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
|
||||
const { setLocation } = useAppSettingsNavigation();
|
||||
const hasSelectedChild = Boolean(
|
||||
isSelected && panel.secondary?.some((secondary) => secondary.id.split('__')[1] === location),
|
||||
);
|
||||
const classes = cx([
|
||||
style.primary,
|
||||
isSelected && !hasSelectedChild && style.active,
|
||||
hasSelectedChild && style.groupActive,
|
||||
panel.highlight && style.highlight,
|
||||
]);
|
||||
const classes = cx([style.primary, isSelected && style.active, panel.highlight && style.highlight]);
|
||||
|
||||
return (
|
||||
<Fragment key={panel.id}>
|
||||
@@ -125,7 +75,6 @@ function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
|
||||
}
|
||||
}}
|
||||
className={secondaryClasses}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
>
|
||||
{secondary.label}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
.search {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
margin: 0.25rem 1rem 0.25rem 0.25rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
color: $gray-400;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.input {
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.clear {
|
||||
position: absolute;
|
||||
right: 0.25rem;
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { KeyboardEvent, useRef } from 'react';
|
||||
import { IoClose, IoSearch } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../common/components/buttons/IconButton';
|
||||
import Input from '../../../common/components/input/input/Input';
|
||||
|
||||
import style from './SettingsSearch.module.scss';
|
||||
|
||||
interface SettingsSearchProps {
|
||||
query: string;
|
||||
onQueryChange: (query: string) => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export default function SettingsSearch({ query, onQueryChange, onSubmit }: SettingsSearchProps) {
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const isSearching = query.trim().length > 0;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Escape' && isSearching) {
|
||||
// Do not let the settings panel close while the user is clearing a search.
|
||||
event.stopPropagation();
|
||||
onQueryChange('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.search}>
|
||||
<IoSearch className={style.icon} />
|
||||
<Input
|
||||
ref={searchRef}
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder='Search settings'
|
||||
className={style.input}
|
||||
fluid
|
||||
/>
|
||||
{isSearching && (
|
||||
<IconButton
|
||||
variant='ghosted-white'
|
||||
size='small'
|
||||
aria-label='Clear search'
|
||||
className={style.clear}
|
||||
onClick={() => {
|
||||
onQueryChange('');
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<IoClose />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,24 @@
|
||||
$inner-padding: 1rem;
|
||||
$card-padding: 2rem;
|
||||
|
||||
.header {
|
||||
font-size: 1.5rem;
|
||||
font-size: 2rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid $white-10;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subheader {
|
||||
font-size: 1.125rem;
|
||||
font-size: 1.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1rem;
|
||||
padding: 0 var(--panel-card-padding, #{$card-padding});
|
||||
font-size: 1.375rem;
|
||||
padding: 0 2rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -37,12 +35,10 @@ $card-padding: 2rem;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
// width is owned by the panel content column, which caps and centres itself.
|
||||
// sections must stay full width so top level cards line up with Panel.Header
|
||||
// and nested sections fill their card rather than shrinking to fit
|
||||
.section {
|
||||
position: relative;
|
||||
margin-top: 2rem;
|
||||
max-width: 1024px;
|
||||
}
|
||||
|
||||
.indent {
|
||||
@@ -55,10 +51,8 @@ $card-padding: 2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
--panel-card-padding: #{$card-padding};
|
||||
|
||||
position: relative;
|
||||
padding: var(--panel-card-padding);
|
||||
padding: 2rem;
|
||||
background-color: $white-3;
|
||||
border: 1px solid $gray-1100;
|
||||
border-radius: 3px;
|
||||
@@ -82,10 +76,10 @@ $card-padding: 2rem;
|
||||
color: $error-red;
|
||||
}
|
||||
|
||||
// tables scroll with the panel rather than owning a nested scroll area,
|
||||
// which keeps the sticky table head anchored to the panel viewport
|
||||
.pad {
|
||||
padding: 0 var(--panel-card-padding, #{$card-padding});
|
||||
padding: 0 2rem;
|
||||
max-height: 550px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.table {
|
||||
@@ -103,13 +97,10 @@ $card-padding: 2rem;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 600;
|
||||
color: $gray-300;
|
||||
font-size: calc(1rem - 3px);
|
||||
letter-spacing: 0.02em;
|
||||
vertical-align: bottom;
|
||||
font-weight: 400;
|
||||
color: $gray-400;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
th,
|
||||
@@ -121,17 +112,6 @@ $card-padding: 2rem;
|
||||
background-color: $white-1;
|
||||
}
|
||||
|
||||
// active rows use a panel-local class today; keep the treatment here so all
|
||||
// settings tables share the same state without each panel owning its colours
|
||||
tr[class*='current'] {
|
||||
background-color: rgba($blue-500, 0.16);
|
||||
|
||||
> td:first-child {
|
||||
box-shadow: inset 3px 0 $blue-400;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// allow highlighting table elements
|
||||
tr[data-warn='true'],
|
||||
td[data-warn='true'] {
|
||||
@@ -140,7 +120,7 @@ $card-padding: 2rem;
|
||||
}
|
||||
|
||||
.listGroup {
|
||||
padding: 0 var(--panel-card-padding, #{$card-padding});
|
||||
padding: 0 2rem;
|
||||
|
||||
> li:not(:last-child) {
|
||||
border-bottom: 1px solid $white-10;
|
||||
@@ -153,17 +133,6 @@ $card-padding: 2rem;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0;
|
||||
|
||||
&.interactive {
|
||||
margin-inline: calc(var(--panel-card-padding, #{$card-padding}) * -1);
|
||||
padding: 0.75rem var(--panel-card-padding, #{$card-padding});
|
||||
transition: background-color $transition-time-action;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
background-color: $white-3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fieldTitle {
|
||||
@@ -194,7 +163,7 @@ $card-padding: 2rem;
|
||||
.divider {
|
||||
border: none;
|
||||
border-top: 1px solid $white-10;
|
||||
margin: 1rem calc(var(--panel-card-padding, #{$card-padding}) * -1);
|
||||
margin: 1rem -2rem;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
@@ -219,35 +188,19 @@ $card-padding: 2rem;
|
||||
animation: animloader 1s ease-in infinite;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
padding: 3rem 1.5rem;
|
||||
.empty {
|
||||
background-color: $black-10;
|
||||
text-align: center;
|
||||
}
|
||||
color: $muted-gray;
|
||||
|
||||
.emptyMessage {
|
||||
width: min(30rem, 100%);
|
||||
margin-inline: auto;
|
||||
}
|
||||
td {
|
||||
padding-block: 5rem;
|
||||
}
|
||||
|
||||
.emptyTitle {
|
||||
margin-bottom: 0.25rem;
|
||||
color: rgba($gray-200, 0.72);
|
||||
font-size: calc(1rem + 2px);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.emptyBody {
|
||||
color: rgba($gray-200, 0.55);
|
||||
font-size: calc(1rem - 3px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.emptyAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
button {
|
||||
margin-top: 1rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.inlineElements {
|
||||
@@ -286,3 +239,14 @@ $card-padding: 2rem;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes animloader {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0.6;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
|
||||
import { IoAdd } from 'react-icons/io5';
|
||||
|
||||
import Button from '../../../common/components/buttons/Button';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
|
||||
import style from './PanelUtils.module.scss';
|
||||
@@ -60,29 +62,16 @@ export function Table({ className, children }: { className?: string; children: R
|
||||
);
|
||||
}
|
||||
|
||||
export interface EmptyStateProps {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description, action }: EmptyStateProps) {
|
||||
export function TableEmpty({ label, handleClick }: { label?: string; handleClick?: () => void }) {
|
||||
return (
|
||||
<div className={style.emptyState}>
|
||||
<div className={style.emptyMessage}>
|
||||
<div className={style.emptyTitle}>{title}</div>
|
||||
{description && <div className={style.emptyBody}>{description}</div>}
|
||||
{action && <div className={style.emptyAction}>{action}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableEmpty(props: EmptyStateProps) {
|
||||
return (
|
||||
<tr>
|
||||
<tr className={style.empty}>
|
||||
<td colSpan={99}>
|
||||
<EmptyState {...props} />
|
||||
<div>{label ?? 'No data yet'}</div>
|
||||
{handleClick && (
|
||||
<Button onClick={handleClick} disabled={!handleClick} variant='primary'>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -92,8 +81,8 @@ export function ListGroup({ className, children }: { className?: string; childre
|
||||
return <ul className={cx([style.listGroup, className])}>{children}</ul>;
|
||||
}
|
||||
|
||||
export function ListItem({ children, interactive = false }: { children: ReactNode; interactive?: boolean }) {
|
||||
return <li className={cx([style.listItem, interactive && style.interactive])}>{children}</li>;
|
||||
export function ListItem({ children }: { children: ReactNode }) {
|
||||
return <li className={style.listItem}>{children}</li>;
|
||||
}
|
||||
|
||||
export function Field({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
import {
|
||||
buyMeACoffeeUrl,
|
||||
discordUrl,
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
} from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import AppVersion from './AppVersion';
|
||||
import CloudPanel from './CloudPanel';
|
||||
import ExternalLinkRow from './ExternalLinkRow';
|
||||
|
||||
export default function AboutPanel() {
|
||||
return (
|
||||
@@ -21,49 +19,28 @@ export default function AboutPanel() {
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>Ontime</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Info>Free, open-source software for managing rundowns and event timers.</Info>
|
||||
<Panel.ListGroup>
|
||||
<ExternalLinkRow href={websiteUrl} title='www.getontime.no' description='App site and downloads' />
|
||||
<ExternalLinkRow
|
||||
href={githubSponsorUrl}
|
||||
title='GitHub Sponsors'
|
||||
description='Support development through GitHub'
|
||||
/>
|
||||
<ExternalLinkRow
|
||||
href={buyMeACoffeeUrl}
|
||||
title='Buy Me a Coffee'
|
||||
description='Make a one-time contribution'
|
||||
/>
|
||||
</Panel.ListGroup>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
<CloudPanel />
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>Current version</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.ListGroup>
|
||||
<AppVersion />
|
||||
</Panel.ListGroup>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>Helpful links</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.ListGroup>
|
||||
<ExternalLinkRow
|
||||
href={documentationUrl}
|
||||
title='Read the docs'
|
||||
description='Setup guides and feature documentation'
|
||||
/>
|
||||
<ExternalLinkRow href={githubUrl} title='GitHub' description='Report issues or contribute to Ontime' />
|
||||
<ExternalLinkRow href={youtubeUrl} title='YouTube' description='Watch tutorials and feature walkthroughs' />
|
||||
<ExternalLinkRow href={discordUrl} title='Discord' description='Get help and share suggestions' />
|
||||
<ExternalLinkRow href={subredditUrl} title='Reddit' description='Join the Ontime community discussion' />
|
||||
</Panel.ListGroup>
|
||||
<Panel.Paragraph>
|
||||
Free, open-source software for managing rundowns and event timers
|
||||
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
|
||||
</Panel.Paragraph>
|
||||
<Panel.Paragraph>
|
||||
Considering sponsoring our work
|
||||
<ExternalLink href={githubSponsorUrl}>GitHub Sponsors</ExternalLink>
|
||||
<ExternalLink href={buyMeACoffeeUrl}>Buy Me a Coffee</ExternalLink>
|
||||
</Panel.Paragraph>
|
||||
<Panel.Paragraph>
|
||||
If you are looking for an online version of Ontime, consider trying out our cloud service
|
||||
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
|
||||
</Panel.Paragraph>
|
||||
</Panel.Card>
|
||||
<Panel.SubHeader>Current version</Panel.SubHeader>
|
||||
<AppVersion />
|
||||
<Panel.SubHeader>Links</Panel.SubHeader>
|
||||
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
|
||||
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
|
||||
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
|
||||
<ExternalLink href={youtubeUrl}>YouTube channel</ExternalLink>
|
||||
<ExternalLink href={subredditUrl}>Subreddit</ExternalLink>
|
||||
</Panel.Section>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
import useAppVersion from '../../../../common/hooks-query/useAppVersion';
|
||||
import { appVersion, isOntimeCloud, websiteUrl } from '../../../../externals';
|
||||
import { appVersion, isOntimeCloud } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
export default function AppVersion() {
|
||||
@@ -8,33 +8,28 @@ export default function AppVersion() {
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title={`Ontime ${appVersion}`} description='' error='Could not fetch version information' />
|
||||
</Panel.ListItem>
|
||||
<Panel.Paragraph>
|
||||
{`You are currently using Ontime version ${appVersion}`}
|
||||
<Panel.Error>Could not fetch version information</Panel.Error>
|
||||
</Panel.Paragraph>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.hasUpdates) {
|
||||
return (
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title={`Ontime ${appVersion}`}
|
||||
description={
|
||||
isOntimeCloud
|
||||
? `Version ${data.version} is available. Restart your stage to update.`
|
||||
: `Version ${data.version} is available.`
|
||||
}
|
||||
/>
|
||||
{!isOntimeCloud && (
|
||||
<ExternalLink href={websiteUrl}>Visit Ontime's page to download the latest version.</ExternalLink>
|
||||
<Panel.Paragraph>
|
||||
{`You are currently using Ontime version ${appVersion}.`}
|
||||
<br />
|
||||
<br />
|
||||
{`A new version ${data.version} is available.`} <br />
|
||||
{isOntimeCloud ? (
|
||||
'You can restart your stage to get the latest available version.'
|
||||
) : (
|
||||
<ExternalLink href={data.url}>Please visit the release page to download</ExternalLink>
|
||||
)}
|
||||
</Panel.ListItem>
|
||||
</Panel.Paragraph>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title={`Ontime ${appVersion}`} description='You are using the latest version.' />
|
||||
</Panel.ListItem>
|
||||
);
|
||||
return <Panel.Paragraph>{`You are currently using the latest version of Ontime: ${appVersion}`}</Panel.Paragraph>;
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
.card {
|
||||
padding: 1.5rem;
|
||||
border-color: rgba($action-blue, 0.42);
|
||||
background-color: rgba($action-blue, 0.06);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: $action-text-color;
|
||||
font-size: calc(1rem - 3px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-top: 0.35rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-width: 33rem;
|
||||
margin-top: 0.9rem;
|
||||
color: $gray-300;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.portability {
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid $white-10;
|
||||
color: $gray-400;
|
||||
font-size: calc(1rem - 2px);
|
||||
}
|
||||
|
||||
.benefits {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem 1rem;
|
||||
margin-top: 1.5rem;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
svg {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 0.1rem;
|
||||
color: $action-text-color;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: $min-tablet) {
|
||||
.benefits {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { IoCheckmarkCircle } from 'react-icons/io5';
|
||||
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
import { isOntimeCloud, websiteUrl } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './CloudPanel.module.scss';
|
||||
|
||||
export default function CloudPanel() {
|
||||
if (isOntimeCloud) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card className={style.card}>
|
||||
<span className={style.eyebrow}>Ontime Cloud</span>
|
||||
<h3 className={style.title}>Run Ontime online with zero setup</h3>
|
||||
<p className={style.description}>
|
||||
<span>
|
||||
Ontime Cloud lets you run and share Ontime online with your team. Subscriptions support continued
|
||||
development while Ontime remains fully open-source and self-hostable.
|
||||
</span>
|
||||
<span className={style.portability}>
|
||||
Project files work locally and in Cloud, so your shows can move freely.
|
||||
</span>
|
||||
</p>
|
||||
<ul className={style.benefits}>
|
||||
<li>
|
||||
<IoCheckmarkCircle />
|
||||
Start in minutes, with no setup or maintenance
|
||||
</li>
|
||||
<li>
|
||||
<IoCheckmarkCircle />
|
||||
Coordinate multi-track rundowns with Overview views
|
||||
</li>
|
||||
<li>
|
||||
<IoCheckmarkCircle />
|
||||
Manage every instance from one dashboard
|
||||
</li>
|
||||
<li>
|
||||
<IoCheckmarkCircle />
|
||||
Share secure read-only views with collaborators
|
||||
</li>
|
||||
</ul>
|
||||
<div className={style.actions}>
|
||||
<ExternalLink href={websiteUrl}>Learn more about Ontime Cloud</ExternalLink>
|
||||
</div>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
.link {
|
||||
width: calc(100% + (var(--panel-card-padding, 2rem) * 2));
|
||||
margin: -0.75rem calc(var(--panel-card-padding, 2rem) * -1);
|
||||
padding: 0.75rem var(--panel-card-padding, 2rem);
|
||||
|
||||
> :first-child {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.action {
|
||||
color: $action-text-color;
|
||||
font-size: calc(1rem - 2px);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './ExternalLinkRow.module.scss';
|
||||
|
||||
interface ExternalLinkRowProps {
|
||||
href: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export default function ExternalLinkRow({ href, title, description }: ExternalLinkRowProps) {
|
||||
return (
|
||||
<Panel.ListItem interactive>
|
||||
<ExternalLink href={href} className={style.link}>
|
||||
<Panel.Field title={title} description={description} />
|
||||
<span className={style.action}>Open</span>
|
||||
</ExternalLink>
|
||||
</Panel.ListItem>
|
||||
);
|
||||
}
|
||||
+1
-8
@@ -1,13 +1,6 @@
|
||||
.outerColumn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
font-size: calc(1rem - 1px);
|
||||
color: $ui-white;
|
||||
|
||||
// the shared modal body owns scrolling for this regular form modal
|
||||
min-height: 100%;
|
||||
padding-block: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
|
||||
+275
-305
@@ -19,12 +19,12 @@ import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
import Modal from '../../../../common/components/modal/Modal';
|
||||
import RadioGroup from '../../../../common/components/radio-group/RadioGroup';
|
||||
import Select from '../../../../common/components/select/Select';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { startsWithHttp } from '../../../../common/utils/regex';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { isAutomation, makeFieldList } from './automationUtils';
|
||||
@@ -34,7 +34,6 @@ import TemplateInput from './template-input/TemplateInput';
|
||||
import style from './AutomationForm.module.scss';
|
||||
|
||||
const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation';
|
||||
const formId = 'automation-form';
|
||||
|
||||
interface AutomationFormProps {
|
||||
automation: Automation | AutomationDTO;
|
||||
@@ -186,320 +185,291 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
const canSubmit = !isSubmitting && isDirty && isValid;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
title={isEdit ? 'Edit automation' : 'Create automation'}
|
||||
bodyElements={
|
||||
<form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.outerColumn}>
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Automation options</h3>
|
||||
<div className={style.titleSection}>
|
||||
<label>
|
||||
Title
|
||||
<Input
|
||||
{...register('title', { required: { value: true, message: 'Required field' } })}
|
||||
fluid
|
||||
placeholder='Load preset'
|
||||
/>
|
||||
</label>
|
||||
<Panel.Error>{errors.title?.message}</Panel.Error>
|
||||
</div>
|
||||
</div>
|
||||
<Panel.Indent
|
||||
as='form'
|
||||
name='automation-form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className={style.outerColumn}
|
||||
onKeyDown={(event) => preventEscape(event, onClose)}
|
||||
>
|
||||
<Panel.SubHeader>{isEdit ? 'Edit automation' : 'Create automation'}</Panel.SubHeader>
|
||||
<div className={style.innerSection}>
|
||||
<h3>Automation options</h3>
|
||||
<div className={style.titleSection}>
|
||||
<label>
|
||||
Title
|
||||
<Input
|
||||
{...register('title', { required: { value: true, message: 'Required field' } })}
|
||||
fluid
|
||||
placeholder='Load preset'
|
||||
/>
|
||||
</label>
|
||||
<Panel.Error>{errors.title?.message}</Panel.Error>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Filters (optional)</h3>
|
||||
<div className={style.ruleSection}>
|
||||
<label>
|
||||
Trigger outputs if
|
||||
<RadioGroup
|
||||
orientation='horizontal'
|
||||
value={watch('filterRule')}
|
||||
onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })}
|
||||
items={[
|
||||
{ value: 'all', label: 'All filters pass' },
|
||||
{ value: 'any', label: 'Any filter passes' },
|
||||
]}
|
||||
/>
|
||||
</label>
|
||||
{fieldFilters.map((field, index) => {
|
||||
const key = `filters.${index}.field.${field.id}`;
|
||||
return (
|
||||
<div key={key} className={style.filterSection}>
|
||||
<label>
|
||||
Runtime data source
|
||||
<Select<string | null>
|
||||
// need to normalize '' to null for the Select to show the placeholder
|
||||
value={watch(`filters.${index}.field`) || null}
|
||||
onValueChange={(value) => {
|
||||
if (value === null) return;
|
||||
setValue(`filters.${index}.field`, value, { shouldDirty: true });
|
||||
}}
|
||||
options={fieldList.map(({ value, label }) => ({
|
||||
value,
|
||||
label,
|
||||
disabled: value === null,
|
||||
}))}
|
||||
aria-label='Event field'
|
||||
/>
|
||||
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Matching condition
|
||||
<Select
|
||||
value={watch(`filters.${index}.operator`)}
|
||||
onValueChange={(value: string | null) => {
|
||||
if (value === null) return;
|
||||
setValue(
|
||||
`filters.${index}.operator`,
|
||||
value as
|
||||
| 'equals'
|
||||
| 'not_equals'
|
||||
| 'greater_than'
|
||||
| 'less_than'
|
||||
| 'contains'
|
||||
| 'not_contains',
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
}}
|
||||
options={[
|
||||
{ value: 'equals', label: 'equals' },
|
||||
{ value: 'not_equals', label: 'not equals' },
|
||||
{ value: 'contains', label: 'contains' },
|
||||
]}
|
||||
aria-label='Operator'
|
||||
/>
|
||||
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Value to match
|
||||
<Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' />
|
||||
</label>
|
||||
<div>
|
||||
<span> </span>
|
||||
<div>
|
||||
<IconButton
|
||||
aria-label='Delete'
|
||||
variant='ghosted-destructive'
|
||||
onClick={() => removeFilter(index)}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.innerSection}>
|
||||
<h3>Filters (optional)</h3>
|
||||
<div className={style.ruleSection}>
|
||||
<label>
|
||||
Trigger outputs if
|
||||
<RadioGroup
|
||||
orientation='horizontal'
|
||||
value={watch('filterRule')}
|
||||
onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })}
|
||||
items={[
|
||||
{ value: 'all', label: 'All filters pass' },
|
||||
{ value: 'any', label: 'Any filter passes' },
|
||||
]}
|
||||
/>
|
||||
</label>
|
||||
{fieldFilters.map((field, index) => {
|
||||
const key = `filters.${index}.field.${field.id}`;
|
||||
return (
|
||||
<div key={key} className={style.filterSection}>
|
||||
<label>
|
||||
Runtime data source
|
||||
<Select<string | null>
|
||||
// need to normalize '' to null for the Select to show the placeholder
|
||||
value={watch(`filters.${index}.field`) || null}
|
||||
onValueChange={(value) => {
|
||||
if (value === null) return;
|
||||
setValue(`filters.${index}.field`, value, { shouldDirty: true });
|
||||
}}
|
||||
options={fieldList.map(({ value, label }) => ({
|
||||
value,
|
||||
label,
|
||||
disabled: value === null,
|
||||
}))}
|
||||
aria-label='Event field'
|
||||
/>
|
||||
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Matching condition
|
||||
<Select
|
||||
value={watch(`filters.${index}.operator`)}
|
||||
onValueChange={(value: string | null) => {
|
||||
if (value === null) return;
|
||||
setValue(
|
||||
`filters.${index}.operator`,
|
||||
value as 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains' | 'not_contains',
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
}}
|
||||
options={[
|
||||
{ value: 'equals', label: 'equals' },
|
||||
{ value: 'not_equals', label: 'not equals' },
|
||||
{ value: 'contains', label: 'contains' },
|
||||
]}
|
||||
aria-label='Operator'
|
||||
/>
|
||||
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Value to match
|
||||
<Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' />
|
||||
</label>
|
||||
<div>
|
||||
<span> </span>
|
||||
<div>
|
||||
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeFilter(index)}>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div>
|
||||
<Button onClick={handleAddNewFilter}>
|
||||
Add filter <IoAdd />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div>
|
||||
<Button onClick={handleAddNewFilter}>
|
||||
Add filter <IoAdd />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Outputs</h3>
|
||||
<Info>
|
||||
Automation outputs can be used to send data from Ontime to external software <br />
|
||||
or to change properties of Ontime itself. <br /> <br />
|
||||
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '}
|
||||
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
|
||||
</Info>
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Outputs</h3>
|
||||
<Info>
|
||||
Automation outputs can be used to send data from Ontime to external software <br />
|
||||
or to change properties of Ontime itself. <br /> <br />
|
||||
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '}
|
||||
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
|
||||
</Info>
|
||||
|
||||
{fieldOutputs.map((output, index) => {
|
||||
if (isOSCOutput(output)) {
|
||||
const rowErrors = errors.outputs?.[index] as
|
||||
| {
|
||||
targetIP?: { message?: string };
|
||||
targetPort?: { message?: string };
|
||||
address?: { message?: string };
|
||||
args?: { message?: string };
|
||||
}
|
||||
| undefined;
|
||||
{fieldOutputs.map((output, index) => {
|
||||
if (isOSCOutput(output)) {
|
||||
const rowErrors = errors.outputs?.[index] as
|
||||
| {
|
||||
targetIP?: { message?: string };
|
||||
targetPort?: { message?: string };
|
||||
address?: { message?: string };
|
||||
args?: { message?: string };
|
||||
}
|
||||
| undefined;
|
||||
|
||||
return (
|
||||
<div key={output.id} className={style.outputCard}>
|
||||
<Tag>OSC</Tag>
|
||||
<div className={style.oscSection}>
|
||||
<label>
|
||||
Target IP
|
||||
<Input
|
||||
{...register(`outputs.${index}.targetIP`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
})}
|
||||
fluid
|
||||
placeholder='127.0.0.1'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Target Port
|
||||
<Input
|
||||
{...register(`outputs.${index}.targetPort`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
setValueAs: (value) => (value === '' ? 0 : Number(value)),
|
||||
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
|
||||
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
|
||||
})}
|
||||
fluid
|
||||
type='number'
|
||||
maxLength={5}
|
||||
placeholder='8000'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Address
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.address`)}
|
||||
value={output.address}
|
||||
fluid
|
||||
placeholder='/cue/start'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Arguments
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.args`)}
|
||||
value={output.args}
|
||||
fluid
|
||||
placeholder='1'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
|
||||
</label>
|
||||
<div>
|
||||
<span> </span>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button variant='ghosted-white' onClick={() => handleTestOSCOutput(index)}>
|
||||
Test
|
||||
</Button>
|
||||
<IconButton
|
||||
aria-label='Delete'
|
||||
variant='ghosted-destructive'
|
||||
onClick={() => removeOutput(index)}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div key={output.id} className={style.outputCard}>
|
||||
<Tag>OSC</Tag>
|
||||
<div className={style.oscSection}>
|
||||
<label>
|
||||
Target IP
|
||||
<Input
|
||||
{...register(`outputs.${index}.targetIP`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
})}
|
||||
fluid
|
||||
placeholder='127.0.0.1'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Target Port
|
||||
<Input
|
||||
{...register(`outputs.${index}.targetPort`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
setValueAs: (value) => (value === '' ? 0 : Number(value)),
|
||||
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
|
||||
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
|
||||
})}
|
||||
fluid
|
||||
type='number'
|
||||
maxLength={5}
|
||||
placeholder='8000'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Address
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.address`)}
|
||||
value={output.address}
|
||||
fluid
|
||||
placeholder='/cue/start'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Arguments
|
||||
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} fluid placeholder='1' />
|
||||
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
|
||||
</label>
|
||||
<div>
|
||||
<span> </span>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button variant='ghosted-white' onClick={() => handleTestOSCOutput(index)}>
|
||||
Test
|
||||
</Button>
|
||||
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isHTTPOutput(output)) {
|
||||
const rowErrors = errors.outputs?.[index] as
|
||||
| {
|
||||
url?: { message?: string };
|
||||
}
|
||||
| undefined;
|
||||
return (
|
||||
<div key={output.id} className={style.outputCard}>
|
||||
<Tag>HTTP</Tag>
|
||||
<div className={style.httpSection}>
|
||||
<label>
|
||||
Target URL
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.url`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
pattern: {
|
||||
value: startsWithHttp,
|
||||
message: 'HTTP messages should target http:// or https://',
|
||||
},
|
||||
})}
|
||||
value={output.url}
|
||||
fluid
|
||||
placeholder='http://127.0.0.1/start/1'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
|
||||
</label>
|
||||
<div>
|
||||
<span> </span>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button variant='ghosted-white' onClick={() => handleTestHTTPOutput(index)}>
|
||||
Test
|
||||
</Button>
|
||||
<IconButton
|
||||
aria-label='Delete'
|
||||
variant='ghosted-destructive'
|
||||
onClick={() => removeOutput(index)}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isHTTPOutput(output)) {
|
||||
const rowErrors = errors.outputs?.[index] as
|
||||
| {
|
||||
url?: { message?: string };
|
||||
}
|
||||
| undefined;
|
||||
return (
|
||||
<div key={output.id} className={style.outputCard}>
|
||||
<Tag>HTTP</Tag>
|
||||
<div className={style.httpSection}>
|
||||
<label>
|
||||
Target URL
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.url`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
pattern: {
|
||||
value: startsWithHttp,
|
||||
message: 'HTTP messages should target http:// or https://',
|
||||
},
|
||||
})}
|
||||
value={output.url}
|
||||
fluid
|
||||
placeholder='http://127.0.0.1/start/1'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
|
||||
</label>
|
||||
<div>
|
||||
<span> </span>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button variant='ghosted-white' onClick={() => handleTestHTTPOutput(index)}>
|
||||
Test
|
||||
</Button>
|
||||
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isOntimeAction(output)) {
|
||||
const rowErrors = errors.outputs?.[index] as
|
||||
| {
|
||||
action?: { message?: string };
|
||||
time?: { message?: string };
|
||||
text?: { message?: string };
|
||||
visible?: { message?: string };
|
||||
secondarySource?: { message?: string };
|
||||
}
|
||||
| undefined;
|
||||
return (
|
||||
<div key={output.id} className={style.outputCard}>
|
||||
<Tag>Ontime action</Tag>
|
||||
<OntimeActionForm
|
||||
value={output.action}
|
||||
index={index}
|
||||
register={register}
|
||||
rowErrors={rowErrors}
|
||||
setValue={setValue}
|
||||
watch={watch}
|
||||
>
|
||||
<span> </span>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button variant='ghosted-white' onClick={() => handleTestOntimeAction(index)}>
|
||||
Test
|
||||
</Button>
|
||||
<IconButton
|
||||
aria-label='Delete'
|
||||
variant='ghosted-destructive'
|
||||
onClick={() => removeOutput(index)}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</Panel.InlineElements>
|
||||
</OntimeActionForm>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isOntimeAction(output)) {
|
||||
const rowErrors = errors.outputs?.[index] as
|
||||
| {
|
||||
action?: { message?: string };
|
||||
time?: { message?: string };
|
||||
text?: { message?: string };
|
||||
visible?: { message?: string };
|
||||
secondarySource?: { message?: string };
|
||||
}
|
||||
| undefined;
|
||||
return (
|
||||
<div key={output.id} className={style.outputCard}>
|
||||
<Tag>Ontime action</Tag>
|
||||
<OntimeActionForm
|
||||
value={output.action}
|
||||
index={index}
|
||||
register={register}
|
||||
rowErrors={rowErrors}
|
||||
setValue={setValue}
|
||||
watch={watch}
|
||||
>
|
||||
<span> </span>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button variant='ghosted-white' onClick={() => handleTestOntimeAction(index)}>
|
||||
Test
|
||||
</Button>
|
||||
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</Panel.InlineElements>
|
||||
</OntimeActionForm>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button onClick={handleAddNewOSCOutput}>
|
||||
OSC <IoAdd />
|
||||
</Button>
|
||||
<Button onClick={handleAddNewHTTPOutput}>
|
||||
HTTP <IoAdd />
|
||||
</Button>
|
||||
<Button onClick={handleAddnewOntimeAction}>
|
||||
Ontime action <IoAdd />
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
footerElements={
|
||||
<>
|
||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant='primary' type='submit' form={formId} disabled={!canSubmit} loading={isSubmitting}>
|
||||
Save
|
||||
// there should be no other output types
|
||||
return null;
|
||||
})}
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button onClick={handleAddNewOSCOutput}>
|
||||
OSC <IoAdd />
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Button onClick={handleAddNewHTTPOutput}>
|
||||
HTTP <IoAdd />
|
||||
</Button>
|
||||
<Button onClick={handleAddnewOntimeAction}>
|
||||
Ontime action <IoAdd />
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
|
||||
<Panel.InlineElements align='end'>
|
||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant='primary' type='submit' disabled={!canSubmit} loading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.Indent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,27 +19,24 @@ export default function AutomationPanel({ location }: PanelBaseProps) {
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Automation</Panel.Header>
|
||||
<div ref={settingsRef}>
|
||||
<AutomationSettingsForm
|
||||
enabledAutomations={data.enabledAutomations}
|
||||
enabledOscIn={data.enabledOscIn}
|
||||
oscPortIn={data.oscPortIn}
|
||||
automationState={automationState}
|
||||
oscInputState={oscInputState}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div ref={automationsRef}>
|
||||
<AutomationsList automations={data.automations} enabledAutomations={automationState} isLoading={isLoading} />
|
||||
</div>
|
||||
<div ref={triggersRef}>
|
||||
<TriggersList
|
||||
triggers={data.triggers}
|
||||
automations={data.automations}
|
||||
enabledAutomations={automationState}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={isLoading} />
|
||||
<div ref={settingsRef}>
|
||||
<AutomationSettingsForm
|
||||
enabledAutomations={data.enabledAutomations}
|
||||
enabledOscIn={data.enabledOscIn}
|
||||
oscPortIn={data.oscPortIn}
|
||||
automationState={automationState}
|
||||
oscInputState={oscInputState}
|
||||
/>
|
||||
</div>
|
||||
<div ref={automationsRef}>
|
||||
<AutomationsList automations={data.automations} enabledAutomations={automationState} />
|
||||
</div>
|
||||
<div ref={triggersRef}>
|
||||
<TriggersList triggers={data.triggers} automations={data.automations} enabledAutomations={automationState} />
|
||||
</div>
|
||||
</Panel.Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+117
-122
@@ -21,7 +21,6 @@ interface AutomationSettingsProps {
|
||||
oscPortIn: number;
|
||||
automationState?: boolean;
|
||||
oscInputState?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export default function AutomationSettingsForm({
|
||||
@@ -30,7 +29,6 @@ export default function AutomationSettingsForm({
|
||||
oscPortIn,
|
||||
automationState,
|
||||
oscInputState,
|
||||
isLoading,
|
||||
}: AutomationSettingsProps) {
|
||||
const {
|
||||
handleSubmit,
|
||||
@@ -67,129 +65,126 @@ export default function AutomationSettingsForm({
|
||||
const oscInputEnabled = watch('enabledOscIn');
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.Loader isLoading={Boolean(isLoading)} />
|
||||
<Panel.SubHeader>
|
||||
Automation settings
|
||||
<Panel.InlineElements>
|
||||
<Button variant='ghosted' onClick={onReset} disabled={!canSubmit}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
type='submit'
|
||||
form='automation-settings-form'
|
||||
disabled={!canSubmit}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Automation settings
|
||||
<Panel.InlineElements>
|
||||
<Button variant='ghosted' onClick={onReset} disabled={!canSubmit}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
type='submit'
|
||||
form='automation-settings-form'
|
||||
disabled={!canSubmit}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
|
||||
<Panel.Divider />
|
||||
<Panel.Divider />
|
||||
|
||||
<Panel.Section>
|
||||
<Info>
|
||||
<span>Control Ontime and share its data with external systems in your workflow.</span>
|
||||
<span>- Automations allow Ontime to send its data on lifecycle triggers.</span>
|
||||
<span>- OSC Input tells Ontime to listen to messages on the specific port.</span>
|
||||
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
<Info>
|
||||
<span>Control Ontime and share its data with external systems in your workflow.</span>
|
||||
<span>- Automations allow Ontime to send its data on lifecycle triggers.</span>
|
||||
<span>- OSC Input tells Ontime to listen to messages on the specific port.</span>
|
||||
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
</Panel.Section>
|
||||
|
||||
<Panel.Section
|
||||
as='form'
|
||||
id='automation-settings-form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
onKeyDown={(event) => preventEscape(event, onReset)}
|
||||
>
|
||||
<Panel.Title>Automation</Panel.Title>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title={
|
||||
<>
|
||||
<span>Enable automations</span>
|
||||
{automationState === false && <Tag variant='warning'>OFF</Tag>}
|
||||
</>
|
||||
}
|
||||
description={
|
||||
automationState === false
|
||||
? 'Automations are OFF. Triggers stay configured, but Ontime will not send messages.'
|
||||
: 'Allow Ontime to send messages on lifecycle triggers'
|
||||
}
|
||||
descriptionTone={automationState === false ? 'warning' : 'default'}
|
||||
error={errors.enabledAutomations?.message}
|
||||
/>
|
||||
<Switch
|
||||
size='large'
|
||||
checked={automationsEnabled}
|
||||
onCheckedChange={(value: boolean) =>
|
||||
setValue('enabledAutomations', value, { shouldDirty: true, shouldValidate: true })
|
||||
}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
<Panel.Title>OSC Input</Panel.Title>
|
||||
<Panel.Section
|
||||
as='form'
|
||||
id='automation-settings-form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
onKeyDown={(event) => preventEscape(event, onReset)}
|
||||
>
|
||||
<Panel.Loader isLoading={false} />
|
||||
|
||||
<Panel.ListGroup>
|
||||
{isOntimeCloud && (
|
||||
<Info>For security reasons OSC integrations are not available in the cloud service.</Info>
|
||||
)}
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title={
|
||||
<>
|
||||
<span>OSC input</span>
|
||||
{oscInputState === false && <Tag variant='warning'>OFF</Tag>}
|
||||
</>
|
||||
}
|
||||
description={
|
||||
oscInputState === false
|
||||
? 'OSC input is OFF. Ontime will not listen for incoming OSC control messages.'
|
||||
: 'Allow control of Ontime through OSC'
|
||||
}
|
||||
descriptionTone={oscInputState === false ? 'warning' : 'default'}
|
||||
error={errors.enabledOscIn?.message}
|
||||
/>
|
||||
<Switch
|
||||
size='large'
|
||||
checked={oscInputEnabled}
|
||||
onCheckedChange={(value: boolean) =>
|
||||
setValue('enabledOscIn', value, { shouldDirty: true, shouldValidate: true })
|
||||
}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Listen on port'
|
||||
description='Port for incoming OSC. Default: 8888'
|
||||
error={errors.oscPortIn?.message}
|
||||
/>
|
||||
<Input
|
||||
id='oscPortIn'
|
||||
placeholder='8888'
|
||||
maxLength={5}
|
||||
style={{ textAlign: 'right', width: '5rem' }}
|
||||
type='number'
|
||||
fluid
|
||||
{...register('oscPortIn', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
|
||||
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
|
||||
pattern: {
|
||||
value: isOnlyNumbers,
|
||||
message: 'Value should be numeric',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
<Panel.Title>Automation</Panel.Title>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title={
|
||||
<>
|
||||
<span>Enable automations</span>
|
||||
{automationState === false && <Tag variant='warning'>OFF</Tag>}
|
||||
</>
|
||||
}
|
||||
description={
|
||||
automationState === false
|
||||
? 'Automations are OFF. Triggers stay configured, but Ontime will not send messages.'
|
||||
: 'Allow Ontime to send messages on lifecycle triggers'
|
||||
}
|
||||
descriptionTone={automationState === false ? 'warning' : 'default'}
|
||||
error={errors.enabledAutomations?.message}
|
||||
/>
|
||||
<Switch
|
||||
size='large'
|
||||
checked={automationsEnabled}
|
||||
onCheckedChange={(value: boolean) =>
|
||||
setValue('enabledAutomations', value, { shouldDirty: true, shouldValidate: true })
|
||||
}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
<Panel.Title>OSC Input</Panel.Title>
|
||||
|
||||
<Panel.ListGroup>
|
||||
{isOntimeCloud && <Info>For security reasons OSC integrations are not available in the cloud service.</Info>}
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title={
|
||||
<>
|
||||
<span>OSC input</span>
|
||||
{oscInputState === false && <Tag variant='warning'>OFF</Tag>}
|
||||
</>
|
||||
}
|
||||
description={
|
||||
oscInputState === false
|
||||
? 'OSC input is OFF. Ontime will not listen for incoming OSC control messages.'
|
||||
: 'Allow control of Ontime through OSC'
|
||||
}
|
||||
descriptionTone={oscInputState === false ? 'warning' : 'default'}
|
||||
error={errors.enabledOscIn?.message}
|
||||
/>
|
||||
<Switch
|
||||
size='large'
|
||||
checked={oscInputEnabled}
|
||||
onCheckedChange={(value: boolean) =>
|
||||
setValue('enabledOscIn', value, { shouldDirty: true, shouldValidate: true })
|
||||
}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Listen on port'
|
||||
description='Port for incoming OSC. Default: 8888'
|
||||
error={errors.oscPortIn?.message}
|
||||
/>
|
||||
<Input
|
||||
id='oscPortIn'
|
||||
placeholder='8888'
|
||||
maxLength={5}
|
||||
style={{ textAlign: 'right', width: '5rem' }}
|
||||
type='number'
|
||||
fluid
|
||||
{...register('oscPortIn', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
|
||||
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
|
||||
pattern: {
|
||||
value: isOnlyNumbers,
|
||||
message: 'Value should be numeric',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,10 +22,10 @@ const automationPlaceholder: AutomationDTO = {
|
||||
interface AutomationsListProps {
|
||||
automations: NormalisedAutomation;
|
||||
enabledAutomations?: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export default function AutomationsList({ automations, enabledAutomations, isLoading }: AutomationsListProps) {
|
||||
export default function AutomationsList(props: AutomationsListProps) {
|
||||
const { automations, enabledAutomations } = props;
|
||||
const { refetch } = useAutomationSettings();
|
||||
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
@@ -44,94 +44,89 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
|
||||
const arrayAutomations = Object.keys(automations);
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
{automationFormData !== null && (
|
||||
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
|
||||
)}
|
||||
<Panel.SubHeader>
|
||||
Manage automations
|
||||
<Button onClick={() => setAutomationFormData(automationPlaceholder)}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Manage automations
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={Boolean(automationFormData)}
|
||||
onClick={() => setAutomationFormData(automationPlaceholder)}
|
||||
>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
|
||||
<Panel.Divider />
|
||||
<Panel.Divider />
|
||||
|
||||
<Panel.Section>
|
||||
{enabledAutomations === false && (
|
||||
<Info>
|
||||
Automations are disabled. You can still manage automation definitions here, but they will not run until
|
||||
enabled.
|
||||
</Info>
|
||||
{enabledAutomations === false && (
|
||||
<Info>
|
||||
Automations are disabled. You can still manage automation definitions here, but they will not run until
|
||||
enabled.
|
||||
</Info>
|
||||
)}
|
||||
|
||||
{automationFormData !== null && (
|
||||
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
|
||||
)}
|
||||
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '45%' }}>Title</th>
|
||||
<th style={{ width: '15%' }}>Trigger rule</th>
|
||||
<th style={{ width: '15%' }}>Filters</th>
|
||||
<th style={{ width: '15%' }}>Outputs</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{arrayAutomations.length === 0 && (
|
||||
<Panel.TableEmpty
|
||||
handleClick={!automationFormData ? () => setAutomationFormData(automationPlaceholder) : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '45%' }}>Title</th>
|
||||
<th style={{ width: '15%' }}>Trigger rule</th>
|
||||
<th style={{ width: '15%' }}>Filters</th>
|
||||
<th style={{ width: '15%' }}>Outputs</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!isLoading && arrayAutomations.length === 0 && (
|
||||
<Panel.TableEmpty
|
||||
title='No automations yet'
|
||||
description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires.'
|
||||
action={
|
||||
<Button variant='primary' onClick={() => setAutomationFormData(automationPlaceholder)}>
|
||||
Create automation <IoAdd />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{arrayAutomations.map((automationId) => {
|
||||
if (!Object.hasOwn(automations, automationId)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Fragment key={automationId}>
|
||||
<tr>
|
||||
<td>{automations[automationId].title}</td>
|
||||
<td>
|
||||
<Tag>{automations[automationId].filterRule}</Tag>
|
||||
</td>
|
||||
<td>{automations[automationId].filters.length}</td>
|
||||
<td>{automations[automationId].outputs.length}</td>
|
||||
<Panel.InlineElements align='end' relation='inner' as='td'>
|
||||
<IconButton
|
||||
variant='ghosted-white'
|
||||
aria-label='Edit entry'
|
||||
onClick={() => setAutomationFormData(automations[automationId])}
|
||||
>
|
||||
<IoPencil />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant='ghosted-destructive'
|
||||
aria-label='Delete entry'
|
||||
onClick={() => handleDelete(automationId)}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</Panel.InlineElements>
|
||||
</tr>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{deleteError && (
|
||||
{arrayAutomations.map((automationId) => {
|
||||
if (!Object.hasOwn(automations, automationId)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Fragment key={automationId}>
|
||||
<tr>
|
||||
<td colSpan={5}>
|
||||
<Panel.Error>{deleteError}</Panel.Error>
|
||||
<td>{automations[automationId].title}</td>
|
||||
<td>
|
||||
<Tag>{automations[automationId].filterRule}</Tag>
|
||||
</td>
|
||||
<td>{automations[automationId].filters.length}</td>
|
||||
<td>{automations[automationId].outputs.length}</td>
|
||||
<Panel.InlineElements align='end' relation='inner' as='td'>
|
||||
<IconButton
|
||||
variant='ghosted-white'
|
||||
aria-label='Edit entry'
|
||||
onClick={() => setAutomationFormData(automations[automationId])}
|
||||
>
|
||||
<IoPencil />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant='ghosted-destructive'
|
||||
aria-label='Delete entry'
|
||||
onClick={() => handleDelete(automationId)}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</Panel.InlineElements>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{deleteError && (
|
||||
<tr>
|
||||
<td colSpan={5}>
|
||||
<Panel.Error>{deleteError}</Panel.Error>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NormalisedAutomation, TimerLifeCycle, Trigger, TriggerDTO } from 'ontime-types';
|
||||
import { NormalisedAutomation, TimerLifeCycle, TriggerDTO } from 'ontime-types';
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
@@ -6,21 +6,30 @@ import { addTrigger, editTrigger } from '../../../../common/api/automation';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import Modal from '../../../../common/components/modal/Modal';
|
||||
import Select from '../../../../common/components/select/Select';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { cycles } from './automationUtils';
|
||||
|
||||
const formId = 'trigger-form';
|
||||
|
||||
interface TriggerFormProps {
|
||||
automations: NormalisedAutomation;
|
||||
trigger: Trigger | null;
|
||||
initialId?: string;
|
||||
initialTitle?: string;
|
||||
initialAutomationId?: string;
|
||||
initialTrigger?: TimerLifeCycle;
|
||||
onCancel: () => void;
|
||||
postSubmit: () => void;
|
||||
}
|
||||
|
||||
export default function TriggerForm({ automations, trigger, onCancel, postSubmit }: TriggerFormProps) {
|
||||
export default function TriggerForm({
|
||||
automations,
|
||||
initialId,
|
||||
initialTitle,
|
||||
initialAutomationId,
|
||||
initialTrigger,
|
||||
onCancel,
|
||||
postSubmit,
|
||||
}: TriggerFormProps) {
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
@@ -31,9 +40,9 @@ export default function TriggerForm({ automations, trigger, onCancel, postSubmit
|
||||
formState: { errors, isSubmitting, isValid, isDirty },
|
||||
} = useForm<TriggerDTO>({
|
||||
defaultValues: {
|
||||
title: trigger?.title,
|
||||
trigger: trigger?.trigger ?? (cycles[0].value as TimerLifeCycle | undefined),
|
||||
automationId: trigger?.automationId ?? automations?.[Object.keys(automations)[0]]?.id,
|
||||
title: initialTitle,
|
||||
trigger: initialTrigger ?? (cycles[0].value as TimerLifeCycle | undefined),
|
||||
automationId: initialAutomationId ?? automations?.[Object.keys(automations)[0]]?.id,
|
||||
},
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
@@ -46,9 +55,10 @@ export default function TriggerForm({ automations, trigger, onCancel, postSubmit
|
||||
}, [setFocus]);
|
||||
|
||||
const onSubmit = async (values: TriggerDTO) => {
|
||||
if (trigger) {
|
||||
// if we were passed an ID we are editing a Trigger
|
||||
if (initialId) {
|
||||
try {
|
||||
await editTrigger(trigger.id, { id: trigger.id, ...values });
|
||||
await editTrigger(initialId, { id: initialId, ...values });
|
||||
postSubmit();
|
||||
} catch (error) {
|
||||
setError('root', { message: `Failed to save changes to trigger ${maybeAxiosError(error)}` });
|
||||
@@ -75,63 +85,56 @@ export default function TriggerForm({ automations, trigger, onCancel, postSubmit
|
||||
const canSubmit = isDirty && isValid;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onCancel}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
size='compact'
|
||||
title={trigger ? 'Edit trigger' : 'Create trigger'}
|
||||
bodyElements={
|
||||
<form id={formId} onSubmit={handleSubmit(onSubmit)}>
|
||||
<label>
|
||||
Title
|
||||
<Input
|
||||
{...register('title', { required: { value: true, message: 'Required field' } })}
|
||||
fluid
|
||||
defaultValue={trigger?.title}
|
||||
/>
|
||||
<Panel.Error>{errors.title?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Lifecycle trigger
|
||||
<Select
|
||||
value={watch('trigger')}
|
||||
onValueChange={(value) => {
|
||||
if (value === null) return;
|
||||
setValue('trigger', value as TimerLifeCycle, { shouldDirty: true });
|
||||
}}
|
||||
options={cycles.map((cycle) => ({ value: cycle.value, label: cycle.label }))}
|
||||
aria-label='Lifecycle trigger'
|
||||
/>
|
||||
<Panel.Error>{errors.trigger?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Automation title
|
||||
<Select
|
||||
value={watch('automationId')}
|
||||
onValueChange={(value: string | null) => {
|
||||
if (value === null) return;
|
||||
setValue('automationId', value, { shouldDirty: true });
|
||||
}}
|
||||
options={automationSelect}
|
||||
aria-label='Automation title'
|
||||
/>
|
||||
<Panel.Error>{errors.automationId?.message}</Panel.Error>
|
||||
</label>
|
||||
</form>
|
||||
}
|
||||
footerElements={
|
||||
<>
|
||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Button disabled={isSubmitting} onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' form={formId} variant='primary' disabled={!canSubmit} loading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Panel.Indent
|
||||
as='form'
|
||||
name='trigger-form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
onKeyDown={(event) => preventEscape(event, onCancel)}
|
||||
>
|
||||
<Panel.SubHeader>{initialId ? 'Edit trigger' : 'Create trigger'}</Panel.SubHeader>
|
||||
<label>
|
||||
Title
|
||||
<Input
|
||||
{...register('title', { required: { value: true, message: 'Required field' } })}
|
||||
fluid
|
||||
defaultValue={initialTitle}
|
||||
/>
|
||||
<Panel.Error>{errors.title?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Lifecycle trigger
|
||||
<Select
|
||||
value={watch('trigger')}
|
||||
onValueChange={(value) => {
|
||||
if (value === null) return;
|
||||
setValue('trigger', value as TimerLifeCycle, { shouldDirty: true });
|
||||
}}
|
||||
options={cycles.map((cycle) => ({ value: cycle.value, label: cycle.label }))}
|
||||
aria-label='Lifecycle trigger'
|
||||
/>
|
||||
<Panel.Error>{errors.trigger?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Automation title
|
||||
<Select
|
||||
value={watch('automationId')}
|
||||
onValueChange={(value: string | null) => {
|
||||
if (value === null) return;
|
||||
setValue('automationId', value, { shouldDirty: true });
|
||||
}}
|
||||
options={automationSelect}
|
||||
aria-label='Automation title'
|
||||
/>
|
||||
<Panel.Error>{errors.automationId?.message}</Panel.Error>
|
||||
</label>
|
||||
<Panel.InlineElements align='end'>
|
||||
<Button disabled={isSubmitting} onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.Indent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,33 +9,23 @@ import Info from '../../../../common/components/info/Info';
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { checkDuplicates } from './automationUtils';
|
||||
import TriggerForm from './TriggerForm';
|
||||
import AutomationForm from './TriggerForm';
|
||||
import TriggersListItem from './TriggersListItem';
|
||||
|
||||
type FormState = {
|
||||
isOpen: boolean;
|
||||
trigger?: Trigger;
|
||||
};
|
||||
|
||||
interface TriggersListProps {
|
||||
triggers: Trigger[];
|
||||
automations: NormalisedAutomation;
|
||||
enabledAutomations?: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export default function TriggersList({ triggers, automations, enabledAutomations, isLoading }: TriggersListProps) {
|
||||
const [formState, setFormState] = useState<FormState>({ isOpen: false, trigger: undefined });
|
||||
export default function TriggersList(props: TriggersListProps) {
|
||||
const { triggers, automations, enabledAutomations } = props;
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const { refetch } = useAutomationSettings();
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const openNewForm = () => setFormState({ isOpen: true });
|
||||
const openEditForm = (trigger: Trigger) => setFormState({ isOpen: true, trigger });
|
||||
const closeForm = () => setFormState({ isOpen: false, trigger: undefined });
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
setDeleteError(null);
|
||||
await deleteTrigger(id);
|
||||
} catch (error) {
|
||||
setDeleteError(maybeAxiosError(error));
|
||||
@@ -45,7 +35,7 @@ export default function TriggersList({ triggers, automations, enabledAutomations
|
||||
};
|
||||
|
||||
const postSubmit = () => {
|
||||
closeForm();
|
||||
setShowForm(false);
|
||||
refetch();
|
||||
};
|
||||
|
||||
@@ -55,86 +45,70 @@ export default function TriggersList({ triggers, automations, enabledAutomations
|
||||
const canAdd = Object.keys(automations).length > 0;
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
{formState.isOpen && (
|
||||
<TriggerForm
|
||||
automations={automations}
|
||||
trigger={formState.trigger ?? null}
|
||||
onCancel={closeForm}
|
||||
postSubmit={postSubmit}
|
||||
/>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Manage triggers
|
||||
<Button type='submit' form='trigger-form' disabled={!canAdd} loading={false} onClick={() => setShowForm(true)}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
{enabledAutomations === false && (
|
||||
<Info>
|
||||
Automations are disabled. You can still manage triggers here, but they will not run until enabled.
|
||||
</Info>
|
||||
)}
|
||||
<Panel.SubHeader>
|
||||
Manage triggers
|
||||
<Button disabled={!canAdd} onClick={openNewForm}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
{enabledAutomations === false && (
|
||||
<Info>
|
||||
Automations are disabled. You can still manage triggers here, but they will not run until enabled.
|
||||
</Info>
|
||||
)}
|
||||
{duplicates && (
|
||||
<Panel.Error>
|
||||
You have created multiple links between the same trigger and automation which can cause performance
|
||||
issues.
|
||||
</Panel.Error>
|
||||
)}
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '35%' }}>Title</th>
|
||||
<th style={{ width: '25%' }}>Lifecycle trigger</th>
|
||||
<th style={{ width: '25%' }}>Automation</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!isLoading && triggers.length === 0 && (
|
||||
<Panel.TableEmpty
|
||||
title='No triggers yet'
|
||||
description={
|
||||
canAdd
|
||||
? 'Triggers run an automation at a given point of the timer lifecycle, like when an event starts or finishes.'
|
||||
: 'Create an automation first, then add a trigger to decide when it should run.'
|
||||
}
|
||||
action={
|
||||
canAdd && (
|
||||
<Button variant='primary' onClick={openNewForm}>
|
||||
Create trigger <IoAdd />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{triggers.map((trigger, index) => {
|
||||
return (
|
||||
<Fragment key={trigger.id}>
|
||||
<TriggersListItem
|
||||
automations={automations}
|
||||
trigger={trigger}
|
||||
duplicate={duplicates?.includes(index)}
|
||||
handleEdit={() => openEditForm(trigger)}
|
||||
handleDelete={() => handleDelete(trigger.id)}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{deleteError && (
|
||||
<tr>
|
||||
<td colSpan={5}>
|
||||
<Panel.Error>{deleteError}</Panel.Error>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
{duplicates && (
|
||||
<Panel.Error>
|
||||
You have created multiple links between the same trigger and automation which can performance issues.
|
||||
</Panel.Error>
|
||||
)}
|
||||
{showForm && (
|
||||
<AutomationForm automations={automations} onCancel={() => setShowForm(false)} postSubmit={postSubmit} />
|
||||
)}
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '35%' }}>Title</th>
|
||||
<th style={{ width: '25%' }}>Lifecycle trigger</th>
|
||||
<th style={{ width: '25%' }}>Automation</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!showForm && triggers.length === 0 && (
|
||||
<Panel.TableEmpty
|
||||
label='Create a trigger to run an automation'
|
||||
handleClick={canAdd ? () => setShowForm(true) : undefined}
|
||||
/>
|
||||
)}
|
||||
{triggers.map((trigger, index) => {
|
||||
return (
|
||||
<Fragment key={trigger.id}>
|
||||
<TriggersListItem
|
||||
automations={automations}
|
||||
id={trigger.id}
|
||||
title={trigger.title}
|
||||
trigger={trigger.trigger}
|
||||
automationId={trigger.automationId}
|
||||
duplicate={duplicates?.includes(index)}
|
||||
handleDelete={() => handleDelete(trigger.id)}
|
||||
postSubmit={postSubmit}
|
||||
/>
|
||||
{deleteError && (
|
||||
<tr>
|
||||
<td colSpan={5}>
|
||||
<Panel.Error>{deleteError}</Panel.Error>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,48 @@
|
||||
import { NormalisedAutomation, Trigger } from 'ontime-types';
|
||||
import { NormalisedAutomation, TimerLifeCycle } from 'ontime-types';
|
||||
import { useState } from 'react';
|
||||
import { IoPencil, IoTrash, IoWarningOutline } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { cycles } from './automationUtils';
|
||||
import AutomationForm from './TriggerForm';
|
||||
|
||||
interface TriggersListItemProps {
|
||||
automations: NormalisedAutomation;
|
||||
trigger: Trigger;
|
||||
id: string;
|
||||
title: string;
|
||||
trigger: TimerLifeCycle;
|
||||
automationId: string;
|
||||
duplicate?: boolean;
|
||||
handleEdit: () => void;
|
||||
handleDelete: () => void;
|
||||
postSubmit: () => void;
|
||||
}
|
||||
|
||||
export default function TriggersListItem(props: TriggersListItemProps) {
|
||||
const { automations, trigger, duplicate, handleEdit, handleDelete } = props;
|
||||
const { automations, id, title, trigger, automationId, duplicate, handleDelete, postSubmit } = props;
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={99}>
|
||||
<AutomationForm
|
||||
automations={automations}
|
||||
initialId={id}
|
||||
initialTitle={title}
|
||||
initialTrigger={trigger}
|
||||
initialAutomationId={automationId}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
postSubmit={() => {
|
||||
setIsEditing(false);
|
||||
postSubmit();
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr data-warn={duplicate}>
|
||||
@@ -25,16 +52,16 @@ export default function TriggersListItem(props: TriggersListItemProps) {
|
||||
color='#FFBC56' // $orange-500
|
||||
/>
|
||||
)}
|
||||
{trigger.title}
|
||||
{title}
|
||||
</Panel.InlineElements>
|
||||
<td>
|
||||
<Tag>{cycles.find((cycle) => cycle.value === trigger.trigger)?.label}</Tag>
|
||||
<Tag>{cycles.find((cycle) => cycle.value === trigger)?.label}</Tag>
|
||||
</td>
|
||||
<td>
|
||||
<Tag>{automations?.[trigger.automationId]?.title}</Tag>
|
||||
<Tag>{automations?.[automationId]?.title}</Tag>
|
||||
</td>
|
||||
<Panel.InlineElements align='end' relation='inner' as='td'>
|
||||
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={handleEdit}>
|
||||
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={() => setIsEditing(true)}>
|
||||
<IoPencil />
|
||||
</IconButton>
|
||||
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={handleDelete}>
|
||||
|
||||
@@ -66,10 +66,7 @@ export default function ReportSettings() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{combinedReport.length === 0 && (
|
||||
<Panel.TableEmpty
|
||||
title='No report data yet'
|
||||
description='Reports are generated as you run through the show, comparing scheduled times against what actually happened.'
|
||||
/>
|
||||
<Panel.TableEmpty label='Reports are generated when running through the show.' />
|
||||
)}
|
||||
|
||||
{combinedReport.map((entry) => {
|
||||
|
||||
@@ -43,7 +43,6 @@ export default function URLPresets() {
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
{formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />}
|
||||
<Panel.SubHeader>
|
||||
URL presets
|
||||
<Button onClick={openNewForm}>
|
||||
@@ -64,6 +63,7 @@ export default function URLPresets() {
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={status === 'pending'} />
|
||||
{formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />}
|
||||
{actionError && <Panel.Error>{actionError}</Panel.Error>}
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
@@ -76,17 +76,7 @@ export default function URLPresets() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 && (
|
||||
<Panel.TableEmpty
|
||||
title='No URL presets yet'
|
||||
description='A preset gives one of your views a short, memorable address, with all of its options baked in.'
|
||||
action={
|
||||
<Button variant='primary' onClick={openNewForm}>
|
||||
Create preset <IoAdd />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{data.length === 0 && <Panel.TableEmpty handleClick={openNewForm} />}
|
||||
{data.map((preset, index) => {
|
||||
const isCuesheet = preset.target === OntimeView.Cuesheet;
|
||||
return (
|
||||
|
||||
+78
-86
@@ -6,9 +6,9 @@ import { maybeAxiosError, unwrapError } 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 Modal from '../../../../../common/components/modal/Modal';
|
||||
import Select, { SelectOption } from '../../../../../common/components/select/Select';
|
||||
import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets';
|
||||
import { preventEscape } from '../../../../../common/utils/keyEvent';
|
||||
import { isUrlSafe } from '../../../../../common/utils/regex';
|
||||
import { enDash } from '../../../../../common/utils/styleUtils';
|
||||
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
|
||||
@@ -28,8 +28,6 @@ const targetOptions: SelectOption<OntimeViewPresettable>[] = [
|
||||
{ value: OntimeView.ProjectInfo, label: 'Project Info' },
|
||||
];
|
||||
|
||||
const formId = 'url-preset-form';
|
||||
|
||||
const defaultValues: URLPreset = {
|
||||
alias: '',
|
||||
target: OntimeView.Timer,
|
||||
@@ -136,101 +134,95 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
size='compact'
|
||||
title={urlPreset ? 'Edit URL preset' : 'Create URL preset'}
|
||||
bodyElements={
|
||||
<form id={formId} onSubmit={handleSubmit(setupSubmit)} className={style.column}>
|
||||
<input hidden name='enabled' value='true' />
|
||||
<Panel.Indent
|
||||
as='form'
|
||||
onSubmit={handleSubmit(setupSubmit)}
|
||||
onKeyDown={(event) => preventEscape(event, onClose)}
|
||||
className={style.column}
|
||||
>
|
||||
<input hidden name='enabled' value='true' />
|
||||
|
||||
<div>1. Enter URL and let Ontime generate the preset options</div>
|
||||
<div>1. Enter URL and let Ontime generate the preset options</div>
|
||||
<Panel.InlineElements>
|
||||
<div>
|
||||
<Panel.Description>Alias</Panel.Description>
|
||||
<Input
|
||||
{...register('alias', {
|
||||
required: 'Alias is required',
|
||||
pattern: {
|
||||
value: isUrlSafe,
|
||||
message: 'Field can only contain URL safe characters (a-z, 0-9, _ and -)',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.expand}>
|
||||
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
|
||||
<Panel.InlineElements>
|
||||
<div>
|
||||
<Panel.Description>Alias</Panel.Description>
|
||||
<Input
|
||||
{...register('alias', {
|
||||
required: 'Alias is required',
|
||||
pattern: {
|
||||
value: isUrlSafe,
|
||||
message: 'Field can only contain URL safe characters (a-z, 0-9, _ and -)',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.expand}>
|
||||
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
|
||||
<Panel.InlineElements>
|
||||
<Input placeholder='Paste URL' fluid ref={urlRef} disabled={isEditingCuesheet} />
|
||||
<Button onClick={generateOptions} disabled={isEditingCuesheet}>
|
||||
Generate
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
<Input placeholder='Paste URL' fluid ref={urlRef} disabled={isEditingCuesheet} />
|
||||
<Button onClick={generateOptions} disabled={isEditingCuesheet}>
|
||||
Generate
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
{errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>}
|
||||
{!isEditingCuesheet && (
|
||||
<>
|
||||
<div>
|
||||
{enDash} or {enDash}
|
||||
</div>
|
||||
<div>2. Choose a view and its parameters</div>
|
||||
<div>
|
||||
<Panel.Description>Target</Panel.Description>
|
||||
<Select
|
||||
options={targetOptions}
|
||||
{...register('target', { required: 'Target is required' })}
|
||||
value={watch('target')}
|
||||
onValueChange={(value: OntimeViewPresettable | null) => {
|
||||
if (value === null) return;
|
||||
setValue('target', value, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Panel.Description>Parameters</Panel.Description>
|
||||
<Textarea
|
||||
fluid
|
||||
rows={3}
|
||||
{...register('search', {
|
||||
validate: validateParams,
|
||||
})}
|
||||
/>
|
||||
<Panel.Error>{errors.search?.message}</Panel.Error>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isCuesheet && (
|
||||
<div>
|
||||
<Panel.Description>Permissions</Panel.Description>
|
||||
<CuesheetLinkOptions
|
||||
initialRead={initialPermissions.current.read}
|
||||
initialWrite={initialPermissions.current.write}
|
||||
onChange={setCuesheetPermissions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
}
|
||||
footerElements={
|
||||
</div>
|
||||
</Panel.InlineElements>
|
||||
{errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>}
|
||||
{!isEditingCuesheet && (
|
||||
<>
|
||||
<Panel.Error>{errors.root?.message}</Panel.Error>
|
||||
<div>
|
||||
{enDash} or {enDash}
|
||||
</div>
|
||||
<div>2. Choose a view and its parameters</div>
|
||||
<div>
|
||||
<Panel.Description>Target</Panel.Description>
|
||||
<Select
|
||||
options={targetOptions}
|
||||
{...register('target', { required: 'Target is required' })}
|
||||
value={watch('target')}
|
||||
onValueChange={(value: OntimeViewPresettable | null) => {
|
||||
if (value === null) return;
|
||||
setValue('target', value, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Panel.Description>Parameters</Panel.Description>
|
||||
<Textarea
|
||||
fluid
|
||||
rows={3}
|
||||
{...register('search', {
|
||||
validate: validateParams,
|
||||
})}
|
||||
/>
|
||||
<Panel.Error>{errors.search?.message}</Panel.Error>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isCuesheet && (
|
||||
<div>
|
||||
<Panel.Description>Permissions</Panel.Description>
|
||||
<CuesheetLinkOptions
|
||||
initialRead={initialPermissions.current.read}
|
||||
initialWrite={initialPermissions.current.write}
|
||||
onChange={setCuesheetPermissions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Panel.Error>{errors.root?.message}</Panel.Error>
|
||||
<Panel.InlineElements align='end'>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
type='submit'
|
||||
form={formId}
|
||||
disabled={!isValid || (!isDirty && !permissionsDirty) || noReadAccess}
|
||||
loading={isSubmitting || isMutating}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
</Panel.Indent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,30 +13,27 @@ import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import CustomFieldEntry from './composite/CustomFieldEntry';
|
||||
import CustomFieldForm from './composite/CustomFieldForm';
|
||||
|
||||
type CustomFieldEntity = CustomField & { key: CustomFieldKey };
|
||||
|
||||
type FormState = {
|
||||
isOpen: boolean;
|
||||
field?: CustomFieldEntity;
|
||||
};
|
||||
|
||||
export default function CustomFieldSettings() {
|
||||
const { data, refetch } = useCustomFields();
|
||||
const [formState, setFormState] = useState<FormState>({ isOpen: false, field: undefined });
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
const openNewForm = () => setFormState({ isOpen: true });
|
||||
const openEditForm = (field: CustomFieldEntity) => setFormState({ isOpen: true, field });
|
||||
const closeForm = () => setFormState({ isOpen: false, field: undefined });
|
||||
const handleInitiateCreate = () => {
|
||||
setIsAdding(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (customField: CustomField) => {
|
||||
const editing = formState.field;
|
||||
if (editing) {
|
||||
await editCustomField(editing.key, customField);
|
||||
} else {
|
||||
await postCustomField(customField);
|
||||
}
|
||||
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();
|
||||
closeForm();
|
||||
};
|
||||
|
||||
const handleDelete = async (key: CustomFieldKey) => {
|
||||
@@ -48,24 +45,12 @@ export default function CustomFieldSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const entries = Object.entries(data);
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
{formState.isOpen && (
|
||||
<CustomFieldForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={closeForm}
|
||||
initialColour={formState.field?.colour}
|
||||
initialLabel={formState.field?.label}
|
||||
initialKey={formState.field?.key}
|
||||
initialType={formState.field?.type}
|
||||
/>
|
||||
)}
|
||||
<Panel.SubHeader>
|
||||
Custom fields
|
||||
<Button onClick={openNewForm}>
|
||||
<Button onClick={handleInitiateCreate}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
@@ -81,6 +66,7 @@ export default function CustomFieldSettings() {
|
||||
</Info>
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
{isAdding && <CustomFieldForm onSubmit={handleCreate} onCancel={handleCancel} />}
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -92,18 +78,7 @@ export default function CustomFieldSettings() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.length === 0 && (
|
||||
<Panel.TableEmpty
|
||||
title='No custom fields yet'
|
||||
description='Custom fields add your own columns to the rundown, and can be shown in views or used in automations.'
|
||||
action={
|
||||
<Button variant='primary' onClick={openNewForm}>
|
||||
Create custom field <IoAdd />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{entries.map(([key, { colour, label, type }]) => {
|
||||
{Object.entries(data).map(([key, { colour, label, type }]) => {
|
||||
return (
|
||||
<CustomFieldEntry
|
||||
key={key}
|
||||
@@ -111,7 +86,7 @@ export default function CustomFieldSettings() {
|
||||
colour={colour}
|
||||
label={label}
|
||||
type={type}
|
||||
onEdit={() => openEditForm({ key, colour, label, type })}
|
||||
onEdit={handleEditField}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -5,14 +5,11 @@ import { uploadCustomView } from '../../../../common/api/customViews';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import Modal from '../../../../common/components/modal/Modal';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { getFileError, getSlugError, getViewUrl, maxUploadLabel } from './customViews.utils';
|
||||
|
||||
import style from './CustomViews.module.scss';
|
||||
|
||||
const formId = 'custom-view-form';
|
||||
|
||||
interface CustomViewFormProps {
|
||||
onComplete: () => void;
|
||||
onClose: () => void;
|
||||
@@ -56,72 +53,62 @@ export default function CustomViewForm({ onComplete, onClose }: CustomViewFormPr
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
size='compact'
|
||||
title='Upload custom view'
|
||||
bodyElements={
|
||||
<form id={formId} onSubmit={handleUpload} className={style.uploadForm}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
type='file'
|
||||
onChange={handleSelectFile}
|
||||
accept='.html,text/html'
|
||||
/>
|
||||
<Panel.Indent as='form' onSubmit={handleUpload} className={style.uploadForm}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
type='file'
|
||||
onChange={handleSelectFile}
|
||||
accept='.html,text/html'
|
||||
/>
|
||||
|
||||
<div className={style.step}>
|
||||
<div className={style.stepTitle}>1. Choose a name</div>
|
||||
<Panel.Description>Name</Panel.Description>
|
||||
<Input
|
||||
value={slug}
|
||||
onChange={(event) => {
|
||||
setSlug(event.target.value);
|
||||
setSlugDirty(true);
|
||||
}}
|
||||
placeholder='my-view'
|
||||
aria-label='Custom view name'
|
||||
autoCapitalize='off'
|
||||
autoComplete='off'
|
||||
fluid
|
||||
/>
|
||||
<Panel.Description>
|
||||
Use lowercase letters, numbers, and dashes. Example: <Panel.Highlight>my-view</Panel.Highlight>
|
||||
</Panel.Description>
|
||||
<Panel.Description>
|
||||
Preview URL: <Panel.Highlight>{previewUrl}</Panel.Highlight>
|
||||
</Panel.Description>
|
||||
{slugDirty && slugError && <Panel.Error>{slugError}</Panel.Error>}
|
||||
</div>
|
||||
<div className={style.step}>
|
||||
<div className={style.stepTitle}>1. Choose a name</div>
|
||||
<Panel.Description>Name</Panel.Description>
|
||||
<Input
|
||||
value={slug}
|
||||
onChange={(event) => {
|
||||
setSlug(event.target.value);
|
||||
setSlugDirty(true);
|
||||
}}
|
||||
placeholder='my-view'
|
||||
aria-label='Custom view name'
|
||||
autoCapitalize='off'
|
||||
autoComplete='off'
|
||||
fluid
|
||||
/>
|
||||
<Panel.Description>
|
||||
Use lowercase letters, numbers, and dashes. Example: <Panel.Highlight>my-view</Panel.Highlight>
|
||||
</Panel.Description>
|
||||
<Panel.Description>
|
||||
Preview URL: <Panel.Highlight>{previewUrl}</Panel.Highlight>
|
||||
</Panel.Description>
|
||||
{slugDirty && slugError && <Panel.Error>{slugError}</Panel.Error>}
|
||||
</div>
|
||||
|
||||
<div className={style.step}>
|
||||
<div className={style.stepTitle}>2. Select index.html</div>
|
||||
<Panel.Description>Upload file</Panel.Description>
|
||||
<Panel.InlineElements wrap='wrap' className={style.filePicker}>
|
||||
<Button onClick={() => fileInputRef.current?.click()}>
|
||||
{selectedFile ? 'Replace index.html' : 'Choose index.html'}
|
||||
</Button>
|
||||
<span className={style.fileName}>
|
||||
{selectedFile ? `${selectedFile.name} (${Math.ceil(selectedFile.size / 1024)} KB)` : 'No file selected'}
|
||||
</span>
|
||||
</Panel.InlineElements>
|
||||
<Panel.Description>Accepted: index.html only, maximum {maxUploadLabel}.</Panel.Description>
|
||||
{fileDirty && fileError && <Panel.Error>{fileError}</Panel.Error>}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
footerElements={
|
||||
<>
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant='primary' type='submit' form={formId} loading={isUploading} disabled={!canUpload}>
|
||||
Upload view <IoCloudUploadOutline />
|
||||
<div className={style.step}>
|
||||
<div className={style.stepTitle}>2. Select index.html</div>
|
||||
<Panel.Description>Upload file</Panel.Description>
|
||||
<Panel.InlineElements wrap='wrap' className={style.filePicker}>
|
||||
<Button onClick={() => fileInputRef.current?.click()}>
|
||||
{selectedFile ? 'Replace index.html' : 'Choose index.html'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<span className={style.fileName}>
|
||||
{selectedFile ? `${selectedFile.name} (${Math.ceil(selectedFile.size / 1024)} KB)` : 'No file selected'}
|
||||
</span>
|
||||
</Panel.InlineElements>
|
||||
<Panel.Description>Accepted: index.html only, maximum {maxUploadLabel}.</Panel.Description>
|
||||
{fileDirty && fileError && <Panel.Error>{fileError}</Panel.Error>}
|
||||
</div>
|
||||
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
|
||||
<Panel.InlineElements align='end'>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant='primary' type='submit' loading={isUploading} disabled={!canUpload}>
|
||||
Upload view <IoCloudUploadOutline />
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.Indent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,14 +37,13 @@ export default function CustomViews() {
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
{isUploadOpen && <CustomViewForm onComplete={handleUploadComplete} onClose={() => setIsUploadOpen(false)} />}
|
||||
<Panel.SubHeader>
|
||||
Custom views
|
||||
<Panel.InlineElements>
|
||||
<Button variant='ghosted' onClick={handleRestoreDemo} disabled={hasDemoView}>
|
||||
Restore demo
|
||||
</Button>
|
||||
<Button onClick={() => setIsUploadOpen(true)}>
|
||||
<Button onClick={() => setIsUploadOpen(true)} disabled={isUploadOpen}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
@@ -64,6 +63,8 @@ export default function CustomViews() {
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={status === 'pending'} />
|
||||
|
||||
{isUploadOpen && <CustomViewForm onComplete={handleUploadComplete} onClose={() => setIsUploadOpen(false)} />}
|
||||
|
||||
{actionError && <Panel.Error>{actionError}</Panel.Error>}
|
||||
|
||||
<CustomViewsList
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { CustomViewSummary } from 'ontime-types';
|
||||
import { useState } from 'react';
|
||||
import { IoCloudUploadOutline } from 'react-icons/io5';
|
||||
|
||||
import { deleteCustomView } from '../../../../common/api/customViews';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
@@ -54,17 +53,7 @@ export default function CustomViewsList({ views, onOpenUpload, onMutate, onError
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{views.length === 0 && (
|
||||
<Panel.TableEmpty
|
||||
title='No custom views yet'
|
||||
description='Upload a self-contained index.html to serve your own view alongside the built-in ones.'
|
||||
action={
|
||||
<Button variant='primary' onClick={onOpenUpload}>
|
||||
Upload view <IoCloudUploadOutline />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{views.length === 0 && <Panel.TableEmpty handleClick={onOpenUpload} label='No custom views yet' />}
|
||||
{views.map((view, index) => (
|
||||
<CustomViewsListItem
|
||||
key={view.slug}
|
||||
|
||||
@@ -12,13 +12,6 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.formColumn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.current {
|
||||
// marker consumed by the shared Panel.Table active-row treatment
|
||||
background-color: inherit;
|
||||
background-color: $blue-1100 !important; // fighting zebra styles
|
||||
}
|
||||
|
||||
@@ -3,12 +3,9 @@ import { useForm } from 'react-hook-form';
|
||||
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import Modal from '../../../../common/components/modal/Modal';
|
||||
import { useMutateProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
const formId = 'new-rundown-form';
|
||||
|
||||
type NewRundownFormState = {
|
||||
title: string;
|
||||
};
|
||||
@@ -45,32 +42,22 @@ export function ManageRundownForm({ onClose }: ManageRundownForm) {
|
||||
}, [setFocus]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
size='small'
|
||||
title='Create rundown'
|
||||
bodyElements={
|
||||
<form id={formId} onSubmit={handleSubmit(createRundown)}>
|
||||
<label>
|
||||
<Panel.Description>Rundown title</Panel.Description>
|
||||
<Input {...register('title')} fluid placeholder='Your rundown name' maxLength={64} />
|
||||
</label>
|
||||
</form>
|
||||
}
|
||||
footerElements={
|
||||
<>
|
||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Button variant='ghosted' disabled={isSubmitting} onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' form={formId} variant='primary' disabled={isSubmitting}>
|
||||
Create rundown
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Panel.Indent as='form' onSubmit={handleSubmit(createRundown)}>
|
||||
<Panel.Section>
|
||||
<label>
|
||||
<Panel.Description>Rundown title</Panel.Description>
|
||||
<Input {...register('title')} fluid placeholder='Your rundown name' />
|
||||
</label>
|
||||
</Panel.Section>
|
||||
<Panel.InlineElements relation='inner' align='end'>
|
||||
<Button variant='ghosted' disabled={isSubmitting} onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' variant='primary' disabled={isSubmitting}>
|
||||
Create rundown
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
</Panel.Indent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ export default function ManageRundowns() {
|
||||
<tr key={id} className={cx([isLoaded && style.current])}>
|
||||
<td>{numEntries}</td>
|
||||
<td>
|
||||
{title} {isLoaded && <Tag variant='active'>Loaded</Tag>}
|
||||
{title} {isLoaded && <Tag>Loaded</Tag>}
|
||||
</td>
|
||||
<td>
|
||||
<DropdownMenu
|
||||
|
||||
+27
-2
@@ -1,4 +1,5 @@
|
||||
import { CustomField, CustomFieldKey } from 'ontime-types';
|
||||
import { useState } from 'react';
|
||||
import { IoPencil, IoTrash } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../../../common/components/buttons/IconButton';
|
||||
@@ -6,6 +7,7 @@ import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
|
||||
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
|
||||
import Tag from '../../../../../common/components/tag/Tag';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
import CustomFieldForm from './CustomFieldForm';
|
||||
|
||||
import style from '../ManagePanel.module.scss';
|
||||
|
||||
@@ -14,12 +16,35 @@ interface CustomFieldEntryProps {
|
||||
label: string;
|
||||
fieldKey: string;
|
||||
type: CustomField['type'];
|
||||
onEdit: () => void;
|
||||
onEdit: (key: CustomFieldKey, patch: CustomField) => Promise<void>;
|
||||
onDelete: (key: CustomFieldKey) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
||||
const { colour, label, fieldKey, type, onEdit, onDelete } = props;
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const handleEdit = async (patch: CustomField) => {
|
||||
await onEdit(fieldKey, patch);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={99}>
|
||||
<CustomFieldForm
|
||||
onCancel={() => setIsEditing(false)}
|
||||
onSubmit={handleEdit}
|
||||
initialColour={colour}
|
||||
initialLabel={label}
|
||||
initialKey={fieldKey}
|
||||
initialType={type}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr>
|
||||
@@ -36,7 +61,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
||||
</CopyTag>
|
||||
</td>
|
||||
<Panel.InlineElements relation='inner' as='td'>
|
||||
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={onEdit}>
|
||||
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={() => setIsEditing(true)}>
|
||||
<IoPencil />
|
||||
</IconButton>
|
||||
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={() => onDelete(fieldKey)}>
|
||||
|
||||
+60
-74
@@ -8,15 +8,13 @@ import Button from '../../../../../common/components/buttons/Button';
|
||||
import Info from '../../../../../common/components/info/Info';
|
||||
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
|
||||
import Input from '../../../../../common/components/input/input/Input';
|
||||
import Modal from '../../../../../common/components/modal/Modal';
|
||||
import RadioGroup from '../../../../../common/components/radio-group/RadioGroup';
|
||||
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||
import { preventEscape } from '../../../../../common/utils/keyEvent';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import style from '../ManagePanel.module.scss';
|
||||
|
||||
const formId = 'custom-field-form';
|
||||
|
||||
interface CustomFieldsFormProps {
|
||||
onSubmit: (field: CustomField) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
@@ -87,77 +85,65 @@ export default function CustomFieldForm({
|
||||
const isEditMode = initialKey !== undefined;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onCancel}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
size='compact'
|
||||
title={isEditMode ? 'Edit custom field' : 'Create custom field'}
|
||||
bodyElements={
|
||||
<form id={formId} onSubmit={handleSubmit(setupSubmit)} className={style.formColumn}>
|
||||
<Info>
|
||||
Please note that images can quickly deteriorate your app's performance.
|
||||
<br />
|
||||
Prefer using small, and compressed images.
|
||||
</Info>
|
||||
<div>
|
||||
<Panel.Description>Type</Panel.Description>
|
||||
<RadioGroup
|
||||
orientation='horizontal'
|
||||
disabled={isEditMode}
|
||||
onValueChange={(value) => setValue('type', value, { shouldDirty: true })}
|
||||
value={watch('type')}
|
||||
items={[
|
||||
{ value: 'text', label: 'Text' },
|
||||
{ value: 'image', label: 'Image' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.twoCols}>
|
||||
<label>
|
||||
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
|
||||
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
|
||||
<Input
|
||||
{...register('label', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'),
|
||||
validate: (value) => {
|
||||
if (value.trim().length === 0) return 'Required field';
|
||||
if (!checkRegex.isAlphanumericWithSpace(value))
|
||||
return 'Only alphanumeric characters and space are allowed';
|
||||
if (!isEditMode && Object.hasOwn(data, customFieldLabelToKey(value) ?? '')) {
|
||||
return 'Custom fields must be unique';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
})}
|
||||
fluid
|
||||
/>
|
||||
</label>
|
||||
<Panel.Indent as='form' onSubmit={handleSubmit(setupSubmit)} onKeyDown={(event) => preventEscape(event, onCancel)}>
|
||||
<Info>
|
||||
Please note that images can quickly deteriorate your app's performance.
|
||||
<br />
|
||||
Prefer using small, and compressed images.
|
||||
</Info>
|
||||
<div>
|
||||
<Panel.Description>Type</Panel.Description>
|
||||
<RadioGroup
|
||||
orientation='horizontal'
|
||||
disabled={isEditMode}
|
||||
onValueChange={(value) => setValue('type', value, { shouldDirty: true })}
|
||||
value={watch('type')}
|
||||
items={[
|
||||
{ value: 'text', label: 'Text' },
|
||||
{ value: 'image', label: 'Image' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.twoCols}>
|
||||
<label>
|
||||
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
|
||||
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
|
||||
<Input
|
||||
{...register('label', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'),
|
||||
validate: (value) => {
|
||||
if (value.trim().length === 0) return 'Required field';
|
||||
if (!checkRegex.isAlphanumericWithSpace(value))
|
||||
return 'Only alphanumeric characters and space are allowed';
|
||||
if (!isEditMode) {
|
||||
if (isEditMode && Object.keys(data).includes(value)) return 'Custom fields must be unique';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
})}
|
||||
fluid
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<Panel.Description>Key (use in Integrations and API)</Panel.Description>
|
||||
<Input {...register('key')} variant='ghosted' readOnly fluid />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<Panel.Description>Colour</Panel.Description>
|
||||
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
|
||||
</label>
|
||||
</form>
|
||||
}
|
||||
footerElements={
|
||||
<>
|
||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Button variant='ghosted' onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' form={formId} variant='primary' disabled={!canSubmit} loading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<label>
|
||||
<Panel.Description>Key (use in Integrations and API)</Panel.Description>
|
||||
<Input {...register('key')} variant='ghosted' readOnly fluid />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<Panel.Description>Colour</Panel.Description>
|
||||
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
|
||||
</label>
|
||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Panel.InlineElements relation='inner' align='end'>
|
||||
<Button variant='ghosted' onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.Indent>
|
||||
);
|
||||
}
|
||||
|
||||
-1
@@ -59,7 +59,6 @@ export default function RundownRenameForm({ onSubmit, onCancel, initialTitle }:
|
||||
return true;
|
||||
},
|
||||
})}
|
||||
maxLength={64}
|
||||
fluid
|
||||
/>
|
||||
{errors.title && <Panel.Error>{errors.title.message}</Panel.Error>}
|
||||
|
||||
-7
@@ -25,13 +25,6 @@
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.finishActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sourceGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
+8
-22
@@ -8,7 +8,6 @@ import type {
|
||||
import { getErrorMessage, ImportMap } from 'ontime-utils';
|
||||
import { ChangeEvent, useCallback, useRef, useState } from 'react';
|
||||
import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import {
|
||||
getWorksheetMetadata as getExcelWorksheetMetadata,
|
||||
@@ -57,11 +56,9 @@ export default function SourcesPanel() {
|
||||
const [error, setError] = useState('');
|
||||
const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none');
|
||||
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
|
||||
const [completedRundownTitle, setCompletedRundownTitle] = useState('');
|
||||
|
||||
const { data: currentRundown } = useRundown();
|
||||
const { applyImport } = useSpreadsheetImport();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -104,7 +101,6 @@ export default function SourcesPanel() {
|
||||
setHasFile('none');
|
||||
setActiveSource(null);
|
||||
setError('');
|
||||
setCompletedRundownTitle('');
|
||||
};
|
||||
|
||||
const openGSheetFlow = () => {
|
||||
@@ -127,12 +123,11 @@ export default function SourcesPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinished = (rundownTitle: string) => {
|
||||
const handleFinished = () => {
|
||||
setImportFlow('finished');
|
||||
setHasFile('none');
|
||||
setActiveSource(null);
|
||||
setError('');
|
||||
setCompletedRundownTitle(rundownTitle);
|
||||
};
|
||||
|
||||
const handleApplyImport = async (
|
||||
@@ -144,7 +139,7 @@ export default function SourcesPanel() {
|
||||
if (mode === 'new') {
|
||||
const title = newRundownTitle.trim() || preview.rundown.title;
|
||||
await applyImport({ mode: 'new', rundown: { ...preview.rundown, title }, customFields: preview.customFields });
|
||||
handleFinished(title);
|
||||
handleFinished();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -161,7 +156,7 @@ export default function SourcesPanel() {
|
||||
customFields: preview.customFields,
|
||||
providedFields,
|
||||
});
|
||||
handleFinished(currentRundown.title);
|
||||
handleFinished();
|
||||
};
|
||||
|
||||
const loadWorksheetMetadata = useCallback(
|
||||
@@ -294,20 +289,11 @@ export default function SourcesPanel() {
|
||||
{showCompleted && (
|
||||
<div className={style.finishSection}>
|
||||
<span className={style.finishBadge}>Import complete</span>
|
||||
<div className={style.finishTitle}>
|
||||
Spreadsheet data applied to {completedRundownTitle || 'your rundown'}.
|
||||
</div>
|
||||
<div className={style.finishDescription}>
|
||||
Review the imported rundown in the editor or start another import.
|
||||
</div>
|
||||
<div className={style.finishActions}>
|
||||
<Button variant='primary' onClick={() => navigate('/editor')}>
|
||||
Open editor
|
||||
</Button>
|
||||
<Button variant='subtle-white' onClick={resetFlow}>
|
||||
Import another
|
||||
</Button>
|
||||
</div>
|
||||
<div className={style.finishTitle}>Spreadsheet data applied.</div>
|
||||
<div className={style.finishDescription}>You can close this flow or start another import.</div>
|
||||
<Button variant='subtle-white' onClick={resetFlow}>
|
||||
Reset flow
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isGSheetFlow && (
|
||||
|
||||
-4
@@ -92,10 +92,6 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toolbarWarning {
|
||||
color: $orange-400;
|
||||
}
|
||||
|
||||
.mappingPaneTitle {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
+2
-17
@@ -132,16 +132,7 @@ export default function SheetImportEditor({
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{toolbarStatus && (
|
||||
<Panel.Description>
|
||||
{toolbarStatus.entries === '–'
|
||||
? 'No import preview yet'
|
||||
: `${toolbarStatus.entries} entries · ${toolbarStatus.groups} groups · ${toolbarStatus.milestones} milestones · ${toolbarStatus.start}–${toolbarStatus.end} · ${toolbarStatus.duration}`}
|
||||
{toolbarStatus.warnings > 0 && (
|
||||
<span className={style.toolbarWarning}> · {toolbarStatus.warnings} warnings</span>
|
||||
)}
|
||||
</Panel.Description>
|
||||
)}
|
||||
{toolbarStatus && <Panel.Description>{toolbarStatus}</Panel.Description>}
|
||||
</Panel.InlineElements>
|
||||
|
||||
<div className={style.editorBody}>
|
||||
@@ -159,19 +150,13 @@ export default function SheetImportEditor({
|
||||
|
||||
<section className={style.previewPane}>
|
||||
<div className={style.previewPaneHeader}>
|
||||
<div className={style.previewPaneHeading}>
|
||||
<span className={style.previewPaneTitle}>Import preview</span>
|
||||
</div>
|
||||
<span className={style.previewPaneTitle}>Import preview</span>
|
||||
</div>
|
||||
<div className={style.tableShell}>
|
||||
<PreviewTable
|
||||
preview={state.preview}
|
||||
columnLabels={columnLabels}
|
||||
canRefresh={canPreview}
|
||||
isLoadingMetadata={isLoadingMetadata}
|
||||
isRefreshing={state.loading === 'preview'}
|
||||
needsPreviewRefresh={state.needsPreviewRefresh}
|
||||
onRefresh={handlePreviewSubmit}
|
||||
worksheetHeaders={worksheetHeaders}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+34
-85
@@ -1,8 +1,27 @@
|
||||
.emptyState {
|
||||
height: 100%;
|
||||
min-height: 16rem;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
gap: 0.35rem;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emptyTitle {
|
||||
color: $ui-white;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.emptyBody {
|
||||
color: $gray-400;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
color: $ui-white;
|
||||
border-collapse: collapse;
|
||||
font-size: calc(1rem - 2px);
|
||||
text-align: left;
|
||||
table-layout: auto;
|
||||
@@ -15,101 +34,31 @@
|
||||
}
|
||||
|
||||
th {
|
||||
color: $gray-300;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
vertical-align: bottom;
|
||||
font-weight: 400;
|
||||
color: $gray-400;
|
||||
text-transform: capitalize;
|
||||
vertical-align: top;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
box-sizing: border-box;
|
||||
padding: 0.5rem;
|
||||
min-width: 8rem;
|
||||
max-width: 20rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-bottom: 1px solid $white-10;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
--entry-colour: transparent;
|
||||
background-color: color-mix(in srgb, $gray-1300 96%, var(--entry-colour) 4%);
|
||||
box-shadow: inset 3px 0 var(--entry-colour);
|
||||
tr:nth-child(even) {
|
||||
background-color: $white-1;
|
||||
}
|
||||
|
||||
td[data-empty='true'] {
|
||||
color: $gray-600;
|
||||
}
|
||||
}
|
||||
|
||||
.rowNumber,
|
||||
.rowType {
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
.rowNumber {
|
||||
left: 0;
|
||||
width: 3.25rem;
|
||||
min-width: 3.25rem !important;
|
||||
color: $gray-400;
|
||||
text-align: right;
|
||||
width: 4.5rem;
|
||||
min-width: 4.5rem;
|
||||
}
|
||||
|
||||
.rowType {
|
||||
left: 3.25rem;
|
||||
width: 6.25rem;
|
||||
min-width: 6.25rem !important;
|
||||
color: $gray-400;
|
||||
font-size: 0.8rem;
|
||||
box-shadow: 1px 0 $white-10;
|
||||
}
|
||||
|
||||
thead .rowNumber,
|
||||
thead .rowType {
|
||||
z-index: 2;
|
||||
background-color: $gray-1350;
|
||||
}
|
||||
|
||||
.numericCell {
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.multilineCell {
|
||||
max-width: 30rem !important;
|
||||
overflow-wrap: anywhere;
|
||||
text-overflow: clip !important;
|
||||
white-space: pre-wrap !important;
|
||||
}
|
||||
|
||||
.eventRow {
|
||||
.rowNumber {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.groupRow {
|
||||
background-color: color-mix(in srgb, $gray-1300 88%, var(--entry-colour) 12%) !important;
|
||||
box-shadow: inset 4px 0 var(--entry-colour) !important;
|
||||
font-weight: 600;
|
||||
|
||||
td {
|
||||
min-height: 3.25rem;
|
||||
border-top: 0.75rem solid $gray-1350;
|
||||
}
|
||||
}
|
||||
|
||||
.milestoneRow {
|
||||
background-color: color-mix(in srgb, $gray-1300 93%, var(--entry-colour) 7%) !important;
|
||||
box-shadow: inset 3px 0 var(--entry-colour) !important;
|
||||
color: $gray-300;
|
||||
font-style: italic;
|
||||
width: 7rem;
|
||||
min-width: 7rem;
|
||||
}
|
||||
|
||||
+16
-123
@@ -1,12 +1,7 @@
|
||||
import type { CustomField, CustomFieldKey, SpreadsheetPreviewResponse } from 'ontime-types';
|
||||
import { isOntimeDelay, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import Button from '../../../../../../../common/components/buttons/Button';
|
||||
import Tag from '../../../../../../../common/components/tag/Tag';
|
||||
import { getRundownMetadata } from '../../../../../../../common/utils/rundownMetadata';
|
||||
import * as Panel from '../../../../../panel-utils/PanelUtils';
|
||||
import { getCellValue } from './previewTableUtils';
|
||||
|
||||
import style from './PreviewTable.module.scss';
|
||||
@@ -14,85 +9,14 @@ import style from './PreviewTable.module.scss';
|
||||
interface PreviewTableProps {
|
||||
preview: SpreadsheetPreviewResponse | null;
|
||||
columnLabels: string[];
|
||||
canRefresh: boolean;
|
||||
isLoadingMetadata: boolean;
|
||||
isRefreshing: boolean;
|
||||
needsPreviewRefresh: boolean;
|
||||
onRefresh: () => void;
|
||||
worksheetHeaders: string[];
|
||||
}
|
||||
|
||||
const priorityColumns = ['Title', 'Cue', 'Start', 'End', 'Duration'];
|
||||
const numericColumns = new Set(['Start', 'End', 'Duration', 'Time warning', 'Time danger']);
|
||||
const transparentColour = 'transparent';
|
||||
|
||||
type PreviewEntry = SpreadsheetPreviewResponse['rundown']['entries'][string];
|
||||
|
||||
function getEntryDisplay(entry: PreviewEntry, groupColour?: string) {
|
||||
if (isOntimeGroup(entry)) {
|
||||
return {
|
||||
rowClassName: style.groupRow,
|
||||
entryColour: entry.colour,
|
||||
entryType: 'Group',
|
||||
};
|
||||
}
|
||||
|
||||
const entryColour = groupColour ?? transparentColour;
|
||||
|
||||
if (isOntimeMilestone(entry)) {
|
||||
return {
|
||||
rowClassName: style.milestoneRow,
|
||||
entryColour,
|
||||
entryType: 'Milestone',
|
||||
};
|
||||
}
|
||||
|
||||
if (isOntimeDelay(entry)) {
|
||||
return {
|
||||
rowClassName: style.eventRow,
|
||||
entryColour,
|
||||
entryType: 'Delay',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
rowClassName: style.eventRow,
|
||||
entryColour,
|
||||
entryType: 'Event',
|
||||
};
|
||||
}
|
||||
|
||||
function getCellClassName(label: string, value: string) {
|
||||
if (value.includes('\n')) {
|
||||
return style.multilineCell;
|
||||
}
|
||||
|
||||
if (numericColumns.has(label)) {
|
||||
return style.numericCell;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getDisplayColumns(columnLabels: string[]) {
|
||||
return [...columnLabels].sort((left, right) => {
|
||||
const leftPriority = priorityColumns.indexOf(left);
|
||||
const rightPriority = priorityColumns.indexOf(right);
|
||||
return (
|
||||
(leftPriority === -1 ? priorityColumns.length : leftPriority) -
|
||||
(rightPriority === -1 ? priorityColumns.length : rightPriority)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default function PreviewTable({
|
||||
preview,
|
||||
columnLabels,
|
||||
canRefresh,
|
||||
isLoadingMetadata,
|
||||
isRefreshing,
|
||||
needsPreviewRefresh,
|
||||
onRefresh,
|
||||
worksheetHeaders,
|
||||
}: PreviewTableProps) {
|
||||
const customFieldKeyByLabel = useMemo(() => {
|
||||
@@ -100,51 +24,33 @@ export default function PreviewTable({
|
||||
return new Map(Object.entries(preview.customFields).map(([fieldId, field]) => [field.label, fieldId]));
|
||||
}, [preview]);
|
||||
|
||||
const displayColumns = useMemo(() => getDisplayColumns(columnLabels), [columnLabels]);
|
||||
|
||||
const previewMetadata = useMemo(() => {
|
||||
if (!preview) return null;
|
||||
return getRundownMetadata(preview.rundown, null);
|
||||
}, [preview]);
|
||||
|
||||
if (!preview) {
|
||||
let emptyTitle = 'Preview not generated';
|
||||
let emptyContent = 'Select the fields you want to import, then click Preview import.';
|
||||
|
||||
if (isLoadingMetadata) {
|
||||
emptyTitle = 'Loading worksheet';
|
||||
emptyContent = 'Loading worksheet metadata...';
|
||||
} else if (worksheetHeaders.length === 0) {
|
||||
emptyTitle = 'No headers found';
|
||||
emptyContent =
|
||||
'No column headers detected in this worksheet. Try a different worksheet or ensure the first row contains column headers.';
|
||||
} else if (needsPreviewRefresh) {
|
||||
emptyTitle = 'Preview needs updating';
|
||||
emptyContent = 'Your column mapping changed. Preview the import again to update this table.';
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel.EmptyState
|
||||
title={emptyTitle}
|
||||
description={emptyContent}
|
||||
action={
|
||||
needsPreviewRefresh && (
|
||||
<Button variant='primary' onClick={onRefresh} disabled={!canRefresh} loading={isRefreshing}>
|
||||
Refresh preview
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className={style.emptyState}>
|
||||
<div className={style.emptyTitle}>Preview not generated</div>
|
||||
<div className={style.emptyBody}>{emptyContent}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let eventIndex = 0;
|
||||
|
||||
return (
|
||||
<table className={style.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={style.rowNumber}>#</th>
|
||||
<th className={style.rowType}>Type</th>
|
||||
{displayColumns.map((label, index) => (
|
||||
{columnLabels.map((label, index) => (
|
||||
<th key={`${label}-${index}`}>{label}</th>
|
||||
))}
|
||||
</tr>
|
||||
@@ -153,29 +59,16 @@ export default function PreviewTable({
|
||||
{preview.rundown.flatOrder.map((entryId) => {
|
||||
const entry = preview.rundown.entries[entryId];
|
||||
const isEvent = isOntimeEvent(entry);
|
||||
const entryMetadata = previewMetadata?.[entryId];
|
||||
const { rowClassName, entryColour, entryType } = getEntryDisplay(entry, entryMetadata?.groupColour);
|
||||
if (isEvent) eventIndex++;
|
||||
const hasType = isEvent || isOntimeGroup(entry) || isOntimeMilestone(entry);
|
||||
|
||||
return (
|
||||
<tr key={entryId} className={rowClassName} style={{ '--entry-colour': entryColour } as CSSProperties}>
|
||||
<td className={style.rowNumber}>{isEvent ? entryMetadata?.eventIndex : ''}</td>
|
||||
<td className={style.rowType}>
|
||||
<Tag>{entryType}</Tag>
|
||||
</td>
|
||||
{displayColumns.map((label, colIndex) => {
|
||||
const value = getCellValue(label, entry, customFieldKeyByLabel);
|
||||
const cellClassName = getCellClassName(label, value);
|
||||
return (
|
||||
<td
|
||||
key={`${entryId}-${colIndex}`}
|
||||
className={cellClassName}
|
||||
data-empty={value === ''}
|
||||
title={value || undefined}
|
||||
>
|
||||
{value}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<tr key={entryId}>
|
||||
<td className={style.rowNumber}>{isEvent ? eventIndex : ''}</td>
|
||||
<td className={style.rowType}>{hasType ? entry.type : ''}</td>
|
||||
{columnLabels.map((label, colIndex) => (
|
||||
<td key={`${entryId}-${colIndex}`}>{getCellValue(label, entry, customFieldKeyByLabel)}</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
+11
-36
@@ -5,8 +5,7 @@ import type {
|
||||
SpreadsheetPreviewResponse,
|
||||
SpreadsheetWorksheetMetadata,
|
||||
} from 'ontime-types';
|
||||
import { isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import { millisToString, removeTrailingZero } from 'ontime-utils';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
|
||||
@@ -33,7 +32,7 @@ type ImportAction =
|
||||
| { type: 'previewSuccess'; preview: SpreadsheetPreviewResponse }
|
||||
| { type: 'applySuccess' }
|
||||
| { type: 'exportSuccess' }
|
||||
| { type: 'clearPreview'; error?: string; needsRefresh?: boolean }
|
||||
| { type: 'clearPreview'; error?: string }
|
||||
| { type: 'failure'; error: string }
|
||||
| { type: 'reset' };
|
||||
|
||||
@@ -41,14 +40,12 @@ type ImportState = {
|
||||
loading: '' | 'preview' | 'apply' | 'export';
|
||||
error: string;
|
||||
preview: SpreadsheetPreviewResponse | null;
|
||||
needsPreviewRefresh: boolean;
|
||||
};
|
||||
|
||||
const initialImportState: ImportState = {
|
||||
loading: '',
|
||||
error: '',
|
||||
preview: null,
|
||||
needsPreviewRefresh: false,
|
||||
};
|
||||
|
||||
function importReducer(state: ImportState, action: ImportAction): ImportState {
|
||||
@@ -60,15 +57,15 @@ function importReducer(state: ImportState, action: ImportAction): ImportState {
|
||||
case 'startExport':
|
||||
return { ...state, loading: 'export', error: '' };
|
||||
case 'previewSuccess':
|
||||
return { loading: '', error: '', preview: action.preview, needsPreviewRefresh: false };
|
||||
return { loading: '', error: '', preview: action.preview };
|
||||
case 'applySuccess':
|
||||
case 'exportSuccess':
|
||||
return { ...state, loading: '' };
|
||||
case 'clearPreview':
|
||||
return { ...state, error: action.error ?? '', preview: null, needsPreviewRefresh: action.needsRefresh ?? false };
|
||||
return { ...state, error: action.error ?? '', preview: null };
|
||||
case 'failure': {
|
||||
if (state.loading === 'preview') {
|
||||
return { loading: '', error: action.error, preview: null, needsPreviewRefresh: false };
|
||||
return { loading: '', error: action.error, preview: null };
|
||||
}
|
||||
return { ...state, loading: '', error: action.error };
|
||||
}
|
||||
@@ -224,7 +221,7 @@ export function useSheetImportForm({
|
||||
const sub = watch(() => {
|
||||
if (!previewRef.current) return;
|
||||
previewRef.current = null;
|
||||
dispatch({ type: 'clearPreview', needsRefresh: true });
|
||||
dispatch({ type: 'clearPreview' });
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
}, [watch]);
|
||||
@@ -300,37 +297,15 @@ export function useSheetImportForm({
|
||||
}, [append]);
|
||||
|
||||
const toolbarStatus = (() => {
|
||||
const warningText = warningCount > 0 ? ` | warnings: ${warningCount}` : '';
|
||||
|
||||
if (!state.preview) {
|
||||
return {
|
||||
entries: '–',
|
||||
groups: '–',
|
||||
milestones: '–',
|
||||
start: '–',
|
||||
end: '–',
|
||||
duration: '–',
|
||||
warnings: warningCount,
|
||||
};
|
||||
return `entries: – | start: – | end: – | duration: –${warningText}`;
|
||||
}
|
||||
|
||||
const { entries, flatOrder } = state.preview.rundown;
|
||||
const { flatOrder } = state.preview.rundown;
|
||||
const { start, end, duration } = state.preview.summary;
|
||||
let groups = 0;
|
||||
let milestones = 0;
|
||||
for (const entryId of flatOrder) {
|
||||
const entry = entries[entryId];
|
||||
if (isOntimeGroup(entry)) groups++;
|
||||
else if (isOntimeMilestone(entry)) milestones++;
|
||||
}
|
||||
|
||||
return {
|
||||
entries: flatOrder.length,
|
||||
groups,
|
||||
milestones,
|
||||
start: removeTrailingZero(millisToString(start)),
|
||||
end: removeTrailingZero(millisToString(end)),
|
||||
duration: formatDuration(duration),
|
||||
warnings: warningCount,
|
||||
};
|
||||
return `entries: ${flatOrder.length} | start: ${millisToString(start)} | end: ${millisToString(end)} | duration: ${formatDuration(duration)}${warningText}`;
|
||||
})();
|
||||
|
||||
return {
|
||||
|
||||
+2
-18
@@ -16,22 +16,6 @@
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.actionsCell {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.actionsHeader {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.actionsGroup {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.current {
|
||||
// marker consumed by the shared Panel.Table active-row treatment
|
||||
background-color: inherit;
|
||||
.self {
|
||||
background-color: $blue-1100;
|
||||
}
|
||||
|
||||
+32
-46
@@ -64,58 +64,50 @@ export default function ClientList() {
|
||||
<tr>
|
||||
<td style={{ width: '20%' }}>Client Name</td>
|
||||
<td>Path</td>
|
||||
<td className={style.actionsHeader} />
|
||||
<td />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ontimeClients.length === 0 && (
|
||||
<Panel.TableEmpty
|
||||
title='No Ontime clients connected'
|
||||
description='Editor, cuesheet and operator windows appear here as they connect.'
|
||||
/>
|
||||
)}
|
||||
{ontimeClients.map(([key, client]) => {
|
||||
const { identify, name, path } = client;
|
||||
const isCurrent = id === key;
|
||||
return (
|
||||
<tr key={key} className={cx([isCurrent && style.current])}>
|
||||
<tr key={key} className={cx([isCurrent && style.self])}>
|
||||
<Panel.InlineElements relation='inner' as='td'>
|
||||
{isCurrent && <Tag variant='active'>SELF</Tag>}
|
||||
{isCurrent && <Tag>SELF</Tag>}
|
||||
{name}
|
||||
</Panel.InlineElements>
|
||||
<td className={style.copiable}>{path}</td>
|
||||
<td className={style.actionsCell}>
|
||||
<Panel.InlineElements relation='inner' align='end' className={style.actionsGroup}>
|
||||
<Button
|
||||
size='small'
|
||||
className={`${identify ? style.blink : ''}`}
|
||||
disabled={isCurrent}
|
||||
variant={identify ? 'primary' : 'subtle'}
|
||||
data-testid={isCurrent ? '' : 'not-self-identify'}
|
||||
onClick={() => {
|
||||
setIdentify({ target: key, identify: !identify });
|
||||
}}
|
||||
>
|
||||
Identify
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
data-testid={isCurrent ? '' : 'not-self-rename'}
|
||||
onClick={() => openRename(key)}
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
<Panel.InlineElements relation='inner' as='td'>
|
||||
<Button
|
||||
size='small'
|
||||
className={`${identify ? style.blink : ''}`}
|
||||
disabled={isCurrent}
|
||||
variant={identify ? 'primary' : 'subtle'}
|
||||
data-testid={isCurrent ? '' : 'not-self-identify'}
|
||||
onClick={() => {
|
||||
setIdentify({ target: key, identify: !identify });
|
||||
}}
|
||||
>
|
||||
Identify
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
data-testid={isCurrent ? '' : 'not-self-rename'}
|
||||
onClick={() => openRename(key)}
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size='small'
|
||||
disabled={isCurrent}
|
||||
data-testid={isCurrent ? '' : 'not-self-redirect'}
|
||||
onClick={() => openRedirect(key)}
|
||||
>
|
||||
Redirect
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</td>
|
||||
<Button
|
||||
size='small'
|
||||
disabled={isCurrent}
|
||||
data-testid={isCurrent ? '' : 'not-self-redirect'}
|
||||
onClick={() => openRedirect(key)}
|
||||
>
|
||||
Redirect
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -133,12 +125,6 @@ export default function ClientList() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{otherClients.length === 0 && (
|
||||
<Panel.TableEmpty
|
||||
title='No other clients connected'
|
||||
description='Views and integrations connected to this server are listed here.'
|
||||
/>
|
||||
)}
|
||||
{otherClients.map(([key, client]) => {
|
||||
const { name, type } = client;
|
||||
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
import { ChangeEvent, useRef, useState } from 'react';
|
||||
import { IoAdd } from 'react-icons/io5';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { uploadProjectFile } from '../../../../common/api/db';
|
||||
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import { validateProjectFile } from '../../../../common/utils/uploadUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import useAppSettingsNavigation from '../../useAppSettingsNavigation';
|
||||
import ProjectCreateForm from './ProjectCreateForm';
|
||||
import ProjectList from './ProjectList';
|
||||
|
||||
export default function ManageProjects() {
|
||||
const { setLocation } = useAppSettingsNavigation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState<'import' | null>(null);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isCreatingProject = searchParams.get('new') === 'true';
|
||||
|
||||
const handleToggleCreate = () => {
|
||||
searchParams.set('new', isCreatingProject ? 'false' : 'true');
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
const handleSelectFile = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
@@ -41,6 +49,11 @@ export default function ManageProjects() {
|
||||
setLoading(null);
|
||||
};
|
||||
|
||||
const handleCloseForm = () => {
|
||||
searchParams.delete('new');
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<input
|
||||
@@ -55,16 +68,21 @@ export default function ManageProjects() {
|
||||
<Panel.SubHeader>
|
||||
Manage projects
|
||||
<Panel.InlineElements>
|
||||
<Button onClick={handleSelectFile} disabled={Boolean(loading)} loading={loading === 'import'}>
|
||||
<Button
|
||||
onClick={handleSelectFile}
|
||||
disabled={Boolean(loading) || isCreatingProject}
|
||||
loading={loading === 'import'}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
<Button onClick={() => setLocation('project__create')} disabled={Boolean(loading)}>
|
||||
<Button onClick={handleToggleCreate} disabled={Boolean(loading) || isCreatingProject}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
<Panel.Divider />
|
||||
{isCreatingProject && <ProjectCreateForm onClose={handleCloseForm} />}
|
||||
<ProjectList />
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { createProject } from '../../../../common/api/db';
|
||||
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 * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './ProjectPanel.module.scss';
|
||||
|
||||
interface ProjectCreateFromProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type ProjectCreateFormValues = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
info?: string;
|
||||
url?: string;
|
||||
custom?: { title: string; value: string }[];
|
||||
};
|
||||
|
||||
export default function ProjectCreateForm({ onClose }: ProjectCreateFromProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { isSubmitting, isValid },
|
||||
setFocus,
|
||||
} = useForm<ProjectCreateFormValues>({
|
||||
defaultValues: { title: '' },
|
||||
values: { title: '' },
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
// set focus to first field
|
||||
useEffect(() => {
|
||||
setFocus('title');
|
||||
}, [setFocus]);
|
||||
|
||||
const handleSubmitCreate = async (values: ProjectCreateFormValues) => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
const filename = values.title ?? 'untitled';
|
||||
|
||||
await createProject({ filename });
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setError(maybeAxiosError(error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Indent
|
||||
as='form'
|
||||
onSubmit={handleSubmit(handleSubmitCreate)}
|
||||
onKeyDown={(event) => preventEscape(event, onClose)}
|
||||
>
|
||||
<Panel.Title>
|
||||
Create new project
|
||||
<Panel.InlineElements>
|
||||
<Button onClick={onClose} variant='ghosted' disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={!isValid} type='submit' loading={isSubmitting} variant='primary'>
|
||||
Create project
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.Title>
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
<Panel.Section className={style.innerColumn}>
|
||||
<Panel.Description>Project title</Panel.Description>
|
||||
<Input fluid placeholder='Your project name' {...register('title')} />
|
||||
</Panel.Section>
|
||||
</Panel.Indent>
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export default function ProjectList() {
|
||||
|
||||
if (status === 'pending') {
|
||||
return (
|
||||
<div className={style.loaderBox}>
|
||||
<div className={style.empty}>
|
||||
<Panel.Loader isLoading />
|
||||
</div>
|
||||
);
|
||||
@@ -74,12 +74,6 @@ export default function ProjectList() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{numProjects === 0 && (
|
||||
<Panel.TableEmpty
|
||||
title='No projects yet'
|
||||
description='Projects are the show files Ontime saves your rundown and settings into. Create one to get started.'
|
||||
/>
|
||||
)}
|
||||
{data.reorderedProjectFiles.map((project) => (
|
||||
<ProjectListItem
|
||||
key={project.filename}
|
||||
|
||||
@@ -20,7 +20,6 @@ import Button from '../../../../common/components/buttons/Button';
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import Dialog from '../../../../common/components/dialog/Dialog';
|
||||
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import ProjectForm, { ProjectFormValues } from './ProjectForm';
|
||||
@@ -144,7 +143,7 @@ export default function ProjectListItem({
|
||||
) : (
|
||||
<>
|
||||
<td className={style.containCell}>{filename}</td>
|
||||
<td>{current ? <Tag variant='active'>Loaded</Tag> : new Date(updatedAt).toLocaleString()}</td>
|
||||
<td>{current ? 'Currently loaded' : new Date(updatedAt).toLocaleString()}</td>
|
||||
<td>
|
||||
<ActionMenu
|
||||
current={current}
|
||||
@@ -159,7 +158,13 @@ export default function ProjectListItem({
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
{showMergeForm && <ProjectMergeForm onClose={handleCancel} fileName={filename} />}
|
||||
{showMergeForm && (
|
||||
<tr>
|
||||
<td colSpan={99}>
|
||||
<ProjectMergeForm onClose={handleCancel} fileName={filename} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<Dialog
|
||||
isOpen={isDeleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
|
||||
@@ -7,15 +7,13 @@ import { getDb, patchData } from '../../../../common/api/db';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import Modal from '../../../../common/components/modal/Modal';
|
||||
import Switch from '../../../../common/components/switch/Switch';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { makeProjectPatch } from './project.utils';
|
||||
|
||||
import style from './ProjectPanel.module.scss';
|
||||
|
||||
const formId = 'project-merge-form';
|
||||
|
||||
interface ProjectMergeFromProps {
|
||||
onClose: () => void;
|
||||
fileName: string;
|
||||
@@ -79,122 +77,68 @@ export default function ProjectMergeForm({ onClose, fileName }: ProjectMergeFrom
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
size='compact'
|
||||
title='Partial project merge'
|
||||
bodyElements={
|
||||
<form id={formId} onSubmit={handleSubmit(handleSubmitCreate)}>
|
||||
<Panel.Section className={style.mergeBody}>
|
||||
<div className={style.mergeIntro}>
|
||||
<Panel.Description>
|
||||
Select the sections from <span className={style.sourceFile}>{`"${fileName}"`}</span> that you want to
|
||||
merge into the current project.
|
||||
</Panel.Description>
|
||||
<div className={style.mergeSummary}>
|
||||
Only the enabled sections are copied. Existing data stays intact.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Info type='warning' className={style.mergeWarningInfo}>
|
||||
<Info.Title>Back up before merging</Info.Title>
|
||||
<Info.Body>
|
||||
This action can overwrite project data. Create a duplicate project first if you want a recovery point.
|
||||
</Info.Body>
|
||||
</Info>
|
||||
|
||||
<Panel.ListGroup className={style.optionList}>
|
||||
<Panel.ListItem>
|
||||
<label className={style.optionRow}>
|
||||
<div className={style.optionCopy}>
|
||||
<span className={style.optionTitle}>Project data</span>
|
||||
<span className={style.optionBody}>Core project metadata and settings.</span>
|
||||
</div>
|
||||
<Switch
|
||||
size='large'
|
||||
checked={watch('project')}
|
||||
onCheckedChange={(value: boolean) => setValue('project', value, { shouldDirty: true })}
|
||||
/>
|
||||
</label>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<label className={style.optionRow}>
|
||||
<div className={style.optionCopy}>
|
||||
<span className={style.optionTitle}>Rundown + Custom Fields</span>
|
||||
<span className={style.optionBody}>Copies all rundowns and any associated custom fields.</span>
|
||||
</div>
|
||||
<Switch
|
||||
size='large'
|
||||
checked={watch('rundowns')}
|
||||
onCheckedChange={(value: boolean) => setValue('rundowns', value, { shouldDirty: true })}
|
||||
/>
|
||||
</label>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<label className={style.optionRow}>
|
||||
<div className={style.optionCopy}>
|
||||
<span className={style.optionTitle}>View Settings</span>
|
||||
<span className={style.optionBody}>View configuration, and display preferences.</span>
|
||||
</div>
|
||||
<Switch
|
||||
size='large'
|
||||
checked={watch('viewSettings')}
|
||||
onCheckedChange={(value: boolean) => setValue('viewSettings', value, { shouldDirty: true })}
|
||||
/>
|
||||
</label>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<label className={style.optionRow}>
|
||||
<div className={style.optionCopy}>
|
||||
<span className={style.optionTitle}>URL Presets</span>
|
||||
<span className={style.optionBody}>Saved links and preset launch parameters.</span>
|
||||
</div>
|
||||
<Switch
|
||||
size='large'
|
||||
checked={watch('urlPresets')}
|
||||
onCheckedChange={(value: boolean) => setValue('urlPresets', value, { shouldDirty: true })}
|
||||
/>
|
||||
</label>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<label className={style.optionRow}>
|
||||
<div className={style.optionCopy}>
|
||||
<span className={style.optionTitle}>Automation Settings</span>
|
||||
<span className={style.optionBody}>Triggers and automation configuration.</span>
|
||||
</div>
|
||||
<Switch
|
||||
size='large'
|
||||
checked={watch('automation')}
|
||||
onCheckedChange={(value: boolean) => setValue('automation', value, { shouldDirty: true })}
|
||||
/>
|
||||
</label>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
</Panel.Section>
|
||||
</form>
|
||||
}
|
||||
footerElements={
|
||||
<div className={style.footerContent}>
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
<div className={style.footerActions}>
|
||||
<Button onClick={onClose} variant='ghosted' disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
form={formId}
|
||||
disabled={!isValid || !isDirty}
|
||||
loading={isSubmitting}
|
||||
variant='primary'
|
||||
>
|
||||
Merge
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Panel.Section as='form' onSubmit={handleSubmit(handleSubmitCreate)}>
|
||||
<Panel.Title>
|
||||
Partial project merge
|
||||
<Panel.InlineElements>
|
||||
<Button onClick={onClose} variant='ghosted' disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' disabled={!isValid || !isDirty} loading={isSubmitting} variant='primary'>
|
||||
Merge
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.Title>
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
<Panel.Section className={cx([style.innerColumn, style.inlineLabels])}>
|
||||
<Panel.Description>
|
||||
Select data from <i>{`"${fileName}"`}</i> to merge into the current project.
|
||||
</Panel.Description>
|
||||
<Info type='warning'>
|
||||
This process is irreversible and can result in data loss. <br />
|
||||
You may want to create a duplicate backup beforehand.
|
||||
</Info>
|
||||
<label>
|
||||
<Switch
|
||||
size='large'
|
||||
checked={watch('project')}
|
||||
onCheckedChange={(value: boolean) => setValue('project', value, { shouldDirty: true })}
|
||||
/>
|
||||
Project data
|
||||
</label>
|
||||
<label>
|
||||
<Switch
|
||||
size='large'
|
||||
checked={watch('rundowns')}
|
||||
onCheckedChange={(value: boolean) => setValue('rundowns', value, { shouldDirty: true })}
|
||||
/>
|
||||
Rundown + Custom Fields
|
||||
</label>
|
||||
<label>
|
||||
<Switch
|
||||
size='large'
|
||||
checked={watch('viewSettings')}
|
||||
onCheckedChange={(value: boolean) => setValue('viewSettings', value, { shouldDirty: true })}
|
||||
/>
|
||||
View Settings
|
||||
</label>
|
||||
<label>
|
||||
<Switch
|
||||
size='large'
|
||||
checked={watch('urlPresets')}
|
||||
onCheckedChange={(value: boolean) => setValue('urlPresets', value, { shouldDirty: true })}
|
||||
/>
|
||||
URL Presets
|
||||
</label>
|
||||
<label>
|
||||
<Switch
|
||||
size='large'
|
||||
checked={watch('automation')}
|
||||
onCheckedChange={(value: boolean) => setValue('automation', value, { shouldDirty: true })}
|
||||
/>
|
||||
Automation Settings
|
||||
</label>
|
||||
</Panel.Section>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
.current {
|
||||
// marker consumed by the shared Panel.Table active-row treatment
|
||||
background-color: inherit;
|
||||
background-color: $blue-1100;
|
||||
}
|
||||
|
||||
.isEditing {
|
||||
@@ -56,90 +55,7 @@
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.loaderBox {
|
||||
.empty {
|
||||
height: 300px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mergeBody {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.mergeIntro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sourceFile {
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background-color: $white-3;
|
||||
color: $orange-400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mergeSummary {
|
||||
color: $gray-400;
|
||||
font-size: calc(1rem - 2px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.mergeWarningInfo {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.optionList {
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.optionList > li:not(:last-child) {
|
||||
border-bottom: 1px solid $white-10;
|
||||
}
|
||||
|
||||
.optionRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.optionCopy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.optionTitle {
|
||||
color: $ui-white;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.optionBody {
|
||||
color: $gray-400;
|
||||
font-size: calc(1rem - 3px);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.footerContent {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type ProjectData } from 'ontime-types';
|
||||
import { ChangeEvent, useEffect, useRef, useState } from 'react';
|
||||
import { ChangeEvent, useEffect, useRef } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { IoAdd, IoDownloadOutline, IoTrash } from 'react-icons/io5';
|
||||
|
||||
@@ -17,16 +17,9 @@ import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './SettingsPanel.module.scss';
|
||||
|
||||
type PendingLogo = {
|
||||
file: File;
|
||||
previewUrl: string;
|
||||
uploadedFilename?: string;
|
||||
};
|
||||
|
||||
export default function ProjectData() {
|
||||
const { data, status } = useProjectData();
|
||||
const { updateProjectData } = useUpdateProjectData();
|
||||
const [pendingLogo, setPendingLogo] = useState<PendingLogo | null>(null);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
@@ -52,22 +45,13 @@ export default function ProjectData() {
|
||||
});
|
||||
|
||||
// reset form values if data changes
|
||||
// Release the browser-managed preview URL when the selection changes or this form unmounts.
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
reset(data);
|
||||
}
|
||||
}, [data, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pendingLogo) {
|
||||
URL.revokeObjectURL(pendingLogo.previewUrl);
|
||||
}
|
||||
};
|
||||
}, [pendingLogo]);
|
||||
|
||||
const handleUploadProjectLogo = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const handleUploadProjectLogo = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
clearErrors('logo');
|
||||
|
||||
@@ -77,16 +61,14 @@ export default function ProjectData() {
|
||||
|
||||
try {
|
||||
validateLogo(file);
|
||||
setPendingLogo({
|
||||
file,
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
const response = await uploadProjectLogo(file);
|
||||
|
||||
setValue('logo', response.data.logoFilename, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
setError('logo', { message });
|
||||
} finally {
|
||||
// Allow selecting the same local file again after an upload attempt.
|
||||
event.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -101,7 +83,6 @@ export default function ProjectData() {
|
||||
const handleDeleteLogo = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setPendingLogo(null);
|
||||
setValue('logo', null, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
@@ -111,29 +92,10 @@ export default function ProjectData() {
|
||||
append({ title: '', value: '', url: '' });
|
||||
};
|
||||
|
||||
const uploadPendingLogo = async () => {
|
||||
if (!pendingLogo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (pendingLogo.uploadedFilename) {
|
||||
return pendingLogo.uploadedFilename;
|
||||
}
|
||||
|
||||
const response = await uploadProjectLogo(pendingLogo.file);
|
||||
const uploadedFilename = response.data.logoFilename;
|
||||
setPendingLogo((currentPendingLogo) =>
|
||||
currentPendingLogo?.file === pendingLogo.file ? { ...currentPendingLogo, uploadedFilename } : currentPendingLogo,
|
||||
);
|
||||
return uploadedFilename;
|
||||
};
|
||||
|
||||
const onSubmit = async (formData: ProjectData) => {
|
||||
try {
|
||||
clearErrors();
|
||||
const logo = (await uploadPendingLogo()) ?? formData.logo;
|
||||
await updateProjectData({ ...formData, logo });
|
||||
setPendingLogo(null);
|
||||
await updateProjectData(formData);
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
setError('root', { message });
|
||||
@@ -142,12 +104,10 @@ export default function ProjectData() {
|
||||
|
||||
// reset data to the initial state
|
||||
const onReset = () => {
|
||||
setPendingLogo(null);
|
||||
reset(data);
|
||||
};
|
||||
|
||||
const isLoading = status === 'pending';
|
||||
const logo = watch('logo');
|
||||
|
||||
return (
|
||||
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} onKeyDown={(event) => preventEscape(event, onReset)}>
|
||||
@@ -158,12 +118,7 @@ export default function ProjectData() {
|
||||
<Button onClick={onReset} disabled={isSubmitting || !isDirty}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
type='submit'
|
||||
disabled={(!isDirty && !pendingLogo) || !isValid}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
<Button variant='primary' type='submit' disabled={!isDirty || !isValid} loading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
@@ -195,24 +150,17 @@ export default function ProjectData() {
|
||||
onChange={handleUploadProjectLogo}
|
||||
/>
|
||||
<Panel.Card className={style.uploadLogoCard}>
|
||||
{logo || pendingLogo ? (
|
||||
{watch('logo') ? (
|
||||
<>
|
||||
<img src={pendingLogo ? pendingLogo.previewUrl : `${projectLogoPath}/${logo}`} />
|
||||
<div className={style.logoActions}>
|
||||
<Button disabled={isSubmitting} onClick={handleClickUpload} type='button'>
|
||||
<IoDownloadOutline />
|
||||
Replace logo
|
||||
</Button>
|
||||
<Button
|
||||
variant='subtle-destructive'
|
||||
disabled={isSubmitting || (!logo && !pendingLogo)}
|
||||
onClick={handleDeleteLogo}
|
||||
type='button'
|
||||
>
|
||||
<IoTrash />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
<img src={`${projectLogoPath}/${watch('logo')}`} />
|
||||
<Button
|
||||
variant='subtle-destructive'
|
||||
disabled={isSubmitting || !watch('logo')}
|
||||
onClick={handleDeleteLogo}
|
||||
>
|
||||
<IoTrash />
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button disabled={isSubmitting} onClick={handleClickUpload} type='button'>
|
||||
|
||||
@@ -15,11 +15,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.logoActions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.customDataItem {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
+5
-2
@@ -90,13 +90,16 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
|
||||
<div className={style.footer}>
|
||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Panel.InlineElements align='apart'>
|
||||
<Button variant='ghosted' onClick={resetToEnglish} disabled={isSubmitting}>
|
||||
<Button variant='ghosted' size='large' onClick={resetToEnglish} disabled={isSubmitting}>
|
||||
Reset to English
|
||||
</Button>
|
||||
<Panel.InlineElements>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button size='large' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
size='large'
|
||||
type='submit'
|
||||
form='custom-translations-form'
|
||||
disabled={isSubmitting || !isDirty || !isValid}
|
||||
|
||||
+6
-3
@@ -109,16 +109,19 @@ export default function CodeEditorModal({ isOpen, onClose }: CodeEditorModalProp
|
||||
<Info>Invalid CSS will be refused by the browser</Info>
|
||||
{error && <Panel.Error className={style.right}>{`Error: ${error}`}</Panel.Error>}
|
||||
<Panel.InlineElements align='apart' className={style.editorActions}>
|
||||
<Button variant='ghosted' onClick={handleRestore} disabled={saveLoading || resetLoading}>
|
||||
<Button variant='ghosted' size='large' onClick={handleRestore} disabled={saveLoading || resetLoading}>
|
||||
Reset to example
|
||||
</Button>
|
||||
<Panel.InlineElements>
|
||||
<Button variant='ghosted' onClick={clear}>
|
||||
<Button variant='ghosted' size='large' onClick={clear}>
|
||||
Clear
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button size='large' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
size='large'
|
||||
onClick={handleSave}
|
||||
disabled={saveLoading || resetLoading || !isDirty}
|
||||
loading={saveLoading}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useRef } from 'react';
|
||||
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Dialog from '../../../../common/components/dialog/Dialog';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import { useElectronEvent } from '../../../../common/hooks/useElectronEvent';
|
||||
import { isLocalhost, isOntimeCloud } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
@@ -24,50 +23,44 @@ export default function ShutdownPanel() {
|
||||
<>
|
||||
<Panel.Header>Shutdown Ontime</Panel.Header>
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>Shutdown</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
{isOntimeCloud ? (
|
||||
<Info type='warning'>
|
||||
For security reasons, shutting down the server must be done from the Ontime Cloud dashboard.
|
||||
</Info>
|
||||
) : (
|
||||
<Panel.Field
|
||||
title='Stop the Ontime server'
|
||||
description='The runtime state will be lost, but your project is kept for next time.'
|
||||
/>
|
||||
)}
|
||||
{!isOntimeCloud && (
|
||||
<Panel.Section>
|
||||
<Button variant='destructive' onClick={handler.open} disabled={!canShutdown}>
|
||||
Shutdown ontime
|
||||
</Button>
|
||||
{!canShutdown && <Panel.Description>Only available from the machine running Ontime.</Panel.Description>}
|
||||
</Panel.Section>
|
||||
)}
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
title='Shutdown Ontime'
|
||||
showCloseButton
|
||||
onClose={handler.close}
|
||||
bodyElements={
|
||||
{isOntimeCloud ? (
|
||||
<Panel.Highlight>
|
||||
For security reasons, shutting down the server must be done from the Ontime Cloud dashboard.
|
||||
</Panel.Highlight>
|
||||
) : (
|
||||
<Panel.Paragraph>
|
||||
This will shutdown the Ontime server. <br /> Are you sure?
|
||||
This will shutdown the Ontime server. <br />
|
||||
The runtime state will be lost, but your project is kept for next time.
|
||||
</Panel.Paragraph>
|
||||
}
|
||||
footerElements={
|
||||
<>
|
||||
<Button ref={cancelRef} onClick={handler.close} variant='ghosted-white'>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={sendShutdown} disabled={!canShutdown}>
|
||||
Shutdown
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Button variant='destructive' onClick={handler.open} disabled={!(isElectron || isLocalhost)}>
|
||||
Shutdown ontime
|
||||
</Button>
|
||||
{!canShutdown && (
|
||||
<Panel.Description>Note: Ontime can only be shutdown from the machine it is running in.</Panel.Description>
|
||||
)}
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
title='Shutdown Ontime'
|
||||
showCloseButton
|
||||
onClose={handler.close}
|
||||
bodyElements={
|
||||
<Panel.Paragraph>
|
||||
This will shutdown the Ontime server. <br /> Are you sure?
|
||||
</Panel.Paragraph>
|
||||
}
|
||||
footerElements={
|
||||
<>
|
||||
<Button ref={cancelRef} onClick={handler.close} variant='ghosted-white'>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={sendShutdown} disabled={!canShutdown}>
|
||||
Shutdown
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Panel.Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,107 +3,64 @@ import { useMemo } from 'react';
|
||||
import useAppVersion from '../../common/hooks-query/useAppVersion';
|
||||
import { isDocker } from '../../externals';
|
||||
|
||||
type SettingsOptionBase = {
|
||||
export type SettingsOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
secondary?: Readonly<SettingsOption[]>;
|
||||
highlight?: string;
|
||||
};
|
||||
|
||||
export type SettingsOption =
|
||||
| (SettingsOptionBase & { secondary: Readonly<SettingsOption[]>; keywords?: never })
|
||||
| (SettingsOptionBase & { secondary?: never; keywords: Readonly<string[]> });
|
||||
|
||||
const staticOptions = [
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
secondary: [
|
||||
{ id: 'settings__data', label: 'Project data', keywords: ['title', 'description', 'logo', 'url', 'info'] },
|
||||
{
|
||||
id: 'settings__general',
|
||||
label: 'General settings',
|
||||
keywords: ['pin', 'password', 'lock', 'language', 'time format', 'timezone'],
|
||||
},
|
||||
{
|
||||
id: 'settings__view',
|
||||
label: 'View settings',
|
||||
keywords: ['css', 'style', 'theme', 'translation', 'freeze', 'overtime'],
|
||||
},
|
||||
{ id: 'settings__custom-views', label: 'Custom views', keywords: ['html', 'upload', 'external', 'embed'] },
|
||||
{ id: 'settings__mcp', label: 'MCP Server', keywords: ['ai', 'agent', 'model'] },
|
||||
...(isDocker ? [] : [{ id: 'settings__port', label: 'Server port', keywords: ['http', 'network', 'address'] }]),
|
||||
{ id: 'settings__data', label: 'Project data' },
|
||||
{ id: 'settings__general', label: 'General settings' },
|
||||
{ id: 'settings__view', label: 'View settings' },
|
||||
{ id: 'settings__custom-views', label: 'Custom views' },
|
||||
{ id: 'settings__mcp', label: 'MCP Server' },
|
||||
...(isDocker ? [] : [{ id: 'settings__port', label: 'Server port' }]),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'project',
|
||||
label: 'Project',
|
||||
secondary: [
|
||||
{ id: 'project__create', label: 'Create...', keywords: ['new project', 'quick start', 'wizard'] },
|
||||
{
|
||||
id: 'project__list',
|
||||
label: 'Manage projects',
|
||||
keywords: ['load', 'open', 'rename', 'duplicate', 'delete', 'download', 'import', 'backup', 'merge'],
|
||||
},
|
||||
{ id: 'project__create', label: 'Create...' },
|
||||
{ id: 'project__list', label: 'Manage projects' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'manage',
|
||||
label: 'Project settings',
|
||||
secondary: [
|
||||
{
|
||||
id: 'manage__defaults',
|
||||
label: 'Rundown defaults',
|
||||
keywords: ['duration', 'warning', 'danger', 'defaults'],
|
||||
},
|
||||
{
|
||||
id: 'manage__custom',
|
||||
label: 'Custom fields',
|
||||
keywords: ['metadata', 'columns', 'extra data', 'image field'],
|
||||
},
|
||||
{
|
||||
id: 'manage__rundowns',
|
||||
label: 'Manage rundowns',
|
||||
keywords: ['rundown', 'xlsx', 'excel', 'load', 'export'],
|
||||
},
|
||||
{
|
||||
id: 'manage__sheets',
|
||||
label: 'Import spreadsheet',
|
||||
keywords: ['google sheet', 'sync', 'spreadsheet', 'xlsx', 'excel', 'csv', 'export'],
|
||||
},
|
||||
{ id: 'manage__defaults', label: 'Rundown defaults' },
|
||||
{ id: 'manage__custom', label: 'Custom fields' },
|
||||
{ id: 'manage__rundowns', label: 'Manage rundowns' },
|
||||
{ id: 'manage__sheets', label: 'Import spreadsheet' },
|
||||
{ id: 'manage__sheets', label: 'Sync with Google Sheet' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'automation',
|
||||
label: 'Automation',
|
||||
secondary: [
|
||||
{
|
||||
id: 'automation__settings',
|
||||
label: 'Automation settings',
|
||||
keywords: ['osc input', 'port', 'enable', 'remote control'],
|
||||
},
|
||||
{
|
||||
id: 'automation__automations',
|
||||
label: 'Manage automations',
|
||||
keywords: ['osc', 'http', 'webhook', 'integration', 'api', 'output', 'action'],
|
||||
},
|
||||
{
|
||||
id: 'automation__triggers',
|
||||
label: 'Manage triggers',
|
||||
keywords: ['lifecycle', 'on load', 'on start', 'on finish', 'on update'],
|
||||
},
|
||||
{ id: 'automation__settings', label: 'Automation settings' },
|
||||
{ id: 'automation__automations', label: 'Manage automations' },
|
||||
{ id: 'automation__triggers', label: 'Manage triggers' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sharing',
|
||||
label: 'Sharing and reporting',
|
||||
secondary: [
|
||||
{ id: 'sharing__presets', label: 'URL Presets', keywords: ['alias', 'link', 'url', 'shortcut'] },
|
||||
{ id: 'sharing__presets', label: 'URL Presets' },
|
||||
{
|
||||
id: 'sharing__link',
|
||||
label: 'Share link',
|
||||
keywords: ['qr code', 'guest', 'cuesheet link', 'permissions', 'read only'],
|
||||
},
|
||||
{ id: 'sharing__report', label: 'Runtime report', keywords: ['actual times', 'csv', 'export', 'history'] },
|
||||
{ id: 'sharing__report', label: 'Runtime report' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -113,24 +70,20 @@ const staticOptions = [
|
||||
{
|
||||
id: 'network__log',
|
||||
label: 'Event log',
|
||||
keywords: ['debug', 'errors', 'console', 'export log'],
|
||||
},
|
||||
{
|
||||
id: 'network__clients',
|
||||
label: 'Manage clients',
|
||||
keywords: ['redirect', 'identify', 'rename client', 'connected'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
label: 'About',
|
||||
keywords: ['version', 'update', 'licence', 'license', 'credits'],
|
||||
},
|
||||
{
|
||||
id: 'shutdown',
|
||||
label: 'Shutdown',
|
||||
keywords: ['quit', 'exit', 'close ontime'],
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -139,51 +92,6 @@ export type SettingsOptionId =
|
||||
| (typeof staticOptions)[number]['id']
|
||||
| Extract<(typeof staticOptions)[number], { secondary: object }>['secondary'][number]['id'];
|
||||
|
||||
function sanitiseSearchInput(query: string): string {
|
||||
return query.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function matchesSettingsOptionQuery(option: SettingsOption, query: string): boolean {
|
||||
const sanitisedQuery = sanitiseSearchInput(query);
|
||||
if (!sanitisedQuery) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sanitisedLabel = sanitiseSearchInput(option.label);
|
||||
if (sanitisedLabel.includes(sanitisedQuery)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// check keywords
|
||||
return Boolean(option.keywords?.some((keyword) => keyword.includes(sanitisedQuery)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the settings menu against a user query.
|
||||
* A group is kept if it matches itself (with all its children) or if any of its children match.
|
||||
*/
|
||||
export function filterSettingsOptions(options: Readonly<SettingsOption[]>, query: string): SettingsOption[] {
|
||||
const sanitisedQuery = sanitiseSearchInput(query);
|
||||
if (!sanitisedQuery) {
|
||||
return [...options];
|
||||
}
|
||||
|
||||
return options.reduce<SettingsOption[]>((accumulator, option) => {
|
||||
if (matchesSettingsOptionQuery(option, sanitisedQuery)) {
|
||||
accumulator.push(option);
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
if (option.secondary) {
|
||||
const secondary = option.secondary.filter((child) => matchesSettingsOptionQuery(child, sanitisedQuery));
|
||||
if (secondary.length) {
|
||||
accumulator.push({ ...option, secondary });
|
||||
}
|
||||
}
|
||||
return accumulator;
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function useAppSettingsMenu() {
|
||||
const { data } = useAppVersion();
|
||||
|
||||
|
||||
@@ -1,54 +1,20 @@
|
||||
$info-gray: $secondary-text-gray;
|
||||
$info-hover: $section-white;
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.extracted {
|
||||
height: 100%;
|
||||
padding: 1.5rem;
|
||||
background-color: $white-3;
|
||||
border: 1px solid $gray-1100;
|
||||
border-radius: $component-border-radius-md;
|
||||
|
||||
.log {
|
||||
flex: 1;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
.log {
|
||||
font-size: $inner-section-text-size;
|
||||
overflow-y: auto;
|
||||
user-select: text;
|
||||
height: 40vh;
|
||||
min-height: 40vh;
|
||||
min-height: 20vh;
|
||||
max-height: 40vh;
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
background-color: $gray-1350;
|
||||
border: 1px solid $gray-1100;
|
||||
border-radius: $component-border-radius-md;
|
||||
box-shadow: inset 0 1px 0 $white-3;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.apart {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.filterLabel {
|
||||
color: $gray-400;
|
||||
font-size: $aux-text-size;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.logEntry {
|
||||
display: flex;
|
||||
|
||||
@@ -78,19 +44,10 @@ $info-hover: $section-white;
|
||||
|
||||
.msg {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 2.5rem 1rem;
|
||||
color: $gray-600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.buttonBar {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -4,14 +4,12 @@ import { IoClose } from 'react-icons/io5';
|
||||
|
||||
import Button from '../../common/components/buttons/Button';
|
||||
import { clearLogs, useLogData } from '../../common/stores/logger';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
import * as Panel from '../app-settings/panel-utils/PanelUtils';
|
||||
|
||||
import style from './Log.module.scss';
|
||||
|
||||
export default function Log() {
|
||||
const { logs: logData } = useLogData();
|
||||
const isExtracted = window.location.pathname.includes('/log');
|
||||
|
||||
const [showClient, setShowClient] = useState(true);
|
||||
const [showServer, setShowServer] = useState(true);
|
||||
@@ -52,14 +50,11 @@ export default function Log() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cx([style.container, isExtracted && style.extracted])}>
|
||||
<>
|
||||
<Panel.InlineElements className={style.buttonBar}>
|
||||
<span className={style.filterLabel}>Filter by</span>
|
||||
<Button
|
||||
variant={showUser ? 'primary' : 'subtle'}
|
||||
size='small'
|
||||
aria-pressed={showUser}
|
||||
aria-label={`${showUser ? 'Hide' : 'Show'} ${LogOrigin.User} events`}
|
||||
onClick={() => setShowUser((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.User)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
@@ -69,8 +64,6 @@ export default function Log() {
|
||||
<Button
|
||||
variant={showClient ? 'primary' : 'subtle'}
|
||||
size='small'
|
||||
aria-pressed={showClient}
|
||||
aria-label={`${showClient ? 'Hide' : 'Show'} ${LogOrigin.Client} events`}
|
||||
onClick={() => setShowClient((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Client)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
@@ -80,8 +73,6 @@ export default function Log() {
|
||||
<Button
|
||||
variant={showServer ? 'primary' : 'subtle'}
|
||||
size='small'
|
||||
aria-pressed={showServer}
|
||||
aria-label={`${showServer ? 'Hide' : 'Show'} ${LogOrigin.Server} events`}
|
||||
onClick={() => setShowServer((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Server)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
@@ -91,8 +82,6 @@ export default function Log() {
|
||||
<Button
|
||||
variant={showPlayback ? 'primary' : 'subtle'}
|
||||
size='small'
|
||||
aria-pressed={showPlayback}
|
||||
aria-label={`${showPlayback ? 'Hide' : 'Show'} ${LogOrigin.Playback} events`}
|
||||
onClick={() => setShowPlayback((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Playback)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
@@ -102,8 +91,6 @@ export default function Log() {
|
||||
<Button
|
||||
variant={showRx ? 'primary' : 'subtle'}
|
||||
size='small'
|
||||
aria-pressed={showRx}
|
||||
aria-label={`${showRx ? 'Hide' : 'Show'} ${LogOrigin.Rx} events`}
|
||||
onClick={() => setShowRx((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Rx)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
@@ -113,8 +100,6 @@ export default function Log() {
|
||||
<Button
|
||||
variant={showTx ? 'primary' : 'subtle'}
|
||||
size='small'
|
||||
aria-pressed={showTx}
|
||||
aria-label={`${showTx ? 'Hide' : 'Show'} ${LogOrigin.Tx} events`}
|
||||
onClick={() => setShowTx((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Tx)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
@@ -125,16 +110,15 @@ export default function Log() {
|
||||
<IoClose /> Clear
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
<ul className={style.log} aria-label='Event log entries'>
|
||||
{filteredData.length === 0 && <li className={style.empty}>No events match the selected filters.</li>}
|
||||
<ul className={style.log}>
|
||||
{filteredData.map((logEntry) => (
|
||||
<li key={logEntry.id} className={`${style.logEntry} ${style[logEntry.level]}`}>
|
||||
<li key={logEntry.id} className={`${style.logEntry} ${style[logEntry.level]} `}>
|
||||
<span className={style.time}>{logEntry.time}</span>
|
||||
<span className={style.origin}>{logEntry.origin}</span>
|
||||
<span className={style.msg}>{logEntry.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import EmptyFill from '../../common/components/state/EmptyFill';
|
||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||
@@ -11,7 +10,6 @@ import { cx } from '../../common/utils/styleUtils';
|
||||
import { throttle } from '../../common/utils/throttle';
|
||||
import { getDefaultFormat } from '../../common/utils/time';
|
||||
import { isTouchDevice } from '../../externals';
|
||||
import { useTranslation } from '../../translation/TranslationProvider';
|
||||
import Loader from '../../views/common/loader/Loader';
|
||||
import CustomFieldEditModal from './custom-field-edit-modal/CustomFieldEditModal';
|
||||
import FollowButton from './follow-button/FollowButton';
|
||||
@@ -37,7 +35,7 @@ export default function OperatorLoader() {
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
|
||||
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
|
||||
}
|
||||
|
||||
return <Operator {...data} />;
|
||||
@@ -45,7 +43,6 @@ export default function OperatorLoader() {
|
||||
|
||||
function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) {
|
||||
const selectedEventId = useSelectedEventId();
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
|
||||
|
||||
const [showEditPrompt, setShowEditPrompt] = useState(false);
|
||||
@@ -116,7 +113,6 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
||||
const operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]);
|
||||
|
||||
const canEdit = shouldEdit && subscribe.length;
|
||||
const hasEvents = rundown.order.length > 0;
|
||||
|
||||
return (
|
||||
<div className={style.operatorContainer} data-testid='operator-view'>
|
||||
@@ -131,121 +127,117 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasEvents ? (
|
||||
<EmptyFill text={getLocalizedString('common.no_data')} />
|
||||
) : (
|
||||
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
|
||||
{rundown.order.map((entryId) => {
|
||||
const entry = rundown.entries[entryId];
|
||||
if (isOntimeEvent(entry)) {
|
||||
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
|
||||
// hide past events (if setting) and skipped events
|
||||
if ((hidePast && isPast) || entry.skip) {
|
||||
return null;
|
||||
}
|
||||
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
|
||||
{rundown.order.map((entryId) => {
|
||||
const entry = rundown.entries[entryId];
|
||||
if (isOntimeEvent(entry)) {
|
||||
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
|
||||
// hide past events (if setting) and skipped events
|
||||
if ((hidePast && isPast) || entry.skip) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { mainField, secondaryField, subscribedData } = getEventData(
|
||||
entry,
|
||||
mainSource,
|
||||
secondarySource,
|
||||
subscribe,
|
||||
customFields,
|
||||
);
|
||||
const { mainField, secondaryField, subscribedData } = getEventData(
|
||||
entry,
|
||||
mainSource,
|
||||
secondarySource,
|
||||
subscribe,
|
||||
customFields,
|
||||
);
|
||||
|
||||
return (
|
||||
<OperatorEvent
|
||||
return (
|
||||
<OperatorEvent
|
||||
key={entry.id}
|
||||
id={entry.id}
|
||||
colour={entry.colour}
|
||||
cue={entry.cue}
|
||||
main={mainField}
|
||||
secondary={secondaryField}
|
||||
timeStart={entry.timeStart}
|
||||
duration={entry.duration}
|
||||
delay={entry.delay}
|
||||
dayOffset={entry.dayOffset}
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
isSelected={isLoaded}
|
||||
isPast={isPast}
|
||||
selectedRef={isLoaded ? selectedRef : undefined}
|
||||
showStart={showStart}
|
||||
subscribed={subscribedData}
|
||||
totalGap={totalGap}
|
||||
onLongPress={canEdit ? handleEdit : () => undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isOntimeGroup(entry)) {
|
||||
const { isPast } = rundownMetadata[entry.id];
|
||||
|
||||
const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId]?.groupId === entry.id : false;
|
||||
|
||||
if (hidePast && isPast && !isCurrentParent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={entry.id}>
|
||||
<OperatorGroup
|
||||
key={entry.id}
|
||||
id={entry.id}
|
||||
title={entry.title}
|
||||
colour={entry.colour}
|
||||
cue={entry.cue}
|
||||
main={mainField}
|
||||
secondary={secondaryField}
|
||||
timeStart={entry.timeStart}
|
||||
count={entry.entries.length}
|
||||
duration={entry.duration}
|
||||
delay={entry.delay}
|
||||
dayOffset={entry.dayOffset}
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
isSelected={isLoaded}
|
||||
isPast={isPast}
|
||||
selectedRef={isLoaded ? selectedRef : undefined}
|
||||
showStart={showStart}
|
||||
subscribed={subscribedData}
|
||||
totalGap={totalGap}
|
||||
onLongPress={canEdit ? handleEdit : () => undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
{entry.entries.map((nestedEntryId) => {
|
||||
const nestedEntry = rundown.entries[nestedEntryId];
|
||||
if (!isOntimeEvent(nestedEntry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isOntimeGroup(entry)) {
|
||||
const { isPast } = rundownMetadata[entry.id];
|
||||
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
|
||||
|
||||
const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId]?.groupId === entry.id : false;
|
||||
// hide past events (if setting) and skipped events
|
||||
if ((hidePast && isPast) || nestedEntry.skip) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hidePast && isPast && !isCurrentParent) {
|
||||
return null;
|
||||
}
|
||||
const { mainField, secondaryField, subscribedData } = getEventData(
|
||||
nestedEntry,
|
||||
mainSource,
|
||||
secondarySource,
|
||||
subscribe,
|
||||
customFields,
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment key={entry.id}>
|
||||
<OperatorGroup
|
||||
key={entry.id}
|
||||
title={entry.title}
|
||||
colour={entry.colour}
|
||||
count={entry.entries.length}
|
||||
duration={entry.duration}
|
||||
/>
|
||||
{entry.entries.map((nestedEntryId) => {
|
||||
const nestedEntry = rundown.entries[nestedEntryId];
|
||||
if (!isOntimeEvent(nestedEntry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
|
||||
|
||||
// hide past events (if setting) and skipped events
|
||||
if ((hidePast && isPast) || nestedEntry.skip) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { mainField, secondaryField, subscribedData } = getEventData(
|
||||
nestedEntry,
|
||||
mainSource,
|
||||
secondarySource,
|
||||
subscribe,
|
||||
customFields,
|
||||
);
|
||||
|
||||
return (
|
||||
<OperatorEvent
|
||||
key={nestedEntry.id}
|
||||
id={nestedEntry.id}
|
||||
colour={nestedEntry.colour}
|
||||
cue={nestedEntry.cue}
|
||||
main={mainField}
|
||||
secondary={secondaryField}
|
||||
timeStart={nestedEntry.timeStart}
|
||||
duration={nestedEntry.duration}
|
||||
delay={nestedEntry.delay}
|
||||
dayOffset={nestedEntry.dayOffset}
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
isSelected={isLoaded}
|
||||
isPast={isPast}
|
||||
groupColour={entry.colour}
|
||||
selectedRef={isLoaded ? selectedRef : undefined}
|
||||
showStart={showStart}
|
||||
subscribed={subscribedData}
|
||||
totalGap={totalGap}
|
||||
onLongPress={canEdit ? handleEdit : () => undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<OperatorEvent
|
||||
key={nestedEntry.id}
|
||||
id={nestedEntry.id}
|
||||
colour={nestedEntry.colour}
|
||||
cue={nestedEntry.cue}
|
||||
main={mainField}
|
||||
secondary={secondaryField}
|
||||
timeStart={nestedEntry.timeStart}
|
||||
duration={nestedEntry.duration}
|
||||
delay={nestedEntry.delay}
|
||||
dayOffset={nestedEntry.dayOffset}
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
isSelected={isLoaded}
|
||||
isPast={isPast}
|
||||
groupColour={entry.colour}
|
||||
selectedRef={isLoaded ? selectedRef : undefined}
|
||||
showStart={showStart}
|
||||
subscribed={subscribedData}
|
||||
totalGap={totalGap}
|
||||
onLongPress={canEdit ? handleEdit : () => undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
<FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
.event {
|
||||
opacity: 1;
|
||||
border-top: 1px solid $white-1;
|
||||
padding-right: 0.5rem;
|
||||
color: $white-90;
|
||||
background-color: $viewer-card-bg-color;
|
||||
@@ -30,6 +31,7 @@
|
||||
}
|
||||
|
||||
&.running {
|
||||
border-top: 1px solid $gray-1300;
|
||||
background-color: var(--operator-running-bg-override, $active-green);
|
||||
}
|
||||
|
||||
@@ -40,7 +42,8 @@
|
||||
}
|
||||
|
||||
&.past {
|
||||
opacity: 0.4;
|
||||
border-top: 1px solid transparent;
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +79,6 @@
|
||||
|
||||
.mainField {
|
||||
grid-area: main;
|
||||
min-width: 0;
|
||||
font-size: 1.5rem;
|
||||
color: $ui-white;
|
||||
|
||||
@@ -142,7 +144,6 @@
|
||||
|
||||
.fields {
|
||||
grid-area: fields;
|
||||
min-width: 0;
|
||||
margin-top: 0;
|
||||
border-top: 0;
|
||||
|
||||
@@ -170,9 +171,10 @@
|
||||
&.fieldsWithContent {
|
||||
margin-top: 0.25rem;
|
||||
padding-block: 0.5rem;
|
||||
border-top: 1px solid $white-13;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
gap: 0.25rem 0.75rem;
|
||||
line-height: 1.25em;
|
||||
font-size: var(--operator-customfield-font-size-override, 1.25rem);
|
||||
|
||||
@@ -82,7 +82,7 @@ function OperatorEvent({
|
||||
const fieldGridStyle =
|
||||
columnCount > 0
|
||||
? ({
|
||||
gridTemplateColumns: `repeat(${columnCount}, minmax(min(12rem, 100%), 1fr))`,
|
||||
gridTemplateColumns: `repeat(${columnCount}, minmax(12rem, 1fr))`,
|
||||
} satisfies CSSProperties)
|
||||
: undefined;
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
|
||||
},
|
||||
{
|
||||
id: 'subscribe',
|
||||
title: 'Show custom fields',
|
||||
description: 'Choose custom fields to show on each event',
|
||||
title: 'Highlight Fields',
|
||||
description: 'Choose custom fields to highlight',
|
||||
type: 'multi-option',
|
||||
values: customFieldSelect,
|
||||
},
|
||||
|
||||
@@ -1,43 +1,14 @@
|
||||
.titleOverview {
|
||||
flex: 0 1 18rem;
|
||||
min-width: 0;
|
||||
width: 18rem;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.project,
|
||||
.rundown {
|
||||
.title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: $ui-white;
|
||||
line-height: 1;
|
||||
@include ellipsis-overflow;
|
||||
}
|
||||
|
||||
.project {
|
||||
.description {
|
||||
font-size: 1rem;
|
||||
color: $secondary-text-gray;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.rundown {
|
||||
color: $ui-white;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
// A lone title should still read as the primary piece of context.
|
||||
.project:only-child,
|
||||
.rundown:only-child {
|
||||
color: $ui-white;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.project:only-child {
|
||||
font-weight: 600;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
@include ellipsis-overflow;
|
||||
}
|
||||
|
||||
@@ -8,17 +8,14 @@ export default function TitleOverview() {
|
||||
const { data: projectData } = useProjectData();
|
||||
const { data: rundownData } = useRundownAuxData();
|
||||
|
||||
const projectTitle = projectData.title.trim();
|
||||
const rundownTitle = rundownData.title.trim();
|
||||
|
||||
if (!projectTitle && !rundownTitle) {
|
||||
if (!projectData.title && !rundownData.title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.titleOverview}>
|
||||
{projectTitle && <div className={style.project}>{projectTitle}</div>}
|
||||
{rundownTitle && <div className={style.rundown}>{rundownTitle}</div>}
|
||||
<div>
|
||||
<div className={style.title}>{projectData.title}</div>
|
||||
<div className={style.description}>{rundownData.title}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import EmptyFill from '../../common/components/state/EmptyFill';
|
||||
import Empty from '../../common/components/state/Empty';
|
||||
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
|
||||
import { useRundownEditor } from '../../common/hooks/useSocket';
|
||||
import { useTranslation } from '../../translation/TranslationProvider';
|
||||
import Rundown from './Rundown';
|
||||
|
||||
export default memo(RundownList);
|
||||
function RundownList() {
|
||||
const { data, status, rundownMetadata } = useRundownWithMetadata();
|
||||
const featureData = useRundownEditor();
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
// avoid showing the editable empty state before we know whether the rundown is actually empty
|
||||
if (status === 'pending') {
|
||||
return <EmptyFill text='Loading…' />;
|
||||
}
|
||||
const isLoading = status !== 'success' || !data || !rundownMetadata;
|
||||
|
||||
if (status === 'error') {
|
||||
return <EmptyFill text={getLocalizedString('common.no_data')} />;
|
||||
if (isLoading) {
|
||||
return <Empty text='Connecting to server' />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -22,10 +22,13 @@ export default function CuesheetEntryEditor({ entryId, rundown }: CuesheetEntryE
|
||||
return event ?? null;
|
||||
}, [entryId, rundown.entries, rundown.order.length]);
|
||||
|
||||
if (isOntimeEvent(entry)) {
|
||||
// the cuesheet always edits a single entry
|
||||
const events = useMemo(() => (isOntimeEvent(entry) ? [entry] : null), [entry]);
|
||||
|
||||
if (events) {
|
||||
return (
|
||||
<div className={style.entryEditor} data-testid='editor-container'>
|
||||
<EventEditor event={entry} />
|
||||
<EventEditor events={events} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,6 +75,26 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
// a label with an action aligned to the end of the row
|
||||
.labelRow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.clearAction {
|
||||
font-size: $aux-text-size;
|
||||
color: $blue-400;
|
||||
margin-bottom: $element-inner-spacing; // match the label
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
|
||||
&:hover {
|
||||
color: $blue-500;
|
||||
}
|
||||
}
|
||||
|
||||
.splitTwo {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
|
||||
import Info from '../../../common/components/info/Info';
|
||||
import AppLink from '../../../common/components/link/app-link/AppLink';
|
||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
||||
import EventEditorBatchSchedule from './composite/EventEditorBatchSchedule';
|
||||
import EntryEditorCustomFields from './composite/EventEditorCustomFields';
|
||||
import EventEditorSchedule from './composite/EventEditorSchedule';
|
||||
import EventEditorTimes from './composite/EventEditorTimes';
|
||||
import EventEditorTitles from './composite/EventEditorTitles';
|
||||
import EventEditorTriggers from './composite/EventEditorTriggers';
|
||||
import { mixedPlaceholder } from './entryEditor.utils';
|
||||
import { mergeEvents, resolveConflict } from './mergeEvents';
|
||||
|
||||
import style from './EntryEditor.module.scss';
|
||||
|
||||
@@ -16,66 +21,115 @@ import style from './EntryEditor.module.scss';
|
||||
export type EventEditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | string;
|
||||
|
||||
interface EventEditorProps {
|
||||
event: OntimeEvent;
|
||||
/** events being edited, editing several events at once shows a merged view of their values */
|
||||
events: OntimeEvent[];
|
||||
}
|
||||
|
||||
export default function EventEditor({ event }: EventEditorProps) {
|
||||
export default function EventEditor({ events }: EventEditorProps) {
|
||||
const { data: customFields } = useCustomFields();
|
||||
const { updateEntry } = useEntryActionsContext();
|
||||
const { updateEntry, batchUpdateEvents } = useEntryActionsContext();
|
||||
|
||||
const isEditor = window.location.pathname.includes('editor');
|
||||
|
||||
const ids = useMemo(() => events.map((event) => event.id), [events]);
|
||||
const merged = useMemo(() => mergeEvents(events), [events]);
|
||||
|
||||
// when editing a single event, we can show the values which are unique to it
|
||||
const singleEvent = events.length === 1 ? events[0] : null;
|
||||
|
||||
/**
|
||||
* Applies a patch to every event being edited
|
||||
*/
|
||||
const submit = useCallback(
|
||||
(patch: Partial<OntimeEvent>) => {
|
||||
if (ids.length === 1) {
|
||||
updateEntry({ id: ids[0], ...patch });
|
||||
return;
|
||||
}
|
||||
batchUpdateEvents(patch, ids);
|
||||
},
|
||||
[batchUpdateEvents, ids, updateEntry],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(field: EventEditorUpdateFields, value: string) => {
|
||||
if (field.startsWith('custom-')) {
|
||||
const fieldLabel = field.split('custom-')[1];
|
||||
updateEntry({ id: event.id, custom: { [fieldLabel]: value } });
|
||||
submit({ custom: { [fieldLabel]: value } });
|
||||
} else {
|
||||
updateEntry({ id: event.id, [field]: value });
|
||||
submit({ [field]: value });
|
||||
}
|
||||
},
|
||||
[event.id, updateEntry],
|
||||
[submit],
|
||||
);
|
||||
|
||||
// inputs keep local state, we remount them when the edited entries change
|
||||
const editorKey = ids.join();
|
||||
|
||||
return (
|
||||
<div className={style.content}>
|
||||
{singleEvent ? (
|
||||
<EventEditorSchedule
|
||||
key={`${editorKey}-schedule`}
|
||||
eventId={singleEvent.id}
|
||||
timeStart={singleEvent.timeStart}
|
||||
timeEnd={singleEvent.timeEnd}
|
||||
duration={singleEvent.duration}
|
||||
timeStrategy={singleEvent.timeStrategy}
|
||||
linkStart={singleEvent.linkStart}
|
||||
delay={singleEvent.delay}
|
||||
/>
|
||||
) : (
|
||||
<EventEditorBatchSchedule
|
||||
key={`${editorKey}-schedule`}
|
||||
duration={resolveConflict(merged.duration)}
|
||||
submit={submit}
|
||||
/>
|
||||
)}
|
||||
<EventEditorTimes
|
||||
key={`${event.id}-times`}
|
||||
eventId={event.id}
|
||||
timeStart={event.timeStart}
|
||||
timeEnd={event.timeEnd}
|
||||
duration={event.duration}
|
||||
timeStrategy={event.timeStrategy}
|
||||
linkStart={event.linkStart}
|
||||
countToEnd={event.countToEnd}
|
||||
delay={event.delay}
|
||||
endAction={event.endAction}
|
||||
timerType={event.timerType}
|
||||
timeWarning={event.timeWarning}
|
||||
timeDanger={event.timeDanger}
|
||||
key={`${editorKey}-times`}
|
||||
countToEnd={resolveConflict(merged.countToEnd)}
|
||||
endAction={resolveConflict(merged.endAction)}
|
||||
timerType={resolveConflict(merged.timerType)}
|
||||
timeWarning={resolveConflict(merged.timeWarning)}
|
||||
timeDanger={resolveConflict(merged.timeDanger)}
|
||||
submit={submit}
|
||||
/>
|
||||
<EventEditorTitles
|
||||
key={`${event.id}-titles`}
|
||||
eventId={event.id}
|
||||
cue={event.cue}
|
||||
flag={event.flag}
|
||||
title={event.title}
|
||||
note={event.note}
|
||||
colour={event.colour}
|
||||
key={`${editorKey}-titles`}
|
||||
eventId={singleEvent?.id ?? null}
|
||||
eventCount={events.length}
|
||||
cue={singleEvent?.cue ?? ''}
|
||||
flag={resolveConflict(merged.flag)}
|
||||
title={resolveConflict(merged.title)}
|
||||
note={resolveConflict(merged.note)}
|
||||
colour={resolveConflict(merged.colour)}
|
||||
submit={submit}
|
||||
/>
|
||||
<div className={style.column}>
|
||||
<Editor.Title>
|
||||
Custom Fields
|
||||
{isEditor && <AppLink search='settings=manage__custom'>Manage Custom Fields</AppLink>}
|
||||
</Editor.Title>
|
||||
<EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} entry={event} />
|
||||
<EntryEditorCustomFields
|
||||
key={`${editorKey}-custom`}
|
||||
fields={customFields}
|
||||
custom={merged.custom}
|
||||
idKey={editorKey}
|
||||
mixedPlaceholder={mixedPlaceholder}
|
||||
handleSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.column}>
|
||||
<Editor.Title>
|
||||
Automations
|
||||
{isEditor && <AppLink search='settings=automation'>Manage Automations</AppLink>}
|
||||
{isEditor && singleEvent && <AppLink search='settings=automation'>Manage Automations</AppLink>}
|
||||
</Editor.Title>
|
||||
<EventEditorTriggers triggers={event.triggers} eventId={event.id} />
|
||||
{singleEvent ? (
|
||||
<EventEditorTriggers triggers={singleEvent.triggers} eventId={singleEvent.id} />
|
||||
) : (
|
||||
<Info>Automations are not available when editing multiple events</Info>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -112,7 +112,12 @@ export default function GroupEditor({ group }: GroupEditorProps) {
|
||||
Custom Fields
|
||||
{isEditor && <AppLink search='settings=manage__custom'>Manage Custom Fields</AppLink>}
|
||||
</Editor.Title>
|
||||
<EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} entry={group} />
|
||||
<EntryEditorCustomFields
|
||||
fields={customFields}
|
||||
handleSubmit={handleSubmit}
|
||||
custom={group.custom}
|
||||
idKey={group.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user