mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-25 08:59:13 +00:00
v2 beta5 modals (#377)
* refactor: folder structure * style: modal wrapper * feat: add nordic translations
This commit is contained in:
@@ -15,7 +15,7 @@
|
|||||||
Ontime is an application for managing event rundowns and running stage timers.
|
Ontime is an application for managing event rundowns and running stage timers.
|
||||||
|
|
||||||
A single, locally hosted central application distributes your event information over the local network.
|
A single, locally hosted central application distributes your event information over the local network.
|
||||||
This enables the distribution of the data to a series of viewers and allows integration into video and control workflows, including OBS and d3.
|
This enables the distribution of the data to a series of views and allows integration into video and control workflows, including OBS and d3.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export async function getView(): Promise<ViewSettings> {
|
|||||||
* @description HTTP request to mutate view settings
|
* @description HTTP request to mutate view settings
|
||||||
* @return {Promise}
|
* @return {Promise}
|
||||||
*/
|
*/
|
||||||
export async function postView(data: ViewSettings) {
|
export async function postViewSettings(data: ViewSettings) {
|
||||||
return axios.post(`${ontimeURL}/views`, data);
|
return axios.post(`${ontimeURL}/views`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { MouseEvent } from 'react';
|
||||||
import { IconButton, IconButtonProps, Tooltip } from '@chakra-ui/react';
|
import { IconButton, IconButtonProps, Tooltip } from '@chakra-ui/react';
|
||||||
|
|
||||||
interface TooltipActionBtnProps extends IconButtonProps {
|
interface TooltipActionBtnProps extends IconButtonProps {
|
||||||
clickHandler: () => void;
|
clickHandler: (event?: MouseEvent) => void;
|
||||||
tooltip: string;
|
tooltip: string;
|
||||||
openDelay?: number;
|
openDelay?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
@use "../../../../theme/_v2Styles" as *;
|
@use "../../../../theme/_v2Styles" as *;
|
||||||
|
|
||||||
.swatch {
|
.swatch {
|
||||||
width: 32px;
|
width: 24px;
|
||||||
height: 32px;
|
height: 24px;
|
||||||
border-radius: 16px;
|
border-radius: 12px;
|
||||||
border: 1px solid $gray-300;
|
border: 1px solid white;
|
||||||
|
box-shadow: 0 0 0 1px $gray-300;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
transition-property: box-shadow;
|
transition-property: box-shadow;
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
import { HexAlphaColorPicker } from 'react-colorful';
|
import { HexAlphaColorPicker } from 'react-colorful';
|
||||||
|
import { useController, UseControllerProps } from 'react-hook-form';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@chakra-ui/react';
|
import { Popover, PopoverContent, PopoverTrigger } from '@chakra-ui/react';
|
||||||
|
import { ViewSettings } from 'ontime-types';
|
||||||
|
|
||||||
import style from './PopoverPicker.module.scss';
|
import style from './PopoverPicker.module.scss';
|
||||||
|
|
||||||
|
export function PopoverPickerRHF(props: UseControllerProps<ViewSettings>) {
|
||||||
|
const { name, control } = props;
|
||||||
|
const {
|
||||||
|
field: { onChange, value },
|
||||||
|
} = useController({ control, name });
|
||||||
|
|
||||||
|
return <PopoverPicker color={value as string} onChange={onChange} />;
|
||||||
|
}
|
||||||
|
|
||||||
interface PopoverPickerProps {
|
interface PopoverPickerProps {
|
||||||
color: string;
|
color: string;
|
||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ export const ontimePlaceholderSettings: Settings = {
|
|||||||
app: 'ontime',
|
app: 'ontime',
|
||||||
version: 2,
|
version: 2,
|
||||||
serverPort: 4001,
|
serverPort: 4001,
|
||||||
lock: null,
|
editorKey: null,
|
||||||
pinCode: null,
|
operatorKey: null,
|
||||||
timeFormat: '24',
|
timeFormat: '24',
|
||||||
|
language: 'en',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export const millisToSeconds = (millis: number | null): number => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Converts milliseconds to seconds
|
* @description Converts milliseconds to seconds
|
||||||
* @param {number} millis - time in seconds
|
* @param {number} millis - time in milliseconds
|
||||||
* @returns {number} Amount in seconds
|
* @returns {number} Amount in seconds
|
||||||
*/
|
*/
|
||||||
export const millisToMinutes = (millis: number): number => {
|
export const millisToMinutes = (millis: number): number => {
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import UploadModal from '../../common/components/upload-modal/UploadModal';
|
|||||||
import MenuBar from '../menu/MenuBar';
|
import MenuBar from '../menu/MenuBar';
|
||||||
import AboutModal from '../modals/about-modal/AboutModal';
|
import AboutModal from '../modals/about-modal/AboutModal';
|
||||||
import IntegrationModal from '../modals/integration-modal/IntegrationModal';
|
import IntegrationModal from '../modals/integration-modal/IntegrationModal';
|
||||||
import ModalManager from '../modals/ModalManager';
|
|
||||||
import QuickStart from '../modals/quick-start/QuickStart';
|
import QuickStart from '../modals/quick-start/QuickStart';
|
||||||
|
import SettingsModal from '../modals/settings-modal/SettingsModal';
|
||||||
|
|
||||||
import styles from './Editor.module.scss';
|
import styles from './Editor.module.scss';
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ export default function Editor() {
|
|||||||
<UploadModal onClose={onUploadModalClose} isOpen={isUploadModalOpen} />
|
<UploadModal onClose={onUploadModalClose} isOpen={isUploadModalOpen} />
|
||||||
<IntegrationModal onClose={onIntegrationModalClose} isOpen={isIntegrationModalOpen} />
|
<IntegrationModal onClose={onIntegrationModalClose} isOpen={isIntegrationModalOpen} />
|
||||||
<AboutModal onClose={onAboutModalClose} isOpen={isAboutModalOpen} />
|
<AboutModal onClose={onAboutModalClose} isOpen={isAboutModalOpen} />
|
||||||
<ModalManager isOpen={isSettingsOpen} onClose={onSettingsClose} />
|
<SettingsModal isOpen={isSettingsOpen} onClose={onSettingsClose} />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
<div className={styles.mainContainer} data-testid='event-editor'>
|
<div className={styles.mainContainer} data-testid='event-editor'>
|
||||||
<Box id='settings' className={styles.settings}>
|
<Box id='settings' className={styles.settings}>
|
||||||
|
|||||||
@@ -1,298 +0,0 @@
|
|||||||
/* eslint-disable jsx-a11y/anchor-has-content */
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { Button, IconButton, Input, ModalBody, Tooltip } from '@chakra-ui/react';
|
|
||||||
import { IoInformationCircleOutline } from '@react-icons/all-files/io5/IoInformationCircleOutline';
|
|
||||||
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
|
|
||||||
import { IoSunny } from '@react-icons/all-files/io5/IoSunny';
|
|
||||||
|
|
||||||
import { useEmitLog } from '@/common/stores/logger';
|
|
||||||
|
|
||||||
import { viewerLocations } from '../../appConstants';
|
|
||||||
import { postAliases } from '../../common/api/ontimeApi';
|
|
||||||
import useAliases from '../../common/hooks-query/useAliases';
|
|
||||||
import { validateAlias } from '../../common/utils/aliases';
|
|
||||||
import { handleLinks, host } from '../../common/utils/linkUtils';
|
|
||||||
import { tooltipDelayFast } from '../../ontimeConfig';
|
|
||||||
|
|
||||||
import SubmitContainer from './SubmitContainer';
|
|
||||||
|
|
||||||
import style from './Modals.module.scss';
|
|
||||||
|
|
||||||
export default function AliasesModal() {
|
|
||||||
const { data, status, refetch } = useAliases();
|
|
||||||
const { emitError } = useEmitLog();
|
|
||||||
const [changed, setChanged] = useState(false);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [aliases, setAliases] = useState([]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set formdata from server state
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
if (data == null) return;
|
|
||||||
if (changed) return;
|
|
||||||
setAliases([...data]);
|
|
||||||
}, [changed, data]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate and submit data
|
|
||||||
*/
|
|
||||||
const submitHandler = useCallback(
|
|
||||||
async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setSubmitting(true);
|
|
||||||
|
|
||||||
const validatedAliases = [...aliases];
|
|
||||||
let errors = false;
|
|
||||||
for (const alias of validatedAliases) {
|
|
||||||
// validate url
|
|
||||||
const isURLValid = validateAlias(alias.pathAndParams);
|
|
||||||
if (!isURLValid.status) {
|
|
||||||
alias.urlError = isURLValid.message;
|
|
||||||
errors = true;
|
|
||||||
} else {
|
|
||||||
alias.urlError = undefined;
|
|
||||||
}
|
|
||||||
// validate alias
|
|
||||||
const isAliasValid = validateAlias(alias.alias);
|
|
||||||
if (!isAliasValid.status) {
|
|
||||||
alias.aliasError = isAliasValid.message;
|
|
||||||
errors = true;
|
|
||||||
} else {
|
|
||||||
alias.aliasError = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setAliases(validatedAliases);
|
|
||||||
|
|
||||||
if (!errors) {
|
|
||||||
try {
|
|
||||||
await postAliases(aliases);
|
|
||||||
} catch (error) {
|
|
||||||
emitError(`Error saving settings: ${error}`);
|
|
||||||
} finally {
|
|
||||||
await refetch();
|
|
||||||
setChanged(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setSubmitting(false);
|
|
||||||
},
|
|
||||||
[aliases, emitError, refetch],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new alias in state with a temporary id
|
|
||||||
*/
|
|
||||||
const addNew = useCallback(() => {
|
|
||||||
if (aliases.length > 20) {
|
|
||||||
emitError('Maximum amount of aliases reacted (20)');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const emptyAlias = {
|
|
||||||
id: Math.floor(Math.random() * 1000),
|
|
||||||
enabled: false,
|
|
||||||
alias: '',
|
|
||||||
pathAndParams: '',
|
|
||||||
};
|
|
||||||
setAliases((prevState) => [...prevState, emptyAlias]);
|
|
||||||
setChanged(true);
|
|
||||||
}, [aliases.length, emitError]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes an alias by a given id
|
|
||||||
* @param {string} id - id of alias to delete
|
|
||||||
*/
|
|
||||||
const deleteAlias = useCallback((id) => {
|
|
||||||
setAliases((prevState) => [...prevState.filter((a) => a.id !== id)]);
|
|
||||||
setChanged(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets enabled flag to true / false
|
|
||||||
* @param {string} id - object id
|
|
||||||
* @param {boolean} isEnabled - whether to enable / disable flag
|
|
||||||
*/
|
|
||||||
const setEnabled = useCallback(
|
|
||||||
(id, isEnabled) => {
|
|
||||||
const aliasesState = [...aliases];
|
|
||||||
for (const a of aliasesState) {
|
|
||||||
if (a.id === id) {
|
|
||||||
if (isEnabled) {
|
|
||||||
if (a.alias === '' || a.pathAndParams === '') {
|
|
||||||
emitError('Alias incomplete');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isRepeated = aliases.some((r) => a.alias === r.alias && r.enabled);
|
|
||||||
if (isRepeated) {
|
|
||||||
emitError('There is already an alias with this name');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
a.enabled = isEnabled;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setChanged(true);
|
|
||||||
setAliases(aliasesState);
|
|
||||||
},
|
|
||||||
[aliases, emitError],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverts local state equals to server state
|
|
||||||
*/
|
|
||||||
const revert = async () => {
|
|
||||||
setChanged(false);
|
|
||||||
await refetch();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles change of input field in local state
|
|
||||||
* @param {number} index - index of item in array
|
|
||||||
* @param {string} field - object parameter to update
|
|
||||||
* @param {string} value - new object parameter value
|
|
||||||
*/
|
|
||||||
const handleChange = useCallback(
|
|
||||||
(index, field, value) => {
|
|
||||||
const temp = [...aliases];
|
|
||||||
temp[index][field] = value;
|
|
||||||
setAliases(temp);
|
|
||||||
setChanged(true);
|
|
||||||
},
|
|
||||||
[aliases],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ModalBody className={style.modalBody}>
|
|
||||||
<p className={style.notes}>
|
|
||||||
Configure easy to use URL Aliases
|
|
||||||
<br />
|
|
||||||
🔥 Changes take effect on save 🔥
|
|
||||||
</p>
|
|
||||||
<form onSubmit={submitHandler}>
|
|
||||||
<div className={style.modalFields}>
|
|
||||||
<div className={style.hSeparator}>Default URLs</div>
|
|
||||||
<div className={style.blockNotes}>
|
|
||||||
{viewerLocations.map((l) => (
|
|
||||||
<a
|
|
||||||
href={l.link}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
className={style.flexNote}
|
|
||||||
key={l.link}
|
|
||||||
onClick={(e) => handleLinks(e, l.link)}
|
|
||||||
>
|
|
||||||
{`${l.label} - http://${host}/${l.link}`}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className={style.hSeparator}>Custom Aliases</div>
|
|
||||||
<div className={style.blockNotes}>
|
|
||||||
<span className={style.inlineFlex}>
|
|
||||||
<IoInformationCircleOutline color='#2b6cb0' fontSize='2em' />
|
|
||||||
URL aliases are useful in two main scenarios
|
|
||||||
</span>
|
|
||||||
<span className={style.labelNote}>Complicated URLs</span>
|
|
||||||
<br />
|
|
||||||
eg. a lower third url with some custom parameters
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td className={style.labelNote} style={{ width: '30%' }}>
|
|
||||||
Alias
|
|
||||||
</td>
|
|
||||||
<td className={style.labelNote}>Page URL</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>mylower</td>
|
|
||||||
<td>lower?bg=ff2&text=f00&size=0.6&transition=5</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<br />
|
|
||||||
<span className={style.labelNote}>URLs to be changed dynamically</span>
|
|
||||||
<br />
|
|
||||||
eg. an unattended screen that you would need to change route from the app
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td className={style.labelNote} style={{ width: '30%' }}>
|
|
||||||
Alias
|
|
||||||
</td>
|
|
||||||
<td className={style.labelNote}>Page URL</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>thirdfloor</td>
|
|
||||||
<td>public</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div className={style.inlineAliasPlaceholder}>
|
|
||||||
<span className={style.labelNote}>Alias</span>
|
|
||||||
<span className={style.labelNote}>Page URL</span>
|
|
||||||
</div>
|
|
||||||
{aliases.map((alias, index) => (
|
|
||||||
<div key={alias.id}>
|
|
||||||
<div className={style.inlineAlias}>
|
|
||||||
<Input
|
|
||||||
size='sm'
|
|
||||||
variant='flushed'
|
|
||||||
name='Alias'
|
|
||||||
placeholder='URL Alias'
|
|
||||||
autoComplete='off'
|
|
||||||
value={alias.alias}
|
|
||||||
isInvalid={alias.aliasError}
|
|
||||||
onChange={(event) => handleChange(index, 'alias', event.target.value)}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
size='sm'
|
|
||||||
fontSize='0.75em'
|
|
||||||
variant='flushed'
|
|
||||||
name='URL'
|
|
||||||
placeholder='URL (portion after ontime Port)'
|
|
||||||
autoComplete='off'
|
|
||||||
value={alias.pathAndParams}
|
|
||||||
isInvalid={alias.urlError}
|
|
||||||
onChange={(event) => handleChange(index, 'pathAndParams', event.target.value)}
|
|
||||||
/>
|
|
||||||
<Tooltip label={`Test /${alias.pathAndParams}`} openDelay={tooltipDelayFast}>
|
|
||||||
<a href='#!' target='_blank' rel='noreferrer' onClick={(e) => handleLinks(e, alias.pathAndParams)} />
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label='Enable alias' openDelay={tooltipDelayFast}>
|
|
||||||
<IconButton
|
|
||||||
aria-label='Enable alias'
|
|
||||||
size='xs'
|
|
||||||
icon={<IoSunny />}
|
|
||||||
colorScheme='blue'
|
|
||||||
variant={alias.enabled ? null : 'outline'}
|
|
||||||
onClick={() => setEnabled(alias.id, !alias.enabled)}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label='Delete alias' openDelay={tooltipDelayFast}>
|
|
||||||
<IconButton
|
|
||||||
aria-label='Delete alias'
|
|
||||||
size='xs'
|
|
||||||
icon={<IoRemove />}
|
|
||||||
colorScheme='red'
|
|
||||||
onClick={() => deleteAlias(alias.id)}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
{alias.aliasError ? <div className={style.error}>{`Alias error: ${alias.aliasError}`}</div> : null}
|
|
||||||
{alias.urlError ? <div className={style.error}>{`URL error: ${alias.urlError}`}</div> : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className={style.inlineAliasPlaceholder}>
|
|
||||||
<Button size='xs' colorScheme='blue' variant='outline' onClick={() => addNew()}>
|
|
||||||
Add new
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<SubmitContainer revert={revert} submitting={submitting} changed={changed} status={status} />
|
|
||||||
</form>
|
|
||||||
</ModalBody>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import isEqual from 'react-fast-compare';
|
|
||||||
import {
|
|
||||||
Checkbox,
|
|
||||||
FormControl,
|
|
||||||
FormLabel,
|
|
||||||
IconButton,
|
|
||||||
Input,
|
|
||||||
ModalBody,
|
|
||||||
PinInput,
|
|
||||||
PinInputField,
|
|
||||||
Select,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
import { FiEye } from '@react-icons/all-files/fi/FiEye';
|
|
||||||
import { FiX } from '@react-icons/all-files/fi/FiX';
|
|
||||||
|
|
||||||
import { useEmitLog } from '@/common/stores/logger';
|
|
||||||
|
|
||||||
import { postSettings } from '../../common/api/ontimeApi';
|
|
||||||
import TooltipActionBtn from '../../common/components/buttons/TooltipActionBtn';
|
|
||||||
import useSettings from '../../common/hooks-query/useSettings';
|
|
||||||
import { ontimePlaceholderSettings } from '../../common/models/OntimeSettings';
|
|
||||||
import { useLocalEvent } from '../../common/stores/localEvent';
|
|
||||||
|
|
||||||
import { inputProps } from './modalHelper';
|
|
||||||
import SubmitContainer from './SubmitContainer';
|
|
||||||
|
|
||||||
import style from './Modals.module.scss';
|
|
||||||
|
|
||||||
export default function AppSettingsModal() {
|
|
||||||
const { data, status, refetch } = useSettings();
|
|
||||||
const { emitError, emitWarning } = useEmitLog();
|
|
||||||
const [formData, setFormData] = useState(ontimePlaceholderSettings);
|
|
||||||
const [changed, setChanged] = useState(false);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [hidePin, setHidePin] = useState(true);
|
|
||||||
|
|
||||||
const eventSettings = useLocalEvent((state) => state.eventSettings);
|
|
||||||
const setLocalEventSettings = useLocalEvent((state) => state.setLocalEventSettings);
|
|
||||||
|
|
||||||
const [formSettings, setFormSettings] = useState(eventSettings);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set formdata from server state
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
if (data == null) return;
|
|
||||||
if (changed) return;
|
|
||||||
setFormData({
|
|
||||||
pinCode: data.pinCode,
|
|
||||||
timeFormat: data.timeFormat,
|
|
||||||
});
|
|
||||||
}, [changed, data]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate and submit data
|
|
||||||
*/
|
|
||||||
const submitHandler = async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setSubmitting(true);
|
|
||||||
const validation = { isValid: false, message: '' };
|
|
||||||
|
|
||||||
const hasChanged = !isEqual(formSettings, eventSettings);
|
|
||||||
if (hasChanged) {
|
|
||||||
setLocalEventSettings(formSettings);
|
|
||||||
validation.isValid = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// we might not have changed this
|
|
||||||
if (formData.pinCode !== data.pinCode) {
|
|
||||||
// Validate fields
|
|
||||||
if (formData.pinCode === '' || formData.pinCode == null) {
|
|
||||||
validation.isValid = true;
|
|
||||||
validation.message += 'App pin code removed';
|
|
||||||
} else {
|
|
||||||
validation.isValid = true;
|
|
||||||
validation.message += 'App pin code added';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.timeFormat !== data.timeFormat) {
|
|
||||||
if (formData.timeFormat === '12' || formData.timeFormat === '24') {
|
|
||||||
validation.isValid = true;
|
|
||||||
} else {
|
|
||||||
validation.isValue = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let resetChange = hasChanged;
|
|
||||||
// set fields with error
|
|
||||||
if (!validation.isValid) {
|
|
||||||
emitError(`Invalid Input: ${validation.message}`);
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
await postSettings(formData);
|
|
||||||
} catch (error) {
|
|
||||||
emitError(`Error saving settings: ${error}`);
|
|
||||||
} finally {
|
|
||||||
await refetch();
|
|
||||||
resetChange = true;
|
|
||||||
}
|
|
||||||
validation?.message && emitWarning(validation.message);
|
|
||||||
}
|
|
||||||
if (resetChange) {
|
|
||||||
setChanged(false);
|
|
||||||
}
|
|
||||||
setSubmitting(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverts local state equals to server state
|
|
||||||
*/
|
|
||||||
const revert = async () => {
|
|
||||||
setChanged(false);
|
|
||||||
// set from context
|
|
||||||
setFormSettings(eventSettings);
|
|
||||||
await refetch();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles change of input field in local state
|
|
||||||
* @param {string} field - object parameter to update
|
|
||||||
* @param {string} value - new object parameter value
|
|
||||||
*/
|
|
||||||
const handleChange = (field, value) => {
|
|
||||||
const temp = { ...formData };
|
|
||||||
temp[field] = value;
|
|
||||||
setFormData(temp);
|
|
||||||
setChanged(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const disableModal = status !== 'success';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ModalBody className={style.modalBody}>
|
|
||||||
<p className={style.notes}>
|
|
||||||
Options related to the application
|
|
||||||
<br />
|
|
||||||
🔥 Changes take effect on save 🔥
|
|
||||||
</p>
|
|
||||||
<form onSubmit={submitHandler}>
|
|
||||||
<div className={style.modalFields}>
|
|
||||||
<div className={style.hSeparator}>General App Settings</div>
|
|
||||||
<div className={style.modalInline}>
|
|
||||||
<FormControl id='serverPort'>
|
|
||||||
<FormLabel htmlFor='serverPort'>
|
|
||||||
Viewer Port
|
|
||||||
<span className={style.labelNote}>
|
|
||||||
<br />
|
|
||||||
Ontime is available at port
|
|
||||||
</span>
|
|
||||||
</FormLabel>
|
|
||||||
<Input {...inputProps} name='title' value={4001} disabled style={{ width: '6em', textAlign: 'center' }} />
|
|
||||||
</FormControl>
|
|
||||||
<FormControl id='editorPin'>
|
|
||||||
<FormLabel htmlFor='editorPin'>
|
|
||||||
Editor Pincode
|
|
||||||
<span className={style.labelNote}>
|
|
||||||
<br />
|
|
||||||
Protect the editor with a Pincode
|
|
||||||
</span>
|
|
||||||
</FormLabel>
|
|
||||||
<div className={style.pin}>
|
|
||||||
<PinInput
|
|
||||||
{...inputProps}
|
|
||||||
type='alphanumeric'
|
|
||||||
name='pinCode'
|
|
||||||
defaultValue=''
|
|
||||||
value={formData.pinCode}
|
|
||||||
mask={hidePin}
|
|
||||||
isDisabled={disableModal}
|
|
||||||
onChange={(value) => handleChange('pinCode', value)}
|
|
||||||
>
|
|
||||||
<PinInputField />
|
|
||||||
<PinInputField />
|
|
||||||
<PinInputField />
|
|
||||||
<PinInputField />
|
|
||||||
</PinInput>
|
|
||||||
<IconButton
|
|
||||||
size='sm'
|
|
||||||
colorScheme='blue'
|
|
||||||
variant='ghost'
|
|
||||||
icon={<FiEye />}
|
|
||||||
aria-label='Editor pin code'
|
|
||||||
onMouseDown={() => setHidePin(false)}
|
|
||||||
onMouseUp={() => setHidePin(true)}
|
|
||||||
isDisabled={disableModal}
|
|
||||||
/>
|
|
||||||
<TooltipActionBtn
|
|
||||||
tooltip='Clear pincode'
|
|
||||||
size='sm'
|
|
||||||
colorScheme='red'
|
|
||||||
variant='ghost'
|
|
||||||
icon={<FiX />}
|
|
||||||
clickHandler={() => handleChange('pinCode', '')}
|
|
||||||
isDisabled={disableModal}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
</div>
|
|
||||||
<div className={style.modalColumn}>
|
|
||||||
<FormControl id='timeFormat'>
|
|
||||||
<FormLabel htmlFor='timeFormat'>
|
|
||||||
Time format
|
|
||||||
<span className={style.labelNote}>
|
|
||||||
<br />
|
|
||||||
12 / 24 hour format (viewers only for now)
|
|
||||||
</span>
|
|
||||||
</FormLabel>
|
|
||||||
<Select
|
|
||||||
size='sm'
|
|
||||||
name='timeFormat'
|
|
||||||
value={formData.timeFormat}
|
|
||||||
isDisabled={disableModal}
|
|
||||||
onChange={(event) => handleChange('timeFormat', event.target.value)}
|
|
||||||
>
|
|
||||||
<option value='12'>12 hours eg. 11:00:10 PM</option>
|
|
||||||
<option value='24'>24 hours eg. 23:00:10</option>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</div>
|
|
||||||
<div className={style.hSeparator}>Create Event Default Settings</div>
|
|
||||||
<div className={style.modalColumn}>
|
|
||||||
<Checkbox
|
|
||||||
isChecked={formSettings.showQuickEntry}
|
|
||||||
onChange={(e) => {
|
|
||||||
setFormSettings((prev) => ({ ...prev, showQuickEntry: e.target.checked }));
|
|
||||||
setChanged(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Show quick entry on cursor
|
|
||||||
</Checkbox>
|
|
||||||
<Checkbox
|
|
||||||
isChecked={formSettings.startTimeIsLastEnd}
|
|
||||||
onChange={(e) => {
|
|
||||||
setFormSettings((prev) => ({ ...prev, startTimeIsLastEnd: e.target.checked }));
|
|
||||||
setChanged(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Start time is last end
|
|
||||||
</Checkbox>
|
|
||||||
<Checkbox
|
|
||||||
isChecked={formSettings.defaultPublic}
|
|
||||||
onChange={(e) => {
|
|
||||||
setFormSettings((prev) => ({ ...prev, defaultPublic: e.target.checked }));
|
|
||||||
setChanged(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Event default public
|
|
||||||
</Checkbox>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<SubmitContainer revert={revert} submitting={submitting} changed={changed} status={status} />
|
|
||||||
</form>
|
|
||||||
</ModalBody>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { FormLabel, Input, ModalBody, Textarea } from '@chakra-ui/react';
|
|
||||||
|
|
||||||
import { useEmitLog } from '@/common/stores/logger';
|
|
||||||
|
|
||||||
import { postEventData } from '../../common/api/eventDataApi';
|
|
||||||
import useEventData from '../../common/hooks-query/useEventData';
|
|
||||||
import { eventDataPlaceholder } from '../../common/models/EventData';
|
|
||||||
|
|
||||||
import { inputProps } from './modalHelper';
|
|
||||||
import SubmitContainer from './SubmitContainer';
|
|
||||||
|
|
||||||
import style from './Modals.module.scss';
|
|
||||||
|
|
||||||
export default function SettingsModal() {
|
|
||||||
const { data, status, refetch } = useEventData();
|
|
||||||
const { emitError } = useEmitLog();
|
|
||||||
const [formData, setFormData] = useState(eventDataPlaceholder);
|
|
||||||
const [changed, setChanged] = useState(false);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set formdata from server state
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
if (data == null) return;
|
|
||||||
if (changed) return;
|
|
||||||
|
|
||||||
setFormData({
|
|
||||||
title: data.title,
|
|
||||||
publicUrl: data.publicUrl,
|
|
||||||
publicInfo: data.publicInfo,
|
|
||||||
backstageUrl: data.backstageUrl,
|
|
||||||
backstageInfo: data.backstageInfo,
|
|
||||||
});
|
|
||||||
}, [changed, data]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate and submit data
|
|
||||||
*/
|
|
||||||
const submitHandler = useCallback(
|
|
||||||
async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setSubmitting(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await postEventData(formData);
|
|
||||||
} catch (error) {
|
|
||||||
emitError(`Error saving event settings: ${error}`);
|
|
||||||
} finally {
|
|
||||||
await refetch();
|
|
||||||
setChanged(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(false);
|
|
||||||
},
|
|
||||||
[emitError, formData, refetch],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverts local state equals to server state
|
|
||||||
*/
|
|
||||||
const revert = useCallback(async () => {
|
|
||||||
setChanged(false);
|
|
||||||
await refetch();
|
|
||||||
}, [refetch]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles change of input field in local state
|
|
||||||
* @param {string} field - object parameter to update
|
|
||||||
* @param {string} value - new object parameter value
|
|
||||||
*/
|
|
||||||
const handleChange = useCallback(
|
|
||||||
(field, value) => {
|
|
||||||
const temp = { ...formData };
|
|
||||||
temp[field] = value;
|
|
||||||
setFormData(temp);
|
|
||||||
setChanged(true);
|
|
||||||
},
|
|
||||||
[formData],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ModalBody className={style.modalBody}>
|
|
||||||
<p className={style.notes}>
|
|
||||||
Options related to the running event
|
|
||||||
<br />
|
|
||||||
Affects rendered views
|
|
||||||
</p>
|
|
||||||
<form onSubmit={submitHandler}>
|
|
||||||
<div className={style.modalFields}>
|
|
||||||
<div className={style.hSeparator}>Event Data</div>
|
|
||||||
<div className={style.spacedEntry}>
|
|
||||||
<FormLabel htmlFor='title'>Event Title</FormLabel>
|
|
||||||
<Input
|
|
||||||
{...inputProps}
|
|
||||||
maxLength={35}
|
|
||||||
name='title'
|
|
||||||
placeholder='Event Title'
|
|
||||||
value={formData.title}
|
|
||||||
onChange={(event) => handleChange('title', event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={style.hSeparator}>Additional Screen Info</div>
|
|
||||||
<div className={style.spacedEntry}>
|
|
||||||
<FormLabel htmlFor='publicUrl'>
|
|
||||||
Public URL
|
|
||||||
<span className={style.labelNote}>
|
|
||||||
<br />
|
|
||||||
QR code to be shown on public screens
|
|
||||||
</span>
|
|
||||||
</FormLabel>
|
|
||||||
<Input
|
|
||||||
{...inputProps}
|
|
||||||
name='publicUrl'
|
|
||||||
placeholder='www.getontime.no'
|
|
||||||
value={formData.publicUrl}
|
|
||||||
onChange={(event) => handleChange('publicUrl', event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={style.spacedEntry}>
|
|
||||||
<FormLabel htmlFor='pubInfo'>
|
|
||||||
Public Info
|
|
||||||
<span className={style.labelNote}>
|
|
||||||
<br />
|
|
||||||
Information to be shown on public screens
|
|
||||||
</span>
|
|
||||||
</FormLabel>
|
|
||||||
<Textarea
|
|
||||||
{...inputProps}
|
|
||||||
name='pubInfo'
|
|
||||||
placeholder='Information to be shown on public screens'
|
|
||||||
value={formData.publicInfo}
|
|
||||||
onChange={(event) => handleChange('publicInfo', event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={style.spacedEntry}>
|
|
||||||
<FormLabel htmlFor='backstageUrl'>
|
|
||||||
Backstage URL
|
|
||||||
<span className={style.labelNote}>
|
|
||||||
<br />
|
|
||||||
QR to be shown on backstage screens
|
|
||||||
</span>
|
|
||||||
</FormLabel>
|
|
||||||
<Input
|
|
||||||
{...inputProps}
|
|
||||||
name='backstageUrl'
|
|
||||||
placeholder='www.getontime.no'
|
|
||||||
value={formData.backstageUrl}
|
|
||||||
onChange={(event) => handleChange('backstageUrl', event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={style.spacedEntry}>
|
|
||||||
<FormLabel htmlFor='backstageInfo'>
|
|
||||||
Backstage Info
|
|
||||||
<span className={style.labelNote}>
|
|
||||||
<br />
|
|
||||||
Information to be shown on backstage screens
|
|
||||||
</span>
|
|
||||||
</FormLabel>
|
|
||||||
<Textarea
|
|
||||||
{...inputProps}
|
|
||||||
name='backstageInfo'
|
|
||||||
placeholder='Information to be shown on backstage screens'
|
|
||||||
resize={false}
|
|
||||||
value={formData.backstageInfo}
|
|
||||||
onChange={(event) => handleChange('backstageInfo', event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<SubmitContainer revert={revert} submitting={submitting} changed={changed} status={status} />
|
|
||||||
</form>
|
|
||||||
</ModalBody>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,7 @@ $el-padding-with-compensation: 24px; // 16 + 8
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.headerNotes {
|
.headerNotes {
|
||||||
font-size: $text-body-size;
|
font-size: $text-body-size;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0 $el-padding-with-compensation;
|
padding: 0 $el-padding-with-compensation;
|
||||||
@@ -26,7 +26,7 @@ $el-padding-with-compensation: 24px; // 16 + 8
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.footerNotes {
|
.footerNotes {
|
||||||
font-size: $inner-section-text-size;
|
font-size: $inner-section-text-size;
|
||||||
padding: 0 $el-padding-with-compensation;
|
padding: 0 $el-padding-with-compensation;
|
||||||
color: $modal-note-color;
|
color: $modal-note-color;
|
||||||
@@ -38,6 +38,14 @@ $el-padding-with-compensation: 24px; // 16 + 8
|
|||||||
border-top: 1px solid $gray-100;
|
border-top: 1px solid $gray-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: $inner-section-text-size;
|
||||||
|
color: $gray-500;
|
||||||
|
padding-left: 8px;
|
||||||
|
margin: 8px 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.sectionContainer {
|
.sectionContainer {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -52,12 +60,20 @@ $el-padding-with-compensation: 24px; // 16 + 8
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: $blue-50;
|
background-color: $gray-50;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.columnSection {
|
||||||
|
@include sectionSpacing;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: $element-inner-spacing;
|
||||||
|
}
|
||||||
|
|
||||||
.splitSection {
|
.splitSection {
|
||||||
@include sectionSpacing;
|
@include sectionSpacing;
|
||||||
|
gap: $section-spacing;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +86,7 @@ $el-padding-with-compensation: 24px; // 16 + 8
|
|||||||
font-size: $inner-section-text-size;
|
font-size: $inner-section-text-size;
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
&.main {
|
&.main {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@@ -102,13 +119,17 @@ $el-padding-with-compensation: 24px; // 16 + 8
|
|||||||
|
|
||||||
.shiftRight {
|
.shiftRight {
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
margin-right: $element-spacing;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.showPointer {
|
.showPointer {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.twoColumn {
|
.twoColumn {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { PropsWithChildren } from 'react';
|
||||||
|
import { FormControl } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import style from './settings-modal/SettingsModal.module.scss';
|
||||||
|
|
||||||
|
interface ModalInputProps {
|
||||||
|
field: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ModalInput(props: PropsWithChildren<ModalInputProps>) {
|
||||||
|
const { field, title, description, error, children } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormControl isInvalid={!!error} className={style.columnSection}>
|
||||||
|
<label htmlFor={field}>
|
||||||
|
<span className={style.sectionTitle}>{title}</span>
|
||||||
|
{error ? (
|
||||||
|
<span className={style.error}>{error}</span>
|
||||||
|
) : (
|
||||||
|
<span className={style.sectionSubtitle}>{description}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,10 @@
|
|||||||
transition-property: color;
|
transition-property: color;
|
||||||
transition-duration: $transition-time-action;
|
transition-duration: $transition-time-action;
|
||||||
|
|
||||||
|
&.inline {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: $ontime-color;
|
color: $ontime-color;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
|
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
|
||||||
|
|
||||||
|
import { cx } from '../../common/utils/styleUtils';
|
||||||
|
|
||||||
import style from './ModalLink.module.scss';
|
import style from './ModalLink.module.scss';
|
||||||
|
|
||||||
interface ModalLinkProps {
|
interface ModalLinkProps {
|
||||||
href: string;
|
href: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
inline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ModalLink(props: ModalLinkProps) {
|
export default function ModalLink(props: ModalLinkProps) {
|
||||||
const { href, children } = props;
|
const { href, inline, children } = props;
|
||||||
|
const classes = cx([style.link, inline ? style.inline : null]);
|
||||||
return (
|
return (
|
||||||
<a href={href} target='_blank' rel='noreferrer' className={style.link}>
|
<a href={href} target='_blank' rel='noreferrer' className={classes}>
|
||||||
{children} <IoOpenOutline />
|
{children} <IoOpenOutline />
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
import {
|
|
||||||
Modal,
|
|
||||||
ModalCloseButton,
|
|
||||||
ModalContent,
|
|
||||||
ModalHeader,
|
|
||||||
ModalOverlay,
|
|
||||||
Tab,
|
|
||||||
TabList,
|
|
||||||
TabPanel,
|
|
||||||
TabPanels,
|
|
||||||
Tabs,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
|
|
||||||
import AliasesModal from './AliasesModal';
|
|
||||||
import AppSettingsModal from './AppSettingsModal';
|
|
||||||
import EventSettingsModal from './EventSettingsModal';
|
|
||||||
import TableOptionsModal from './TableOptionsModal';
|
|
||||||
import ViewsSettingsModal from './ViewsSettingsModal';
|
|
||||||
|
|
||||||
interface ModalManagerProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ModalManager(props: ModalManagerProps) {
|
|
||||||
const { isOpen, onClose } = props;
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
isOpen={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
closeOnOverlayClick={false}
|
|
||||||
motionPreset='slideInBottom'
|
|
||||||
size='xl'
|
|
||||||
scrollBehavior='inside'
|
|
||||||
>
|
|
||||||
<ModalOverlay />
|
|
||||||
<ModalContent>
|
|
||||||
<ModalHeader>Ontime Settings</ModalHeader>
|
|
||||||
<ModalCloseButton />
|
|
||||||
|
|
||||||
<Tabs size='sm' isLazy>
|
|
||||||
<TabList>
|
|
||||||
<Tab>App Settings</Tab>
|
|
||||||
<Tab>Viewers</Tab>
|
|
||||||
<Tab>Event Data</Tab>
|
|
||||||
<Tab>URL Aliases</Tab>
|
|
||||||
<Tab>Cuesheet</Tab>
|
|
||||||
</TabList>
|
|
||||||
<TabPanels>
|
|
||||||
<TabPanel>
|
|
||||||
<AppSettingsModal />
|
|
||||||
</TabPanel>
|
|
||||||
<TabPanel>
|
|
||||||
<ViewsSettingsModal />
|
|
||||||
</TabPanel>
|
|
||||||
<TabPanel>
|
|
||||||
<EventSettingsModal />
|
|
||||||
</TabPanel>
|
|
||||||
<TabPanel>
|
|
||||||
<AliasesModal />
|
|
||||||
</TabPanel>
|
|
||||||
<TabPanel>
|
|
||||||
<TableOptionsModal />
|
|
||||||
</TabPanel>
|
|
||||||
</TabPanels>
|
|
||||||
</Tabs>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { PropsWithChildren } from 'react';
|
||||||
|
import { FormControl } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import style from './settings-modal/SettingsModal.module.scss';
|
||||||
|
|
||||||
|
interface ModalSplitInputProps {
|
||||||
|
field: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ModalSplitInput(props: PropsWithChildren<ModalSplitInputProps>) {
|
||||||
|
const { field, title, description, error, children } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormControl isInvalid={!!error} className={style.splitSection}>
|
||||||
|
<label htmlFor={field}>
|
||||||
|
<span className={style.sectionTitle}>{title}</span>
|
||||||
|
{error ? (
|
||||||
|
<span className={style.error}>{error}</span>
|
||||||
|
) : (
|
||||||
|
<span className={style.sectionSubtitle}>{description}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
@use '../../theme/ontimeColours' as *;
|
|
||||||
@use '../../theme/v2Styles' as *;
|
|
||||||
|
|
||||||
// style file to be deprecated
|
|
||||||
|
|
||||||
//////////////////////////////////// main
|
|
||||||
|
|
||||||
.modalBody {
|
|
||||||
font-weight: 400;
|
|
||||||
|
|
||||||
.notes {
|
|
||||||
font-weight: 400;
|
|
||||||
color: $action-blue;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
height: 4em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modalFields {
|
|
||||||
max-height: 45vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
scrollbar-color: rgba($action-blue, 0.35) rgba($action-blue, 0.15);
|
|
||||||
padding-right: 6px;
|
|
||||||
|
|
||||||
label {
|
|
||||||
//font-weight: 400;
|
|
||||||
font-size: 0.8em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inlineAlias,
|
|
||||||
.inlineAliasPlaceholder {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 20% 1fr 1em 1.5em 1.5em;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.5em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
font-size: 0.8em;
|
|
||||||
color: $error-red;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inlineAliasPlaceholder {
|
|
||||||
grid-template-columns: 20% 1fr 4em;
|
|
||||||
|
|
||||||
.placeholder {
|
|
||||||
background: black;
|
|
||||||
width: 100%;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.overflow {
|
|
||||||
overflow-y: visible !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Track */
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: rgba($gray-50, 0.15);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Handle */
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba($gray-100, 0.35);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Handle on hover */
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba($gray-200, 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modalInline {
|
|
||||||
display: flex;
|
|
||||||
gap: 2em;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 0.5em 0.5em 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modalColumn {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding: 0 0.5em 0.5em 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spacedEntry {
|
|
||||||
padding: 0 0.5em 0.5em 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pin {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5em;
|
|
||||||
border-radius: 50%;
|
|
||||||
|
|
||||||
input {
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.submitContainer {
|
|
||||||
margin-top: auto;
|
|
||||||
padding-top: 2em;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.modalBody > * {
|
|
||||||
margin-top: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
//////////////////////////////////// notes
|
|
||||||
|
|
||||||
ul.featureList {
|
|
||||||
li {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
svg {
|
|
||||||
color: black;
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
&.notes {
|
|
||||||
text-align: center;
|
|
||||||
border-color: black;
|
|
||||||
border-width: 0 2px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
margin-bottom: 1em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
&.notes {
|
|
||||||
font-size: 0.9em;
|
|
||||||
padding-left: 0.4em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.blockNotes {
|
|
||||||
background-color: #fff;
|
|
||||||
margin: 1em 0;
|
|
||||||
padding: 0.5em;
|
|
||||||
font-size: 0.8em;
|
|
||||||
border-radius: 2px;
|
|
||||||
|
|
||||||
table {
|
|
||||||
background-color: #fff;
|
|
||||||
border-left: 4px solid lighten($ontime-color, 5%);
|
|
||||||
width: 100%;
|
|
||||||
margin: 0.5em 0;
|
|
||||||
border-radius: 2px;
|
|
||||||
|
|
||||||
:first-child {
|
|
||||||
padding-left: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
user-select: text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.noteItem {
|
|
||||||
user-select: text;
|
|
||||||
font-weight: 600;
|
|
||||||
padding-right: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flexNote {
|
|
||||||
user-select: text;
|
|
||||||
padding-bottom: 0.3em;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emNote {
|
|
||||||
user-select: text;
|
|
||||||
display: block;
|
|
||||||
background-color: #fffc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.labelNote {
|
|
||||||
color: $action-blue;
|
|
||||||
padding-right: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.labelNoteInline {
|
|
||||||
color: $action-blue;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inlineFlex {
|
|
||||||
display: flex;
|
|
||||||
gap: 1em;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1em;
|
|
||||||
}
|
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { Button, ModalFooter } from '@chakra-ui/react';
|
import { Button, ModalFooter } from '@chakra-ui/react';
|
||||||
|
|
||||||
import styles from '../Modal.module.scss';
|
import styles from './Modal.module.scss';
|
||||||
|
|
||||||
interface OntimeModalFooterProps {
|
interface OntimeModalFooterProps {
|
||||||
formId: string;
|
formId: string;
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { Button } from '@chakra-ui/react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import style from './Modals.module.scss';
|
|
||||||
|
|
||||||
export default function SubmitContainer(props) {
|
|
||||||
const { submitting, changed, revert, status } = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={style.submitContainer}>
|
|
||||||
<Button
|
|
||||||
isDisabled={submitting || !changed}
|
|
||||||
variant='ghosted'
|
|
||||||
onClick={revert}
|
|
||||||
>
|
|
||||||
Revert
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
colorScheme='blue'
|
|
||||||
type='submit'
|
|
||||||
isLoading={submitting}
|
|
||||||
disabled={!changed || status !== 'success'}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
SubmitContainer.propTypes = {
|
|
||||||
submitting: PropTypes.bool,
|
|
||||||
changed: PropTypes.bool,
|
|
||||||
status: PropTypes.string,
|
|
||||||
revert: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { Input, ModalBody } from '@chakra-ui/react';
|
|
||||||
import { IoInformationCircleOutline } from '@react-icons/all-files/io5/IoInformationCircleOutline';
|
|
||||||
|
|
||||||
import { useEmitLog } from '@/common/stores/logger';
|
|
||||||
|
|
||||||
import { postUserFields } from '../../common/api/ontimeApi';
|
|
||||||
import useUserFields from '../../common/hooks-query/useUserFields';
|
|
||||||
import { userFieldsPlaceholder } from '../../common/models/UserFields';
|
|
||||||
import { handleLinks, host } from '../../common/utils/linkUtils';
|
|
||||||
|
|
||||||
import SubmitContainer from './SubmitContainer';
|
|
||||||
|
|
||||||
import style from './Modals.module.scss';
|
|
||||||
|
|
||||||
export default function TableOptionsModal() {
|
|
||||||
const { data, status, refetch } = useUserFields();
|
|
||||||
const { emitError } = useEmitLog();
|
|
||||||
const [changed, setChanged] = useState(false);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [userFields, setUserFields] = useState(userFieldsPlaceholder);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set formdata from server state
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
if (data == null) return;
|
|
||||||
if (changed) return;
|
|
||||||
// Todo: we need some validation on API replies
|
|
||||||
setUserFields(data);
|
|
||||||
}, [changed, data]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate and submit data
|
|
||||||
*/
|
|
||||||
const submitHandler = useCallback(async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setSubmitting(true);
|
|
||||||
|
|
||||||
// validation step makes clean string
|
|
||||||
const validatedFields = { ...userFields };
|
|
||||||
const errors = false;
|
|
||||||
for (const field in validatedFields) {
|
|
||||||
validatedFields[field] = validatedFields[field].trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!errors) {
|
|
||||||
try {
|
|
||||||
await postUserFields(validatedFields);
|
|
||||||
} catch (error) {
|
|
||||||
emitError(`Error saving table options: ${error}`)
|
|
||||||
}
|
|
||||||
await refetch();
|
|
||||||
setChanged(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(false);
|
|
||||||
},[emitError, refetch, userFields]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverts local state equals to server state
|
|
||||||
*/
|
|
||||||
const revert = useCallback(async () => {
|
|
||||||
setChanged(false);
|
|
||||||
await refetch();
|
|
||||||
},[refetch]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles change of input field in local state
|
|
||||||
* @param {string} field - object parameter to update
|
|
||||||
* @param {string} value - new object parameter value
|
|
||||||
*/
|
|
||||||
const handleChange = useCallback((field, value) => {
|
|
||||||
if (value.length < 30) {
|
|
||||||
const temp = { ...userFields };
|
|
||||||
temp[field] = value;
|
|
||||||
setUserFields(temp);
|
|
||||||
setChanged(true);
|
|
||||||
}
|
|
||||||
},[userFields]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ModalBody className={style.modalBody}>
|
|
||||||
<p className={style.notes}>
|
|
||||||
Options related to cuesheets
|
|
||||||
<br />
|
|
||||||
🔥 Changes take effect on save 🔥
|
|
||||||
</p>
|
|
||||||
<form onSubmit={submitHandler}>
|
|
||||||
<div className={style.modalFields}>
|
|
||||||
<div className={style.hSeparator}>User Fields</div>
|
|
||||||
<div className={style.blockNotes}>
|
|
||||||
<span className={style.inlineFlex}>
|
|
||||||
<IoInformationCircleOutline color='#2b6cb0' fontSize='2em' />
|
|
||||||
User Fields
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
Userfields facilitate adding custom fields to an event (eg: light, sound, camera).{' '}
|
|
||||||
<br />
|
|
||||||
These are available for excel imports and shown in the{' '}
|
|
||||||
<a
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
href={`http://${host}cuesheet`}
|
|
||||||
onClick={(e) => handleLinks(e, 'cuesheet')}
|
|
||||||
>
|
|
||||||
cuesheet
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className={style.inlineAliasPlaceholder} style={{ padding: '0.5em 0' }}>
|
|
||||||
<span className={style.labelNote}>User Field</span>
|
|
||||||
<span className={style.labelNote}>Display Name</span>
|
|
||||||
</div>
|
|
||||||
{Object.keys(userFields).map((field) => (
|
|
||||||
<div className={style.inlineAlias} key={field}>
|
|
||||||
<span>{field}</span>
|
|
||||||
<Input
|
|
||||||
size='sm'
|
|
||||||
variant='flushed'
|
|
||||||
name='Alias'
|
|
||||||
placeholder={field}
|
|
||||||
autoComplete='off'
|
|
||||||
value={userFields[field]}
|
|
||||||
onChange={(event) => handleChange(field, event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<SubmitContainer
|
|
||||||
revert={revert}
|
|
||||||
submitting={submitting}
|
|
||||||
changed={changed}
|
|
||||||
status={status}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</ModalBody>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
import { ChangeEvent, FormEvent, useCallback, useEffect, useState } from 'react';
|
|
||||||
import { FormControl, FormLabel, Input, ModalBody, Switch } from '@chakra-ui/react';
|
|
||||||
import { ViewSettings } from 'ontime-types';
|
|
||||||
|
|
||||||
import { postView } from '../../common/api/ontimeApi';
|
|
||||||
import PopoverPicker from '../../common/components/input/popover-picker/PopoverPicker';
|
|
||||||
import useViewSettings from '../../common/hooks-query/useViewSettings';
|
|
||||||
import { viewsSettingsPlaceholder } from '../../common/models/ViewSettings.type';
|
|
||||||
import { useEmitLog } from '../../common/stores/logger';
|
|
||||||
import { forgivingStringToMillis, millisToMinutes } from '../../common/utils/dateConfig';
|
|
||||||
|
|
||||||
import { inputProps } from './modalHelper';
|
|
||||||
import SubmitContainer from './SubmitContainer';
|
|
||||||
|
|
||||||
import styles from './Modal.module.scss';
|
|
||||||
import style from './Modals.module.scss';
|
|
||||||
|
|
||||||
const stylingDocsUrl = 'https://cpvalente.gitbook.io/ontime/features/custom-styling';
|
|
||||||
|
|
||||||
export default function ViewsSettingsModal() {
|
|
||||||
const { data, status, refetch } = useViewSettings();
|
|
||||||
|
|
||||||
const { emitError } = useEmitLog();
|
|
||||||
const [formData, setFormData] = useState(viewsSettingsPlaceholder);
|
|
||||||
const [changed, setChanged] = useState(false);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set formdata from server state
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
if (data == null) return;
|
|
||||||
if (changed) return;
|
|
||||||
setFormData({ ...data });
|
|
||||||
}, [changed, data]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate and submit data
|
|
||||||
*/
|
|
||||||
const submitHandler = useCallback(
|
|
||||||
async (event: FormEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
await postView(formData);
|
|
||||||
} catch (error) {
|
|
||||||
emitError(`Error view settings: ${error}`);
|
|
||||||
} finally {
|
|
||||||
await refetch();
|
|
||||||
setChanged(false);
|
|
||||||
}
|
|
||||||
setSubmitting(false);
|
|
||||||
},
|
|
||||||
[emitError, formData, refetch],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverts local state equals to server state
|
|
||||||
*/
|
|
||||||
const revert = useCallback(async () => {
|
|
||||||
setChanged(false);
|
|
||||||
await refetch();
|
|
||||||
}, [refetch]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles change of input field in local state
|
|
||||||
* @param {string} field - object parameter to update
|
|
||||||
* @param {(string | number | boolean)} value - new object parameter value
|
|
||||||
*/
|
|
||||||
const handleChange = useCallback(
|
|
||||||
(field: string, value: string | number | boolean) => {
|
|
||||||
const temp = { ...formData };
|
|
||||||
//@ts-expect-error - TODO: dont know what to type here
|
|
||||||
temp[field] = value;
|
|
||||||
setFormData(temp);
|
|
||||||
setChanged(true);
|
|
||||||
},
|
|
||||||
[formData],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles change of number input field in local state
|
|
||||||
* @param {string} field - object parameter to update
|
|
||||||
* @param {(ChangeEvent<HTMLInputElement>)} value - new object parameter value
|
|
||||||
*/
|
|
||||||
function handleThresholdChange(field: string, event: ChangeEvent<HTMLInputElement>) {
|
|
||||||
const temp: ViewSettings = { ...formData };
|
|
||||||
const converted: number = forgivingStringToMillis(event.target.value);
|
|
||||||
//@ts-expect-error - TODO: dont know what to type here
|
|
||||||
temp[field] = converted;
|
|
||||||
setFormData(temp);
|
|
||||||
setChanged(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dangerThresholdValue = millisToMinutes(formData.dangerThreshold);
|
|
||||||
const warningThresholdValue = millisToMinutes(formData.warningThreshold);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={submitHandler} className={styles.sectionContainer} id='viewSettings'>
|
|
||||||
<ModalBody className={style.modalBody}>
|
|
||||||
<p className={style.notes}>
|
|
||||||
Options related to the viewers
|
|
||||||
<br />
|
|
||||||
<a href={stylingDocsUrl} target='_blank' rel='noreferrer'>
|
|
||||||
Read the docs
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<div className={style.hSeparator}>Timer end message</div>
|
|
||||||
<div className={style.spacedEntry}>
|
|
||||||
<FormLabel htmlFor='endMessage'>
|
|
||||||
End Message
|
|
||||||
<span className={style.labelNote}>
|
|
||||||
<br />
|
|
||||||
Shown on presenter view when time is finished
|
|
||||||
</span>
|
|
||||||
</FormLabel>
|
|
||||||
<Input
|
|
||||||
{...inputProps}
|
|
||||||
maxLength={50}
|
|
||||||
name='endMessage'
|
|
||||||
placeholder='Empty message shows elapsed time'
|
|
||||||
value={formData.endMessage}
|
|
||||||
onChange={(event) => handleChange('endMessage', event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={style.hSeparator}>Style Options</div>
|
|
||||||
<div className={styles.splitSection}>
|
|
||||||
<div>
|
|
||||||
<span className={`${styles.sectionTitle} ${styles.main}`}>Override CSS Styles</span>
|
|
||||||
<span className={styles.sectionSubtitle}>Enable / Disable override</span>
|
|
||||||
</div>
|
|
||||||
<Switch onChange={() => handleChange('overrideStyles', !formData.overrideStyles)} variant='ontime-on-light' />
|
|
||||||
</div>
|
|
||||||
<hr className={styles.divider} />
|
|
||||||
<div className={style.hSeparator}>Timer Options</div>
|
|
||||||
<div className={styles.splitSection}>
|
|
||||||
<div>
|
|
||||||
<span className={`${styles.sectionTitle} ${styles.main}`}>Normal Color</span>
|
|
||||||
<span className={styles.sectionSubtitle}>Change timer Normal Color</span>
|
|
||||||
</div>
|
|
||||||
<PopoverPicker color={formData.normalColor} onChange={(event) => handleChange('normalColor', event)} />
|
|
||||||
</div>
|
|
||||||
<div className={styles.splitSection}>
|
|
||||||
<div>
|
|
||||||
<span className={`${styles.sectionTitle} ${styles.main}`}>Warning Color</span>
|
|
||||||
<span className={styles.sectionSubtitle}>Change timer Warning Color</span>
|
|
||||||
</div>
|
|
||||||
<PopoverPicker color={formData.warningColor} onChange={(event) => handleChange('warningColor', event)} />
|
|
||||||
</div>
|
|
||||||
<FormControl className={styles.splitSection}>
|
|
||||||
<label htmlFor='warningThreshold'>
|
|
||||||
<span className={styles.sectionTitle}>Warning Time</span>
|
|
||||||
<span className={styles.sectionSubtitle}>The time (in minutes) when the color changes</span>
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
{...inputProps}
|
|
||||||
id='warningThreshold'
|
|
||||||
variant='ontime-filled-on-light'
|
|
||||||
value={warningThresholdValue}
|
|
||||||
onChange={(event) => handleThresholdChange('warningThreshold', event)}
|
|
||||||
width='75px'
|
|
||||||
size='sm'
|
|
||||||
textAlign='right'
|
|
||||||
maxLength={3}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<div className={styles.splitSection}>
|
|
||||||
<div>
|
|
||||||
<span className={`${styles.sectionTitle} ${styles.main}`}>Danger Color</span>
|
|
||||||
<span className={styles.sectionSubtitle}>Change timer Danger Color</span>
|
|
||||||
</div>
|
|
||||||
<PopoverPicker color={formData.dangerColor} onChange={(event) => handleChange('dangerColor', event)} />
|
|
||||||
</div>
|
|
||||||
<FormControl className={styles.splitSection}>
|
|
||||||
<label htmlFor='dangerThreshold'>
|
|
||||||
<span className={styles.sectionTitle}>Danger Time</span>
|
|
||||||
<span className={styles.sectionSubtitle}>The time (in minutes) when the color changes</span>
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
{...inputProps}
|
|
||||||
id='dangerThreshold'
|
|
||||||
variant='ontime-filled-on-light'
|
|
||||||
value={dangerThresholdValue}
|
|
||||||
onChange={(event) => handleThresholdChange('dangerThreshold', event)}
|
|
||||||
width='75px'
|
|
||||||
size='sm'
|
|
||||||
textAlign='right'
|
|
||||||
maxLength={3}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<div className={style.modalFields}>
|
|
||||||
<SubmitContainer revert={revert} submitting={submitting} changed={changed} status={status} />
|
|
||||||
</div>
|
|
||||||
</ModalBody>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,8 +7,8 @@ import { generateId } from 'ontime-utils';
|
|||||||
import useOscSettings, { useOscSettingsMutation } from '../../../common/hooks-query/useOscSettings';
|
import useOscSettings, { useOscSettingsMutation } from '../../../common/hooks-query/useOscSettings';
|
||||||
import { oscPlaceholderSettings, PlaceholderSettings } from '../../../common/models/OscSettings';
|
import { oscPlaceholderSettings, PlaceholderSettings } from '../../../common/models/OscSettings';
|
||||||
import { useEmitLog } from '../../../common/stores/logger';
|
import { useEmitLog } from '../../../common/stores/logger';
|
||||||
|
import OntimeModalFooter from '../OntimeModalFooter';
|
||||||
|
|
||||||
import OntimeModalFooter from './OntimeModalFooter';
|
|
||||||
import OscSubscriptionRow from './OscSubscriptionRow';
|
import OscSubscriptionRow from './OscSubscriptionRow';
|
||||||
|
|
||||||
import styles from '../Modal.module.scss';
|
import styles from '../Modal.module.scss';
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import useOscSettings, { useOscSettingsMutation } from '../../../common/hooks-qu
|
|||||||
import { PlaceholderSettings } from '../../../common/models/OscSettings';
|
import { PlaceholderSettings } from '../../../common/models/OscSettings';
|
||||||
import { useEmitLog } from '../../../common/stores/logger';
|
import { useEmitLog } from '../../../common/stores/logger';
|
||||||
import { isIPAddress, isOnlyNumbers } from '../../../common/utils/regex';
|
import { isIPAddress, isOnlyNumbers } from '../../../common/utils/regex';
|
||||||
|
import OntimeModalFooter from '../OntimeModalFooter';
|
||||||
import OntimeModalFooter from './OntimeModalFooter';
|
|
||||||
|
|
||||||
import styles from '../Modal.module.scss';
|
import styles from '../Modal.module.scss';
|
||||||
|
|
||||||
|
|||||||
@@ -41,13 +41,6 @@ export default function OscSubscriptionRow(props: OscSubscriptionRowProps) {
|
|||||||
{subscriptionOptions.map((option, idx) => (
|
{subscriptionOptions.map((option, idx) => (
|
||||||
<div key={option.id} className={styles.entryRow}>
|
<div key={option.id} className={styles.entryRow}>
|
||||||
<input type='hidden' {...register(`${registerPrefix}[${idx}].id`)} value={option.id} />
|
<input type='hidden' {...register(`${registerPrefix}[${idx}].id`)} value={option.id} />
|
||||||
<Switch size='sm' {...register(`${registerPrefix}[${idx}].enabled`)} />
|
|
||||||
<Input
|
|
||||||
placeholder='OSC Message'
|
|
||||||
size='xs'
|
|
||||||
variant='ontime-filled-on-light'
|
|
||||||
{...register(`${registerPrefix}[${idx}].message`)}
|
|
||||||
/>
|
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<IoRemove />}
|
icon={<IoRemove />}
|
||||||
onClick={() => handleDelete(cycle, option.id)}
|
onClick={() => handleDelete(cycle, option.id)}
|
||||||
@@ -55,6 +48,13 @@ export default function OscSubscriptionRow(props: OscSubscriptionRowProps) {
|
|||||||
size='xs'
|
size='xs'
|
||||||
colorScheme='red'
|
colorScheme='red'
|
||||||
/>
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder='OSC Message'
|
||||||
|
size='xs'
|
||||||
|
variant='ontime-filled-on-light'
|
||||||
|
{...register(`${registerPrefix}[${idx}].message`)}
|
||||||
|
/>
|
||||||
|
<Switch variant='ontime-on-light' {...register(`${registerPrefix}[${idx}].enabled`)} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
export const inputProps = {
|
export const inputProps = {
|
||||||
size: 'sm',
|
size: 'sm',
|
||||||
autoComplete: 'off',
|
autoComplete: 'off',
|
||||||
variant: 'outline',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const portInputProps = {
|
export const portInputProps = {
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
import { Alert, AlertDescription, AlertIcon, AlertTitle, Button, IconButton, Input, Switch } from '@chakra-ui/react';
|
||||||
|
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
|
||||||
|
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
|
||||||
|
import { Alias } from 'ontime-types';
|
||||||
|
|
||||||
|
import { postAliases } from '../../../common/api/ontimeApi';
|
||||||
|
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
|
||||||
|
import useAliases from '../../../common/hooks-query/useAliases';
|
||||||
|
import { useEmitLog } from '../../../common/stores/logger';
|
||||||
|
import { handleLinks } from '../../../common/utils/linkUtils';
|
||||||
|
import { inputProps } from '../modalHelper';
|
||||||
|
import ModalLink from '../ModalLink';
|
||||||
|
import OntimeModalFooter from '../OntimeModalFooter';
|
||||||
|
|
||||||
|
import style from './SettingsModal.module.scss';
|
||||||
|
|
||||||
|
const aliasesDocsUrl = 'https://ontime.gitbook.io/v2/features/url-aliases';
|
||||||
|
|
||||||
|
// we wrap the array in an object to be simplify react-hook-form
|
||||||
|
type Aliases = { aliases: Alias[] };
|
||||||
|
|
||||||
|
export default function AliasesForm() {
|
||||||
|
const { data, status, refetch } = useAliases();
|
||||||
|
const { emitError } = useEmitLog();
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
register,
|
||||||
|
reset,
|
||||||
|
formState: { isSubmitting, isDirty, isValid },
|
||||||
|
} = useForm<Aliases>({
|
||||||
|
defaultValues: { aliases: data },
|
||||||
|
values: { aliases: data || [] },
|
||||||
|
});
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
name: 'aliases',
|
||||||
|
control,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (formData: Aliases) => {
|
||||||
|
try {
|
||||||
|
await postAliases(formData.aliases);
|
||||||
|
} catch (error) {
|
||||||
|
emitError(`Error saving aliases: ${error}`);
|
||||||
|
} finally {
|
||||||
|
await refetch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReset = () => {
|
||||||
|
reset({ aliases: data });
|
||||||
|
};
|
||||||
|
|
||||||
|
const addNew = () => {
|
||||||
|
if (fields.length > 20) {
|
||||||
|
emitError('Maximum amount of aliases reacted (20)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
append({
|
||||||
|
enabled: false,
|
||||||
|
alias: '',
|
||||||
|
pathAndParams: '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const disableInputs = status === 'loading';
|
||||||
|
const hasTooManyOptions = fields.length >= 20;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} id='aliases' className={style.sectionContainer}>
|
||||||
|
<div style={{ height: '16px' }} />
|
||||||
|
<Alert status='info' variant='ontime-on-light-info'>
|
||||||
|
<AlertIcon />
|
||||||
|
<div className={style.column}>
|
||||||
|
<AlertTitle>URL Aliases</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Custom aliases allow providing a short name for any ontime URL. <br />
|
||||||
|
It serves two primary purposes: <br />
|
||||||
|
- Providing dynamic URLs for automation or unattended screens <br />
|
||||||
|
- Simplifying complex URLs
|
||||||
|
<ModalLink href={aliasesDocsUrl}>For more information, see the docs</ModalLink>
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
<div style={{ height: '16px' }} />
|
||||||
|
<ul className={style.aliases}>
|
||||||
|
{fields.map((alias, index) => {
|
||||||
|
return (
|
||||||
|
<li className={style.aliasRow} key={alias.id}>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
aria-label='delete'
|
||||||
|
size='xs'
|
||||||
|
icon={<IoRemove />}
|
||||||
|
colorScheme='red'
|
||||||
|
isDisabled={disableInputs}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
{...inputProps}
|
||||||
|
{...register(`aliases.${index}.alias`)}
|
||||||
|
width='12em'
|
||||||
|
size='xs'
|
||||||
|
variant='ontime-filled-on-light'
|
||||||
|
placeholder='URL Alias'
|
||||||
|
isDisabled={disableInputs}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
{...inputProps}
|
||||||
|
{...register(`aliases.${index}.pathAndParams`)}
|
||||||
|
className={style.grow}
|
||||||
|
size='xs'
|
||||||
|
variant='ontime-filled-on-light'
|
||||||
|
placeholder='URL (portion after ontime Port)'
|
||||||
|
isDisabled={disableInputs}
|
||||||
|
/>
|
||||||
|
<TooltipActionBtn
|
||||||
|
clickHandler={(event) => handleLinks(event, alias.pathAndParams)}
|
||||||
|
tooltip='Test alias'
|
||||||
|
aria-label='Test alias'
|
||||||
|
size='xs'
|
||||||
|
variant='ontime-ghost-on-light'
|
||||||
|
icon={<IoOpenOutline />}
|
||||||
|
colorScheme='red'
|
||||||
|
isDisabled={disableInputs}
|
||||||
|
/>
|
||||||
|
<Switch {...register(`aliases.${index}.enabled`)} variant='ontime-on-light' isDisabled={disableInputs} />
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<Button
|
||||||
|
onClick={addNew}
|
||||||
|
className={style.shiftRight}
|
||||||
|
isDisabled={hasTooManyOptions}
|
||||||
|
size='xs'
|
||||||
|
colorScheme='blue'
|
||||||
|
variant='outline'
|
||||||
|
padding='0 2em'
|
||||||
|
>
|
||||||
|
Add new
|
||||||
|
</Button>
|
||||||
|
<OntimeModalFooter
|
||||||
|
formId='aliases'
|
||||||
|
handleRevert={onReset}
|
||||||
|
isDirty={isDirty}
|
||||||
|
isValid={isValid}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { Input, Select } from '@chakra-ui/react';
|
||||||
|
import type { Settings } from 'ontime-types';
|
||||||
|
|
||||||
|
import { postSettings } from '../../../common/api/ontimeApi';
|
||||||
|
import useSettings from '../../../common/hooks-query/useSettings';
|
||||||
|
import { useEmitLog } from '../../../common/stores/logger';
|
||||||
|
import { isOnlyNumbers } from '../../../common/utils/regex';
|
||||||
|
import ModalSplitInput from '../ModalSplitInput';
|
||||||
|
import OntimeModalFooter from '../OntimeModalFooter';
|
||||||
|
|
||||||
|
import ModalPinInput from './ModalPinInput';
|
||||||
|
|
||||||
|
import style from './SettingsModal.module.scss';
|
||||||
|
|
||||||
|
export default function AppSettingsModal() {
|
||||||
|
const { data, status, refetch } = useSettings();
|
||||||
|
const { emitError } = useEmitLog();
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
register,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isSubmitting, isDirty, isValid },
|
||||||
|
} = useForm<Settings>({
|
||||||
|
defaultValues: data,
|
||||||
|
values: data,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (formData: Settings) => {
|
||||||
|
try {
|
||||||
|
await postSettings(formData);
|
||||||
|
} catch (error) {
|
||||||
|
emitError(`Error saving settings: ${error}`);
|
||||||
|
} finally {
|
||||||
|
await refetch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReset = () => {
|
||||||
|
reset(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const disableInputs = status === 'loading';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} id='app-settings' className={style.sectionContainer}>
|
||||||
|
<ModalSplitInput
|
||||||
|
field='serverPort'
|
||||||
|
title='Ontime is available on port'
|
||||||
|
description='Default 4001'
|
||||||
|
error={errors.serverPort?.message}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
width='75px'
|
||||||
|
size='sm'
|
||||||
|
textAlign='right'
|
||||||
|
maxLength={5}
|
||||||
|
disabled
|
||||||
|
variant='ontime-filled-on-light'
|
||||||
|
{...register('serverPort', {
|
||||||
|
required: { value: true, message: 'Required field' },
|
||||||
|
max: { value: 65535, message: 'Port in incorrect range (1024 - 65535)' },
|
||||||
|
min: { value: 1024, message: 'Port in incorrect range (1024 - 65535)' },
|
||||||
|
pattern: {
|
||||||
|
value: isOnlyNumbers,
|
||||||
|
message: 'Value should be numeric',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</ModalSplitInput>
|
||||||
|
<ModalSplitInput
|
||||||
|
field='editorKey'
|
||||||
|
title='Editor pin code'
|
||||||
|
description='Protect the editor with a pin code'
|
||||||
|
error={errors.editorKey?.message}
|
||||||
|
>
|
||||||
|
<ModalPinInput register={register} formName='editorKey' isDisabled={disableInputs} />
|
||||||
|
</ModalSplitInput>
|
||||||
|
<ModalSplitInput
|
||||||
|
field='operatorKey'
|
||||||
|
title='Operator pin code'
|
||||||
|
description='Protect the cuesheet with a pin code'
|
||||||
|
error={errors.operatorKey?.message}
|
||||||
|
>
|
||||||
|
<ModalPinInput register={register} formName='operatorKey' isDisabled={disableInputs} />
|
||||||
|
</ModalSplitInput>
|
||||||
|
<div style={{ height: '16px' }} />
|
||||||
|
<ModalSplitInput
|
||||||
|
field='timeFormat'
|
||||||
|
title='Time Format'
|
||||||
|
description='Views 12 / 24 hours'
|
||||||
|
error={errors.timeFormat?.message}
|
||||||
|
>
|
||||||
|
<Select backgroundColor='white' size='sm' width='auto' isDisabled={disableInputs} {...register('timeFormat')}>
|
||||||
|
<option value='12'>12 hours eg. 11:00:10 PM</option>
|
||||||
|
<option value='24'>24 hours eg. 23:00:10</option>
|
||||||
|
</Select>
|
||||||
|
</ModalSplitInput>
|
||||||
|
<ModalSplitInput
|
||||||
|
field='language'
|
||||||
|
title='Views Language'
|
||||||
|
description='Language for static fields in views'
|
||||||
|
error={errors.language?.message}
|
||||||
|
>
|
||||||
|
<Select backgroundColor='white' size='sm' width='auto' isDisabled={disableInputs} {...register('language')}>
|
||||||
|
<option value='en'>English</option>
|
||||||
|
<option value='de'>German</option>
|
||||||
|
<option value='no'>Norwegian</option>
|
||||||
|
</Select>
|
||||||
|
</ModalSplitInput>
|
||||||
|
<OntimeModalFooter
|
||||||
|
formId='app-settings'
|
||||||
|
handleRevert={onReset}
|
||||||
|
isDirty={isDirty}
|
||||||
|
isValid={isValid}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { Alert, AlertDescription, AlertIcon, AlertTitle, Input } from '@chakra-ui/react';
|
||||||
|
import { UserFields } from 'ontime-types';
|
||||||
|
|
||||||
|
import { postUserFields } from '../../../common/api/ontimeApi';
|
||||||
|
import useUserFields from '../../../common/hooks-query/useUserFields';
|
||||||
|
import { useEmitLog } from '../../../common/stores/logger';
|
||||||
|
import { inputProps } from '../modalHelper';
|
||||||
|
import ModalLink from '../ModalLink';
|
||||||
|
import ModalSplitInput from '../ModalSplitInput';
|
||||||
|
import OntimeModalFooter from '../OntimeModalFooter';
|
||||||
|
|
||||||
|
import style from './SettingsModal.module.scss';
|
||||||
|
|
||||||
|
const userFieldsDocsUrl = 'https://ontime.gitbook.io/v2/features/user-fields';
|
||||||
|
|
||||||
|
export default function CuesheetSettings() {
|
||||||
|
const { data, status, refetch } = useUserFields();
|
||||||
|
const { emitError } = useEmitLog();
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
register,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isSubmitting, isDirty, isValid },
|
||||||
|
} = useForm<UserFields>({
|
||||||
|
defaultValues: data,
|
||||||
|
values: data,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (formData: UserFields) => {
|
||||||
|
try {
|
||||||
|
await postUserFields(formData);
|
||||||
|
} catch (error) {
|
||||||
|
emitError(`Error saving cuesheet settings: ${error}`);
|
||||||
|
} finally {
|
||||||
|
await refetch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReset = () => {
|
||||||
|
reset(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const disableInputs = status === 'loading';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} id='cuesheet-settings' className={style.sectionContainer}>
|
||||||
|
<div style={{ height: '16px' }} />
|
||||||
|
<Alert status='info' variant='ontime-on-light-info'>
|
||||||
|
<AlertIcon />
|
||||||
|
<div className={style.column}>
|
||||||
|
<AlertTitle>User Fields</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Allow for custom naming of additional data fields on each event (eg. light, sound, camera). <br />
|
||||||
|
<ModalLink href={userFieldsDocsUrl}>See the docs</ModalLink>
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
<div style={{ height: '16px' }} />
|
||||||
|
<ModalSplitInput field='user0' title='User0' description='' error={errors.user0?.message}>
|
||||||
|
<Input
|
||||||
|
{...inputProps}
|
||||||
|
width='300px'
|
||||||
|
variant='ontime-filled-on-light'
|
||||||
|
isDisabled={disableInputs}
|
||||||
|
placeholder='Display name for user field'
|
||||||
|
{...register('user0')}
|
||||||
|
/>
|
||||||
|
</ModalSplitInput>
|
||||||
|
<ModalSplitInput field='user1' title='User1' description='' error={errors.user1?.message}>
|
||||||
|
<Input
|
||||||
|
{...inputProps}
|
||||||
|
width='300px'
|
||||||
|
variant='ontime-filled-on-light'
|
||||||
|
isDisabled={disableInputs}
|
||||||
|
placeholder='Display name for user field'
|
||||||
|
{...register('user1')}
|
||||||
|
/>
|
||||||
|
</ModalSplitInput>
|
||||||
|
<ModalSplitInput field='user2' title='User2' description='' error={errors.user2?.message}>
|
||||||
|
<Input
|
||||||
|
{...inputProps}
|
||||||
|
width='300px'
|
||||||
|
variant='ontime-filled-on-light'
|
||||||
|
isDisabled={disableInputs}
|
||||||
|
placeholder='Display name for user field'
|
||||||
|
{...register('user2')}
|
||||||
|
/>
|
||||||
|
</ModalSplitInput>
|
||||||
|
<ModalSplitInput field='user3' title='User3' description='' error={errors.user3?.message}>
|
||||||
|
<Input
|
||||||
|
{...inputProps}
|
||||||
|
width='300px'
|
||||||
|
variant='ontime-filled-on-light'
|
||||||
|
isDisabled={disableInputs}
|
||||||
|
placeholder='Display name for user field'
|
||||||
|
{...register('user3')}
|
||||||
|
/>
|
||||||
|
</ModalSplitInput>
|
||||||
|
<ModalSplitInput field='user4' title='User4' description='' error={errors.user4?.message}>
|
||||||
|
<Input
|
||||||
|
{...inputProps}
|
||||||
|
width='300px'
|
||||||
|
variant='ontime-filled-on-light'
|
||||||
|
isDisabled={disableInputs}
|
||||||
|
placeholder='Display name for user field'
|
||||||
|
{...register('user4')}
|
||||||
|
/>
|
||||||
|
</ModalSplitInput>
|
||||||
|
<ModalSplitInput field='user5' title='User5' description='' error={errors.user5?.message}>
|
||||||
|
<Input
|
||||||
|
{...inputProps}
|
||||||
|
width='300px'
|
||||||
|
variant='ontime-filled-on-light'
|
||||||
|
isDisabled={disableInputs}
|
||||||
|
placeholder='Display name for user field'
|
||||||
|
{...register('user5')}
|
||||||
|
/>
|
||||||
|
</ModalSplitInput>
|
||||||
|
<ModalSplitInput field='user6' title='User6' description='' error={errors.user6?.message}>
|
||||||
|
<Input
|
||||||
|
{...inputProps}
|
||||||
|
width='300px'
|
||||||
|
variant='ontime-filled-on-light'
|
||||||
|
isDisabled={disableInputs}
|
||||||
|
placeholder='Display name for user field'
|
||||||
|
{...register('user6')}
|
||||||
|
/>
|
||||||
|
</ModalSplitInput>
|
||||||
|
<ModalSplitInput field='user7' title='User7' description='' error={errors.user7?.message}>
|
||||||
|
<Input
|
||||||
|
{...inputProps}
|
||||||
|
width='300px'
|
||||||
|
variant='ontime-filled-on-light'
|
||||||
|
isDisabled={disableInputs}
|
||||||
|
placeholder='Display name for user field'
|
||||||
|
{...register('user7')}
|
||||||
|
/>
|
||||||
|
</ModalSplitInput>
|
||||||
|
<ModalSplitInput field='user8' title='User8' description='' error={errors.user8?.message}>
|
||||||
|
<Input
|
||||||
|
{...inputProps}
|
||||||
|
width='300px'
|
||||||
|
variant='ontime-filled-on-light'
|
||||||
|
isDisabled={disableInputs}
|
||||||
|
placeholder='Display name for user field'
|
||||||
|
{...register('user8')}
|
||||||
|
/>
|
||||||
|
</ModalSplitInput>
|
||||||
|
<ModalSplitInput field='user9' title='User9' description='' error={errors.user9?.message}>
|
||||||
|
<Input
|
||||||
|
{...inputProps}
|
||||||
|
width='300px'
|
||||||
|
variant='ontime-filled-on-light'
|
||||||
|
isDisabled={disableInputs}
|
||||||
|
placeholder='Display name for user field'
|
||||||
|
{...register('user9')}
|
||||||
|
/>
|
||||||
|
</ModalSplitInput>
|
||||||
|
<OntimeModalFooter
|
||||||
|
formId='cuesheet-settings'
|
||||||
|
handleRevert={onReset}
|
||||||
|
isDirty={isDirty}
|
||||||
|
isValid={isValid}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Switch } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { useLocalEvent } from '../../../common/stores/localEvent';
|
||||||
|
import ModalSplitInput from '../ModalSplitInput';
|
||||||
|
|
||||||
|
import style from './SettingsModal.module.scss';
|
||||||
|
|
||||||
|
export default function EditorSettings() {
|
||||||
|
const eventSettings = useLocalEvent((state) => state.eventSettings);
|
||||||
|
const setShowQuickEntry = useLocalEvent((state) => state.setShowQuickEntry);
|
||||||
|
const setStartTimeIsLastEnd = useLocalEvent((state) => state.setStartTimeIsLastEnd);
|
||||||
|
const setDefaultPublic = useLocalEvent((state) => state.setDefaultPublic);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.sectionContainer}>
|
||||||
|
<span className={style.title}>Rundown settings</span>
|
||||||
|
<ModalSplitInput field='' title='Show quick entry' description='Whether quick entry shows under selected event'>
|
||||||
|
<Switch
|
||||||
|
variant='ontime-on-light'
|
||||||
|
defaultChecked={eventSettings.showQuickEntry}
|
||||||
|
onChange={(event) => setShowQuickEntry(event.target.checked)}
|
||||||
|
/>
|
||||||
|
</ModalSplitInput>
|
||||||
|
<ModalSplitInput
|
||||||
|
field=''
|
||||||
|
title='Start time is last end'
|
||||||
|
description='New events start time will be previous event end'
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
variant='ontime-on-light'
|
||||||
|
defaultChecked={eventSettings.startTimeIsLastEnd}
|
||||||
|
onChange={(event) => setStartTimeIsLastEnd(event.target.checked)}
|
||||||
|
/>
|
||||||
|
</ModalSplitInput>
|
||||||
|
<ModalSplitInput field='' title='Default public' description='New events will be public'>
|
||||||
|
<Switch
|
||||||
|
variant='ontime-on-light'
|
||||||
|
defaultChecked={eventSettings.defaultPublic}
|
||||||
|
onChange={(event) => setDefaultPublic(event.target.checked)}
|
||||||
|
/>
|
||||||
|
</ModalSplitInput>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { Input, Textarea } from '@chakra-ui/react';
|
||||||
|
import { EventData } from 'ontime-types';
|
||||||
|
|
||||||
|
import { postEventData } from '../../../common/api/eventDataApi';
|
||||||
|
import useEventData from '../../../common/hooks-query/useEventData';
|
||||||
|
import { useEmitLog } from '../../../common/stores/logger';
|
||||||
|
import { inputProps } from '../modalHelper';
|
||||||
|
import ModalInput from '../ModalInput';
|
||||||
|
import OntimeModalFooter from '../OntimeModalFooter';
|
||||||
|
|
||||||
|
import style from './SettingsModal.module.scss';
|
||||||
|
|
||||||
|
export default function EventDataForm() {
|
||||||
|
const { data, status, refetch } = useEventData();
|
||||||
|
const { emitError } = useEmitLog();
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
register,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isSubmitting, isDirty, isValid },
|
||||||
|
} = useForm<EventData>({
|
||||||
|
defaultValues: data,
|
||||||
|
values: data,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (formData: EventData) => {
|
||||||
|
try {
|
||||||
|
await postEventData(formData);
|
||||||
|
} catch (error) {
|
||||||
|
emitError(`Error saving event settings: ${error}`);
|
||||||
|
} finally {
|
||||||
|
await refetch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReset = () => {
|
||||||
|
reset(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const disableInputs = status === 'loading';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} id='event-data' className={style.sectionContainer}>
|
||||||
|
<ModalInput
|
||||||
|
field='title'
|
||||||
|
title='Event title'
|
||||||
|
description='Shown in overview screens'
|
||||||
|
error={errors.title?.message}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
{...inputProps}
|
||||||
|
variant='ontime-filled-on-light'
|
||||||
|
maxLength={50}
|
||||||
|
placeholder='Eurovision song contest'
|
||||||
|
isDisabled={disableInputs}
|
||||||
|
{...register('title')}
|
||||||
|
/>
|
||||||
|
</ModalInput>
|
||||||
|
<div style={{ height: '16px' }} />
|
||||||
|
<ModalInput field='publicInfo' title='Public Info' description='Information shown in public screens'>
|
||||||
|
<Textarea
|
||||||
|
{...inputProps}
|
||||||
|
variant='ontime-filled-on-light'
|
||||||
|
maxLength={150}
|
||||||
|
placeholder='Shows always start ontime'
|
||||||
|
isDisabled={disableInputs}
|
||||||
|
{...register('publicInfo')}
|
||||||
|
/>
|
||||||
|
</ModalInput>
|
||||||
|
<ModalInput field='publicUrl' title='Public URL' description='QR code to be shown on public screens'>
|
||||||
|
<Input
|
||||||
|
{...inputProps}
|
||||||
|
variant='ontime-filled-on-light'
|
||||||
|
placeholder='www.getontime.no'
|
||||||
|
isDisabled={disableInputs}
|
||||||
|
{...register('publicUrl')}
|
||||||
|
/>
|
||||||
|
</ModalInput>
|
||||||
|
<div style={{ height: '16px' }} />
|
||||||
|
<ModalInput field='backstageInfo' title='Backstage Info' description='Information shown in public screens'>
|
||||||
|
<Textarea
|
||||||
|
{...inputProps}
|
||||||
|
variant='ontime-filled-on-light'
|
||||||
|
maxLength={150}
|
||||||
|
placeholder='Wi-Fi password: 1234'
|
||||||
|
isDisabled={disableInputs}
|
||||||
|
{...register('backstageInfo')}
|
||||||
|
/>
|
||||||
|
</ModalInput>
|
||||||
|
<ModalInput field='backstageUrl' title='Backstage URL' description='QR code to be shown on public screens'>
|
||||||
|
<Input
|
||||||
|
{...inputProps}
|
||||||
|
variant='ontime-filled-on-light'
|
||||||
|
size='sm'
|
||||||
|
placeholder='www.ontime.gitbook.io'
|
||||||
|
isDisabled={disableInputs}
|
||||||
|
{...register('backstageUrl')}
|
||||||
|
/>
|
||||||
|
</ModalInput>
|
||||||
|
<OntimeModalFooter
|
||||||
|
formId='event-data'
|
||||||
|
handleRevert={onReset}
|
||||||
|
isDirty={isDirty}
|
||||||
|
isValid={isValid}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useController, UseControllerProps } from 'react-hook-form';
|
||||||
|
import { Input } from '@chakra-ui/react';
|
||||||
|
import { ViewSettings } from 'ontime-types';
|
||||||
|
|
||||||
|
import { millisToMinutes } from '../../../common/utils/dateConfig';
|
||||||
|
import { inputProps } from '../modalHelper';
|
||||||
|
|
||||||
|
export default function InputMillisWithString(props: UseControllerProps<ViewSettings>) {
|
||||||
|
const { name, control } = props;
|
||||||
|
const {
|
||||||
|
field: { onChange, value },
|
||||||
|
} = useController({
|
||||||
|
control,
|
||||||
|
name,
|
||||||
|
rules: {
|
||||||
|
pattern: {
|
||||||
|
value: /^[0-9]+$/,
|
||||||
|
message: 'Only numbers are valid',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
{...inputProps}
|
||||||
|
type='number'
|
||||||
|
variant='ontime-filled-on-light'
|
||||||
|
width='75px'
|
||||||
|
size='sm'
|
||||||
|
maxLength={3}
|
||||||
|
defaultValue={millisToMinutes(value as number)}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { UseFormRegister } from 'react-hook-form';
|
||||||
|
import { IconButton, Input, InputGroup, InputRightElement } from '@chakra-ui/react';
|
||||||
|
import { IoEyeOutline } from '@react-icons/all-files/io5/IoEyeOutline';
|
||||||
|
|
||||||
|
interface ModalPinInputProps {
|
||||||
|
register: UseFormRegister<any>;
|
||||||
|
formName: string;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ModalPinInput({ register, formName, isDisabled }: ModalPinInputProps) {
|
||||||
|
const [isVisible, setVisible] = useState(false);
|
||||||
|
return (
|
||||||
|
<InputGroup size='sm' width='100px'>
|
||||||
|
<Input
|
||||||
|
type={isVisible ? 'text' : 'password'}
|
||||||
|
maxLength={4}
|
||||||
|
{...register(formName)}
|
||||||
|
placeholder='-'
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
/>
|
||||||
|
<InputRightElement>
|
||||||
|
<IconButton
|
||||||
|
onMouseDown={() => setVisible(true)}
|
||||||
|
onMouseUp={() => setVisible(false)}
|
||||||
|
size='sm'
|
||||||
|
variant='ontime-ghost-on-light'
|
||||||
|
icon={<IoEyeOutline />}
|
||||||
|
aria-label='Show pin code'
|
||||||
|
/>
|
||||||
|
</InputRightElement>
|
||||||
|
</InputGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
@import "../Modal.module.scss";
|
||||||
|
|
||||||
|
.aliases {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 0;
|
||||||
|
|
||||||
|
.aliasRow {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grow {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { ModalBody, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import ModalWrapper from '../ModalWrapper';
|
||||||
|
|
||||||
|
import AliasesForm from './AliasesForm';
|
||||||
|
import AppSettingsModal from './AppSettings';
|
||||||
|
import CuesheetSettings from './CuesheetSettings';
|
||||||
|
import EditorSettings from './EditorSettings';
|
||||||
|
import EventDataForm from './EventDataForm';
|
||||||
|
import ViewSettingsForm from './ViewSettingsForm';
|
||||||
|
|
||||||
|
interface ModalManagerProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsModal(props: ModalManagerProps) {
|
||||||
|
const { isOpen, onClose } = props;
|
||||||
|
return (
|
||||||
|
<ModalWrapper title='Ontime Settings' isOpen={isOpen} onClose={onClose}>
|
||||||
|
<ModalBody>
|
||||||
|
<Tabs variant='ontime' size='sm' isLazy>
|
||||||
|
<TabList>
|
||||||
|
<Tab>App Settings</Tab>
|
||||||
|
<Tab>Event Data</Tab>
|
||||||
|
<Tab>Editor</Tab>
|
||||||
|
<Tab>Cuesheet</Tab>
|
||||||
|
<Tab>Views</Tab>
|
||||||
|
<Tab>URL Aliases</Tab>
|
||||||
|
</TabList>
|
||||||
|
<TabPanels>
|
||||||
|
<TabPanel>
|
||||||
|
<AppSettingsModal />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel>
|
||||||
|
<EventDataForm />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel>
|
||||||
|
<EditorSettings />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel>
|
||||||
|
<CuesheetSettings />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel>
|
||||||
|
<ViewSettingsForm />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel>
|
||||||
|
<AliasesForm />
|
||||||
|
</TabPanel>
|
||||||
|
</TabPanels>
|
||||||
|
</Tabs>
|
||||||
|
</ModalBody>
|
||||||
|
</ModalWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { Input, Switch } from '@chakra-ui/react';
|
||||||
|
import { ViewSettings } from 'ontime-types';
|
||||||
|
|
||||||
|
import { postViewSettings } from '../../../common/api/ontimeApi';
|
||||||
|
import { PopoverPickerRHF } from '../../../common/components/input/popover-picker/PopoverPicker';
|
||||||
|
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
||||||
|
import { useEmitLog } from '../../../common/stores/logger';
|
||||||
|
import { mtm } from '../../../common/utils/timeConstants';
|
||||||
|
import { inputProps } from '../modalHelper';
|
||||||
|
import ModalInput from '../ModalInput';
|
||||||
|
import ModalSplitInput from '../ModalSplitInput';
|
||||||
|
import OntimeModalFooter from '../OntimeModalFooter';
|
||||||
|
|
||||||
|
import InputMillisWithString from './InputMillisWithString';
|
||||||
|
|
||||||
|
import style from './SettingsModal.module.scss';
|
||||||
|
|
||||||
|
export default function ViewSettingsForm() {
|
||||||
|
const { data, status, refetch } = useViewSettings();
|
||||||
|
const { emitError } = useEmitLog();
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
register,
|
||||||
|
reset,
|
||||||
|
formState: { isSubmitting, isDirty, isValid, dirtyFields },
|
||||||
|
} = useForm<ViewSettings>({
|
||||||
|
defaultValues: data,
|
||||||
|
values: data,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (formData: ViewSettings) => {
|
||||||
|
const parsedWarningThreshold = dirtyFields?.warningThreshold
|
||||||
|
? // @ts-expect-error -- trust me
|
||||||
|
Number.parseInt(formData.warningThreshold) * mtm
|
||||||
|
: formData.warningThreshold;
|
||||||
|
const parsedDangerThreshold = dirtyFields?.dangerThreshold
|
||||||
|
? // @ts-expect-error -- trust me
|
||||||
|
Number.parseInt(formData.dangerThreshold) * mtm
|
||||||
|
: formData.dangerThreshold;
|
||||||
|
|
||||||
|
const newData = {
|
||||||
|
...formData,
|
||||||
|
warningThreshold: parsedWarningThreshold,
|
||||||
|
dangerThreshold: parsedDangerThreshold,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await postViewSettings(newData);
|
||||||
|
} catch (error) {
|
||||||
|
emitError(`Error saving view settings: ${error}`);
|
||||||
|
} finally {
|
||||||
|
await refetch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReset = () => {
|
||||||
|
reset(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!control) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const disableInputs = status === 'loading';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} id='view-settings' className={style.sectionContainer}>
|
||||||
|
<span className={style.title}>General view settings</span>
|
||||||
|
<ModalSplitInput
|
||||||
|
field='overrideStyles'
|
||||||
|
title='Override CSS Styles'
|
||||||
|
description='Enables overriding view styles with custom stylesheet'
|
||||||
|
>
|
||||||
|
<Switch {...register('overrideStyles')} variant='ontime-on-light' />
|
||||||
|
</ModalSplitInput>
|
||||||
|
<span className={style.title}>Timer view settings</span>
|
||||||
|
<ModalSplitInput field='normalColor' title='Timer colour' description='Normal colour of a running timer'>
|
||||||
|
<PopoverPickerRHF name='normalColor' control={control} />
|
||||||
|
</ModalSplitInput>
|
||||||
|
<ModalSplitInput
|
||||||
|
field='warningColor'
|
||||||
|
title='Warning Color'
|
||||||
|
description='Time (in minutes) when the timer moves to warning mode'
|
||||||
|
>
|
||||||
|
<InputMillisWithString name='warningThreshold' control={control} />
|
||||||
|
</ModalSplitInput>
|
||||||
|
<ModalSplitInput field='warningColor' title='Warning Color' description='Colour of timer in warning mode'>
|
||||||
|
<PopoverPickerRHF name='warningColor' control={control} />
|
||||||
|
</ModalSplitInput>
|
||||||
|
<ModalSplitInput
|
||||||
|
field='dangerThreshold'
|
||||||
|
title='Danger colour'
|
||||||
|
description='Time (in minutes) when the timer moves to danger mode'
|
||||||
|
>
|
||||||
|
<InputMillisWithString name='dangerThreshold' control={control} />
|
||||||
|
</ModalSplitInput>
|
||||||
|
<ModalSplitInput field='dangerColor' title='Timer colour' description='Colour of timer in danger mode'>
|
||||||
|
<PopoverPickerRHF name='dangerColor' control={control} />
|
||||||
|
</ModalSplitInput>
|
||||||
|
<div style={{ height: '16px' }} />
|
||||||
|
<ModalInput
|
||||||
|
field='endMessage'
|
||||||
|
title='End Message'
|
||||||
|
description='If no end message is provided, timer will continue in overtime mode'
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
{...inputProps}
|
||||||
|
variant='ontime-filled-on-light'
|
||||||
|
maxLength={150}
|
||||||
|
placeholder='Message to be shown when timer reaches end'
|
||||||
|
isDisabled={disableInputs}
|
||||||
|
{...register('endMessage')}
|
||||||
|
/>
|
||||||
|
</ModalInput>
|
||||||
|
<OntimeModalFooter
|
||||||
|
formId='view-settings'
|
||||||
|
handleRevert={onReset}
|
||||||
|
isDirty={isDirty}
|
||||||
|
isValid={isValid}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export const ontimeAlertOnLight = {
|
||||||
|
container: {
|
||||||
|
fontSize: '14px',
|
||||||
|
backgroundColor: '#f6f6f6', // $gray-50
|
||||||
|
color: '#101010', // $ui-black
|
||||||
|
borderRadius: '3px',
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
color: '#578AF4', // $blue-500
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -13,13 +13,13 @@ export const ontimeSwitch = {
|
|||||||
|
|
||||||
export const lightSwitch = {
|
export const lightSwitch = {
|
||||||
track: {
|
track: {
|
||||||
border: '2px solid transparent',
|
border: '1px solid transparent',
|
||||||
background: '#cfcfcf', // $gray-300
|
background: '#cfcfcf', // $gray-300
|
||||||
_checked: {
|
_checked: {
|
||||||
background: `#578AF4`, // $blue-500
|
background: `#578AF4`, // $blue-500
|
||||||
},
|
},
|
||||||
_focus: {
|
_focus: {
|
||||||
border: '2px solid #D2DDFF', // $blue-200
|
border: '1px solid #D2DDFF', // $blue-200
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { extendTheme } from '@chakra-ui/react';
|
import { extendTheme } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { ontimeAlertOnLight } from './OntimeAlert';
|
||||||
import {
|
import {
|
||||||
ontimeButtonFilled,
|
ontimeButtonFilled,
|
||||||
ontimeButtonOutlined,
|
ontimeButtonOutlined,
|
||||||
@@ -26,6 +27,11 @@ import { ontimeTooltip } from './ontimeTooltip';
|
|||||||
|
|
||||||
const theme = extendTheme({
|
const theme = extendTheme({
|
||||||
components: {
|
components: {
|
||||||
|
Alert: {
|
||||||
|
variants: {
|
||||||
|
'ontime-on-light-info': { ...ontimeAlertOnLight },
|
||||||
|
},
|
||||||
|
},
|
||||||
Button: {
|
Button: {
|
||||||
baseStyle: {
|
baseStyle: {
|
||||||
letterSpacing: '0.3px',
|
letterSpacing: '0.3px',
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ import React, { createContext, useCallback, useContext, useState } from 'react';
|
|||||||
|
|
||||||
import { langDe } from '@/translation/languages/de';
|
import { langDe } from '@/translation/languages/de';
|
||||||
import { langEn } from '@/translation/languages/en';
|
import { langEn } from '@/translation/languages/en';
|
||||||
|
import { langNo } from '@/translation/languages/no';
|
||||||
|
import { langSv } from '@/translation/languages/sv';
|
||||||
|
|
||||||
const translationsList = {
|
const translationsList = {
|
||||||
en: langEn,
|
en: langEn,
|
||||||
de: langDe,
|
de: langDe,
|
||||||
|
no: langNo,
|
||||||
|
sv: langSv,
|
||||||
};
|
};
|
||||||
|
|
||||||
const ALLOWED_LANGUAGES = Object.keys(translationsList);
|
const ALLOWED_LANGUAGES = Object.keys(translationsList);
|
||||||
const DEFAULT_LANGUAGE = 'en';
|
const DEFAULT_LANGUAGE = 'en';
|
||||||
export const TranslationContext = createContext(undefined);
|
export const TranslationContext = createContext(undefined);
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { TranslationObject } from '../Translation.types';
|
||||||
|
|
||||||
|
export const langNo: TranslationObject = {
|
||||||
|
'common.end_time': 'Sluttid',
|
||||||
|
'common.expected_finish': 'Forventet slutt',
|
||||||
|
'common.now': 'Nå',
|
||||||
|
'common.next': 'Neste',
|
||||||
|
'common.public_message': 'Offentlig beskjed',
|
||||||
|
'common.start_time': 'Starttid',
|
||||||
|
'common.stage_timer': 'Scenetimer',
|
||||||
|
'common.started_at': 'Startet',
|
||||||
|
'common.time_now': 'Tid nå',
|
||||||
|
'countdown.ended': 'Hendelse avsluttet',
|
||||||
|
'countdown.running': 'Hendelse pågår',
|
||||||
|
'countdown.select_event': 'Velg en hendelse å følge',
|
||||||
|
'countdown.to_start': 'Tid til start',
|
||||||
|
'countdown.waiting': 'Venter på start',
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { TranslationObject } from '../Translation.types';
|
||||||
|
|
||||||
|
export const langSv: TranslationObject = {
|
||||||
|
'common.end_time': 'Sluttid',
|
||||||
|
'common.expected_finish': 'Förväntat slut',
|
||||||
|
'common.now': 'Nu',
|
||||||
|
'common.next': 'Nästa',
|
||||||
|
'common.public_message': 'Offentligt meddelande',
|
||||||
|
'common.start_time': 'Starttid',
|
||||||
|
'common.stage_timer': 'Timer för scenen',
|
||||||
|
'common.started_at': 'Började vid',
|
||||||
|
'common.time_now': 'Tid nu',
|
||||||
|
'countdown.ended': 'Evenemanget avslutades vid',
|
||||||
|
'countdown.running': 'Evenemang pågår',
|
||||||
|
'countdown.select_event': 'Välj ett evenemang att följa',
|
||||||
|
'countdown.to_start': 'Tid till start',
|
||||||
|
'countdown.waiting': '"Väntar på att evenemanget ska starta',
|
||||||
|
};
|
||||||
@@ -44,8 +44,12 @@ export class DataProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async deleteEvent(eventId) {
|
static async deleteEvent(eventId) {
|
||||||
data.rundown = Array.from(data.rundown).filter((e) => e.id !== eventId);
|
const eventIndex = data.rundown.findIndex((e) => e.id === eventId);
|
||||||
await this.persist();
|
|
||||||
|
if (eventIndex !== -1) {
|
||||||
|
data.rundown.splice(eventIndex, 1);
|
||||||
|
await this.persist();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static getRundownLength() {
|
static getRundownLength() {
|
||||||
@@ -165,7 +169,6 @@ export class DataProvider {
|
|||||||
data.settings = mergedData.settings;
|
data.settings = mergedData.settings;
|
||||||
data.viewSettings = mergedData.viewSettings;
|
data.viewSettings = mergedData.viewSettings;
|
||||||
data.osc = mergedData.osc;
|
data.osc = mergedData.osc;
|
||||||
data.http = mergedData.http;
|
|
||||||
data.aliases = mergedData.aliases;
|
data.aliases = mergedData.aliases;
|
||||||
data.userFields = mergedData.userFields;
|
data.userFields = mergedData.userFields;
|
||||||
data.rundown = mergedData.rundown;
|
data.rundown = mergedData.rundown;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import type { EventData } from 'ontime-types';
|
import type { Alias, EventData } from 'ontime-types';
|
||||||
import { networkInterfaces } from 'os';
|
import { networkInterfaces } from 'os';
|
||||||
import { generateId } from 'ontime-utils';
|
|
||||||
import { fileHandler } from '../utils/parser.js';
|
import { fileHandler } from '../utils/parser.js';
|
||||||
import { DataProvider } from '../classes/data-provider/DataProvider.js';
|
import { DataProvider } from '../classes/data-provider/DataProvider.js';
|
||||||
import { failEmptyObjects, failIsNotArray } from '../utils/routerUtils.js';
|
import { failEmptyObjects, failIsNotArray } from '../utils/routerUtils.js';
|
||||||
@@ -136,10 +135,9 @@ export const postAliases = async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const newAliases = [];
|
const newAliases: Alias[] = [];
|
||||||
req.body.forEach((a) => {
|
req.body.forEach((a) => {
|
||||||
newAliases.push({
|
newAliases.push({
|
||||||
id: generateId(),
|
|
||||||
enabled: a.enabled,
|
enabled: a.enabled,
|
||||||
alias: a.alias,
|
alias: a.alias,
|
||||||
pathAndParams: a.pathAndParams,
|
pathAndParams: a.pathAndParams,
|
||||||
@@ -178,16 +176,23 @@ export const postUserFields = async (req, res) => {
|
|||||||
// Create controller for POST request to '/ontime/settings'
|
// Create controller for POST request to '/ontime/settings'
|
||||||
// Returns -
|
// Returns -
|
||||||
export const getSettings = async (req, res) => {
|
export const getSettings = async (req, res) => {
|
||||||
const { version, serverPort, pinCode, timeFormat } = DataProvider.getSettings();
|
const settings = DataProvider.getSettings();
|
||||||
|
res.status(200).send(settings);
|
||||||
res.status(200).send({
|
|
||||||
version,
|
|
||||||
serverPort,
|
|
||||||
pinCode,
|
|
||||||
timeFormat,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function extractPin(value: string | undefined | null, fallback: string | null): string | null {
|
||||||
|
if (value === null) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'undefined') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
if (value.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
// Create controller for POST request to '/ontime/settings'
|
// Create controller for POST request to '/ontime/settings'
|
||||||
// Returns ACK message
|
// Returns ACK message
|
||||||
export const postSettings = async (req, res) => {
|
export const postSettings = async (req, res) => {
|
||||||
@@ -196,26 +201,22 @@ export const postSettings = async (req, res) => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const settings = DataProvider.getSettings();
|
const settings = DataProvider.getSettings();
|
||||||
let pin = settings.pinCode;
|
const editorKey = extractPin(req.body?.editorKey, settings.editorKey);
|
||||||
if (typeof req.body?.pinCode === 'string') {
|
const operatorKey = extractPin(req.body?.operatorKey, settings.operatorKey);
|
||||||
if (req.body?.pinCode.length === 0) {
|
|
||||||
pin = null;
|
let timeFormat = settings.timeFormat;
|
||||||
} else if (req.body?.pinCode.length <= 4) {
|
if (req.body?.timeFormat === '12' || req.body?.timeFormat === '24') {
|
||||||
pin = req.body?.pinCode;
|
timeFormat = req.body.timeFormat;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let format = settings.timeFormat;
|
const language = req.body?.language || 'en';
|
||||||
if (typeof req.body?.timeFormat === 'string') {
|
|
||||||
if (req.body?.timeFormat === '12' || req.body?.timeFormat === '24') {
|
|
||||||
format = req.body.timeFormat;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newData = {
|
const newData = {
|
||||||
...settings,
|
...settings,
|
||||||
pinCode: pin,
|
editorKey,
|
||||||
timeFormat: format,
|
operatorKey,
|
||||||
|
timeFormat,
|
||||||
|
language,
|
||||||
};
|
};
|
||||||
await DataProvider.setSettings(newData);
|
await DataProvider.setSettings(newData);
|
||||||
res.status(200).send(newData);
|
res.status(200).send(newData);
|
||||||
|
|||||||
@@ -54,8 +54,10 @@ export const validateUserFields = [
|
|||||||
* @description Validates object for POST /ontime/settings
|
* @description Validates object for POST /ontime/settings
|
||||||
*/
|
*/
|
||||||
export const validateSettings = [
|
export const validateSettings = [
|
||||||
body('pinCode').isString().isLength({ min: 0, max: 4 }).optional({ nullable: true }),
|
body('editorKey').isString().isLength({ min: 0, max: 4 }).optional({ nullable: true }),
|
||||||
|
body('operatorKey').isString().isLength({ min: 0, max: 4 }).optional({ nullable: true }),
|
||||||
body('timeFormat').isString().isIn(['12', '24']),
|
body('timeFormat').isString().isIn(['12', '24']),
|
||||||
|
body('language').isString(),
|
||||||
(req, res, next) => {
|
(req, res, next) => {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ export const dbModel: DatabaseModel = {
|
|||||||
app: 'ontime',
|
app: 'ontime',
|
||||||
version: 2,
|
version: 2,
|
||||||
serverPort: 4001,
|
serverPort: 4001,
|
||||||
lock: null,
|
editorKey: null,
|
||||||
pinCode: null,
|
operatorKey: null,
|
||||||
timeFormat: '24',
|
timeFormat: '24',
|
||||||
|
language: 'en',
|
||||||
},
|
},
|
||||||
viewSettings: {
|
viewSettings: {
|
||||||
overrideStyles: false,
|
overrideStyles: false,
|
||||||
@@ -54,35 +55,4 @@ export const dbModel: DatabaseModel = {
|
|||||||
onFinish: [],
|
onFinish: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
http: {
|
|
||||||
user: null,
|
|
||||||
pwd: null,
|
|
||||||
messages: {
|
|
||||||
onLoad: {
|
|
||||||
url: '',
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
onStart: {
|
|
||||||
url: '',
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
onUpdate: {
|
|
||||||
url: '',
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
onPause: {
|
|
||||||
url: '',
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
onStop: {
|
|
||||||
url: '',
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
onFinish: {
|
|
||||||
url: '',
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -112,9 +112,10 @@ export const parseSettings = (data, enforce): Settings => {
|
|||||||
console.log('ERROR: unknown app version, skipping');
|
console.log('ERROR: unknown app version, skipping');
|
||||||
} else {
|
} else {
|
||||||
const settings = {
|
const settings = {
|
||||||
lock: s.lock || null,
|
editorKey: s.editorKey || null,
|
||||||
pinCode: s.pinCode || null,
|
operatorKey: s.operatorKey || null,
|
||||||
timeFormat: s.timeFormat || '24',
|
timeFormat: s.timeFormat || '24',
|
||||||
|
language: s.language || 'en',
|
||||||
};
|
};
|
||||||
|
|
||||||
// write to db
|
// write to db
|
||||||
@@ -221,23 +222,8 @@ export const parseHttp = (data, enforce) => {
|
|||||||
const newHttp = {};
|
const newHttp = {};
|
||||||
if ('http' in data) {
|
if ('http' in data) {
|
||||||
console.log('Found HTTP definition, importing...');
|
console.log('Found HTTP definition, importing...');
|
||||||
const h = data.http;
|
|
||||||
const http = {};
|
|
||||||
|
|
||||||
// @ts-expect-error -- not yet
|
|
||||||
if (h.user) http.user = h.user;
|
|
||||||
// @ts-expect-error -- not yet
|
|
||||||
if (h.pwd) http.pwd = h.pwd;
|
|
||||||
|
|
||||||
// @ts-expect-error -- not yet
|
|
||||||
newHttp.http = {
|
|
||||||
...dbModel.http,
|
|
||||||
...http,
|
|
||||||
};
|
|
||||||
} else if (enforce) {
|
} else if (enforce) {
|
||||||
// @ts-expect-error -- not yet
|
/* Not yet */
|
||||||
newHttp.http = { ...dbModel.http };
|
|
||||||
console.log('Created http object in db');
|
|
||||||
}
|
}
|
||||||
return newHttp;
|
return newHttp;
|
||||||
};
|
};
|
||||||
@@ -251,22 +237,13 @@ export const parseAliases = (data): Alias[] => {
|
|||||||
const newAliases: Alias[] = [];
|
const newAliases: Alias[] = [];
|
||||||
if ('aliases' in data) {
|
if ('aliases' in data) {
|
||||||
console.log('Found Aliases definition, importing...');
|
console.log('Found Aliases definition, importing...');
|
||||||
const ids = [];
|
|
||||||
try {
|
try {
|
||||||
for (const a of data.aliases) {
|
for (const a of data.aliases) {
|
||||||
// double check unique ids
|
|
||||||
if (ids.indexOf(a?.id) !== -1) {
|
|
||||||
console.log('ERROR: ID collision on import, skipping');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const newAlias = {
|
const newAlias = {
|
||||||
id: a.id || generateId(),
|
|
||||||
enabled: a.enabled || false,
|
enabled: a.enabled || false,
|
||||||
alias: a.alias || '',
|
alias: a.alias || '',
|
||||||
pathAndParams: a.pathAndParams || '',
|
pathAndParams: a.pathAndParams || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
ids.push(newAlias.id);
|
|
||||||
newAliases.push(newAlias);
|
newAliases.push(newAlias);
|
||||||
}
|
}
|
||||||
console.log(`Uploaded ${newAliases?.length || 0} alias(es)`);
|
console.log(`Uploaded ${newAliases?.length || 0} alias(es)`);
|
||||||
|
|||||||
@@ -14,5 +14,4 @@ export type DatabaseModel = {
|
|||||||
aliases: Alias[];
|
aliases: Alias[];
|
||||||
userFields: UserFields;
|
userFields: UserFields;
|
||||||
osc: OSCSettings;
|
osc: OSCSettings;
|
||||||
http: any;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ export type Settings = {
|
|||||||
app: 'ontime';
|
app: 'ontime';
|
||||||
version: 2;
|
version: 2;
|
||||||
serverPort: 4001;
|
serverPort: 4001;
|
||||||
lock: null | boolean;
|
editorKey: null | string;
|
||||||
pinCode: null | number | string;
|
operatorKey: null | string;
|
||||||
timeFormat: TimeFormat;
|
timeFormat: TimeFormat;
|
||||||
|
language: string;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user