mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-03 22:47:59 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 342533e128 | |||
| 5600235ba1 | |||
| a8ea1080f3 | |||
| 36223ff49f | |||
| 3ce5b42e05 | |||
| c6c1e4a5d7 | |||
| 84b73fc02a | |||
| cd8e014f08 | |||
| 2e9ee698e9 | |||
| d06b5af538 | |||
| 24a3823d3b | |||
| 8ad260d28a | |||
| 0726c04f20 |
@@ -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",
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-2
@@ -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 };
|
||||
|
||||
@@ -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
-1
@@ -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> </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> </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> </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> </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>
|
||||
|
||||
+9
-11
@@ -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>
|
||||
);
|
||||
|
||||
+2
-2
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+5
@@ -12,3 +12,8 @@
|
||||
color: $label-gray;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.copiableLink {
|
||||
user-select: text;
|
||||
color: $ui-white;
|
||||
}
|
||||
+46
-52
@@ -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)} />;
|
||||
}
|
||||
+5
-16
@@ -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>
|
||||
+15
-17
@@ -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>
|
||||
);
|
||||
-30
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
-103
@@ -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>
|
||||
);
|
||||
}
|
||||
+4
-8
@@ -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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+3
-41
@@ -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>
|
||||
);
|
||||
+22
-22
@@ -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>
|
||||
);
|
||||
+8
-8
@@ -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>
|
||||
+2
-2
@@ -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/';
|
||||
|
||||
+17
-38
@@ -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>
|
||||
+4
-4
@@ -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>
|
||||
+75
-86
@@ -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>
|
||||
);
|
||||
}
|
||||
+22
-28
@@ -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
-3
@@ -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';
|
||||
|
||||
+4
-4
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import ClientList from './ClientList';
|
||||
|
||||
+26
-25
@@ -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>
|
||||
|
||||
+10
-29
@@ -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>
|
||||
</>
|
||||
|
||||
+9
-16
@@ -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' },
|
||||
+40
-80
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+17
-36
@@ -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')}
|
||||
/>
|
||||
+1
-1
@@ -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
Reference in New Issue
Block a user