Compare commits

..

13 Commits

Author SHA1 Message Date
google-labs-jules[bot] 342533e128 refactor(CustomFields): Implement UI-only reordering with Save button
This commit refactors the custom field ordering functionality to support
UI-only reordering with an explicit "Save Order" button. This replaces
the previous approach where each move operation updated the backend.

Changes include:

- **Frontend State Management:**
    - `CustomFields.tsx` now manages a local state (`displayedFields`) for the order of custom fields.
    - Reordering operations ("Move Up"/"Move Down") update this local state directly without immediate backend calls.
- **"Save Order" Button:**
    - A "Save Order" button has been added to the UI.
    - This button is enabled only when changes to the order have been made locally.
    - Clicking "Save Order" triggers API calls to persist the new order of all changed fields to the backend.
- **API Calls:**
    - `editCustomField` is now called in a batch when "Save Order" is pressed, only for fields whose order has effectively changed.
- **Local Order Integrity:**
    - The `onMove` handler now ensures that the local `order` properties are dense and sequential (0, 1, 2...) before saving.
- **Testing Plan:** Updated manual testing steps to reflect the new UI flow.

This approach improves UX by making reordering feel instantaneous and reduces the number of API calls compared to updating on every move.
2025-07-04 17:28:29 +00:00
Carlos Valente 5600235ba1 refactor: restructure settings
refactor: migrate react components
2025-07-04 15:32:33 +02:00
Carlos Valente a8ea1080f3 refactor: migrate modal components 2025-07-04 15:24:14 +02:00
Carlos Valente 36223ff49f fix: resizing columns in cuesheet 2025-07-04 08:55:46 +02:00
Carlos Valente 3ce5b42e05 refactor: prepare rundown loading process 2025-07-04 08:55:46 +02:00
Carlos Valente c6c1e4a5d7 feat: share link from cuesheet 2025-07-04 08:55:46 +02:00
Carlos Valente 84b73fc02a refactor: migrate related components 2025-07-04 08:55:46 +02:00
google-labs-jules[bot] cd8e014f08 refactor: optimize cuesheet IntersectionObserver
Decouples the IntersectionObserver from the React component tree to
prevent
unnecessary re-renders of the entire Cuesheet table when row visibility
changes. Restores rootMargin for better UX and memoizes EventRow.

- Introduced `rowObserver.ts` to manage a global IntersectionObserver.
  - Observer callback directly updates `useVisibleRowsStore`.
  - Configured with `rootMargin: '400px 0px'` and `threshold: 0.01`
    to pre-load and retain rows near the viewport, improving UX.
- `EventRow` components use `observeRow`/`unobserveRow` from
`rowObserver.ts`.
- `EventRow` is now wrapped in `React.memo` to prevent re-renders if
props
  haven't changed when its parent re-renders.
- Removed observer creation and prop-drilling from `CuesheetBody.tsx`.

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

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