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
206 changed files with 6082 additions and 5271 deletions
+2 -2
View File
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"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",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -13,7 +13,7 @@
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^7.17.2",
"@mantine/hooks": "^8.1.2",
"@sentry/react": "^8.43.0",
"@table-nav/react": "^0.0.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 PROJECT_DATA = ['project'];
export const PROJECT_LIST = ['projectList'];
export const PROJECT_RUNDOWNS = ['projectRundowns'];
export const RUNDOWN = ['rundown'];
export const RUNTIME = ['runtimeStore'];
export const URL_PRESETS = ['urlpresets'];
+17 -10
View File
@@ -1,38 +1,45 @@
import axios from 'axios';
import { CustomField, CustomFieldKey, CustomFields } from 'ontime-types';
import { CustomField, CustomFieldKey } from 'ontime-types'; // Removed CustomFields
import { apiEntryUrl } from './constants';
// Define CustomFieldWithKey for client-side usage
export type CustomFieldWithKey = CustomField & { key: CustomFieldKey };
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> {
const res = await axios.get(customFieldsPath);
export async function getCustomFields(): Promise<CustomFieldWithKey[]> {
const res = await axios.get<CustomFieldWithKey[]>(customFieldsPath);
return res.data;
}
/**
* Sets list of known custom fields
* Returns the updated list, sorted by order
*/
export async function postCustomField(newField: CustomField): Promise<CustomFields> {
const res = await axios.post(customFieldsPath, { ...newField });
export async function postCustomField(newField: CustomField): Promise<CustomFieldWithKey[]> {
const res = await axios.post<CustomFieldWithKey[]>(customFieldsPath, { ...newField });
return res.data;
}
/**
* Edits single custom field
* Returns the updated list, sorted by order
*/
export async function editCustomField(key: CustomFieldKey, newField: CustomField): Promise<CustomFields> {
const res = await axios.put(`${customFieldsPath}/${key}`, { ...newField });
export async function editCustomField(key: CustomFieldKey, newField: Partial<CustomField>): Promise<CustomFieldWithKey[]> {
// Ensure newField can include 'order' by using Partial<CustomField>
const res = await axios.put<CustomFieldWithKey[]>(`${customFieldsPath}/${key}`, { ...newField });
return res.data;
}
/**
* Deletes single custom field
* Returns the updated list, sorted by order
*/
export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFields> {
const res = await axios.delete(`${customFieldsPath}/${key}`);
export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFieldWithKey[]> {
const res = await axios.delete<CustomFieldWithKey[]>(`${customFieldsPath}/${key}`);
return res.data;
}
@@ -1,6 +1,7 @@
.subtle {
background: $gray-1050;
color: $blue-400;
line-height: 1em;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
@@ -79,3 +80,61 @@
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';
.baseButton {
position: relative;
display: flex;
align-items: center;
justify-content: center;
@@ -25,6 +26,36 @@
outline: 2px solid $blue-500;
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 {
@@ -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 style from './Button.module.scss';
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';
fluid?: boolean;
loading?: boolean;
}
export default function Button(props: ButtonProps) {
const { className, children, variant = 'subtle', size = 'medium', fluid, ...buttonProps } = props;
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ 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
className={cx([style.baseButton, style[variant], style[size], fluid && style.fluid, className])}
type='button'
{...buttonProps}
>
{children}
</button>
);
}
Button.displayName = 'Button';
export default Button;
@@ -7,7 +7,7 @@
place-content: center;
border: 1px solid transparent;
border-radius: 3px;
border-radius: $component-border-radius-md;
cursor: pointer;
@@ -17,6 +17,11 @@
}
}
.small {
height: 1.5rem;
font-size: calc(1rem - 3px);
}
.medium {
height: 2rem;
width: 2rem;
@@ -5,8 +5,16 @@ import { cx } from '../../utils/styleUtils';
import style from './IconButton.module.scss';
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'subtle' | 'subtle-white' | 'destructive' | 'subtle-destructive';
size?: 'medium' | 'large' | 'xlarge';
variant?:
| 'primary'
| 'subtle'
| 'subtle-white'
| 'destructive'
| 'subtle-destructive'
| 'ghosted'
| 'ghosted-white'
| 'ghosted-destructive';
size?: 'small' | 'medium' | 'large' | 'xlarge';
}
export default function IconButton({
@@ -1,22 +1,15 @@
import { useState } from 'react';
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 { setClientRemote } from '../../hooks/useSocket';
import useUrlPresets from '../../hooks-query/useUrlPresets';
import Button from '../buttons/Button';
import Info from '../info/Info';
import Input from '../input/input/Input';
import AppLink from '../link/app-link/AppLink';
import Modal from '../modal/Modal';
import Select from '../select/Select';
import style from './RedirectClientModal.module.scss';
@@ -29,8 +22,7 @@ interface RedirectClientModalProps {
onClose: () => void;
}
export function RedirectClientModal(props: RedirectClientModalProps) {
const { id, isOpen, name, currentPath, origin, onClose } = props;
export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onClose }: RedirectClientModalProps) {
const { data } = useUrlPresets();
const [path, setPath] = useState(currentPath);
const [selected, setSelected] = useState('/');
@@ -47,13 +39,26 @@ export function RedirectClientModal(props: RedirectClientModalProps) {
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 (
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
<ModalOverlay />
<ModalContent maxWidth='max(480px, 35vw)'>
<ModalHeader>Redirect: {name}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Modal
isOpen={isOpen}
onClose={onClose}
showCloseButton
showBackdrop
title={`Redirect: ${name}`}
bodyElements={
<>
<Info>
Remotely redirect the client to a different URL. <br />
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>
<div className={style.textEntry}>
<Select
size='md'
variant='ontime'
isDisabled={enabledPresets.length === 0}
onChange={(event) => setSelected(event.target.value)}
>
<option value='/'>Select view or preset</option>
{navigatorConstants.map((view) => {
return (
<option key={view.url} value={`/${view.url}`}>
{view.label}
</option>
);
})}
{enabledPresets.map((preset) => {
return (
<option key={preset.pathAndParams} value={preset.pathAndParams}>
{`Preset: ${preset.alias}`}
</option>
);
})}
</Select>
<IconButton
variant='ontime-filled'
size='md'
fluid
options={viewOptions}
defaultValue={viewOptions[0].value}
onValueChange={(value) => setSelected(value)}
disabled={enabledPresets.length === 0}
/>
<Button
variant='primary'
aria-label='Redirect to preset'
className={style.redirect}
icon={<IoArrowForward />}
isDisabled={enabledPresets.length === 0 || selected === '/'}
disabled={enabledPresets.length === 0 || selected === '/'}
onClick={() => handleRedirect(selected)}
/>
>
Redirect <IoArrowForward />
</Button>
</div>
</div>
<div className={style.inlineEntry}>
@@ -102,25 +92,24 @@ export function RedirectClientModal(props: RedirectClientModalProps) {
<label className={style.textEntry}>
{origin}
<Input
variant='ontime-filled'
size='md'
placeholder='eg. /minimal?key=0000ffff'
fluid
value={path}
onChange={(event) => setPath(event.target.value)}
/>
</label>
<IconButton
variant='ontime-filled'
size='md'
<Button
variant='primary'
aria-label='Redirect'
isDisabled={path === currentPath || path === ''}
disabled={path === currentPath || path === ''}
className={style.redirect}
icon={<IoArrowForward />}
onClick={() => handleRedirect(path)}
/>
>
Redirect <IoArrowForward />
</Button>
</div>
</ModalBody>
</ModalContent>
</Modal>
</>
}
/>
);
}
@@ -13,7 +13,7 @@
color: $ui-white;
border-radius: 3px;
box-shadow: $box-shadow-l1;
border: 1px solid $gray-1200;
border: 1px solid $gray-1100;
}
.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;
font-size: $text-body-size;
padding: 1rem;
.content {
color: $gray-200;
}
color: $gray-200;
svg {
min-width: 1.5rem;
align-self: start;
font-size: 1.5rem;
}
}
.info {
svg {
color: $info-blue;
}
}
.warning {
svg {
color: $orange-500;
}
}
.error {
svg {
color: $red-500;
}
}
@@ -1,5 +1,5 @@
import { PropsWithChildren } from 'react';
import { IoAlertCircle } from 'react-icons/io5';
import { IoAlertCircle, IoWarning } from 'react-icons/io5';
import { cx } from '../../utils/styleUtils';
@@ -7,14 +7,15 @@ import style from './Info.module.scss';
interface InfoProps {
className?: string;
type?: 'info' | 'warning' | 'error';
}
export default function Info(props: PropsWithChildren<InfoProps>) {
const { className, children } = props;
export default function Info({ className, type = 'info', children }: PropsWithChildren<InfoProps>) {
return (
<div className={cx([style.infoLabel, className])}>
<IoAlertCircle />
<div className={cx([style.infoLabel, style[type], className])}>
{type === 'info' && <IoAlertCircle />}
{type === 'warning' && <IoWarning />}
{type === 'error' && <IoWarning />}
<div>{children}</div>
</div>
);
@@ -9,7 +9,7 @@ $input-font-size: 15px;
.inputField {
font-size: $input-font-size;
letter-spacing: 1px;
letter-spacing: 0.5px;
max-width: 7em;
padding-left: 16px;
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';
interface UseReactiveTextInputReturn {
@@ -21,6 +21,8 @@ export default function useReactiveTextInput(
},
): UseReactiveTextInputReturn {
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(() => {
if (typeof initialText === 'undefined') {
@@ -99,11 +101,25 @@ export default function useReactiveTextInput(
];
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) {
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);
@@ -126,7 +142,11 @@ export default function useReactiveTextInput(
return {
value: text,
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,
};
}
@@ -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 {
width: 100%;
max-width: 7.5em;
letter-spacing: 1px;
letter-spacing: 0.5px;
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;
color: $ui-white;
border-right: 1px solid $gray-1200;
border-right: 1px solid $gray-1100;
&[data-open] {
transform: translateX(0%);
@@ -1,6 +1,5 @@
import { memo } from 'react';
import { useDisclosure } from '@chakra-ui/react';
import { useHotkeys } from '@mantine/hooks';
import { useDisclosure, useHotkeys } from '@mantine/hooks';
import FloatingNavigation from './floating-navigation/FloatingNavigation';
import ViewLockedIcon from './view-locked-icon/ViewLockedIcon';
@@ -14,17 +13,15 @@ interface ViewNavigationMenuProps {
export default memo(ViewNavigationMenu);
function ViewNavigationMenu({ isLockable, supressSettings }: ViewNavigationMenuProps) {
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
const [isMenuOpen, menuHandler] = useDisclosure();
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable });
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
useHotkeys([
[
'Space',
() => {
if (isViewLocked) return;
toggleMenu();
menuHandler.toggle();
},
{ preventDefault: true },
],
@@ -45,10 +42,10 @@ function ViewNavigationMenu({ isLockable, supressSettings }: ViewNavigationMenuP
return (
<>
<FloatingNavigation
toggleMenu={toggleMenu}
toggleMenu={menuHandler.toggle}
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() {
const navigate = useNavigate();
const isSmallDevide = useIsSmallDevice();
const isSmallDevice = useIsSmallDevice();
if (!isSmallDevide) {
if (!isSmallDevice) {
return (
<NavigationMenuItem active={location.pathname === '/editor'} onClick={() => navigate('/editor')}>
<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);
white-space: nowrap;
&:hover:not(:disabled) {
&:hover:not([data-disabled]) {
background-color: $gray-1100;
}
&:active {
&:active:not([data-disabled]) {
background-color: $gray-1000;
}
@@ -29,10 +29,14 @@
background-color: $gray-1000;
}
&:disabled {
&[data-disabled] {
opacity: 0.4;
cursor: not-allowed;
}
&.fluid {
width: 100%;
}
}
.selectIcon {
@@ -2,31 +2,24 @@ import { IoCheckmark } from 'react-icons/io5';
import { LuChevronsUpDown } from 'react-icons/lu';
import { Select as BaseSelect } from '@base-ui-components/react/select';
import { cx } from '../../utils/styleUtils';
import styles from './Select.module.scss';
interface SelectProps<T extends string | null = string> {
defaultValue?: T;
interface SelectProps<T> extends Omit<BaseSelect.Root.Props<T>, 'items'> {
// overload items to not allow undefined values
options: {
value: NonNullable<T>;
value: T;
label: string;
disabled?: boolean; // exposed to allow creating a non-selectable option
}[];
placeholder?: string;
value?: T;
onChange?: (value: NonNullable<T>) => void;
fluid?: boolean;
}
export default function Select<T extends string | null = string>({
defaultValue,
options,
placeholder,
value,
onChange,
}: SelectProps<T>) {
export default function Select<T>({ options, fluid, ...selectRootProps }: SelectProps<T>) {
return (
<BaseSelect.Root defaultValue={defaultValue} onValueChange={onChange} value={value}>
<BaseSelect.Trigger className={styles.select}>
<BaseSelect.Value placeholder={placeholder} />
<BaseSelect.Root items={options} {...selectRootProps}>
<BaseSelect.Trigger className={cx([styles.select, fluid && styles.fluid])}>
<BaseSelect.Value />
<BaseSelect.Icon className={styles.selectIcon}>
<LuChevronsUpDown />
</BaseSelect.Icon>
@@ -35,16 +28,14 @@ export default function Select<T extends string | null = string>({
<BaseSelect.Positioner side='bottom' align='start'>
<BaseSelect.ScrollUpArrow className={styles.scrollArrow} />
<BaseSelect.Popup className={styles.popup}>
{options.map((option) => {
return (
<BaseSelect.Item key={option.value} className={styles.item} value={option.value}>
<BaseSelect.ItemIndicator className={styles.itemIndicator}>
<IoCheckmark className={styles.itemIndicatorIcon} />
</BaseSelect.ItemIndicator>
<BaseSelect.ItemText className={styles.itemLabel}>{option.label}</BaseSelect.ItemText>
</BaseSelect.Item>
);
})}
{options.map(({ label, value }) => (
<BaseSelect.Item key={String(value)} className={styles.item} value={value}>
<BaseSelect.ItemIndicator className={styles.itemIndicator}>
<IoCheckmark className={styles.itemIndicatorIcon} />
</BaseSelect.ItemIndicator>
<BaseSelect.ItemText className={styles.itemLabel}>{label}</BaseSelect.ItemText>
</BaseSelect.Item>
))}
</BaseSelect.Popup>
<BaseSelect.ScrollDownArrow className={styles.scrollArrow} />
</BaseSelect.Positioner>
@@ -38,7 +38,7 @@
background-color: $gray-1250;
color: $ui-white;
border-left: 1px solid $gray-1200;
border-left: 1px solid $gray-1100;
&[data-open] {
transform: translateX(0%);
@@ -1,17 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import { CustomFields } from 'ontime-types';
// CustomFields record type is no longer used here
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
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() {
const { data, status, isFetching, isError, refetch } = useQuery({
// Explicitly type the useQuery hook
const { data, status, isFetching, isError, refetch } = useQuery<CustomFieldWithKey[], Error>({
queryKey: CUSTOM_FIELDS,
queryFn: getCustomFields,
placeholderData: (previousData, _previousQuery) => previousData,
placeholderData: (previousData, _previousQuery) => previousData ?? placeholder,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
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 { 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({
queryKey: URL_PRESETS,
queryFn: getUrlPresets,
@@ -13,6 +17,7 @@ export default function useUrlPresets() {
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
enabled: !skip,
});
return { data: data ?? [], status, isError, refetch };
+69 -59
View File
@@ -74,7 +74,7 @@ export const useEntryActions = () => {
* Calls mutation to add new entry
* @private
*/
const _addEntryMutation = useMutation({
const { mutateAsync: addEntryMutation } = useMutation({
// TODO(v4): optimistic create entry
mutationFn: postAddEntry,
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
@@ -143,13 +143,13 @@ export const useEntryActions = () => {
}
try {
await _addEntryMutation.mutateAsync(newEntry);
await addEntryMutation(newEntry);
} catch (error) {
logAxiosError('Failed adding event', error);
}
},
[
_addEntryMutation,
addEntryMutation,
defaultDangerTime,
defaultDuration,
defaultEndAction,
@@ -165,7 +165,7 @@ export const useEntryActions = () => {
* Calls mutation to clone a selection
* @private
*/
const _cloneMutation = useMutation({
const { mutateAsync: cloneEntryMutation } = useMutation({
mutationFn: postCloneEntry,
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
});
@@ -176,19 +176,19 @@ export const useEntryActions = () => {
const clone = useCallback(
async (entryId: EntryId) => {
try {
await _cloneMutation.mutateAsync(entryId);
await cloneEntryMutation(entryId);
} catch (error) {
logAxiosError('Error cloning entry', error);
}
},
[_cloneMutation],
[cloneEntryMutation],
);
/**
* Calls mutation to update existing entry
* @private
*/
const _updateEntryMutation = useMutation({
const { mutateAsync: updateEntryMutation } = useMutation({
mutationFn: putEditEntry,
// we optimistically update here
onMutate: async (newEvent) => {
@@ -234,12 +234,12 @@ export const useEntryActions = () => {
const updateEntry = useCallback(
async (event: Partial<OntimeEntry>) => {
try {
await _updateEntryMutation.mutateAsync(event);
await updateEntryMutation(event);
} catch (error) {
logAxiosError('Error updating event', error);
}
},
[_updateEntryMutation],
[updateEntryMutation],
);
const updateCustomField = useCallback(
@@ -287,7 +287,7 @@ export const useEntryActions = () => {
}
try {
await _updateEntryMutation.mutateAsync(newEvent);
await updateEntryMutation(newEvent);
} catch (error) {
logAxiosError('Error updating event', error);
}
@@ -339,14 +339,14 @@ export const useEntryActions = () => {
return previousEnd;
}
},
[_updateEntryMutation, queryClient],
[updateEntryMutation, queryClient],
);
/**
* Calls mutation to edit multiple events
* @private
*/
const _batchUpdateEventsMutation = useMutation({
const { mutateAsync: batchUpdateEventsMutation } = useMutation({
mutationFn: putBatchEditEvents,
onMutate: async ({ ids, data }) => {
// cancel ongoing queries
@@ -405,19 +405,19 @@ export const useEntryActions = () => {
const batchUpdateEvents = useCallback(
async (data: Partial<OntimeEvent>, eventIds: string[]) => {
try {
await _batchUpdateEventsMutation.mutateAsync({ ids: eventIds, data });
await batchUpdateEventsMutation({ ids: eventIds, data });
} catch (error) {
logAxiosError('Error updating events', error);
}
},
[_batchUpdateEventsMutation],
[batchUpdateEventsMutation],
);
/**
* Calls mutation to delete an entry
* @private
*/
const _deleteEntryMutation = useMutation({
const { mutateAsync: deleteEntryMutation } = useMutation({
mutationFn: deleteEntries,
// we optimistically update here
onMutate: async (entryIds: EntryId[]) => {
@@ -462,19 +462,19 @@ export const useEntryActions = () => {
const deleteEntry = useCallback(
async (entryIds: EntryId[]) => {
try {
await _deleteEntryMutation.mutateAsync(entryIds);
await deleteEntryMutation(entryIds);
} catch (error) {
logAxiosError('Error deleting event', error);
}
},
[_deleteEntryMutation],
[deleteEntryMutation],
);
/**
* Calls mutation to delete all events
* @private
*/
const _deleteAllEntriesMutation = useMutation({
const { mutateAsync: deleteAllEntriesMutation } = useMutation({
mutationFn: requestDeleteAll,
// we optimistically update here
onMutate: async () => {
@@ -514,17 +514,17 @@ export const useEntryActions = () => {
*/
const deleteAllEntries = useCallback(async () => {
try {
await _deleteAllEntriesMutation.mutateAsync();
await deleteAllEntriesMutation();
} catch (error) {
logAxiosError('Error deleting events', error);
}
}, [_deleteAllEntriesMutation]);
}, [deleteAllEntriesMutation]);
/**
* Calls mutation to apply a delay
* @private
*/
const _applyDelayMutation = useMutation({
const { mutateAsync: applyDelayMutation } = useMutation({
mutationFn: requestApplyDelay,
onSuccess: (response) => {
if (!response.data) return;
@@ -551,19 +551,19 @@ export const useEntryActions = () => {
const applyDelay = useCallback(
async (delayEventId: EntryId) => {
try {
await _applyDelayMutation.mutateAsync(delayEventId);
await applyDelayMutation(delayEventId);
} catch (error) {
logAxiosError('Error applying delay', error);
}
},
[_applyDelayMutation],
[applyDelayMutation],
);
/**
* Calls mutation to dissolve a block
* @private
*/
const _ungroupMutation = useMutation({
const { mutateAsync: ungroupMutation } = useMutation({
mutationFn: requestUngroup,
onSuccess: (response) => {
if (!response.data) return;
@@ -587,19 +587,19 @@ export const useEntryActions = () => {
const ungroup = useCallback(
async (blockId: EntryId) => {
try {
await _ungroupMutation.mutateAsync(blockId);
await ungroupMutation(blockId);
} catch (error) {
logAxiosError('Error dissolving block', error);
}
},
[_ungroupMutation],
[ungroupMutation],
);
/**
* Calls mutation to create a block with a selection
* @private
*/
const _groupEntriesMutation = useMutation({
const { mutateAsync: groupEntriesMutation } = useMutation({
mutationFn: requestGroupEntries,
onSuccess: (response) => {
if (!response.data) return;
@@ -623,19 +623,19 @@ export const useEntryActions = () => {
const groupEntries = useCallback(
async (entryIds: EntryId[]) => {
try {
await _groupEntriesMutation.mutateAsync(entryIds);
await groupEntriesMutation(entryIds);
} catch (error) {
logAxiosError('Error grouping entries', error);
}
},
[_groupEntriesMutation],
[groupEntriesMutation],
);
/**
* Calls mutation to reorder an entry
* @private
*/
const _reorderEntryMutation = useMutation({
const { mutateAsync: reorderEntryMutation } = useMutation({
mutationFn: patchReorderEntry,
// Mutation finished, failed or successful
// 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
*/
@@ -655,43 +689,19 @@ export const useEntryActions = () => {
destinationId,
order,
};
await _reorderEntryMutation.mutateAsync(reorderObject);
await reorderEntryMutation(reorderObject);
} catch (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
* @private
*/
const _swapEvents = useMutation({
const { mutateAsync: swapEventsMutation } = useMutation({
mutationFn: requestEventSwap,
// we optimistically update here
onMutate: async ({ from, to }) => {
@@ -745,12 +755,12 @@ export const useEntryActions = () => {
const swapEvents = useCallback(
async ({ from, to }: SwapEntry) => {
try {
await _swapEvents.mutateAsync({ from, to });
await swapEventsMutation({ from, to });
} catch (error) {
logAxiosError('Error re-ordering event', error);
}
},
[_swapEvents],
[swapEventsMutation],
);
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>(
componentRef: MutableRefObject<ComponentRef>,
@@ -16,6 +18,23 @@ function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends H
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 {
followRef: MutableRefObject<HTMLElement | null>;
scrollRef: MutableRefObject<HTMLElement | null>;
@@ -62,3 +81,32 @@ export default function useFollowComponent(props: UseFollowComponentProps) {
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 { useOs, useViewportSize } from '@mantine/hooks';
export function useIsMobile(): boolean {
export function useIsMobileDevice(): boolean {
const { width } = useViewportSize();
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 style from './FeatureWrapper.module.scss';
import style from './EditorFeatureWrapper.module.scss';
export default function EditorFeatureWrapper({ children }: PropsWithChildren) {
return (
@@ -4,12 +4,12 @@ import { useKeyDown } from '../../common/hooks/useKeyDown';
import AboutPanel from './panel/about-panel/AboutPanel';
import AutomationPanel from './panel/automations-panel/AutomationPanel';
import FeatureSettingsPanel from './panel/feature-settings-panel/FeatureSettingsPanel';
import GeneralPanel from './panel/general-panel/GeneralPanel';
import FeaturePanel from './panel/feature-panel/FeaturePanel';
import ManagePanel from './panel/manage-panel/ManagePanel';
import NetworkLogPanel from './panel/network-panel/NetworkLogPanel';
import ProjectPanel from './panel/project-panel/ProjectPanel';
import SettingsPanel from './panel/settings-panel/SettingsPanel';
import ShutdownPanel from './panel/shutdown-panel/ShutdownPanel';
import SourcesPanel from './panel/sources-panel/SourcesPanel';
import PanelContent from './panel-content/PanelContent';
import PanelList from './panel-list/PanelList';
import useAppSettingsNavigation from './useAppSettingsNavigation';
@@ -25,11 +25,11 @@ export default function AppSettings() {
<ErrorBoundary>
<PanelList selectedPanel={panel} location={location} />
<PanelContent onClose={close}>
{panel === 'settings' && <SettingsPanel location={location} />}
{panel === 'project' && <ProjectPanel location={location} setLocation={setLocation} />}
{panel === 'general' && <GeneralPanel location={location} />}
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
{panel === 'sources' && <SourcesPanel />}
{panel === 'manage' && <ManagePanel location={location} />}
{panel === 'automation' && <AutomationPanel location={location} />}
{panel === 'sharing' && <FeaturePanel location={location} />}
{panel === 'network' && <NetworkLogPanel location={location} />}
{panel === 'about' && <AboutPanel />}
{panel === 'shutdown' && <ShutdownPanel />}
@@ -1,6 +1,7 @@
import { PropsWithChildren } from 'react';
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';
@@ -8,14 +9,12 @@ interface PanelContentProps {
onClose: () => void;
}
export default function PanelContent(props: PropsWithChildren<PanelContentProps>) {
const { onClose, children } = props;
export default function PanelContent({ onClose, children }: PropsWithChildren<PanelContentProps>) {
return (
<div className={style.contentWrapper}>
<div className={style.corner}>
<Button onClick={onClose} aria-label='close' rightIcon={<IoClose />} variant='ontime-subtle'>
Close settings
<Button size='large' onClick={onClose}>
Close settings <IoClose />
</Button>
</div>
<div className={style.content}>{children}</div>
@@ -68,12 +68,12 @@ function PanelListItem(props: PanelListItemProps) {
>
{panel.label}
</li>
{panel.secondary?.map((secondary) => {
{panel.secondary?.map((secondary, index) => {
const id = secondary.id.split('__')[1];
const secondaryClasses = cx([style.secondary, isSelected && location === id ? style.active : null]);
return (
<li
key={secondary.id}
key={secondary.id + index}
onClick={() => setLocation(secondary.id as SettingsOptionId)}
onKeyDown={(event) => {
isKeyEnter(event) && setLocation(secondary.id as SettingsOptionId);
@@ -188,6 +188,7 @@ $inner-padding: 1rem;
button {
margin-top: 1rem;
margin-inline: auto;
}
}
@@ -1,7 +1,7 @@
import { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
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 style from './PanelUtils.module.scss';
@@ -68,14 +68,8 @@ export function TableEmpty({ label, handleClick }: { label?: string; handleClick
<td colSpan={99}>
<div>{label ?? 'No data yet'}</div>
{handleClick && (
<Button
onClick={handleClick}
isDisabled={!handleClick}
variant='ontime-filled'
rightIcon={<IoAdd />}
size='sm'
>
New
<Button onClick={handleClick} disabled={!handleClick} variant='primary'>
New <IoAdd />
</Button>
)}
</td>
@@ -1,7 +1,7 @@
import { useEffect, useMemo } from 'react';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
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 {
Automation,
AutomationDTO,
@@ -15,7 +15,10 @@ import {
import { addAutomation, editAutomation, testOutput } from '../../../../common/api/automation';
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 Input from '../../../../common/components/input/input/Input';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
@@ -198,10 +201,8 @@ export default function AutomationForm(props: AutomationFormProps) {
Title
<Input
{...register('title', { required: { value: true, message: 'Required field' } })}
variant='ontime-filled'
size='sm'
fluid
placeholder='Load preset'
autoComplete='off'
/>
</label>
<Panel.Error>{errors.title?.message}</Panel.Error>
@@ -272,43 +273,22 @@ export default function AutomationForm(props: AutomationFormProps) {
</label>
<label>
Value to match
<Input
{...register(`filters.${index}.value`)}
variant='ontime-filled'
size='sm'
placeholder='<empty / no value>'
autoComplete='off'
/>
<Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' />
</label>
<div>
<span>&nbsp;</span>
<div>
<IconButton
aria-label='Delete'
icon={<IoTrash />}
variant='ontime-ghosted'
size='sm'
color='#FA5656' // $red-500
onClick={() => removeFilter(index)}
isDisabled={false}
isLoading={false}
/>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeFilter(index)}>
<IoTrash />
</IconButton>
</div>
</div>
</div>
);
})}
<div>
<Button
variant='ontime-subtle'
size='sm'
type='submit'
rightIcon={<IoAdd />}
onClick={handleAddNewFilter}
isDisabled={false}
isLoading={false}
>
Add filter
<Button type='submit' onClick={handleAddNewFilter}>
Add filter <IoAdd />
</Button>
</div>
</div>
@@ -342,10 +322,8 @@ export default function AutomationForm(props: AutomationFormProps) {
{...register(`outputs.${index}.targetIP`, {
required: { value: true, message: 'Required field' },
})}
variant='ontime-filled'
size='sm'
fluid
placeholder='127.0.0.1'
autoComplete='off'
/>
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error>
</label>
@@ -358,51 +336,32 @@ export default function AutomationForm(props: AutomationFormProps) {
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
})}
variant='ontime-filled'
size='sm'
fluid
type='number'
maxLength={5}
placeholder='8000'
autoComplete='off'
/>
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
</label>
<label>
Address
<Input
{...register(`outputs.${index}.address`)}
variant='ontime-filled'
size='sm'
placeholder='/cue/start'
autoComplete='off'
/>
<Input {...register(`outputs.${index}.address`)} fluid placeholder='/cue/start' />
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
</label>
<label>
Arguments
<TemplateInput
{...register(`outputs.${index}.args`)}
value={output.args}
variant='ontime-filled'
size='sm'
placeholder='1'
/>
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} placeholder='1' />
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label>
<div>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button size='sm' variant='ontime-ghosted-white' onClick={() => handleTestOSCOutput(index)}>
<Button variant='ghosted-white' onClick={() => handleTestOSCOutput(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
icon={<IoTrash />}
variant='ontime-ghosted'
size='sm'
onClick={() => removeOutput(index)}
color='#FA5656' // $red-500
/>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</div>
</div>
@@ -429,27 +388,20 @@ export default function AutomationForm(props: AutomationFormProps) {
message: 'HTTP messages should target http:// or https://',
},
})}
variant='ontime-filled'
size='sm'
fluid
placeholder='http://127.0.0.1/start/1'
autoComplete='off'
/>
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
</label>
<div>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button size='sm' variant='ontime-ghosted-white' onClick={() => handleTestHTTPOutput(index)}>
<Button variant='ghosted-white' onClick={() => handleTestHTTPOutput(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
icon={<IoTrash />}
variant='ontime-ghosted'
size='sm'
onClick={() => removeOutput(index)}
color='#FA5656' // $red-500
/>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</div>
</div>
@@ -479,17 +431,12 @@ export default function AutomationForm(props: AutomationFormProps) {
>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button size='sm' variant='ontime-ghosted-white' onClick={() => handleTestOntimeAction(index)}>
<Button variant='ghosted-white' onClick={() => handleTestOntimeAction(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
icon={<IoTrash />}
variant='ontime-ghosted'
size='sm'
onClick={() => removeOutput(index)}
color='#FA5656' // $red-500
/>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</OntimeActionForm>
</div>
@@ -500,24 +447,22 @@ export default function AutomationForm(props: AutomationFormProps) {
return null;
})}
<Panel.InlineElements relation='inner'>
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleAddNewOSCOutput}>
OSC
<Button onClick={handleAddNewOSCOutput}>
OSC <IoAdd />
</Button>
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleAddNewHTTPOutput}>
HTTP
<Button onClick={handleAddNewHTTPOutput}>
HTTP <IoAdd />
</Button>
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleAddnewOntimeAction}>
Ontime action
<Button onClick={handleAddnewOntimeAction}>
Ontime action <IoAdd />
</Button>
</Panel.InlineElements>
</div>
<Panel.InlineElements align='end'>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button variant='ontime-subtle' size='sm' onClick={onClose}>
Cancel
</Button>
<Button variant='ontime-filled' size='sm' type='submit' isDisabled={!canSubmit} isLoading={isSubmitting}>
<Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
@@ -1,9 +1,11 @@
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 { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOnlyNumbers } from '../../../../common/utils/regex';
@@ -57,16 +59,15 @@ export default function AutomationSettingsForm(props: AutomationSettingsProps) {
<Panel.SubHeader>
Automation settings
<Panel.InlineElements>
<Button variant='ontime-ghosted' size='sm' onClick={onReset} isDisabled={!canSubmit}>
<Button variant='ghosted' onClick={onReset} disabled={!canSubmit}>
Revert to saved
</Button>
<Button
variant='ontime-filled'
size='sm'
variant='primary'
type='submit'
form='automation-settings-form'
isDisabled={!canSubmit}
isLoading={isSubmitting}
disabled={!canSubmit}
loading={isSubmitting}
>
Save
</Button>
@@ -139,13 +140,10 @@ export default function AutomationSettingsForm(props: AutomationSettingsProps) {
<Input
id='oscPortIn'
placeholder='8888'
width='5rem'
maxLength={5}
size='sm'
textAlign='right'
variant='ontime-filled'
style={{ textAlign: 'right', width: '5rem' }}
type='number'
autoComplete='off'
fluid
{...register('oscPortIn', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
@@ -1,10 +1,11 @@
import { Fragment, useState } from 'react';
import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5';
import { Button, IconButton } from '@chakra-ui/react';
import { AutomationDTO, NormalisedAutomation } from 'ontime-types';
import { deleteAutomation } from '../../../../common/api/automation';
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 useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -46,14 +47,11 @@ export default function AutomationsList(props: AutomationsListProps) {
<Panel.SubHeader>
Manage automations
<Button
variant='ontime-subtle'
rightIcon={<IoAdd />}
size='sm'
type='submit'
isDisabled={Boolean(automationFormData)}
disabled={Boolean(automationFormData)}
onClick={() => setAutomationFormData(automationPlaceholder)}
>
New
New <IoAdd />
</Button>
</Panel.SubHeader>
@@ -94,21 +92,19 @@ export default function AutomationsList(props: AutomationsListProps) {
<td>{automations[automationId].outputs.length}</td>
<Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#e2e2e2' // $gray-200
icon={<IoPencil />}
variant='ghosted-white'
aria-label='Edit entry'
onClick={() => setAutomationFormData(automations[automationId])}
/>
>
<IoPencil />
</IconButton>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
variant='ghosted-destructive'
aria-label='Delete entry'
onClick={() => handleDelete(automationId)}
/>
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</tr>
{deleteError && (
@@ -1,8 +1,9 @@
import { PropsWithChildren, useState } from 'react';
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 Input from '../../../../common/components/input/input/Input';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -67,10 +68,8 @@ export default function OntimeActionForm(props: PropsWithChildren<OntimeActionFo
{...register(`outputs.${index}.time`, {
required: { value: true, message: 'Required field' },
})}
variant='ontime-filled'
size='sm'
fluid
placeholder='eg: 10m5s'
autoComplete='off'
/>
<Panel.Error>{rowErrors?.time?.message}</Panel.Error>
</label>
@@ -80,13 +79,7 @@ export default function OntimeActionForm(props: PropsWithChildren<OntimeActionFo
<>
<label>
Text (leave empty for no change)
<Input
{...register(`outputs.${index}.text`)}
variant='ontime-filled'
size='sm'
placeholder='eg: Timer is finished'
autoComplete='off'
/>
<Input {...register(`outputs.${index}.text`)} fluid placeholder='eg: Timer is finished' />
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
</label>
<label>
@@ -1,10 +1,12 @@
import { useEffect } from 'react';
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 { addTrigger, editTrigger } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import { preventEscape } from '../../../../common/utils/keyEvent';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -87,9 +89,7 @@ export default function TriggerForm(props: TriggerFormProps) {
Title
<Input
{...register('title', { required: { value: true, message: 'Required field' } })}
size='sm'
variant='ontime-filled'
autoComplete='off'
fluid
defaultValue={initialTitle}
/>
<Panel.Error>{errors.title?.message}</Panel.Error>
@@ -127,10 +127,10 @@ export default function TriggerForm(props: TriggerFormProps) {
<Panel.Error>{errors.automationId?.message}</Panel.Error>
</label>
<Panel.InlineElements align='end'>
<Button size='sm' variant='ontime-subtle' isDisabled={isSubmitting} onClick={onCancel}>
<Button disabled={isSubmitting} onClick={onCancel}>
Cancel
</Button>
<Button type='submit' size='sm' variant='ontime-filled' isDisabled={!canSubmit} isLoading={isSubmitting}>
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
@@ -1,10 +1,10 @@
import { Fragment, useMemo, useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react';
import { NormalisedAutomation, Trigger } from 'ontime-types';
import { deleteTrigger } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -47,17 +47,8 @@ export default function TriggersList(props: TriggersListProps) {
<Panel.Card>
<Panel.SubHeader>
Manage triggers
<Button
variant='ontime-subtle'
rightIcon={<IoAdd />}
size='sm'
type='submit'
form='trigger-form'
isDisabled={!canAdd}
isLoading={false}
onClick={() => setShowForm(true)}
>
New
<Button type='submit' form='trigger-form' disabled={!canAdd} loading={false} onClick={() => setShowForm(true)}>
New <IoAdd />
</Button>
</Panel.SubHeader>
<Panel.Divider />
@@ -1,8 +1,8 @@
import { useState } from 'react';
import { IoPencil, IoTrash, IoWarningOutline } from 'react-icons/io5';
import { IconButton } from '@chakra-ui/react';
import { NormalisedAutomation, TimerLifeCycle } from 'ontime-types';
import IconButton from '../../../../common/components/buttons/IconButton';
import Tag from '../../../../common/components/tag/Tag';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -62,22 +62,12 @@ export default function TriggersListItem(props: TriggersListItemProps) {
<Tag>{automations?.[automationId]?.title}</Tag>
</td>
<Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#e2e2e2' // $gray-200
icon={<IoPencil />}
aria-label='Edit entry'
onClick={() => setIsEditing(true)}
/>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={handleDelete}
/>
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={() => setIsEditing(true)}>
<IoPencil />
</IconButton>
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={handleDelete}>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</tr>
);
@@ -1,7 +1,7 @@
import { forwardRef, useMemo, useState } from 'react';
import { type InputProps, Input } from '@chakra-ui/react';
import { mergeRefs, useClickOutside } from '@mantine/hooks';
import Input, { type InputProps } from '../../../../../common/components/input/input/Input';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { makeAutoCompleteList, matchRemaining, selectFromLastTemplate } from './templateInput.utils';
@@ -53,7 +53,7 @@ const TemplateInput = forwardRef(function TemplateInput(props: TemplateInputProp
return (
<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 && (
<ul className={style.suggestions}>
{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;
user-select: text;
}
.copiableLink {
user-select: text;
color: $ui-white;
}
@@ -1,13 +1,13 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import QRCode from 'react-qr-code';
import { Button, Select, Switch } from '@chakra-ui/react';
import { generateUrl } from '../../../../common/api/session';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import useInfo from '../../../../common/hooks-query/useInfo';
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
import Select from '../../../../common/components/select/Select';
import Switch from '../../../../common/components/switch/Switch';
import copyToClipboard from '../../../../common/utils/copyToClipboard';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { linkToOtherHost } from '../../../../common/utils/linkUtils';
@@ -16,6 +16,12 @@ import * as Panel from '../../panel-utils/PanelUtils';
import style from './GenerateLinkForm.module.scss';
interface GenerateLinkFormProps {
hostOptions: { value: string; label: string }[];
pathOptions: { value: string; label: string }[];
isLockedToView?: boolean;
}
interface GenerateLinkFormOptions {
baseUrl: string;
path: string;
@@ -25,22 +31,21 @@ interface GenerateLinkFormOptions {
type GenerateLinkState = 'pending' | 'loading' | 'success' | 'error';
export default function GenerateLinkForm() {
const { data: infoData } = useInfo();
const { data: urlPresetData } = useUrlPresets();
export default function GenerateLinkForm({ hostOptions, pathOptions, isLockedToView }: GenerateLinkFormProps) {
const [formState, setFormState] = useState<GenerateLinkState>('pending');
const [url, setUrl] = useState(serverURL);
const {
handleSubmit,
register,
setError,
watch,
setValue,
formState: { errors },
} = useForm<GenerateLinkFormOptions>({
mode: 'onChange',
defaultValues: {
baseUrl: currentHostName,
path: '',
path: isLockedToView ? pathOptions[0].value : 'timer',
lock: false,
authenticate: false,
},
@@ -70,75 +75,64 @@ export default function GenerateLinkForm() {
return (
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} onKeyDown={(event) => preventEscape(event)}>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Info>
<Panel.Paragraph>
You can generate a link to share with your team or to use in automation (such as companion).
</Panel.Paragraph>
</Info>
{!isLockedToView ? (
<Info>
<Panel.Paragraph>
You can generate a link to share with your team or to use in automation (such as companion).
</Panel.Paragraph>
</Info>
) : (
<Info>
<Panel.Paragraph>You can generate a link to share with your team</Panel.Paragraph>
</Info>
)}
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Host IP'
description={`Which IP address will be used${isOntimeCloud ? ' (not applicable in Ontime Cloud)' : ''}`}
/>
<Select variant='ontime' isDisabled={isOntimeCloud} size='sm' {...register('baseUrl')}>
{infoData.networkInterfaces.map((nif) => {
return (
<option key={nif.name} value={nif.address}>
{`${nif.name} - ${nif.address}`}
</option>
);
})}
</Select>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='URL Preset'
description='Which preset will the link point to (will default to /timer if none is given)'
<Select
disabled={isOntimeCloud}
options={hostOptions}
value={watch('baseUrl')}
onValueChange={(value) => setValue('baseUrl', value)}
/>
<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>
{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.Field
title='Lock navigation'
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.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.ListGroup>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='Generate link' description='Fill form and generate link and QR code' />
<Button
variant='ontime-filled'
size='sm'
isLoading={formState === 'loading'}
type='submit'
style={{ alignSelf: 'end' }}
>
<Button variant='primary' loading={formState === 'loading'} type='submit' style={{ alignSelf: 'end' }}>
{formState === 'success' ? 'Link copied to clipboard!' : 'Update share link'}
</Button>
<div className={style.column}>
<QRCode size={172} value={url} className={style.qrCode} />
<div>{url}</div>
<div className={style.copiableLink}>{url}</div>
</div>
</Panel.ListItem>
</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 { IoTrashBin } from 'react-icons/io5';
import { Button } from '@chakra-ui/react';
import { deleteAllReport } from '../../../../common/api/report';
import { createBlob, downloadBlob } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import useReport from '../../../../common/hooks-query/useReport';
import useRundown from '../../../../common/hooks-query/useRundown';
import { cx } from '../../../../common/utils/styleUtils';
@@ -41,23 +41,12 @@ export default function ReportSettings() {
<Panel.Title>
Manage report
<Panel.InlineElements>
<Button
variant='ontime-subtle'
leftIcon={<IoTrashBin />}
size='sm'
onClick={() => downloadCSV(combinedReport)}
isDisabled={combinedReport.length === 0}
>
<Button onClick={() => downloadCSV(combinedReport)} disabled={combinedReport.length === 0}>
<IoTrashBin />
Export CSV
</Button>
<Button
variant='ontime-subtle'
leftIcon={<IoTrashBin />}
size='sm'
color='#FA5656'
onClick={clearReport}
isDisabled={combinedReport.length === 0}
>
<Button variant='subtle-destructive' onClick={clearReport} disabled={combinedReport.length === 0}>
<IoTrashBin />
Clear All
</Button>
</Panel.InlineElements>
@@ -1,13 +1,16 @@
import { useEffect } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
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 { postUrlPresets } from '../../../../common/api/urlPresets';
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 Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
import { preventEscape } from '../../../../common/utils/keyEvent';
@@ -15,7 +18,7 @@ import { handleLinks } from '../../../../common/utils/linkUtils';
import { validateUrlPresetPath } from '../../../../common/utils/urlPresets';
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/';
@@ -98,10 +101,10 @@ export default function UrlPresetsForm() {
<Panel.SubHeader>
URL presets
<Panel.InlineElements>
<Button variant='ontime-ghosted' size='sm' onClick={onReset} isDisabled={!canSubmit}>
<Button variant='ghosted' onClick={onReset} disabled={!canSubmit}>
Revert to saved
</Button>
<Button variant='ontime-filled' size='sm' type='submit' isDisabled={!canSubmit} isLoading={isSubmitting}>
<Button variant='primary' type='submit' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
@@ -134,8 +137,8 @@ export default function UrlPresetsForm() {
<Panel.Loader isLoading={isLoading} />
<Panel.Title>
Manage presets
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={addNew}>
New
<Button onClick={addNew}>
New <IoAdd />
</Button>
</Panel.Title>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
@@ -173,11 +176,9 @@ export default function UrlPresetsForm() {
{...register(`data.${index}.alias`, {
required: { value: true, message: 'Required field' },
})}
size='sm'
variant='ontime-filled'
fluid
placeholder='URL Preset'
data-testid={`field__alias_${index}`}
autoComplete='off'
/>
<Panel.Error>{maybeAliasError}</Panel.Error>
</td>
@@ -186,11 +187,9 @@ export default function UrlPresetsForm() {
{...register(`data.${index}.pathAndParams`, {
required: { value: true, message: 'Required field' },
})}
size='sm'
variant='ontime-filled'
fluid
placeholder='URL (portion after ontime Port)'
data-testid={`field__url_${index}`}
autoComplete='off'
/>
<Panel.Error>{maybeUrlError}</Panel.Error>
</td>
@@ -207,14 +206,13 @@ export default function UrlPresetsForm() {
data-testid={`field__test_${index}`}
/>
<IconButton
size='sm'
onClick={() => remove(index)}
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
variant='ghosted-destructive'
aria-label='Delete entry'
data-testid={`field__delete_${index}`}
/>
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</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;
}
.fit {
width: fit-content;
}
.aliasConstrain {
min-width: 12em;
}
.twoCols {
display: grid;
grid-template-columns: 1fr 1fr;
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 * as Panel from '../../panel-utils/PanelUtils';
export default function EditorSettingsForm() {
export default function RundownDefaultSettings() {
const {
defaultDuration,
linkPrevious,
@@ -31,10 +31,10 @@ export default function EditorSettingsForm() {
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Editor settings</Panel.SubHeader>
<Panel.SubHeader>Rundown defaults</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
<Panel.Title>Rundown defaults for new events</Panel.Title>
<Panel.Title>Default settings for new events</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
@@ -126,44 +126,6 @@ export default function EditorSettingsForm() {
</Panel.ListItem>
</Panel.ListGroup>
</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.Section>
);
@@ -1,8 +1,8 @@
import { useState } from 'react';
import { IoPencil, IoTrash } from 'react-icons/io5';
import { IconButton } from '@chakra-ui/react';
import { IoArrowDown, IoArrowUp, IoPencil, IoTrash } from 'react-icons/io5';
import { CustomField, CustomFieldKey } from 'ontime-types';
import IconButton from '../../../../../common/components/buttons/IconButton';
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
import Tag from '../../../../../common/components/tag/Tag';
@@ -10,22 +10,26 @@ import * as Panel from '../../../panel-utils/PanelUtils';
import CustomFieldForm from './CustomFieldForm';
import style from '../FeatureSettings.module.scss';
import style from '../ManagePanel.module.scss';
interface CustomFieldEntryProps {
colour: string;
label: string;
fieldKey: string;
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>;
isFirst: boolean;
isLast: boolean;
onMove: (direction: 'up' | 'down') => void; // Changed from Promise<void> to void
}
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 handleEdit = async (patch: CustomField) => {
const handleEdit = async (patch: CustomField) => { // This patch comes from CustomFieldForm, so it's a full CustomField
await onEdit(fieldKey, patch);
setIsEditing(false);
};
@@ -61,22 +65,18 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
</CopyTag>
</td>
<Panel.InlineElements relation='inner' as='td'>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#e2e2e2' // $gray-200
icon={<IoPencil />}
aria-label='Edit entry'
onClick={() => setIsEditing(true)}
/>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={() => onDelete(fieldKey)}
/>
<IconButton variant='ghosted-white' aria-label='Move field up' onClick={() => onMove('up')} disabled={isFirst}>
<IoArrowUp />
</IconButton>
<IconButton variant='ghosted-white' aria-label='Move field down' onClick={() => onMove('down')} disabled={isLast}>
<IoArrowDown />
</IconButton>
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={() => setIsEditing(true)}>
<IoPencil />
</IconButton>
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={() => onDelete(fieldKey)}>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</tr>
);
@@ -1,17 +1,19 @@
import { useEffect, useState } from 'react';
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 { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
import { maybeAxiosError } from '../../../../../common/api/utils';
import Button from '../../../../../common/components/buttons/Button';
import Info from '../../../../../common/components/info/Info';
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
import Input from '../../../../../common/components/input/input/Input';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { preventEscape } from '../../../../../common/utils/keyEvent';
import * as Panel from '../../../panel-utils/PanelUtils';
import style from '../FeatureSettings.module.scss';
import style from '../ManagePanel.module.scss';
interface CustomFieldsFormProps {
onSubmit: (field: CustomField) => Promise<void>;
@@ -118,15 +120,13 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
return true;
},
})}
size='sm'
variant='ontime-filled'
autoComplete='off'
fluid
/>
</div>
<div>
<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>
@@ -135,10 +135,10 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
</div>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.InlineElements relation='inner' align='end'>
<Button size='sm' variant='ontime-ghosted' onClick={onCancel}>
<Button variant='ghosted' onClick={onCancel}>
Cancel
</Button>
<Button size='sm' type='submit' variant='ontime-filled' isDisabled={!canSubmit} isLoading={isSubmitting}>
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
@@ -1,5 +1,5 @@
import Info from '../../../../common/components/info/Info';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import Info from '../../../../../common/components/info/Info';
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadsheet-gsheet/';
@@ -1,12 +1,13 @@
import { ChangeEvent, useEffect, useState } from 'react';
import { IoCheckmark, IoShieldCheckmarkOutline } from 'react-icons/io5';
import { Button, Input, Spinner } from '@chakra-ui/react';
import { getWorksheetNames } from '../../../../common/api/sheets';
import { maybeAxiosError } from '../../../../common/api/utils';
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import { openLink } from '../../../../common/utils/linkUtils';
import * as Panel from '../../panel-utils/PanelUtils';
import { getWorksheetNames } from '../../../../../common/api/sheets';
import { maybeAxiosError } from '../../../../../common/api/utils';
import Button from '../../../../../common/components/buttons/Button';
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
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 { useSheetStore } from './useSheetStore';
@@ -138,50 +139,33 @@ export default function GSheetSetup(props: GSheetSetupProps) {
<Panel.Title>
Sync with Google Sheet (experimental)
{isAuthenticated ? (
<Button variant='ontime-subtle' size='sm' onClick={handleRevoke} isLoading={loading === 'cancel'}>
<Button onClick={handleRevoke} loading={loading === 'cancel'}>
Revoke Authentication
</Button>
) : (
<Button variant='ontime-subtle' size='sm' onClick={handleCancelFlow}>
Go Back
</Button>
<Button onClick={handleCancelFlow}>Go Back</Button>
)}
</Panel.Title>
<Panel.ListGroup>
<Panel.Description>Upload Client Secret provided by Google</Panel.Description>
<Panel.Error>{authenticationError}</Panel.Error>
<Input
type='file'
onChange={handleClientSecret}
accept='.json'
size='sm'
variant='ontime-filled'
isDisabled={isLoading || canAuthenticate}
/>
<Input fluid type='file' onChange={handleClientSecret} accept='.json' disabled={isLoading || canAuthenticate} />
</Panel.ListGroup>
<Panel.ListGroup>
<Panel.Description>Enter ID of sheet to synchronise</Panel.Description>
<Panel.Error>{undefined}</Panel.Error>
<Input
size='sm'
variant='ontime-filled'
autoComplete='off'
fluid
placeholder='Sheet ID'
onChange={(event) => setSheetId(event.target.value)}
isDisabled={isLoading || canAuthenticate}
disabled={isLoading || canAuthenticate}
/>
</Panel.ListGroup>
{!canAuthenticate ? (
<Panel.ListGroup>
<Panel.InlineElements>
<Button
variant='ontime-subtle'
size='sm'
leftIcon={<IoCheckmark />}
onClick={handleConnect}
isDisabled={!canConnect || isLoading}
isLoading={loading === 'connect'}
>
<Button onClick={handleConnect} disabled={!canConnect || isLoading} loading={loading === 'connect'}>
<IoCheckmark />
Connect
</Button>
</Panel.InlineElements>
@@ -189,17 +173,12 @@ export default function GSheetSetup(props: GSheetSetupProps) {
) : (
<Panel.ListGroup>
<Panel.InlineElements>
{isAuthenticating && <Spinner />}
{isAuthenticating && <span>Authenticating...</span>}
<CopyTag copyValue={authKey ?? ''} label='Google Auth Key' disabled={!canAuthenticate} size='sm'>
{authKey ? authKey : 'Upload files to generate Auth Key'}
</CopyTag>
<Button
variant='ontime-filled'
size='sm'
leftIcon={<IoShieldCheckmarkOutline />}
onClick={handleAuthenticate}
isDisabled={!canAuthenticate}
>
<Button onClick={handleAuthenticate} disabled={!canAuthenticate}>
<IoShieldCheckmarkOutline />
Authenticate
</Button>
</Panel.InlineElements>
@@ -1,8 +1,8 @@
import { useState } from 'react';
import { Button } from '@chakra-ui/react';
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 useGoogleSheet from './useGoogleSheet';
@@ -44,10 +44,10 @@ export default function ImportReview(props: ImportReviewProps) {
<Panel.Title>
Review Rundown
<Panel.InlineElements>
<Button onClick={handleCancel} variant='ontime-ghosted' size='sm' isDisabled={loading}>
<Button onClick={handleCancel} variant='ghosted' disabled={loading}>
Cancel
</Button>
<Button onClick={applyImport} variant='ontime-filled' size='sm' isLoading={loading}>
<Button onClick={applyImport} variant='primary' loading={loading}>
Apply
</Button>
</Panel.InlineElements>
@@ -1,17 +1,18 @@
import { ChangeEvent, useRef, useState } from 'react';
import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
import { Button, Input } from '@chakra-ui/react';
import { getErrorMessage, ImportMap } from 'ontime-utils';
import {
getWorksheetNames as getWorksheetNamesExcel,
importRundownPreview as importRundownPreviewExcel,
upload as uploadExcel,
} from '../../../../common/api/excel';
import { getWorksheetNames } from '../../../../common/api/sheets';
import { maybeAxiosError } from '../../../../common/api/utils';
import { validateExcelImport } from '../../../../common/utils/uploadUtils';
import * as Panel from '../../panel-utils/PanelUtils';
} from '../../../../../common/api/excel';
import { getWorksheetNames } from '../../../../../common/api/sheets';
import { maybeAxiosError } from '../../../../../common/api/utils';
import Button from '../../../../../common/components/buttons/Button';
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 GSheetInfo from './GSheetInfo';
@@ -154,87 +155,75 @@ export default function SourcesPanel() {
const showReview = rundown !== null && customFields !== null;
return (
<>
<Panel.Header>Data sources</Panel.Header>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Synchronise your rundown with an external source</Panel.SubHeader>
{error && <Panel.Error>{error}</Panel.Error>}
{showInput && (
<>
<GSheetInfo />
<Input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
onChange={handleFile}
accept='.xlsx'
data-testid='file-input'
/>
<div className={style.uploadSection}>
<div>
<Button
variant='ontime-filled'
size='sm'
leftIcon={<IoDownloadOutline />}
onClick={handleUpload}
isLoading={hasFile === 'loading'}
>
Import from spreadsheet
</Button>
<Panel.Description>Accepts .xlsx files</Panel.Description>
</div>
<div>
<Button
variant='ontime-filled'
size='sm'
leftIcon={<IoCloudOutline />}
onClick={openGSheetFlow}
isDisabled={hasFile !== 'none'}
>
Synchronise with Google
</Button>
<Panel.Description>Start authentication process</Panel.Description>
</div>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Synchronise your rundown with an external source</Panel.SubHeader>
{error && <Panel.Error>{error}</Panel.Error>}
{showInput && (
<>
<GSheetInfo />
<input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
onChange={handleFile}
accept='.xlsx'
data-testid='file-input'
/>
<div className={style.uploadSection}>
<div>
<Button variant='primary' onClick={handleUpload} loading={hasFile === 'loading'}>
<IoDownloadOutline />
Import from spreadsheet
</Button>
<Panel.Description>Accepts .xlsx files</Panel.Description>
</div>
<Editor.Separator orientation='vertical' />
<div>
<Button variant='primary' onClick={openGSheetFlow} disabled={hasFile !== 'none'}>
<IoCloudOutline />
Synchronise with Google
</Button>
<Panel.Description>Start authentication process</Panel.Description>
</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>
)}
{showAuth && <GSheetSetup onCancel={cancelGSheetFlow} />}
{showImportMap && !showReview && (
<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>
</>
</>
)}
{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='primary' onClick={resetFlow}>
Return
</Button>
</div>
)}
{showAuth && <GSheetSetup onCancel={cancelGSheetFlow} />}
{showImportMap && !showReview && (
<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 { useFieldArray, useForm } from 'react-hook-form';
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 * 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 { useSheetStore } from '../useSheetStore';
@@ -95,31 +98,29 @@ export default function ImportMapForm(props: ImportMapFormProps) {
<Panel.InlineElements>
{!isSpreadsheet && (
<Tooltip label='Revoke the google authentication'>
<Button variant='ontime-subtle' size='sm' onClick={handleRevoke} isDisabled={isLoading}>
<Button onClick={handleRevoke} disabled={isLoading}>
Revoke
</Button>
</Tooltip>
)}
<Button variant='ontime-subtle' size='sm' onClick={onCancel} isDisabled={isLoading}>
<Button onClick={onCancel} disabled={isLoading}>
Cancel
</Button>
{!isSpreadsheet && (
<Button
variant='ontime-filled'
size='sm'
variant='primary'
onClick={handleSubmit(handleExport)}
isDisabled={!canSubmitGSheet}
isLoading={loading === 'export'}
disabled={!canSubmitGSheet}
loading={loading === 'export'}
>
Export
</Button>
)}
<Button
variant='ontime-filled'
size='sm'
variant='primary'
onClick={handleSubmit(handleImportPreview)}
isDisabled={!canSubmit}
isLoading={loading === 'import'}
disabled={!canSubmit}
loading={loading === 'import'}
>
Import preview
</Button>
@@ -168,9 +169,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
<td>
<Input
id={importName as string}
size='sm'
variant='ontime-filled'
autoComplete='off'
fluid
maxLength={25}
defaultValue={importName as string}
placeholder='Use default column name'
@@ -190,10 +189,8 @@ export default function ImportMapForm(props: ImportMapFormProps) {
<tr key={key}>
<td>
<Input
size='sm'
variant='ontime-filled'
autoComplete='off'
maxLength={25}
fluid
defaultValue={ontimeName}
placeholder='Name of the field as shown in Ontime'
{...register(`custom.${index}.ontimeName`, {
@@ -208,10 +205,8 @@ export default function ImportMapForm(props: ImportMapFormProps) {
</td>
<td>
<Input
size='sm'
variant='ontime-filled'
autoComplete='off'
maxLength={25}
fluid
defaultValue={importName}
placeholder='Name of the column in the spreadsheet'
{...register(`custom.${index}.importName`)}
@@ -219,13 +214,12 @@ export default function ImportMapForm(props: ImportMapFormProps) {
</td>
<td className={style.singleActionCell}>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
variant='ghosted-destructive'
aria-label='Delete entry'
onClick={() => deleteCustomImport(index)}
/>
>
<IoTrash />
</IconButton>
</td>
</tr>
);
@@ -233,8 +227,8 @@ export default function ImportMapForm(props: ImportMapFormProps) {
<tr>
<td />
<Panel.InlineElements as='td' align='end'>
<Button size='sm' variant='ontime-subtle' rightIcon={<IoAdd />} onClick={addCustomImport}>
Add custom field
<Button onClick={addCustomImport}>
Add custom field <IoAdd />
</Button>
</Panel.InlineElements>
<td />
@@ -3,9 +3,9 @@ import { IoLink } from 'react-icons/io5';
import { CustomFields, isOntimeBlock, isOntimeEvent, Rundown } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import Tag from '../../../../../common/components/tag/Tag';
import { getAccessibleColour } from '../../../../../common/utils/styleUtils';
import * as Panel from '../../../panel-utils/PanelUtils';
import Tag from '../../../../../../common/components/tag/Tag';
import { getAccessibleColour } from '../../../../../../common/utils/styleUtils';
import * as Panel from '../../../../panel-utils/PanelUtils';
import style from './PreviewRundown.module.scss';
@@ -2,16 +2,16 @@ import { useQueryClient } from '@tanstack/react-query';
import { AuthenticationStatus, CustomFields, ProjectRundowns } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { CUSTOM_FIELDS, RUNDOWN } from '../../../../common/api/constants';
import { patchData } from '../../../../common/api/db';
import { CUSTOM_FIELDS, RUNDOWN } from '../../../../../common/api/constants';
import { patchData } from '../../../../../common/api/db';
import {
previewRundown,
requestConnection,
revokeAuthentication,
uploadRundown,
verifyAuthenticationStatus,
} from '../../../../common/api/sheets';
import { maybeAxiosError } from '../../../../common/api/utils';
} from '../../../../../common/api/sheets';
import { maybeAxiosError } from '../../../../../common/api/utils';
import { useSheetStore } from './useSheetStore';
@@ -1,7 +1,7 @@
import { MouseEvent } from 'react';
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 Log from '../../../log/Log';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -18,13 +18,8 @@ export default function LogExport() {
<Panel.Card>
<Panel.SubHeader>
Event log
<Button
variant='ontime-subtle'
size='sm'
rightIcon={<IoArrowUp className={style.iconRotate} />}
onClick={extract}
>
Extract
<Button onClick={extract}>
Extract <IoArrowUp className={style.iconRotate} />
</Button>
</Panel.SubHeader>
<Panel.Divider />
@@ -4,17 +4,14 @@ import { MessageTag } from 'ontime-types';
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { usePing } from '../../../../common/hooks/useSocket';
import { sendSocket } from '../../../../common/utils/socket';
import { isDockerImage, isOntimeCloud } from '../../../../externals';
import { isDockerImage } from '../../../../externals';
import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import ClientControlPanel from '../client-control-panel/ClientControlPanel';
import GenerateLinkForm from './GenerateLinkForm';
import InfoNif from './NetworkInterfaces';
import ClientControlPanel from './client-control/ClientControlPanel';
import LogExport from './NetworkLogExport';
export default function NetworkLogPanel({ location }: PanelBaseProps) {
const linkRef = useScrollIntoView<HTMLDivElement>('link', location);
const clientsRef = useScrollIntoView<HTMLDivElement>('clients', location);
const logRef = useScrollIntoView<HTMLDivElement>('log', location);
@@ -26,21 +23,6 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
<OntimeCloudStats />
</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}>
<LogExport />
</div>
@@ -1,4 +1,4 @@
import * as Panel from '../../panel-utils/PanelUtils';
import * as Panel from '../../../panel-utils/PanelUtils';
import ClientList from './ClientList';
@@ -1,32 +1,34 @@
import { useState } from 'react';
import { Badge, Button, useDisclosure } from '@chakra-ui/react';
import { useDisclosure } from '@mantine/hooks';
import { Client } from 'ontime-types';
import { RedirectClientModal } from '../../../../common/components/client-modal/RedirectClientModal';
import { RenameClientModal } from '../../../../common/components/client-modal/RenameClientModal';
import { setClientRemote } from '../../../../common/hooks/useSocket';
import { useClientStore } from '../../../../common/stores/clientStore';
import * as Panel from '../../panel-utils/PanelUtils';
import Button from '../../../../../common/components/buttons/Button';
import { RedirectClientModal } from '../../../../../common/components/client-modal/RedirectClientModal';
import { RenameClientModal } from '../../../../../common/components/client-modal/RenameClientModal';
import Tag from '../../../../../common/components/tag/Tag';
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';
export default function ClientList() {
const id = useClientStore((store) => store.id);
const clients = useClientStore((store) => store.clients);
const { isOpen: isOpenRedirect, onOpen: onOpenRedirect, onClose: onCloseRedirect } = useDisclosure();
const { isOpen: isOpenRename, onOpen: onOpenRename, onClose: onCloseRename } = useDisclosure();
const [isOpenRedirect, redirectHandler] = useDisclosure();
const [isOpenRename, renameHandler] = useDisclosure();
const { setIdentify } = setClientRemote;
const [targetId, setTargetId] = useState('');
const openRename = (targetId: string) => {
setTargetId(targetId);
onOpenRename();
renameHandler.open();
};
const openRedirect = (targetId: string) => {
setTargetId(targetId);
onOpenRedirect();
redirectHandler.open();
};
const ontimeClients = Object.entries(clients).filter(([_, { type }]) => type === 'ontime');
@@ -43,11 +45,16 @@ export default function ClientList() {
origin={targetClient.origin}
currentPath={targetClient.path}
isOpen={isOpenRedirect}
onClose={onCloseRedirect}
onClose={redirectHandler.close}
/>
)}
{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.Title>Ontime Clients ({ontimeClients.length})</Panel.Title>
@@ -66,20 +73,16 @@ export default function ClientList() {
return (
<tr key={key}>
<Panel.InlineElements relation='inner' as='td'>
{isCurrent && (
<Badge variant='outline' colorScheme='yellow' size='xs'>
self
</Badge>
)}
{isCurrent && <Tag>SELF</Tag>}
{name}
</Panel.InlineElements>
<td>{path}</td>
<Panel.InlineElements relation='inner'>
<Button
size='xs'
size='small'
className={`${identify ? style.blink : ''}`}
isDisabled={isCurrent}
variant={identify ? 'ontime-filled' : 'ontime-subtle'}
disabled={isCurrent}
variant={identify ? 'primary' : 'subtle'}
data-testid={isCurrent ? '' : 'not-self-identify'}
onClick={() => {
setIdentify({ target: key, identify: !identify });
@@ -88,8 +91,7 @@ export default function ClientList() {
Identify
</Button>
<Button
size='xs'
variant='ontime-subtle'
size='small'
data-testid={isCurrent ? '' : 'not-self-rename'}
onClick={() => openRename(key)}
>
@@ -97,9 +99,8 @@ export default function ClientList() {
</Button>
<Button
size='xs'
variant='ontime-subtle'
isDisabled={isCurrent}
size='small'
disabled={isCurrent}
data-testid={isCurrent ? '' : 'not-self-redirect'}
onClick={() => openRedirect(key)}
>
@@ -1,10 +1,10 @@
import { ChangeEvent, useRef, useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import { useSearchParams } from 'react-router-dom';
import { Button, Input } from '@chakra-ui/react';
import { uploadProjectFile } from '../../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import { validateProjectFile } from '../../../../common/utils/uploadUtils';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -57,7 +57,7 @@ export default function ManageProjects() {
return (
<Panel.Section>
<Input
<input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
@@ -70,22 +70,14 @@ export default function ManageProjects() {
Manage projects
<Panel.InlineElements>
<Button
variant='ontime-subtle'
onClick={handleSelectFile}
size='sm'
isDisabled={Boolean(loading) || isCreatingProject}
isLoading={loading === 'import'}
disabled={Boolean(loading) || isCreatingProject}
loading={loading === 'import'}
>
Import
</Button>
<Button
variant='ontime-subtle'
onClick={handleToggleCreate}
size='sm'
isDisabled={Boolean(loading) || isCreatingProject}
rightIcon={<IoAdd />}
>
New
<Button onClick={handleToggleCreate} disabled={Boolean(loading) || isCreatingProject}>
New <IoAdd />
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
@@ -1,12 +1,14 @@
import { useEffect, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { IoTrash } from 'react-icons/io5';
import { Button, Input, Textarea } from '@chakra-ui/react';
import { IoAdd, IoTrash } from 'react-icons/io5';
import { useQueryClient } from '@tanstack/react-query';
import { PROJECT_LIST } from '../../../../common/api/constants';
import { createProject } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import Textarea from '../../../../common/components/input/textarea/Textarea';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { documentationUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -86,10 +88,10 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
<Panel.Title>
Create new project
<Panel.InlineElements>
<Button onClick={onClose} variant='ontime-ghosted' size='sm' isDisabled={isSubmitting}>
<Button onClick={onClose} variant='ghosted' disabled={isSubmitting}>
Cancel
</Button>
<Button isDisabled={!isValid} type='submit' isLoading={isSubmitting} variant='ontime-filled' size='sm'>
<Button disabled={!isValid} type='submit' loading={isSubmitting} variant='primary'>
Create project
</Button>
</Panel.InlineElements>
@@ -98,53 +100,31 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
<Panel.Section className={style.innerColumn}>
<label>
Project title
<Input
variant='ontime-filled'
size='sm'
maxLength={50}
placeholder='Your project name'
autoComplete='off'
{...register('title')}
/>
<Input fluid maxLength={50} placeholder='Your project name' {...register('title')} />
</label>
<label>
Project description
<Input
variant='ontime-filled'
size='sm'
maxLength={100}
placeholder='Euro Love, Malmö 2024'
autoComplete='off'
{...register('description')}
/>
<Input fluid maxLength={100} placeholder='Euro Love, Malmö 2024' {...register('description')} />
</label>
<label>
Backstage info
<Textarea
variant='ontime-filled'
size='sm'
fluid
maxLength={150}
placeholder='Wi-Fi password: 1234'
autoComplete='off'
resize='none'
resize='vertical'
{...register('backstageInfo')}
/>
</label>
<label>
Backstage QR code Url
<Input
variant='ontime-filled'
size='sm'
placeholder={documentationUrl}
autoComplete='off'
{...register('backstageUrl')}
/>
<Input fluid placeholder={documentationUrl} {...register('backstageUrl')} />
</label>
<Panel.Section>
<Panel.ListItem>
<Panel.Field title='Custom data' description='Add custom data for your project' />
<Button variant='ontime-subtle' onClick={handleAddCustom}>
+
<Button onClick={handleAddCustom}>
Add <IoAdd />
</Button>
</Panel.ListItem>
{fields.map((field, idx) => (
@@ -152,25 +132,13 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
<Panel.Paragraph>{idx + 1}.</Panel.Paragraph>
<label>
Title
<Input
variant='ontime-filled'
size='sm'
placeholder={field.title}
autoComplete='off'
{...register(`custom.${idx}.title` as const)}
/>
<Input placeholder={field.title} {...register(`custom.${idx}.title` as const)} />
</label>
<label>
Value
<Input
variant='ontime-filled'
size='sm'
placeholder={field.value}
autoComplete='off'
{...register(`custom.${idx}.value` as const)}
/>
<Input placeholder={field.value} autoComplete='off' {...register(`custom.${idx}.value` as const)} />
</label>
<Button variant='ontime-ghosted' onClick={() => remove(idx)}>
<Button variant='ghosted' onClick={() => remove(idx)}>
<IoTrash />
</Button>
</div>
@@ -1,7 +1,8 @@
import { useEffect } from 'react';
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 * as Panel from '../../panel-utils/PanelUtils';
@@ -45,21 +46,16 @@ export default function ProjectForm({ action, filename, onSubmit, onCancel }: Pr
<Input
className={style.formInput}
id='filename'
size='sm'
type='text'
variant='ontime-filled'
placeholder='Enter new name'
autoComplete='off'
{...register('filename', { required: true })}
/>
<Panel.InlineElements relation='inner'>
<Button onClick={onCancel} size='sm' variant='ontime-ghosted' disabled={isSubmitting}>
<Button onClick={onCancel} variant='ghosted' disabled={isSubmitting}>
Cancel
</Button>
<Button
size='sm'
variant='ontime-filled'
isDisabled={!isDirty || !isValid || isSubmitting}
variant='primary'
disabled={!isDirty || !isValid || isSubmitting}
type='submit'
className={style.saveButton}
>
@@ -1,5 +1,6 @@
import { useState } from 'react';
import Info from '../../../../common/components/info/Info';
import { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -8,7 +9,7 @@ import ProjectListItem, { EditMode } from './ProjectListItem';
import style from './ProjectPanel.module.scss';
export default function ProjectList() {
const { data, refetch } = useOrderedProjectList();
const { data, refetch, status } = useOrderedProjectList();
const [editingMode, setEditingMode] = useState<EditMode | null>(null);
const [editingFilename, setEditingFilename] = useState<string | null>(null);
@@ -27,30 +28,47 @@ export default function ProjectList() {
await refetch();
};
if (status === 'pending') {
return (
<div className={style.empty}>
<Panel.Loader isLoading />
</div>
);
}
const numProjects = data.reorderedProjectFiles.length;
return (
<Panel.Table>
<thead>
<tr>
<th className={style.containCell}>File Name</th>
<th>Last Used</th>
<th />
</tr>
</thead>
<tbody>
{data.reorderedProjectFiles.map((project) => (
<ProjectListItem
key={project.filename}
filename={project.filename}
updatedAt={project.updatedAt}
onToggleEditMode={handleToggleEditMode}
onSubmit={handleClear}
onRefetch={handleRefetch}
editingFilename={editingFilename}
editingMode={editingMode}
current={project.filename === data.lastLoadedProject}
/>
))}
</tbody>
</Panel.Table>
<>
{numProjects > 20 && (
<Info className={style.warningInfo} type='warning'>
You have {numProjects} projects. Consider deleting unused projects to improve performance.
</Info>
)}
<Panel.Table>
<thead>
<tr>
<th className={style.containCell}>File Name</th>
<th>Last Used</th>
<th />
</tr>
</thead>
<tbody>
{data.reorderedProjectFiles.map((project) => (
<ProjectListItem
key={project.filename}
filename={project.filename}
updatedAt={project.updatedAt}
onToggleEditMode={handleToggleEditMode}
onSubmit={handleClear}
onRefetch={handleRefetch}
editingFilename={editingFilename}
editingMode={editingMode}
current={project.filename === data.lastLoadedProject}
/>
))}
</tbody>
</Panel.Table>
</>
);
}
@@ -1,11 +1,12 @@
import { useState } from 'react';
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 { PROJECT_DATA } from '../../../../common/api/constants';
import { getDb, patchData } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -76,16 +77,10 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
<Panel.Title>
Merge {`"${fileName}"`}
<Panel.InlineElements>
<Button onClick={onClose} variant='ontime-ghosted' size='sm' isDisabled={isSubmitting}>
<Button onClick={onClose} variant='ghosted' disabled={isSubmitting}>
Cancel
</Button>
<Button
isDisabled={!isValid || !isDirty}
type='submit'
isLoading={isSubmitting}
variant='ontime-filled'
size='sm'
>
<Button type='submit' disabled={!isValid || !isDirty} loading={isSubmitting} variant='primary'>
Merge
</Button>
</Panel.InlineElements>
@@ -26,6 +26,10 @@
max-width: 400px;
}
.fullWidth {
width: 100%;
}
.innerColumn {
margin: 0 2rem;
margin-bottom: 2rem;
@@ -41,34 +45,11 @@
}
}
.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;
}
.warningInfo {
margin-bottom: 1rem;
}
.customDataItem {
display: contents;
width: 100%;
.titleRow{
display: flex;
gap: 1rem;
align-items: end;
label {
flex: 1;
}
}
}
.empty {
height: 300px;
position: relative;
}
@@ -5,15 +5,13 @@ import QuickStart from '../../quick-start/QuickStart';
import type { SettingsOptionId } from '../../useAppSettingsMenu';
import ManageProjects from './ManageProjects';
import ProjectData from './ProjectData';
interface ProjectPanelProps extends PanelBaseProps {
setLocation: (location: SettingsOptionId) => void;
}
export default function ProjectPanel({ location, setLocation }: ProjectPanelProps) {
const projectRef = useScrollIntoView<HTMLDivElement>('data', location);
const manageRef = useScrollIntoView<HTMLDivElement>('manage', location);
const manageProjectsRef = useScrollIntoView<HTMLDivElement>('list', location);
const handleQuickClose = () => {
setLocation('project');
@@ -23,10 +21,7 @@ export default function ProjectPanel({ location, setLocation }: ProjectPanelProp
<>
<Panel.Header>Project</Panel.Header>
<QuickStart isOpen={location === 'create'} onClose={handleQuickClose} />
<div ref={projectRef}>
<ProjectData />
</div>
<div ref={manageRef}>
<div ref={manageProjectsRef}>
<ManageProjects />
</div>
</>
@@ -1,19 +1,21 @@
import { useEffect } from 'react';
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 { postSettings } from '../../../../common/api/settings';
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 { preventEscape } from '../../../../common/utils/keyEvent';
import { isOnlyNumbers } from '../../../../common/utils/regex';
import { isOntimeCloud } from '../../../../externals';
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 {
handleSubmit,
@@ -69,17 +71,10 @@ export default function GeneralPanelForm() {
<Panel.SubHeader>
General settings
<Panel.InlineElements>
<Button isDisabled={!isDirty || isSubmitting} variant='ontime-ghosted' size='sm' onClick={onReset}>
<Button disabled={!isDirty || isSubmitting} variant='ghosted' onClick={onReset}>
Revert to saved
</Button>
<Button
type='submit'
form='app-settings'
isLoading={isSubmitting}
isDisabled={disableSubmit}
variant='ontime-filled'
size='sm'
>
<Button type='submit' form='app-settings' loading={isSubmitting} disabled={disableSubmit} variant='primary'>
Save
</Button>
</Panel.InlineElements>
@@ -101,12 +96,10 @@ export default function GeneralPanelForm() {
/>
<Input
id='serverPort'
size='sm'
type='number'
variant='ontime-filled'
maxLength={5}
width='75px'
isDisabled={isOntimeCloud}
style={{ width: '75px' }}
disabled={isOntimeCloud}
{...register('serverPort', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
@@ -1,19 +1,21 @@
import { ChangeEvent, useEffect, useRef } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoDownloadOutline, IoTrash } from 'react-icons/io5';
import { Button, Input, Textarea } from '@chakra-ui/react';
import { type ProjectData } from 'ontime-types';
import { projectLogoPath } from '../../../../common/api/constants';
import { postProjectData, uploadProjectLogo } from '../../../../common/api/project';
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 { preventEscape } from '../../../../common/utils/keyEvent';
import { validateLogo } from '../../../../common/utils/uploadUtils';
import { documentationUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './ProjectPanel.module.scss';
import style from './SettingsPanel.module.scss';
export default function ProjectData() {
const { data, status, refetch } = useProjectData();
@@ -112,16 +114,10 @@ export default function ProjectData() {
<Panel.SubHeader>
Project data
<Panel.InlineElements>
<Button variant='ontime-ghosted' size='sm' onClick={onReset} isDisabled={isSubmitting || !isDirty}>
<Button onClick={onReset} disabled={isSubmitting || !isDirty}>
Revert to saved
</Button>
<Button
variant='ontime-filled'
size='sm'
type='submit'
isDisabled={!isDirty || !isValid}
isLoading={isSubmitting}
>
<Button variant='primary' type='submit' disabled={!isDirty || !isValid} loading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
@@ -132,20 +128,16 @@ export default function ProjectData() {
<label>
Project title
<Input
variant='ontime-filled'
size='sm'
fluid
maxLength={50}
placeholder='Project title is shown in production views'
autoComplete='off'
{...register('title')}
/>
</label>
<Panel.Section style={{ marginTop: 0 }}>
<label>
Project logo
<Input
variant='ontime-filled'
size='sm'
<input
type='file'
style={{ display: 'none' }}
accept='image/*'
@@ -161,25 +153,17 @@ export default function ProjectData() {
<>
<img src={`${projectLogoPath}/${watch('projectLogo')}`} />
<Button
size='sm'
variant='ontime-filled'
isDisabled={isSubmitting || !watch('projectLogo')}
leftIcon={<IoTrash />}
variant='subtle-destructive'
disabled={isSubmitting || !watch('projectLogo')}
onClick={handleDeleteLogo}
type='button'
>
<IoTrash />
Delete
</Button>
</>
) : (
<Button
variant='ontime-filled'
size='sm'
isDisabled={isSubmitting}
leftIcon={<IoDownloadOutline />}
onClick={handleClickUpload}
type='button'
>
<Button disabled={isSubmitting} onClick={handleClickUpload} type='button'>
<IoDownloadOutline />
Upload logo
</Button>
)}
@@ -187,44 +171,30 @@ export default function ProjectData() {
</Panel.Card>
</label>
</Panel.Section>
<label>
Project description
<Input
variant='ontime-filled'
size='sm'
maxLength={100}
placeholder='Euro Love, Malmö 2024'
autoComplete='off'
{...register('description')}
/>
<Input fluid maxLength={100} placeholder='Euro Love, Malmö 2024' {...register('description')} />
</label>
<label>
Backstage info
<Textarea
variant='ontime-filled'
size='sm'
fluid
maxLength={150}
placeholder='Wi-Fi password: 1234'
autoComplete='off'
resize='none'
resize='vertical'
{...register('backstageInfo')}
/>
</label>
<label>
Backstage QR code URL
<Input
variant='ontime-filled'
size='sm'
placeholder={documentationUrl}
autoComplete='off'
{...register('backstageUrl')}
/>
<Input fluid placeholder={documentationUrl} {...register('backstageUrl')} />
</label>
<Panel.Section style={{ marginTop: 0 }}>
<Panel.ListItem>
<Panel.Field title='Custom data' description='' />
<Button leftIcon={<IoAdd />} size='sm' variant='ontime-subtle' onClick={handleAddCustom}>
Add
<Button onClick={handleAddCustom}>
Add <IoAdd />
</Button>
</Panel.ListItem>
{fields.length > 0 &&
@@ -237,41 +207,31 @@ export default function ProjectData() {
| undefined;
return (
<div key={field.id} className={style.customDataItem}>
<div>
<div className={style.titleRow}>
<label>
Title
<Input
variant='ontime-filled'
size='sm'
defaultValue={field.title}
placeholder='Title of your custom data'
autoComplete='off'
{...register(`custom.${idx}.title`, {
required: { value: true, message: 'Field cannot be empty' },
})}
/>
</label>
<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 className={style.titleRow}>
<label>
Title
<Input
fluid
defaultValue={field.title}
placeholder='Title of your custom data'
{...register(`custom.${idx}.title`, {
required: { value: true, message: 'Field cannot be empty' },
})}
/>
</label>
<Button variant='subtle-destructive' onClick={() => remove(idx)}>
<IoTrash />
Delete Entry
</Button>
</div>
{rowErrors?.title?.message && <Panel.Error>{rowErrors.title.message}</Panel.Error>}
<label>
Value
<Textarea
variant='ontime-filled'
resize='none'
size='sm'
fluid
rows={3}
resize='vertical'
defaultValue={field.value}
autoComplete='off'
placeholder='Text of your custom data'
{...register(`custom.${idx}.value`, {
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 { Controller, useForm } from 'react-hook-form';
import { Button, Input, Switch, useDisclosure } from '@chakra-ui/react';
import { ViewSettings } from 'ontime-types';
import { Switch } from '@chakra-ui/react';
import { useDisclosure } from '@mantine/hooks';
import { ViewSettings as ViewSettingsType } from 'ontime-types';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import { SwatchPickerRHF } from '../../../../common/components/input/colour-input/SwatchPicker';
import Input from '../../../../common/components/input/input/Input';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import useInfo from '../../../../common/hooks-query/useInfo';
import useViewSettings from '../../../../common/hooks-query/useViewSettings';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOntimeCloud } from '../../../../externals';
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/';
export default function ViewSettingsForm() {
export default function ViewSettings() {
const { data, isPending, mutateAsync } = useViewSettings();
const { data: info, status: infoStatus } = useInfo();
const { isOpen: isCodeEditorOpen, onOpen: onCodeEditorOpen, onClose: onCodeEditorClose } = useDisclosure();
const [isCodeEditorOpen, codeEditorHandler] = useDisclosure();
const {
control,
@@ -29,7 +29,7 @@ export default function ViewSettingsForm() {
register,
reset,
formState: { isSubmitting, isDirty, errors },
} = useForm<ViewSettings>({
} = useForm<ViewSettingsType>({
defaultValues: data,
values: data,
resetOptions: {
@@ -44,7 +44,7 @@ export default function ViewSettingsForm() {
}
}, [data, reset]);
const onSubmit = async (formData: ViewSettings) => {
const onSubmit = async (formData: ViewSettingsType) => {
try {
mutateAsync(formData);
} catch (error) {
@@ -61,8 +61,6 @@ export default function ViewSettingsForm() {
return null;
}
const isLoading = isPending || infoStatus === 'pending';
return (
<Panel.Section
as='form'
@@ -74,33 +72,25 @@ export default function ViewSettingsForm() {
<Panel.SubHeader>
View settings
<Panel.InlineElements>
<Button isDisabled={!isDirty} variant='ontime-ghosted' size='sm' onClick={onReset}>
<Button disabled={!isDirty} variant='ghosted' onClick={onReset}>
Revert to saved
</Button>
<Button type='submit' isLoading={isSubmitting} isDisabled={!isDirty} variant='ontime-filled' size='sm'>
<Button type='submit' loading={isSubmitting} disabled={!isDirty} variant='primary'>
Save
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
<Panel.Divider />
<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 />
{!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>
</Info>
<Panel.Section>
<Panel.Loader isLoading={isLoading} />
<Panel.Loader isLoading={isPending} />
<Panel.Error>{errors.root?.message}</Panel.Error>
<Panel.ListGroup>
<CodeEditorModal isOpen={isCodeEditorOpen} onClose={onCodeEditorClose} />
<CodeEditorModal isOpen={isCodeEditorOpen} onClose={codeEditorHandler.close} />
<Panel.ListItem>
<Panel.Field
title='Override CSS styles'
@@ -113,13 +103,7 @@ export default function ViewSettingsForm() {
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
<Button
onClick={onCodeEditorOpen}
variant='ontime-subtle'
size='sm'
isDisabled={isSubmitting}
width='fit-content'
>
<Button onClick={codeEditorHandler.open} disabled={isSubmitting}>
Edit CSS override
</Button>
</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'
/>
<Input
size='sm'
autoComplete='off'
variant='ontime-filled'
maxLength={150}
width='275px'
style={{ width: '275px' }}
placeholder='Shown when timer reaches end'
{...register('endMessage')}
/>
@@ -4,7 +4,7 @@ import { IoEyeOutline } from 'react-icons/io5';
import { IconButton, Input, InputGroup, InputRightElement } from '@chakra-ui/react';
import { Settings } from 'ontime-types';
import { isAlphanumeric } from '../../../../common/utils/regex';
import { isAlphanumeric } from '../../../../../common/utils/regex';
interface GeneralPinInputProps {
register: UseFormRegister<Settings>;

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