mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-03 06:28:01 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 672267c0b3 | |||
| ca65d31071 | |||
| 305d6b6476 | |||
| 139b667e20 | |||
| 29a9167d61 | |||
| 38654f8981 |
@@ -15,7 +15,7 @@
|
||||
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.
|
||||
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.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "2.0.0-beta5",
|
||||
"version": "2.0.0-beta",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^2.5.5",
|
||||
@@ -21,6 +21,7 @@
|
||||
"deepmerge": "^4.3.0",
|
||||
"framer-motion": "^10.10.0",
|
||||
"react": "^18.2.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-fast-compare": "^3.2.0",
|
||||
"react-hook-form": "^7.43.5",
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { lazy, useEffect } from 'react';
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import useAliases from './common/hooks-query/useAliases';
|
||||
import withData from './features/viewers/ViewWrapper';
|
||||
import { useTranslation } from './translation/TranslationProvider';
|
||||
|
||||
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
|
||||
const Table = lazy(() => import('./features/table/ProtectedTable'));
|
||||
@@ -27,7 +26,7 @@ const SPublic = withData(Public);
|
||||
const SLowerThird = withData(Lower);
|
||||
const SStudio = withData(StudioClock);
|
||||
|
||||
const FeatureWrapper = lazy(() => import('./features/FeatureWrapper'));
|
||||
const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'));
|
||||
const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
|
||||
const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport'));
|
||||
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
|
||||
@@ -37,17 +36,6 @@ export default function AppRouter() {
|
||||
const { data } = useAliases();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { setLanguage } = useTranslation();
|
||||
|
||||
// Set output language
|
||||
useEffect(() => {
|
||||
const langParam = searchParams.get('lang');
|
||||
if (langParam && langParam.length === 2) {
|
||||
setLanguage(searchParams.get('lang'));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams]);
|
||||
|
||||
// navigate if is alias route
|
||||
useEffect(() => {
|
||||
@@ -95,33 +83,33 @@ export default function AppRouter() {
|
||||
<Route
|
||||
path='/rundown'
|
||||
element={
|
||||
<FeatureWrapper>
|
||||
<EditorFeatureWrapper>
|
||||
<RundownPanel />
|
||||
</FeatureWrapper>
|
||||
</EditorFeatureWrapper>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/timercontrol'
|
||||
element={
|
||||
<FeatureWrapper>
|
||||
<EditorFeatureWrapper>
|
||||
<TimerControl />
|
||||
</FeatureWrapper>
|
||||
</EditorFeatureWrapper>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/messagecontrol'
|
||||
element={
|
||||
<FeatureWrapper>
|
||||
<EditorFeatureWrapper>
|
||||
<MessageControl />
|
||||
</FeatureWrapper>
|
||||
</EditorFeatureWrapper>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/info'
|
||||
element={
|
||||
<FeatureWrapper>
|
||||
<EditorFeatureWrapper>
|
||||
<Info />
|
||||
</FeatureWrapper>
|
||||
</EditorFeatureWrapper>
|
||||
}
|
||||
/>
|
||||
{/*/!* Send to default if nothing found *!/*/}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const STATIC_PORT = 4001;
|
||||
|
||||
// REST stuff
|
||||
export const EVENTDATA_TABLE = ['eventdata'];
|
||||
export const EVENT_DATA = ['eventdata'];
|
||||
export const ALIASES = ['aliases'];
|
||||
export const USERFIELDS = ['userFields'];
|
||||
export const RUNDOWN_TABLE_KEY = 'rundown';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import { Alias, OSCSettings, Settings, UserFields, ViewSettings } from 'ontime-types';
|
||||
import { Alias, EventData, OSCSettings, Settings, UserFields, ViewSettings } from 'ontime-types';
|
||||
|
||||
import { apiRepoLatest } from '../../externals';
|
||||
import { InfoType } from '../models/Info';
|
||||
@@ -45,7 +45,7 @@ export async function getView(): Promise<ViewSettings> {
|
||||
* @description HTTP request to mutate view settings
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function postView(data: ViewSettings) {
|
||||
export async function postViewSettings(data: ViewSettings) {
|
||||
return axios.post(`${ontimeURL}/views`, data);
|
||||
}
|
||||
|
||||
@@ -169,3 +169,7 @@ export async function getLatestVersion(): Promise<HasUpdate> {
|
||||
version: res.data.tag_name as string,
|
||||
};
|
||||
}
|
||||
|
||||
export async function postNew(initialData: Partial<EventData>) {
|
||||
return axios.post(`${ontimeURL}/new`, initialData);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { MouseEvent } from 'react';
|
||||
import { IconButton, IconButtonProps, Tooltip } from '@chakra-ui/react';
|
||||
|
||||
interface TooltipActionBtnProps extends IconButtonProps {
|
||||
clickHandler: () => void;
|
||||
clickHandler: (event?: MouseEvent) => void;
|
||||
tooltip: string;
|
||||
openDelay?: number;
|
||||
}
|
||||
|
||||
@@ -14,21 +14,18 @@ interface CopyTagProps {
|
||||
export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
|
||||
const { label, className, size = 'xs', children } = props;
|
||||
|
||||
const handleClick = () => {
|
||||
// we need to this as a promise because safari
|
||||
setTimeout(async () => await navigator.clipboard.writeText(children as string));
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip label={label} openDelay={tooltipDelayFast}>
|
||||
<ButtonGroup
|
||||
size={size}
|
||||
isAttached
|
||||
className={className}
|
||||
>
|
||||
<Button variant='ontime-subtle' tabIndex={-1}>{children}</Button>
|
||||
<IconButton
|
||||
aria-label={label}
|
||||
icon={<IoCopy />}
|
||||
variant='ontime-filled'
|
||||
tabIndex={-1}
|
||||
onClick={() => navigator.clipboard.writeText(children as string)}
|
||||
/>
|
||||
<ButtonGroup size={size} isAttached className={className}>
|
||||
<Button variant='ontime-subtle' tabIndex={-1}>
|
||||
{children}
|
||||
</Button>
|
||||
<IconButton aria-label={label} icon={<IoCopy />} variant='ontime-filled' tabIndex={-1} onClick={handleClick} />
|
||||
</ButtonGroup>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -28,12 +28,6 @@ class ErrorBoundary extends React.Component {
|
||||
const eventId = Sentry.captureException(error);
|
||||
this.setState({ eventId, info });
|
||||
});
|
||||
|
||||
try {
|
||||
this.context.emitError(error.toString());
|
||||
} catch (e) {
|
||||
Sentry.captureMessage(`Unable to emit error ${error} ${e}`);
|
||||
}
|
||||
this.reportContent = `${error} ${info.componentStack}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ export const AutoTextArea = (props: AutoTextAreaProps) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
overflow='hidden'
|
||||
@@ -27,7 +26,7 @@ export const AutoTextArea = (props: AutoTextAreaProps) => {
|
||||
resize='none'
|
||||
ref={ref}
|
||||
transition='height none'
|
||||
variant={isDark ? 'ontime-filled' : 'ontime-filled-onlight'}
|
||||
variant={isDark ? 'ontime-filled' : 'ontime-filled-on-light'}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
@use "../../../../theme/_ontimeColours" as *;
|
||||
@use "../../../../theme/_v2Styles" as *;
|
||||
|
||||
.swatch {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid white;
|
||||
box-shadow: 0 0 0 1px $gray-300;
|
||||
cursor: pointer;
|
||||
|
||||
transition-property: box-shadow;
|
||||
transition-duration: $transition-time-action;
|
||||
}
|
||||
|
||||
.swatch:hover {
|
||||
box-shadow: 0 0 0 1px $blue-300;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { HexAlphaColorPicker } from 'react-colorful';
|
||||
import { useController, UseControllerProps } from 'react-hook-form';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@chakra-ui/react';
|
||||
import { ViewSettings } from 'ontime-types';
|
||||
|
||||
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 {
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
}
|
||||
|
||||
export default function PopoverPicker(props: PopoverPickerProps) {
|
||||
const { color, onChange } = props;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<div className={style.swatch} style={{ backgroundColor: color }} />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent style={{ width: 'auto' }}>
|
||||
<HexAlphaColorPicker color={color} onChange={onChange} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button, Input, InputGroup, InputLeftElement, Tooltip } from '@chakra-ui/react';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
@@ -21,6 +21,20 @@ interface TimeInputProps {
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
function ButtonInitial(name: TimeEntryField) {
|
||||
if (name === 'timeStart') return 'S';
|
||||
if (name === 'timeEnd') return 'E';
|
||||
if (name === 'durationOverride') return 'D';
|
||||
return '';
|
||||
}
|
||||
|
||||
function ButtonTooltip(name: TimeEntryField, warning?: string) {
|
||||
if (name === 'timeStart') return `Start${warning ? `: ${warning}` : ''}`;
|
||||
if (name === 'timeEnd') return `End${warning ? `: ${warning}` : ''}`;
|
||||
if (name === 'durationOverride') return `Duration${warning ? `: ${warning}` : ''}`;
|
||||
return '';
|
||||
}
|
||||
|
||||
export default function TimeInput(props: TimeInputProps) {
|
||||
const { name, submitHandler, time = 0, delay = 0, placeholder, validationHandler, previousEnd = 0, warning } = props;
|
||||
const { emitError } = useEmitLog();
|
||||
@@ -142,30 +156,24 @@ export default function TimeInput(props: TimeInputProps) {
|
||||
useEffect(() => {
|
||||
if (time == null) return;
|
||||
resetValue();
|
||||
}, [emitError, resetValue, time]);
|
||||
|
||||
const ButtonInitial = () => {
|
||||
if (name === 'timeStart') return 'S';
|
||||
if (name === 'timeEnd') return 'E';
|
||||
if (name === 'durationOverride') return 'D';
|
||||
return '';
|
||||
};
|
||||
|
||||
const ButtonTooltip = () => {
|
||||
if (name === 'timeStart') return `Start${warning ? `: ${warning}` : ''}`;
|
||||
if (name === 'timeEnd') return `End${warning ? `: ${warning}` : ''}`;
|
||||
if (name === 'durationOverride') return `Duration${warning ? `: ${warning}` : ''}`;
|
||||
return '';
|
||||
};
|
||||
}, [resetValue, time]);
|
||||
|
||||
const isDelayed = delay !== 0;
|
||||
const inputClasses = cx([style.timeInput, isDelayed ? style.delayed : null]);
|
||||
const buttonClasses = cx([style.inputButton, isDelayed ? style.delayed : null, warning ? style.warn : null]);
|
||||
|
||||
const TooltipLabel = useMemo(() => {
|
||||
return ButtonTooltip(name, warning);
|
||||
}, [name, warning]);
|
||||
|
||||
const ButtonText = useMemo(() => {
|
||||
return ButtonInitial(name);
|
||||
}, [name]);
|
||||
|
||||
return (
|
||||
<InputGroup size='sm' className={inputClasses}>
|
||||
<InputLeftElement className={style.inputLeft}>
|
||||
<Tooltip label={ButtonTooltip()} openDelay={tooltipDelayFast} variant='ontime-ondark'>
|
||||
<Tooltip label={TooltipLabel} openDelay={tooltipDelayFast} variant='ontime-ondark'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='ontime-subtle-white'
|
||||
@@ -175,7 +183,7 @@ export default function TimeInput(props: TimeInputProps) {
|
||||
borderRight='1px solid transparent'
|
||||
borderRadius='2px 0 0 2px'
|
||||
>
|
||||
{ButtonInitial()}
|
||||
{ButtonText}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</InputLeftElement>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
$progress-bar-size: 12px;
|
||||
$progress-bar-br: 3px;
|
||||
|
||||
.progress-bar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: $progress-bar-size;
|
||||
border-radius: $progress-bar-br;
|
||||
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
|
||||
display: flex;
|
||||
|
||||
&--hidden {
|
||||
display: none;
|
||||
transition: display 0.5s;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar__indicator {
|
||||
position: absolute;
|
||||
height: inherit;
|
||||
background-color: black;
|
||||
opacity: 0.8;
|
||||
transition: 1s linear;
|
||||
transition-property: width;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.progress-bar__bg-normal {
|
||||
position: absolute;
|
||||
height: inherit;
|
||||
right: 0;
|
||||
border-radius: $progress-bar-br;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.progress-bar__bg-warning {
|
||||
position: absolute;
|
||||
height: inherit;
|
||||
right: 0;
|
||||
border-radius: 0 $progress-bar-br $progress-bar-br 0;
|
||||
}
|
||||
|
||||
.progress-bar__bg-danger {
|
||||
position: absolute;
|
||||
height: inherit;
|
||||
right: 0;
|
||||
border-radius: 0 $progress-bar-br $progress-bar-br 0;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { clamp } from '../../utils/math';
|
||||
|
||||
import './MultiPartProgressBar.scss';
|
||||
|
||||
interface MultiPartProgressBar {
|
||||
now: number;
|
||||
complete: number;
|
||||
normalColor: string;
|
||||
warning: number;
|
||||
warningColor: string;
|
||||
danger: number;
|
||||
dangerColor: string;
|
||||
hidden?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
||||
const { now, complete, normalColor, warning, warningColor, danger, dangerColor, hidden, className = '' } = props;
|
||||
|
||||
const percentComplete = 100 - clamp(100 - (Math.max(now, 0) * 100) / complete, 0, 100);
|
||||
|
||||
const dangerWidth = clamp((danger / complete) * 100, 0, 100);
|
||||
const warningWidth = clamp((warning / complete) * 100, 0, 100);
|
||||
|
||||
return (
|
||||
<div className={`progress-bar ${hidden ? 'progress-bar--hidden' : ''} ${className}`}>
|
||||
<div className='progress-bar__bg-normal' style={{ backgroundColor: normalColor }} />
|
||||
<div className='progress-bar__bg-warning' style={{ width: `${warningWidth}%`, backgroundColor: warningColor }} />
|
||||
<div className='progress-bar__bg-danger' style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }} />
|
||||
<div className='progress-bar__indicator' style={{ width: `${percentComplete}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { HStack, IconButton, PinInput, PinInputField } from '@chakra-ui/react';
|
||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
||||
|
||||
import style from './ProtectRoute.module.scss';
|
||||
|
||||
interface PinPageProps {
|
||||
permission: 'editor' | 'operator';
|
||||
handleValidation: (pin: string) => boolean;
|
||||
}
|
||||
|
||||
export default function PinPage(props: PinPageProps) {
|
||||
const { permission, handleValidation } = props;
|
||||
const [pin, setPin] = useState('');
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
const validate = useCallback(() => {
|
||||
const isValid = handleValidation(pin);
|
||||
if (!isValid) {
|
||||
setFailed(true);
|
||||
setPin('');
|
||||
}
|
||||
}, [handleValidation, pin]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (event: KeyboardEvent) => {
|
||||
if (event.repeat) return;
|
||||
if (event.key === 'Enter') {
|
||||
validate();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyPress);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyPress);
|
||||
};
|
||||
}, [validate]);
|
||||
|
||||
return (
|
||||
<div className={style.container}>
|
||||
{`Ontime ${permission || ''}`}
|
||||
<HStack spacing='10px' className={failed ? style.pin__failed : style.pin}>
|
||||
<PinInput
|
||||
type='alphanumeric'
|
||||
size='lg'
|
||||
mask
|
||||
autoFocus
|
||||
value={pin}
|
||||
onChange={(value) => {
|
||||
setFailed(false);
|
||||
setPin(value);
|
||||
}}
|
||||
>
|
||||
<PinInputField />
|
||||
<PinInputField />
|
||||
<PinInputField />
|
||||
<PinInputField />
|
||||
</PinInput>
|
||||
<IconButton aria-label='Enter' size='lg' isRound icon={<IoCheckmark />} onClick={validate} />
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { HStack, IconButton, PinInput, PinInputField } from '@chakra-ui/react';
|
||||
import { FiCheck } from '@react-icons/all-files/fi/FiCheck';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { AppContext } from '../../context/AppContext';
|
||||
|
||||
import style from './ProtectRoute.module.scss';
|
||||
|
||||
export default function ProtectRoute({ children }) {
|
||||
const isLocal =
|
||||
window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||
const [pin, setPin] = useState('');
|
||||
const [failed, setFailed] = useState(false);
|
||||
const { auth, validate } = useContext(AppContext);
|
||||
|
||||
const handleValidation = useCallback(() => {
|
||||
const r = validate(pin);
|
||||
if (!r) {
|
||||
setFailed(true);
|
||||
setPin('');
|
||||
}
|
||||
}, [pin, validate]);
|
||||
|
||||
// Set window title
|
||||
useEffect(() => {
|
||||
document.title = 'ontime';
|
||||
}, []);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyPress = useCallback(
|
||||
(e) => {
|
||||
// handle held key
|
||||
if (e.repeat) return;
|
||||
// Space bar
|
||||
if (e.keyCode === 13) {
|
||||
handleValidation();
|
||||
}
|
||||
},
|
||||
[handleValidation]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// attach the event listener
|
||||
document.addEventListener('keydown', handleKeyPress);
|
||||
|
||||
// remove the event listener
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyPress);
|
||||
};
|
||||
}, [handleKeyPress]);
|
||||
|
||||
if (isLocal || auth) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.container}>
|
||||
ontime
|
||||
<HStack spacing='10px' className={failed ? style.pin__failed : style.pin}>
|
||||
<PinInput
|
||||
type='alphanumeric'
|
||||
size='lg'
|
||||
mask
|
||||
autoFocus
|
||||
value={pin}
|
||||
onChange={(value) => {
|
||||
setFailed(false);
|
||||
setPin(value);
|
||||
}}
|
||||
>
|
||||
<PinInputField />
|
||||
<PinInputField />
|
||||
<PinInputField />
|
||||
<PinInputField />
|
||||
</PinInput>
|
||||
<IconButton
|
||||
aria-label='Enter'
|
||||
size='lg'
|
||||
isRound
|
||||
icon={<FiCheck />}
|
||||
onClick={() => handleValidation()}
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ProtectRoute.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { PropsWithChildren, useCallback, useContext } from 'react';
|
||||
|
||||
import { AppContext } from '../../context/AppContext';
|
||||
|
||||
import PinPage from './PinPage';
|
||||
|
||||
interface ProtectRouteProps {
|
||||
permission: 'editor' | 'operator';
|
||||
}
|
||||
|
||||
export default function ProtectRoute({ permission, children }: PropsWithChildren<ProtectRouteProps>) {
|
||||
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||
const { editorAuth, operatorAuth, validate } = useContext(AppContext);
|
||||
|
||||
const handleValidation = useCallback(
|
||||
(pin: string) => {
|
||||
return validate(pin, permission);
|
||||
},
|
||||
[permission, validate],
|
||||
);
|
||||
|
||||
const hasRelevantAuth = () => {
|
||||
if (permission === 'editor') {
|
||||
return editorAuth;
|
||||
}
|
||||
if (permission === 'operator') {
|
||||
return operatorAuth;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (isLocal || hasRelevantAuth()) {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment -- trying to make typescript happy
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return <PinPage permission={permission} handleValidation={handleValidation} />;
|
||||
}
|
||||
@@ -4,9 +4,9 @@ import './TitleCard.scss';
|
||||
|
||||
interface TitleCardProps {
|
||||
label: 'now' | 'next';
|
||||
title: string;
|
||||
subtitle: string;
|
||||
presenter: string;
|
||||
title: string | null;
|
||||
subtitle: string | null;
|
||||
presenter: string | null;
|
||||
}
|
||||
|
||||
export default function TitleCard(props: TitleCardProps) {
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { createContext, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import useSettings from '../hooks-query/useSettings';
|
||||
|
||||
export const AppContext = createContext({
|
||||
auth: false,
|
||||
data: {
|
||||
pinCode: null,
|
||||
},
|
||||
});
|
||||
|
||||
export const AppContextProvider = ({ children }) => {
|
||||
const [auth, setAuth] = useState(true);
|
||||
const { data } = useSettings();
|
||||
|
||||
useEffect(() => {
|
||||
if (data == null) return;
|
||||
const previousEntry = sessionStorage.getItem('ontime-entry');
|
||||
if (previousEntry) {
|
||||
if (previousEntry === data?.pinCode) {
|
||||
setAuth(true);
|
||||
} else {
|
||||
sessionStorage.removeItem('ontime-entry');
|
||||
}
|
||||
} else if (data?.pinCode == null || data?.pinCode === '') {
|
||||
setAuth(true);
|
||||
} else {
|
||||
setAuth(false);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
/**
|
||||
* Validates a pincode
|
||||
* @return boolean - whether the pin is valid
|
||||
*/
|
||||
const validate = useCallback(
|
||||
(pin) => {
|
||||
let correct;
|
||||
if (data?.pinCode == null || data?.pinCode === '') {
|
||||
correct = true;
|
||||
} else {
|
||||
correct = pin === data?.pinCode;
|
||||
}
|
||||
if (correct) {
|
||||
sessionStorage.setItem('ontime-entry', pin);
|
||||
}
|
||||
setAuth(correct);
|
||||
return correct;
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
return <AppContext.Provider value={{ auth, validate }}>{children}</AppContext.Provider>;
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createContext, PropsWithChildren, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import useSettings from '../hooks-query/useSettings';
|
||||
|
||||
interface AppContextType {
|
||||
editorAuth: boolean;
|
||||
operatorAuth: boolean;
|
||||
validate: (pin: string, permission: 'editor' | 'operator') => boolean;
|
||||
}
|
||||
|
||||
export const AppContext = createContext<AppContextType>({
|
||||
editorAuth: false,
|
||||
operatorAuth: false,
|
||||
validate: () => false,
|
||||
});
|
||||
|
||||
const storageKeys = {
|
||||
editor: 'ontime-editor-entry',
|
||||
operator: 'ontime-operator-entry',
|
||||
};
|
||||
|
||||
export const AppContextProvider = ({ children }: PropsWithChildren) => {
|
||||
const { status, data } = useSettings();
|
||||
const [editorAuth, setEditorAuth] = useState(true);
|
||||
const [operatorAuth, setOperatorAuth] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'loading') return;
|
||||
if (!data) return;
|
||||
const previousEditor = sessionStorage.getItem(storageKeys.editor);
|
||||
|
||||
if (previousEditor && previousEditor === data.editorKey) {
|
||||
setEditorAuth(true);
|
||||
} else {
|
||||
setEditorAuth(data.editorKey == null || data.editorKey === '');
|
||||
}
|
||||
|
||||
const previousOperator = sessionStorage.getItem(storageKeys.operator);
|
||||
if (previousOperator && previousOperator === data.operatorKey) {
|
||||
setOperatorAuth(true);
|
||||
} else {
|
||||
setOperatorAuth(data.operatorKey == null || data.operatorKey === '');
|
||||
}
|
||||
}, [data, status]);
|
||||
|
||||
/**
|
||||
* Validates a pincode
|
||||
* @return boolean - whether the pin is valid
|
||||
*/
|
||||
const validate = useCallback(
|
||||
(pin: string, permission: 'editor' | 'operator'): boolean => {
|
||||
function isValid(pin: string, savedPin?: string | null): boolean {
|
||||
return savedPin == null || savedPin === '' || pin === savedPin;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (permission === 'editor') {
|
||||
const correct = isValid(pin, data.editorKey);
|
||||
if (correct) {
|
||||
sessionStorage.setItem(storageKeys.editor, pin);
|
||||
}
|
||||
setEditorAuth(correct);
|
||||
return correct;
|
||||
} else if (permission === 'operator') {
|
||||
const correct = isValid(pin, data.operatorKey);
|
||||
if (correct) {
|
||||
sessionStorage.setItem(storageKeys.operator, pin);
|
||||
}
|
||||
setOperatorAuth(correct);
|
||||
return correct;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
return <AppContext.Provider value={{ editorAuth, operatorAuth, validate }}>{children}</AppContext.Provider>;
|
||||
};
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { EVENTDATA_TABLE } from '../api/apiConstants';
|
||||
import { EVENT_DATA } from '../api/apiConstants';
|
||||
import { fetchEventData } from '../api/eventDataApi';
|
||||
import { eventDataPlaceholder } from '../models/EventData';
|
||||
|
||||
export default function useEventData() {
|
||||
const { data, status, isError, refetch } = useQuery({
|
||||
queryKey: EVENTDATA_TABLE,
|
||||
queryKey: EVENT_DATA,
|
||||
queryFn: fetchEventData,
|
||||
placeholderData: eventDataPlaceholder,
|
||||
retry: 5,
|
||||
|
||||
@@ -6,5 +6,4 @@ export const eventDataPlaceholder: EventData = {
|
||||
publicInfo: '',
|
||||
backstageUrl: '',
|
||||
backstageInfo: '',
|
||||
endMessage: '',
|
||||
};
|
||||
|
||||
@@ -4,7 +4,8 @@ export const ontimePlaceholderSettings: Settings = {
|
||||
app: 'ontime',
|
||||
version: 2,
|
||||
serverPort: 4001,
|
||||
lock: null,
|
||||
pinCode: null,
|
||||
editorKey: null,
|
||||
operatorKey: null,
|
||||
timeFormat: '24',
|
||||
language: 'en',
|
||||
};
|
||||
|
||||
@@ -13,4 +13,5 @@ export type OverridableOptions = {
|
||||
hideMessagesOverlay?: boolean;
|
||||
hideEndMessage?: boolean;
|
||||
language?: string;
|
||||
showProgressBar?: boolean;
|
||||
};
|
||||
|
||||
@@ -2,4 +2,10 @@ import { ViewSettings } from 'ontime-types';
|
||||
|
||||
export const viewsSettingsPlaceholder: ViewSettings = {
|
||||
overrideStyles: false,
|
||||
normalColor: '#ffffffcc',
|
||||
warningColor: '#FFAB33',
|
||||
warningThreshold: 120000,
|
||||
dangerColor: '#ED3333',
|
||||
dangerThreshold: 60000,
|
||||
endMessage: '',
|
||||
};
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
export const ontimeQueryClient = new QueryClient();
|
||||
export const ontimeQueryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
cacheTime: 1000 * 60 * 10, // 10 min
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,6 +5,16 @@ export enum AppMode {
|
||||
Edit = 'edit',
|
||||
}
|
||||
|
||||
const appModeKey = 'ontime-app-mode';
|
||||
|
||||
function getModeFromSession() {
|
||||
return localStorage.getItem(appModeKey) === AppMode.Run ? AppMode.Run : AppMode.Edit;
|
||||
}
|
||||
|
||||
async function persistModeToSession(mode: AppMode) {
|
||||
localStorage.setItem(appModeKey, mode);
|
||||
}
|
||||
|
||||
type AppModeStore = {
|
||||
mode: AppMode;
|
||||
cursor: string | null;
|
||||
@@ -15,11 +25,12 @@ type AppModeStore = {
|
||||
};
|
||||
|
||||
export const useAppMode = create<AppModeStore>()((set) => ({
|
||||
mode: AppMode.Edit,
|
||||
mode: getModeFromSession(),
|
||||
cursor: null,
|
||||
editId: null,
|
||||
setMode: (mode: AppMode) =>
|
||||
set((state) => {
|
||||
persistModeToSession(mode);
|
||||
return mode === AppMode.Edit
|
||||
? {
|
||||
editId: state.cursor,
|
||||
|
||||
@@ -42,7 +42,7 @@ export const millisToSeconds = (millis: number | null): number => {
|
||||
|
||||
/**
|
||||
* @description Converts milliseconds to seconds
|
||||
* @param {number} millis - time in seconds
|
||||
* @param {number} millis - time in milliseconds
|
||||
* @returns {number} Amount in seconds
|
||||
*/
|
||||
export const millisToMinutes = (millis: number): number => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const githubUrl = 'https://www.github.com/cpvalente/ontime';
|
||||
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
|
||||
|
||||
export const gitbookUrl = 'https://cpvalente.gitbook.io';
|
||||
export const gitbookUrl = 'https://ontime.gitbook.io';
|
||||
|
||||
+3
-7
@@ -1,16 +1,12 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import ProtectRoute from '../common/components/protect-route/ProtectRoute';
|
||||
|
||||
import style from './FeatureWrapper.module.scss';
|
||||
|
||||
interface FeatureWrapperProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function FeatureWrapper({ children }: FeatureWrapperProps) {
|
||||
export default function EditorFeatureWrapper({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<ProtectRoute>
|
||||
<ProtectRoute permission='editor'>
|
||||
<div className={style.wrapper}>{children}</div>
|
||||
</ProtectRoute>
|
||||
);
|
||||
@@ -34,17 +34,26 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
|
||||
const isLast = selectedEventIndex === numEvents - 1;
|
||||
const noEvents = numEvents === 0;
|
||||
|
||||
const disableGo = isRolling || noEvents || isLast;
|
||||
const disableGo = isRolling || noEvents || (isLast && !isArmed);
|
||||
const disablePrev = isRolling || noEvents || isFirst;
|
||||
|
||||
const goModeText = selectedEventIndex === null || isArmed ? 'Start' : 'Next';
|
||||
const goModeAction = () => {
|
||||
if (isArmed) {
|
||||
setPlayback.start();
|
||||
} else {
|
||||
setPlayback.startNext();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.buttonContainer}>
|
||||
<TapButton disabled={disableGo} onClick={() => setPlayback.startNext()} aspect='fill' className={styles.go}>
|
||||
GO
|
||||
<TapButton disabled={disableGo} onClick={goModeAction} aspect='fill' className={styles.go}>
|
||||
{goModeText}
|
||||
</TapButton>
|
||||
<div className={style.playbackContainer}>
|
||||
<TapButton
|
||||
onClick={() => setPlayback.start()}
|
||||
onClick={setPlayback.start}
|
||||
disabled={isStopped || isRolling}
|
||||
theme={Playback.Play}
|
||||
active={isPlaying}
|
||||
@@ -53,7 +62,7 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
|
||||
</TapButton>
|
||||
|
||||
<TapButton
|
||||
onClick={() => setPlayback.pause()}
|
||||
onClick={setPlayback.pause}
|
||||
disabled={isStopped || isRolling || isArmed}
|
||||
theme={Playback.Pause}
|
||||
active={isPaused}
|
||||
@@ -63,19 +72,19 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
|
||||
</div>
|
||||
<div className={style.transportContainer}>
|
||||
<Tooltip label='Previous event' openDelay={tooltipDelayMid}>
|
||||
<TapButton onClick={() => setPlayback.previous()} disabled={disablePrev}>
|
||||
<TapButton onClick={setPlayback.previous} disabled={disablePrev}>
|
||||
<IoPlaySkipBack />
|
||||
</TapButton>
|
||||
</Tooltip>
|
||||
<Tooltip label='Next event' openDelay={tooltipDelayMid}>
|
||||
<TapButton onClick={() => setPlayback.next()} disabled={disableGo}>
|
||||
<TapButton onClick={setPlayback.next} disabled={disableGo}>
|
||||
<IoPlaySkipForward />
|
||||
</TapButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={styles.extra}>
|
||||
<TapButton
|
||||
onClick={() => setPlayback.roll()}
|
||||
onClick={setPlayback.roll}
|
||||
disabled={!isStopped || noEvents}
|
||||
theme={Playback.Roll}
|
||||
active={isRolling}
|
||||
@@ -83,12 +92,12 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
|
||||
<IoTimeOutline />
|
||||
</TapButton>
|
||||
<Tooltip label='Reload event' openDelay={tooltipDelayMid}>
|
||||
<TapButton onClick={() => setPlayback.reload()} disabled={isStopped || isRolling}>
|
||||
<TapButton onClick={setPlayback.reload} disabled={isStopped || isRolling}>
|
||||
<IoReload className={style.invertX} />
|
||||
</TapButton>
|
||||
</Tooltip>
|
||||
<Tooltip label='Unload Event' openDelay={tooltipDelayMid}>
|
||||
<TapButton onClick={() => setPlayback.stop()} disabled={isStopped && !isRolling} theme={Playback.Stop}>
|
||||
<TapButton onClick={setPlayback.stop} disabled={isStopped && !isRolling} theme={Playback.Stop}>
|
||||
<IoStop />
|
||||
</TapButton>
|
||||
</Tooltip>
|
||||
|
||||
@@ -6,7 +6,8 @@ import UploadModal from '../../common/components/upload-modal/UploadModal';
|
||||
import MenuBar from '../menu/MenuBar';
|
||||
import AboutModal from '../modals/about-modal/AboutModal';
|
||||
import IntegrationModal from '../modals/integration-modal/IntegrationModal';
|
||||
import ModalManager from '../modals/ModalManager';
|
||||
import QuickStart from '../modals/quick-start/QuickStart';
|
||||
import SettingsModal from '../modals/settings-modal/SettingsModal';
|
||||
|
||||
import styles from './Editor.module.scss';
|
||||
|
||||
@@ -25,6 +26,7 @@ export default function Editor() {
|
||||
onClose: onIntegrationModalClose,
|
||||
} = useDisclosure();
|
||||
const { isOpen: isAboutModalOpen, onOpen: onAboutModalOpen, onClose: onAboutModalClose } = useDisclosure();
|
||||
const { isOpen: isQuickStartOpen, onOpen: onQuickStartOpen, onClose: onQuickStartClose } = useDisclosure();
|
||||
|
||||
// Set window title
|
||||
useEffect(() => {
|
||||
@@ -34,10 +36,11 @@ export default function Editor() {
|
||||
return (
|
||||
<>
|
||||
<ErrorBoundary>
|
||||
<QuickStart onClose={onQuickStartClose} isOpen={isQuickStartOpen} />
|
||||
<UploadModal onClose={onUploadModalClose} isOpen={isUploadModalOpen} />
|
||||
<IntegrationModal onClose={onIntegrationModalClose} isOpen={isIntegrationModalOpen} />
|
||||
<AboutModal onClose={onAboutModalClose} isOpen={isAboutModalOpen} />
|
||||
<ModalManager isOpen={isSettingsOpen} onClose={onSettingsClose} />
|
||||
<SettingsModal isOpen={isSettingsOpen} onClose={onSettingsClose} />
|
||||
</ErrorBoundary>
|
||||
<div className={styles.mainContainer} data-testid='event-editor'>
|
||||
<Box id='settings' className={styles.settings}>
|
||||
@@ -52,6 +55,8 @@ export default function Editor() {
|
||||
onIntegrationOpen={onIntegrationModalOpen}
|
||||
isAboutOpen={isAboutModalOpen}
|
||||
onAboutOpen={onAboutModalOpen}
|
||||
isQuickStartOpen={isQuickStartOpen}
|
||||
onQuickStartOpen={onQuickStartOpen}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</Box>
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import Editor from './Editor';
|
||||
|
||||
export default function ProtectedEditor() {
|
||||
return (
|
||||
<ProtectRoute>
|
||||
<ProtectRoute permission='editor'>
|
||||
<Editor />
|
||||
</ProtectRoute>
|
||||
);
|
||||
@@ -1,9 +1,9 @@
|
||||
import { memo } from 'react';
|
||||
import { Box, IconButton } from '@chakra-ui/react';
|
||||
import { FiX } from '@react-icons/all-files/fi/FiX';
|
||||
import { IoClose } from '@react-icons/all-files/io5/IoClose';
|
||||
|
||||
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
|
||||
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
|
||||
import { useAppMode } from '../../common/stores/appModeStore';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
|
||||
import EventEditor from './EventEditor';
|
||||
@@ -19,13 +19,11 @@ const closeBtnStyle = {
|
||||
};
|
||||
|
||||
const EventEditorExport = () => {
|
||||
const appMode = useAppMode((state) => state.mode);
|
||||
const editId = useAppMode((state) => state.editId);
|
||||
const setEditId = useAppMode((state) => state.setEditId);
|
||||
|
||||
const editorStyle = cx([style.eventEditor, !editId ? style.noEvent : null]);
|
||||
const removeOpenEvent = () => setEditId(null);
|
||||
const canRemoveOpenId = appMode === AppMode.Run;
|
||||
|
||||
return (
|
||||
<Box className={editorStyle}>
|
||||
@@ -33,13 +31,7 @@ const EventEditorExport = () => {
|
||||
<div className={style.eventEditorLayout}>
|
||||
<EventEditor />
|
||||
<div className={style.header}>
|
||||
<IconButton
|
||||
aria-label='Close Menu'
|
||||
icon={<FiX />}
|
||||
onClick={removeOpenEvent}
|
||||
isDisabled={!canRemoveOpenId}
|
||||
{...closeBtnStyle}
|
||||
/>
|
||||
<IconButton aria-label='Close Menu' icon={<IoClose />} onClick={removeOpenEvent} {...closeBtnStyle} />
|
||||
</div>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { memo, useCallback, useEffect } from 'react';
|
||||
import { VStack } from '@chakra-ui/react';
|
||||
import { FiSave } from '@react-icons/all-files/fi/FiSave';
|
||||
import { FiUpload } from '@react-icons/all-files/fi/FiUpload';
|
||||
import { IoColorWand } from '@react-icons/all-files/io5/IoColorWand';
|
||||
import { IoExtensionPuzzle } from '@react-icons/all-files/io5/IoExtensionPuzzle';
|
||||
import { IoExtensionPuzzleOutline } from '@react-icons/all-files/io5/IoExtensionPuzzleOutline';
|
||||
import { IoHelp } from '@react-icons/all-files/io5/IoHelp';
|
||||
import { IoOptions } from '@react-icons/all-files/io5/IoOptions';
|
||||
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
|
||||
import { IoPushOutline } from '@react-icons/all-files/io5/IoPushOutline';
|
||||
import { IoSaveOutline } from '@react-icons/all-files/io5/IoSaveOutline';
|
||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||
|
||||
import { downloadRundown } from '../../common/api/ontimeApi';
|
||||
@@ -27,6 +28,8 @@ interface MenuBarProps {
|
||||
onIntegrationOpen: () => void;
|
||||
isAboutOpen: boolean;
|
||||
onAboutOpen: () => void;
|
||||
isQuickStartOpen: boolean;
|
||||
onQuickStartOpen: () => void;
|
||||
}
|
||||
|
||||
const buttonStyle = {
|
||||
@@ -41,7 +44,7 @@ const buttonStyle = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function MenuBar(props: MenuBarProps) {
|
||||
const MenuBar = (props: MenuBarProps) => {
|
||||
const {
|
||||
isSettingsOpen,
|
||||
onSettingsOpen,
|
||||
@@ -52,6 +55,8 @@ export default function MenuBar(props: MenuBarProps) {
|
||||
onIntegrationOpen,
|
||||
isAboutOpen,
|
||||
onAboutOpen,
|
||||
isQuickStartOpen,
|
||||
onQuickStartOpen,
|
||||
} = props;
|
||||
const { isElectron, sendToElectron } = useElectronEvent();
|
||||
|
||||
@@ -102,7 +107,15 @@ export default function MenuBar(props: MenuBarProps) {
|
||||
<div className={style.gap} />
|
||||
<TooltipActionBtn
|
||||
{...buttonStyle}
|
||||
icon={<FiUpload />}
|
||||
icon={<IoColorWand />}
|
||||
className={isQuickStartOpen ? style.open : ''}
|
||||
clickHandler={onQuickStartOpen}
|
||||
tooltip='Quick start'
|
||||
aria-label='Quick start'
|
||||
/>
|
||||
<TooltipActionBtn
|
||||
{...buttonStyle}
|
||||
icon={<IoPushOutline />}
|
||||
className={isUploadOpen ? style.open : ''}
|
||||
clickHandler={onUploadOpen}
|
||||
tooltip='Upload showfile'
|
||||
@@ -110,7 +123,7 @@ export default function MenuBar(props: MenuBarProps) {
|
||||
/>
|
||||
<TooltipActionBtn
|
||||
{...buttonStyle}
|
||||
icon={<FiSave />}
|
||||
icon={<IoSaveOutline />}
|
||||
clickHandler={downloadRundown}
|
||||
tooltip='Export showfile'
|
||||
aria-label='Export showfile'
|
||||
@@ -162,4 +175,6 @@ export default function MenuBar(props: MenuBarProps) {
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default memo(MenuBar);
|
||||
|
||||
@@ -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,193 +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,
|
||||
endMessage: data.endMessage,
|
||||
});
|
||||
}, [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 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={30}
|
||||
name='endMessage'
|
||||
placeholder='Empty message shows elapsed time'
|
||||
value={formData.endMessage}
|
||||
onChange={(event) => handleChange('endMessage', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SubmitContainer revert={revert} submitting={submitting} changed={changed} status={status} />
|
||||
</form>
|
||||
</ModalBody>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
@use '../../theme/v2Styles' as *;
|
||||
@use '../../theme/ontimeColours' as *;
|
||||
|
||||
$el-padding-with-compensation: 24px; // 16 + 8
|
||||
|
||||
@mixin modal-link {
|
||||
color: $blue-500;
|
||||
transition-property: color;
|
||||
@@ -11,10 +13,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.headerNotes {
|
||||
.headerNotes {
|
||||
font-size: $text-body-size;
|
||||
width: 100%;
|
||||
padding: 0 $section-spacing;
|
||||
padding: 0 $el-padding-with-compensation;
|
||||
color: $modal-note-color;
|
||||
margin-bottom: $section-spacing;
|
||||
|
||||
@@ -24,13 +26,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
.footerNotes {
|
||||
font-size: $inner-section-text-size;
|
||||
padding: 0 $el-padding-with-compensation;
|
||||
color: $modal-note-color;
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: $element-spacing 0;
|
||||
border: 0;
|
||||
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 {
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%
|
||||
@@ -43,12 +60,20 @@
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
background-color: $blue-50;
|
||||
background-color: $gray-50;
|
||||
}
|
||||
}
|
||||
|
||||
.columnSection {
|
||||
@include sectionSpacing;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: $element-inner-spacing;
|
||||
}
|
||||
|
||||
.splitSection {
|
||||
@include sectionSpacing;
|
||||
gap: $section-spacing;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
@@ -60,6 +85,7 @@
|
||||
.sectionTitle {
|
||||
font-size: $inner-section-text-size;
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
&.main {
|
||||
font-weight: 600;
|
||||
@@ -83,9 +109,6 @@
|
||||
|
||||
.buttonSection {
|
||||
margin-top: $section-spacing;
|
||||
padding-top: $section-spacing;
|
||||
padding-left: -24px;
|
||||
border-top: 1px solid $gray-100;
|
||||
display: flex;
|
||||
gap: $section-spacing;
|
||||
}
|
||||
@@ -96,20 +119,24 @@
|
||||
|
||||
.shiftRight {
|
||||
align-self: flex-end;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.showPointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.twoColumn {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.padBottom {
|
||||
padding-bottom: 8px;
|
||||
padding-bottom: $element-spacing;
|
||||
}
|
||||
|
||||
.logo {
|
||||
@@ -117,17 +144,22 @@
|
||||
height: 48px;
|
||||
display: inline-block;
|
||||
vertical-align: text-bottom;
|
||||
margin-right: 16px;
|
||||
margin-right: $section-spacing;
|
||||
}
|
||||
|
||||
.test {
|
||||
padding-top: 24px;
|
||||
.updateSection {
|
||||
padding-top: $el-padding-with-compensation;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
gap: $element-inner-spacing;
|
||||
|
||||
.error {
|
||||
font-size: $error-red;
|
||||
}
|
||||
}
|
||||
|
||||
.overflowContainer {
|
||||
overflow-y: auto;
|
||||
max-height: 40%;
|
||||
}
|
||||
@@ -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-duration: $transition-time-action;
|
||||
|
||||
&.inline {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: $ontime-color;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { MouseEvent, ReactNode } from 'react';
|
||||
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
|
||||
|
||||
import { openLink } from '../../common/utils/linkUtils';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
|
||||
import style from './ModalLink.module.scss';
|
||||
|
||||
interface ModalLinkProps {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
export default function ModalLink(props: ModalLinkProps) {
|
||||
const { href, children } = props;
|
||||
const { href, inline, children } = props;
|
||||
const classes = cx([style.link, inline ? style.inline : null]);
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
openLink(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<a href={href} target='_blank' rel='noreferrer' className={style.link}>
|
||||
<a href='#!' target='_blank' rel='noreferrer' className={classes} onClick={handleClick}>
|
||||
{children} <IoOpenOutline />
|
||||
</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,199 +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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { Button, ModalFooter } from '@chakra-ui/react';
|
||||
|
||||
import styles from '../Modal.module.scss';
|
||||
import styles from './Modal.module.scss';
|
||||
|
||||
interface OntimeModalFooterProps {
|
||||
formId: string;
|
||||
@@ -17,7 +17,7 @@ export default function OntimeModalFooter(props: OntimeModalFooterProps) {
|
||||
const disableSubmit = isSubmitting || !isDirty || !isValid;
|
||||
|
||||
return (
|
||||
<ModalFooter className={styles.buttonSection} paddingInlineStart={0} paddingInlineEnd={0} paddingBottom={0}>
|
||||
<ModalFooter className={styles.buttonSection}>
|
||||
<Button isDisabled={disableRevert} variant='ontime-ghost-on-light' size='sm' onClick={handleRevert}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
@@ -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,148 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { FormControl, FormLabel, ModalBody } from '@chakra-ui/react';
|
||||
import { IoCheckmarkSharp } from '@react-icons/all-files/io5/IoCheckmarkSharp';
|
||||
import { IoInformationCircleOutline } from '@react-icons/all-files/io5/IoInformationCircleOutline';
|
||||
|
||||
import { useEmitLog } from '@/common/stores/logger';
|
||||
|
||||
import { postView } from '../../common/api/ontimeApi';
|
||||
import EnableBtn from '../../common/components/buttons/EnableBtn';
|
||||
import useViewSettings from '../../common/hooks-query/useViewSettings';
|
||||
import { viewsSettingsPlaceholder } from '../../common/models/ViewSettings.type';
|
||||
import { openLink } from '../../common/utils/linkUtils';
|
||||
|
||||
import SubmitContainer from './SubmitContainer';
|
||||
|
||||
import style from './Modals.module.scss';
|
||||
|
||||
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) => {
|
||||
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, 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 viewers
|
||||
<br />
|
||||
🔥 Changes take effect immediately 🔥
|
||||
</p>
|
||||
<form onSubmit={submitHandler}>
|
||||
<div className={style.hSeparator}>Style Options</div>
|
||||
<div className={style.blockNotes}>
|
||||
<span className={style.inlineFlex}>
|
||||
<IoInformationCircleOutline color='#2b6cb0' fontSize='2em' />
|
||||
CSS Style Overrides
|
||||
</span>
|
||||
This feature allows user defined CSS to override the application stylesheets as a way to customise viewers
|
||||
appearance.
|
||||
<br />
|
||||
Currently the feature affects the following views
|
||||
<br />
|
||||
<ul className={style.featureList}>
|
||||
<li>
|
||||
<IoCheckmarkSharp /> Stage timer
|
||||
</li>
|
||||
<li>
|
||||
<IoCheckmarkSharp /> Clock
|
||||
</li>
|
||||
<li>
|
||||
<IoCheckmarkSharp /> Minimal timer
|
||||
</li>
|
||||
<li>
|
||||
<IoCheckmarkSharp /> Backstage screen
|
||||
</li>
|
||||
<li>
|
||||
<IoCheckmarkSharp /> Public screen
|
||||
</li>
|
||||
<li>
|
||||
<IoCheckmarkSharp /> Countdown
|
||||
</li>
|
||||
</ul>
|
||||
Read more about it in the documentation{' '}
|
||||
<a
|
||||
href='#!'
|
||||
onClick={() => openLink('https://cpvalente.gitbook.io/ontime/features/custom-styling')}
|
||||
className={style.if}
|
||||
>
|
||||
over at Gitbook
|
||||
</a>
|
||||
</div>
|
||||
<div className={style.modalFields}>
|
||||
<div className={style.modalInline}>
|
||||
<FormControl>
|
||||
<FormLabel htmlFor='overrideStyles'>
|
||||
Override CSS Styles
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
Enable / Disable override
|
||||
</span>
|
||||
</FormLabel>
|
||||
<EnableBtn
|
||||
active={formData.overrideStyles}
|
||||
text={formData.overrideStyles ? 'Style Override Enabled' : 'Style Override Disabled'}
|
||||
actionHandler={() => handleChange('overrideStyles', !formData.overrideStyles)}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<SubmitContainer revert={revert} submitting={submitting} changed={changed} status={status} />
|
||||
</div>
|
||||
</form>
|
||||
</ModalBody>
|
||||
);
|
||||
}
|
||||
@@ -43,7 +43,7 @@ export default function AboutModal(props: AboutModalProps) {
|
||||
<div className={styles.padBottom}>
|
||||
<span className={styles.sectionTitle}>Ontime</span>
|
||||
Free Open Source Software for managing rundowns and event timers
|
||||
<ModalLink href='www.getontime.no'>www.getontime.no</ModalLink>
|
||||
<ModalLink href='https://www.getontime.no'>www.getontime.no</ModalLink>
|
||||
</div>
|
||||
<div className={styles.padBottom}>
|
||||
<span className={styles.sectionTitle}>Current version</span>
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function UpdateChecker(props: UpdateCheckerProps) {
|
||||
const disableButton = Boolean(updateMessage && 'version' in updateMessage);
|
||||
|
||||
return (
|
||||
<div className={styles.test}>
|
||||
<div className={styles.updateSection}>
|
||||
<Button onClick={versionCheck} variant='ontime-filled' isLoading={isFetching} isDisabled={disableButton}>
|
||||
Check for updates
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
|
||||
import { ModalBody, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
|
||||
|
||||
import ModalWrapper from '../ModalWrapper';
|
||||
|
||||
@@ -12,33 +12,35 @@ interface IntegrationModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const oscDocsUrl = 'https://cpvalente.gitbook.io/ontime/control-and-feedback/osc';
|
||||
const oscDocsUrl = 'https://ontime.gitbook.io/v2/control-and-feedback/osc';
|
||||
|
||||
export default function IntegrationModal(props: IntegrationModalProps) {
|
||||
const { isOpen, onClose } = props;
|
||||
|
||||
return (
|
||||
<ModalWrapper title='Integration Settings' isOpen={isOpen} onClose={onClose}>
|
||||
<div className={styles.headerNotes}>
|
||||
Manage settings related to protocol integrations
|
||||
<a href={oscDocsUrl} target='_blank' rel='noreferrer'>
|
||||
Read the docs
|
||||
</a>
|
||||
</div>
|
||||
<Tabs variant='ontime' size='sm' isLazy>
|
||||
<TabList>
|
||||
<Tab>OSC</Tab>
|
||||
<Tab>OSC Integration</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<OscSettings />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<OscIntegration />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
<ModalBody>
|
||||
<div className={styles.headerNotes}>
|
||||
Manage settings related to protocol integrations
|
||||
<a href={oscDocsUrl} target='_blank' rel='noreferrer'>
|
||||
Read the docs
|
||||
</a>
|
||||
</div>
|
||||
<Tabs variant='ontime' size='sm' isLazy>
|
||||
<TabList>
|
||||
<Tab>OSC</Tab>
|
||||
<Tab>OSC Integration</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<OscSettings />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<OscIntegration />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</ModalBody>
|
||||
</ModalWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { ModalBody } from '@chakra-ui/react';
|
||||
import type { OSCSettings, OscSubscription } from 'ontime-types';
|
||||
import { TimerLifeCycle } from 'ontime-types';
|
||||
import { generateId } from 'ontime-utils';
|
||||
@@ -8,8 +7,8 @@ import { generateId } from 'ontime-utils';
|
||||
import useOscSettings, { useOscSettingsMutation } from '../../../common/hooks-query/useOscSettings';
|
||||
import { oscPlaceholderSettings, PlaceholderSettings } from '../../../common/models/OscSettings';
|
||||
import { useEmitLog } from '../../../common/stores/logger';
|
||||
import OntimeModalFooter from '../OntimeModalFooter';
|
||||
|
||||
import OntimeModalFooter from './OntimeModalFooter';
|
||||
import OscSubscriptionRow from './OscSubscriptionRow';
|
||||
|
||||
import styles from '../Modal.module.scss';
|
||||
@@ -103,27 +102,25 @@ export default function OscIntegration() {
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={styles.sectionContainer} id='oscSubscriptions'>
|
||||
<ModalBody>
|
||||
{subscriptionKeys.map((cycle, idx) => {
|
||||
return (
|
||||
<>
|
||||
<OscSubscriptionRow
|
||||
key={cycle}
|
||||
cycle={cycle as TimerLifeCycle}
|
||||
title={sectionText[cycle as TimerLifeCycle].title}
|
||||
subtitle={sectionText[cycle as TimerLifeCycle].subtitle}
|
||||
visible={showSection === cycle}
|
||||
setShowSection={setShowSection}
|
||||
subscriptionOptions={subscriptionState[cycle as TimerLifeCycle]}
|
||||
handleDelete={deleteSubscriptionEntry}
|
||||
handleAddNew={addNewSubscriptionEntry}
|
||||
register={register}
|
||||
/>
|
||||
{idx < subscriptionKeys.length - 1 && <hr className={styles.divider} />}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</ModalBody>
|
||||
{subscriptionKeys.map((cycle, idx) => {
|
||||
return (
|
||||
<div key={`${cycle}-${idx}`}>
|
||||
<OscSubscriptionRow
|
||||
key={cycle}
|
||||
cycle={cycle as TimerLifeCycle}
|
||||
title={sectionText[cycle as TimerLifeCycle].title}
|
||||
subtitle={sectionText[cycle as TimerLifeCycle].subtitle}
|
||||
visible={showSection === cycle}
|
||||
setShowSection={setShowSection}
|
||||
subscriptionOptions={subscriptionState[cycle as TimerLifeCycle]}
|
||||
handleDelete={deleteSubscriptionEntry}
|
||||
handleAddNew={addNewSubscriptionEntry}
|
||||
register={register}
|
||||
/>
|
||||
{idx < subscriptionKeys.length - 1 && <hr className={styles.divider} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<OntimeModalFooter
|
||||
formId='oscSubscriptions'
|
||||
handleRevert={resetForm}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { FormControl, Input, ModalBody, Switch } from '@chakra-ui/react';
|
||||
import { FormControl, Input, Switch } from '@chakra-ui/react';
|
||||
|
||||
import useOscSettings, { useOscSettingsMutation } from '../../../common/hooks-query/useOscSettings';
|
||||
import { PlaceholderSettings } from '../../../common/models/OscSettings';
|
||||
import { useEmitLog } from '../../../common/stores/logger';
|
||||
import { isIPAddress, isOnlyNumbers } from '../../../common/utils/regex';
|
||||
|
||||
import OntimeModalFooter from './OntimeModalFooter';
|
||||
import OntimeModalFooter from '../OntimeModalFooter';
|
||||
|
||||
import styles from '../Modal.module.scss';
|
||||
|
||||
@@ -53,114 +52,108 @@ export default function OscSettings() {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={styles.sectionContainer} id='oscSettings'>
|
||||
<ModalBody>
|
||||
<div className={styles.splitSection}>
|
||||
<div>
|
||||
<span className={`${styles.sectionTitle} ${styles.main}`}>OSC Input</span>
|
||||
<span className={styles.sectionSubtitle}>Control Ontime with OSC</span>
|
||||
</div>
|
||||
<Switch {...register('enabledIn')} variant='ontime-on-light' />
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={styles.sectionContainer} id='oscSettings'>
|
||||
<div className={styles.splitSection}>
|
||||
<div>
|
||||
<span className={`${styles.sectionTitle} ${styles.main}`}>OSC Input</span>
|
||||
<span className={styles.sectionSubtitle}>Control Ontime with OSC</span>
|
||||
</div>
|
||||
<Switch {...register('enabledIn')} variant='ontime-on-light' />
|
||||
</div>
|
||||
|
||||
<FormControl isInvalid={!!errors.portIn} className={styles.splitSection}>
|
||||
<label htmlFor='portIn'>
|
||||
<span className={styles.sectionTitle}>Listen on Port</span>
|
||||
{errors.portIn ? (
|
||||
<span className={styles.error}>{errors.portIn.message}</span>
|
||||
) : (
|
||||
<span className={styles.sectionSubtitle}>Default 8888</span>
|
||||
)}
|
||||
</label>
|
||||
<Input
|
||||
id='portIn'
|
||||
placeholder='8888'
|
||||
width='75px'
|
||||
size='sm'
|
||||
textAlign='right'
|
||||
maxLength={5}
|
||||
variant='ontime-filled-on-light'
|
||||
{...register('portIn', {
|
||||
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',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl isInvalid={!!errors.portIn} className={styles.splitSection}>
|
||||
<label htmlFor='portIn'>
|
||||
<span className={styles.sectionTitle}>Listen on Port</span>
|
||||
{errors.portIn ? (
|
||||
<span className={styles.error}>{errors.portIn.message}</span>
|
||||
) : (
|
||||
<span className={styles.sectionSubtitle}>Default 8888</span>
|
||||
)}
|
||||
</label>
|
||||
<Input
|
||||
id='portIn'
|
||||
placeholder='8888'
|
||||
width='75px'
|
||||
size='sm'
|
||||
textAlign='right'
|
||||
maxLength={5}
|
||||
variant='ontime-filled-on-light'
|
||||
{...register('portIn', {
|
||||
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',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</FormControl>
|
||||
<div style={{ height: '16px' }} />
|
||||
<div className={styles.splitSection}>
|
||||
<div>
|
||||
<span className={styles.sectionTitle} style={{ fontWeight: 600 }}>
|
||||
OSC Output
|
||||
</span>
|
||||
<span className={styles.sectionSubtitle}>Ontime data feedback</span>
|
||||
</div>
|
||||
<Switch {...register('enabledOut')} variant='ontime-on-light' />
|
||||
</div>
|
||||
|
||||
<hr className={styles.divider} />
|
||||
<FormControl isInvalid={!!errors.targetIP} className={styles.splitSection}>
|
||||
<label htmlFor='targetIP'>
|
||||
<span className={styles.sectionTitle}>OSC target IP</span>
|
||||
{errors.targetIP ? (
|
||||
<span className={styles.error}>{errors.targetIP.message}</span>
|
||||
) : (
|
||||
<span className={styles.sectionSubtitle}>Default 127.0.0.1</span>
|
||||
)}
|
||||
</label>
|
||||
<Input
|
||||
id='targetIP'
|
||||
placeholder='127.0.0.1'
|
||||
width='140px'
|
||||
size='sm'
|
||||
textAlign='right'
|
||||
variant='ontime-filled-on-light'
|
||||
{...register('targetIP', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
pattern: {
|
||||
value: isIPAddress,
|
||||
message: 'Invalid IP address',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<div className={styles.splitSection}>
|
||||
<div>
|
||||
<span className={styles.sectionTitle} style={{ fontWeight: 600 }}>
|
||||
OSC Output
|
||||
</span>
|
||||
<span className={styles.sectionSubtitle}>Ontime data feedback</span>
|
||||
</div>
|
||||
<Switch {...register('enabledOut')} variant='ontime-on-light' />
|
||||
</div>
|
||||
|
||||
<FormControl isInvalid={!!errors.targetIP} className={styles.splitSection}>
|
||||
<label htmlFor='targetIP'>
|
||||
<span className={styles.sectionTitle}>OSC target IP</span>
|
||||
{errors.targetIP ? (
|
||||
<span className={styles.error}>{errors.targetIP.message}</span>
|
||||
) : (
|
||||
<span className={styles.sectionSubtitle}>Default 127.0.0.1</span>
|
||||
)}
|
||||
</label>
|
||||
<Input
|
||||
id='targetIP'
|
||||
placeholder='127.0.0.1'
|
||||
width='140px'
|
||||
size='sm'
|
||||
textAlign='right'
|
||||
variant='ontime-filled-on-light'
|
||||
{...register('targetIP', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
pattern: {
|
||||
value: isIPAddress,
|
||||
message: 'Invalid IP address',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl className={styles.splitSection}>
|
||||
<label htmlFor='portOut'>
|
||||
<span className={styles.sectionTitle}>OSC target Port</span>
|
||||
{errors.portOut ? (
|
||||
<span className={styles.error}>{errors.portOut.message}</span>
|
||||
) : (
|
||||
<span className={styles.sectionSubtitle}>Default 9999</span>
|
||||
)}
|
||||
</label>
|
||||
<Input
|
||||
id='portOut'
|
||||
placeholder='9999'
|
||||
width='75px'
|
||||
size='sm'
|
||||
textAlign='right'
|
||||
maxLength={5}
|
||||
variant='ontime-filled-on-light'
|
||||
{...register('portOut', {
|
||||
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',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</FormControl>
|
||||
</ModalBody>
|
||||
</form>
|
||||
<FormControl className={styles.splitSection}>
|
||||
<label htmlFor='portOut'>
|
||||
<span className={styles.sectionTitle}>OSC target Port</span>
|
||||
{errors.portOut ? (
|
||||
<span className={styles.error}>{errors.portOut.message}</span>
|
||||
) : (
|
||||
<span className={styles.sectionSubtitle}>Default 9999</span>
|
||||
)}
|
||||
</label>
|
||||
<Input
|
||||
id='portOut'
|
||||
placeholder='9999'
|
||||
width='75px'
|
||||
size='sm'
|
||||
textAlign='right'
|
||||
maxLength={5}
|
||||
variant='ontime-filled-on-light'
|
||||
{...register('portOut', {
|
||||
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',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</FormControl>
|
||||
<OntimeModalFooter
|
||||
formId='oscSettings'
|
||||
handleRevert={resetForm}
|
||||
@@ -168,6 +161,6 @@ export default function OscSettings() {
|
||||
isValid={isValid}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,13 +41,6 @@ export default function OscSubscriptionRow(props: OscSubscriptionRowProps) {
|
||||
{subscriptionOptions.map((option, idx) => (
|
||||
<div key={option.id} className={styles.entryRow}>
|
||||
<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
|
||||
icon={<IoRemove />}
|
||||
onClick={() => handleDelete(cycle, option.id)}
|
||||
@@ -55,6 +48,13 @@ export default function OscSubscriptionRow(props: OscSubscriptionRowProps) {
|
||||
size='xs'
|
||||
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>
|
||||
))}
|
||||
<Button
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export const inputProps = {
|
||||
size: 'sm',
|
||||
autoComplete: 'off',
|
||||
variant: 'outline',
|
||||
};
|
||||
|
||||
export const portInputProps = {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Textarea,
|
||||
} from '@chakra-ui/react';
|
||||
import type { EventData } from 'ontime-types';
|
||||
|
||||
import { EVENT_DATA, RUNDOWN_TABLE } from '../../../common/api/apiConstants';
|
||||
import { postNew } from '../../../common/api/ontimeApi';
|
||||
import useEventData from '../../../common/hooks-query/useEventData';
|
||||
import { eventDataPlaceholder } from '../../../common/models/EventData';
|
||||
import { ontimeQueryClient } from '../../../common/queryClient';
|
||||
|
||||
import styles from '../Modal.module.scss';
|
||||
|
||||
interface QuickStartProps {
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
|
||||
const { data, status } = useEventData();
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
formState: { isSubmitting },
|
||||
} = useForm({ defaultValues: data });
|
||||
|
||||
useEffect(() => {
|
||||
reset(data);
|
||||
}, [reset, data]);
|
||||
|
||||
const onSubmit = async (data: Partial<EventData>) => {
|
||||
await postNew(data);
|
||||
await ontimeQueryClient.invalidateQueries(EVENT_DATA);
|
||||
await ontimeQueryClient.invalidateQueries(RUNDOWN_TABLE);
|
||||
};
|
||||
|
||||
const onReset = () => reset(eventDataPlaceholder);
|
||||
|
||||
const disableButtons = status !== 'success' || isSubmitting;
|
||||
return (
|
||||
<Modal
|
||||
onClose={onClose}
|
||||
isOpen={isOpen}
|
||||
closeOnOverlayClick={false}
|
||||
motionPreset='slideInBottom'
|
||||
size='xl'
|
||||
scrollBehavior='inside'
|
||||
preserveScrollBarGap
|
||||
variant='ontime'
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Ontime quick start</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody className={styles.pad}>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={styles.sectionContainer}>
|
||||
<div className={styles.entryRow}>
|
||||
<label className={styles.sectionTitle}>
|
||||
Event title
|
||||
<Input
|
||||
variant='ontime-filled-on-light'
|
||||
size='sm'
|
||||
maxLength={50}
|
||||
placeholder='Eurovision song contest'
|
||||
{...register('title')}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className={styles.entryRow}>
|
||||
<label className={styles.sectionTitle}>
|
||||
Public Info
|
||||
<Textarea
|
||||
variant='ontime-filled-on-light'
|
||||
size='sm'
|
||||
maxLength={150}
|
||||
placeholder='Shows always start ontime'
|
||||
{...register('publicInfo')}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className={styles.entryRow}>
|
||||
<label className={styles.sectionTitle}>
|
||||
Public QR Code Url
|
||||
<Input
|
||||
variant='ontime-filled-on-light'
|
||||
size='sm'
|
||||
placeholder='www.getontime.no'
|
||||
{...register('publicUrl')}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className={styles.entryRow}>
|
||||
<label className={styles.sectionTitle}>
|
||||
Backstage Info
|
||||
<Textarea
|
||||
variant='ontime-filled-on-light'
|
||||
size='sm'
|
||||
maxLength={150}
|
||||
placeholder='Wi-Fi password: 1234'
|
||||
{...register('backstageInfo')}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className={styles.entryRow}>
|
||||
<label className={styles.sectionTitle}>
|
||||
Backstage QR Code Url
|
||||
<Input
|
||||
variant='ontime-filled-on-light'
|
||||
size='sm'
|
||||
placeholder='www.ontime.gitbook.io'
|
||||
{...register('backstageUrl')}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className={styles.footerNotes}>
|
||||
Note: Application options will be kept but rundown and event data will be reset <br />
|
||||
</div>
|
||||
<ModalFooter className={styles.buttonSection}>
|
||||
<Button onClick={onReset} isDisabled={disableButtons} variant='ontime-ghost-on-light' size='sm'>
|
||||
Clear data
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={disableButtons}
|
||||
variant='ontime-filled'
|
||||
padding='0 2em'
|
||||
size='sm'
|
||||
>
|
||||
New showfile
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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,123 @@
|
||||
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>
|
||||
<option value='pt'>Portuguese</option>
|
||||
<option value='es'>Spanish</option>
|
||||
<option value='sv'>Swedish</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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { MutableRefObject, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { OntimeRundown, Playback, SupportedEvent } from 'ontime-types';
|
||||
@@ -37,7 +37,8 @@ export default function Rundown(props: RundownProps) {
|
||||
const appMode = useAppMode((state) => state.mode);
|
||||
const viewFollowsCursor = appMode === AppMode.Run;
|
||||
const moveCursorTo = useAppMode((state) => state.setCursor);
|
||||
const cursorRef = useRef<HTMLDivElement>();
|
||||
const cursorRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// DND KIT
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
@@ -153,19 +154,25 @@ export default function Rundown(props: RundownProps) {
|
||||
|
||||
// when cursor moves, view should follow
|
||||
useEffect(() => {
|
||||
if (!cursorRef?.current) return;
|
||||
function scrollToComponent(
|
||||
componentRef: MutableRefObject<HTMLDivElement>,
|
||||
scrollRef: MutableRefObject<HTMLDivElement>,
|
||||
) {
|
||||
const componentRect = componentRef.current.getBoundingClientRect();
|
||||
const scrollRect = scrollRef.current.getBoundingClientRect();
|
||||
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - 100;
|
||||
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// using start in block parameter causes jumpy behaviour
|
||||
// could alternatively scroll using scrollTo and
|
||||
// calculate position within a range
|
||||
// if the item is near the top half, we are ok
|
||||
// otherwise scroll difference
|
||||
cursorRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'start',
|
||||
});
|
||||
}, [cursorRef]);
|
||||
if (cursorRef.current && scrollRef.current) {
|
||||
// Use requestAnimationFrame to ensure the component is fully loaded
|
||||
window.requestAnimationFrame(() => {
|
||||
scrollToComponent(cursorRef as MutableRefObject<HTMLDivElement>, scrollRef as MutableRefObject<HTMLDivElement>);
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line -- the prompt seems incorrect
|
||||
}, [cursorRef?.current, scrollRef]);
|
||||
|
||||
useEffect(() => {
|
||||
// in run mode, we follow selection
|
||||
@@ -200,9 +207,10 @@ export default function Rundown(props: RundownProps) {
|
||||
let thisEnd = 0;
|
||||
let previousEventId: string | undefined;
|
||||
let eventIndex = -1;
|
||||
let isPast = Boolean(featureData?.selectedEventId);
|
||||
|
||||
return (
|
||||
<div className={style.eventContainer}>
|
||||
<div className={style.eventContainer} ref={scrollRef}>
|
||||
<DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}>
|
||||
<SortableContext items={statefulEntries} strategy={verticalListSortingStrategy}>
|
||||
<div className={style.list}>
|
||||
@@ -225,12 +233,16 @@ export default function Rundown(props: RundownProps) {
|
||||
const isSelected = featureData?.selectedEventId === entry.id;
|
||||
const isNext = featureData?.nextEventId === entry.id;
|
||||
const hasCursor = entry.id === cursor;
|
||||
if (isSelected) {
|
||||
isPast = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={entry.id} ref={hasCursor ? cursorRef : undefined}>
|
||||
<RundownEntry
|
||||
type={entry.type}
|
||||
eventIndex={eventIndex}
|
||||
isPast={isPast}
|
||||
data={entry}
|
||||
selected={isSelected}
|
||||
hasCursor={hasCursor}
|
||||
|
||||
@@ -17,6 +17,7 @@ export type EventItemActions = 'set-cursor' | 'event' | 'delay' | 'block' | 'del
|
||||
interface RundownEntryProps {
|
||||
type: SupportedEvent;
|
||||
eventIndex: number;
|
||||
isPast: boolean;
|
||||
data: OntimeRundownEntry;
|
||||
selected: boolean;
|
||||
hasCursor: boolean;
|
||||
@@ -32,6 +33,7 @@ interface RundownEntryProps {
|
||||
export default function RundownEntry(props: RundownEntryProps) {
|
||||
const {
|
||||
eventIndex,
|
||||
isPast,
|
||||
data,
|
||||
selected,
|
||||
hasCursor,
|
||||
@@ -168,6 +170,7 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
delay={delay}
|
||||
previousEnd={previousEnd}
|
||||
colour={data.colour}
|
||||
isPast={isPast}
|
||||
next={next}
|
||||
skip={data.skip}
|
||||
selected={selected}
|
||||
|
||||
@@ -10,9 +10,9 @@ $skip-opacity: 0.1;
|
||||
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"binder ... ... ..."
|
||||
"binder pb-actions times actions"
|
||||
"binder pb-actions title title"
|
||||
"binder ... ... ..."
|
||||
"binder pb-actions times actions"
|
||||
"binder pb-actions title next"
|
||||
"binder pb-actions estatus estatus"
|
||||
"binder ... ... ...";
|
||||
|
||||
@@ -25,7 +25,7 @@ $skip-opacity: 0.1;
|
||||
transition-property: background-color;
|
||||
transition-duration: $transition-time-feedback;
|
||||
|
||||
@mixin declare-overrides(){
|
||||
@mixin declare-overrides() {
|
||||
--status-color-override: #{$gray-200};
|
||||
--status-color-active-override: #{$green-400};
|
||||
}
|
||||
@@ -45,7 +45,7 @@ $skip-opacity: 0.1;
|
||||
}
|
||||
|
||||
&.pause {
|
||||
background-color: $orange-700;
|
||||
background-color: rgba($ontime-paused, 0.6);
|
||||
@include declare-overrides;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,10 @@ $skip-opacity: 0.1;
|
||||
outline: 1px solid $block-cursor-color;
|
||||
}
|
||||
|
||||
&.past:not(.skip) {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&.skip {
|
||||
border: 1px solid $white-3;
|
||||
|
||||
@@ -121,7 +125,6 @@ $skip-opacity: 0.1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
|
||||
.eventOptions {
|
||||
margin: $element-spacing 16px $element-spacing 0;
|
||||
}
|
||||
@@ -163,6 +166,16 @@ $skip-opacity: 0.1;
|
||||
line-height: 13px;
|
||||
}
|
||||
|
||||
|
||||
.nextTag {
|
||||
grid-area: next;
|
||||
font-size: 1em;
|
||||
color: $orange-500;
|
||||
letter-spacing: 0.03px;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.eventStatus {
|
||||
grid-area: status;
|
||||
display: flex;
|
||||
@@ -171,11 +184,6 @@ $skip-opacity: 0.1;
|
||||
gap: 8px;
|
||||
color: var(--status-color-override, $gray-500);
|
||||
|
||||
.tag {
|
||||
padding-top: 1px;
|
||||
font-size: 0.55em;
|
||||
color: $active-indicator;
|
||||
}
|
||||
|
||||
.statusIcon {
|
||||
width: 16px;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
|
||||
@@ -26,6 +26,7 @@ interface EventBlockProps {
|
||||
delay: number;
|
||||
previousEnd: number;
|
||||
colour: string;
|
||||
isPast: boolean;
|
||||
next: boolean;
|
||||
skip: boolean;
|
||||
selected: boolean;
|
||||
@@ -59,6 +60,7 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
delay,
|
||||
previousEnd,
|
||||
colour,
|
||||
isPast,
|
||||
next,
|
||||
skip = false,
|
||||
selected,
|
||||
@@ -128,19 +130,20 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
const blockClasses = cx([
|
||||
style.eventBlock,
|
||||
skip ? style.skip : null,
|
||||
isPast ? style.past : null,
|
||||
selected ? style.selected : null,
|
||||
playback ? style[playback] : null,
|
||||
hasCursor ? style.hasCursor : null,
|
||||
]);
|
||||
|
||||
const handleFocusClick = (event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
moveCursorTo(eventId, true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={blockClasses} ref={setNodeRef} style={dragStyle}>
|
||||
<div
|
||||
className={style.binder}
|
||||
style={{ ...binderColours }}
|
||||
tabIndex={-1}
|
||||
onClick={() => moveCursorTo(eventId, true)}
|
||||
>
|
||||
<div className={blockClasses} ref={setNodeRef} style={dragStyle} onClick={handleFocusClick}>
|
||||
<div className={style.binder} style={{ ...binderColours }} tabIndex={-1}>
|
||||
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
|
||||
<IoReorderTwo />
|
||||
</span>
|
||||
|
||||
@@ -113,6 +113,11 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
|
||||
previousEnd={previousEnd}
|
||||
/>
|
||||
<EditableBlockTitle title={title} eventId={eventId} placeholder='Event title' className={style.eventTitle} />
|
||||
{next && (
|
||||
<Tooltip label='Next event' {...tooltipProps}>
|
||||
<span className={style.nextTag}>UP NEXT</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<EventBlockPlayback
|
||||
eventId={eventId}
|
||||
skip={skip}
|
||||
@@ -127,11 +132,6 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
|
||||
{selected && <EventBlockProgressBar playback={playback} />}
|
||||
</div>
|
||||
<div className={style.eventStatus} tabIndex={-1}>
|
||||
{next && (
|
||||
<Tooltip label='Next event' {...tooltipProps}>
|
||||
<span className={style.tag}>NEXT</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={`Time type: ${timerType}`} {...tooltipProps}>
|
||||
<span>
|
||||
<TimerIcon type={timerType} className={style.statusIcon} />
|
||||
|
||||
+2
-2
@@ -5,10 +5,10 @@ import TableWrapper from './TableWrapper';
|
||||
|
||||
export default function ProtectedTable() {
|
||||
return (
|
||||
<ProtectRoute>
|
||||
<ProtectRoute permission='operator'>
|
||||
<TableSettingsProvider>
|
||||
<TableWrapper />
|
||||
</TableSettingsProvider>
|
||||
</ProtectRoute>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
import { Playback, TitleBlock } from 'ontime-types';
|
||||
/* eslint-disable react/display-name */
|
||||
import { ComponentType, useMemo } from 'react';
|
||||
import { TitleBlock } from 'ontime-types';
|
||||
import { useStore } from 'zustand';
|
||||
|
||||
import useEventData from '../../common/hooks-query/useEventData';
|
||||
@@ -10,22 +11,22 @@ import { useViewOptionsStore } from '../../common/stores/viewOptions';
|
||||
|
||||
export type TitleManager = TitleBlock & { showNow: boolean; showNext: boolean };
|
||||
|
||||
const withData = (Component: ReactNode) => {
|
||||
return (props) => {
|
||||
const withData = <P extends object>(Component: ComponentType<P>) => {
|
||||
return (props: Partial<P>) => {
|
||||
// persisted app state
|
||||
const isMirrored = useViewOptionsStore((state) => state.mirror);
|
||||
|
||||
// HTTP API data
|
||||
const { data: eventsData } = useRundown();
|
||||
const { data: genData } = useEventData();
|
||||
const { data: rundownData } = useRundown();
|
||||
const { data: eventData } = useEventData();
|
||||
const { data: viewSettings } = useViewSettings();
|
||||
|
||||
const publicEvents = useMemo(() => {
|
||||
if (Array.isArray(eventsData)) {
|
||||
return eventsData.filter((e) => e.type === 'event' && e.title && e.isPublic);
|
||||
if (Array.isArray(rundownData)) {
|
||||
return rundownData.filter((e) => e.type === 'event' && e.title && e.isPublic);
|
||||
}
|
||||
return [];
|
||||
}, [eventsData]);
|
||||
}, [rundownData]);
|
||||
|
||||
// websocket data
|
||||
const data = useStore(runtime);
|
||||
@@ -79,7 +80,6 @@ const withData = (Component: ReactNode) => {
|
||||
// get clock string
|
||||
const TimeManagerType = {
|
||||
...timer,
|
||||
finished: playback === Playback.Play && (timer.current ?? 0) < 0 && timer.startedAt,
|
||||
playback,
|
||||
};
|
||||
|
||||
@@ -99,12 +99,12 @@ const withData = (Component: ReactNode) => {
|
||||
publicTitle={publicTitleManager}
|
||||
time={TimeManagerType}
|
||||
events={publicEvents}
|
||||
backstageEvents={eventsData}
|
||||
backstageEvents={rundownData}
|
||||
selectedId={selectedId}
|
||||
publicSelectedId={publicSelectedId}
|
||||
viewSettings={viewSettings}
|
||||
nextId={nextId}
|
||||
general={genData}
|
||||
general={eventData}
|
||||
onAir={onAir}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -91,7 +91,8 @@ export default function Countdown(props: CountdownProps) {
|
||||
}
|
||||
|
||||
const standby = time.playback !== Playback.Play && time.playback !== Playback.Roll && selectedId === follow?.id;
|
||||
const isRunningFinished = time.finished && runningMessage === TimerMessage.running;
|
||||
const finished = time.playback === Playback.Play && (time.current ?? 0) < 0 && time.startedAt;
|
||||
const isRunningFinished = finished && runningMessage === TimerMessage.running;
|
||||
const isSelected = runningMessage === TimerMessage.running;
|
||||
const delayedTimerStyles = delay > 0 ? 'aux-timers__value--delayed' : '';
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ interface MinimalTimerProps {
|
||||
}
|
||||
|
||||
export default function MinimalTimer(props: MinimalTimerProps) {
|
||||
const { isMirrored, pres, time, viewSettings, general } = props;
|
||||
const { isMirrored, pres, time, viewSettings } = props;
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
@@ -129,9 +129,20 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
||||
const isPlaying = time.playback !== Playback.Pause;
|
||||
const isNegative =
|
||||
(time.current ?? 0) < 0 && time.timerType !== TimerType.Clock && time.timerType !== TimerType.CountUp;
|
||||
const showEndMessage = (time.current ?? 0) < 0 && general.endMessage && !hideEndMessage;
|
||||
const showFinished =
|
||||
time.finished && !userOptions?.hideOvertime && (time.timerType !== TimerType.Clock || showEndMessage);
|
||||
const showEndMessage = (time.current ?? 0) < 0 && viewSettings.endMessage && !hideEndMessage;
|
||||
const finished = time.playback === Playback.Play && (time.current ?? 0) < 0 && time.startedAt;
|
||||
const showFinished = finished && !userOptions?.hideOvertime && (time.timerType !== TimerType.Clock || showEndMessage);
|
||||
|
||||
const showProgress = time.playback !== Playback.Stop;
|
||||
const showWarning = (time.current ?? 1) < viewSettings.warningThreshold;
|
||||
const showDanger = (time.current ?? 1) < viewSettings.dangerThreshold;
|
||||
const timerColor = userOptions.textColour
|
||||
? userOptions.textColour
|
||||
: showProgress && showDanger
|
||||
? viewSettings.dangerColor
|
||||
: showProgress && showWarning
|
||||
? viewSettings.warningColor
|
||||
: viewSettings.normalColor;
|
||||
|
||||
const stageTimer = getTimerByType(time);
|
||||
let display = formatTimerDisplay(stageTimer);
|
||||
@@ -162,12 +173,12 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
||||
</div>
|
||||
)}
|
||||
{showEndMessage ? (
|
||||
<div className='end-message'>{general.endMessage}</div>
|
||||
<div className='end-message'>{viewSettings.endMessage}</div>
|
||||
) : (
|
||||
<div
|
||||
className={timerClasses}
|
||||
style={{
|
||||
color: userOptions.textColour,
|
||||
color: timerColor,
|
||||
fontSize: `${timerFontSize}vw`,
|
||||
fontFamily: userOptions.font,
|
||||
top: userOptions.top,
|
||||
|
||||
@@ -3,8 +3,8 @@ import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { EventData, Message, Playback, TimerType, ViewSettings } from 'ontime-types';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/apiConstants';
|
||||
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
||||
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
||||
import ProgressBar from '../../../common/components/progress-bar/ProgressBar';
|
||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||
@@ -46,7 +46,7 @@ interface TimerProps {
|
||||
}
|
||||
|
||||
export default function Timer(props: TimerProps) {
|
||||
const { isMirrored, general, pres, title, time, viewSettings } = props;
|
||||
const { isMirrored, pres, title, time, viewSettings } = props;
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
@@ -65,9 +65,18 @@ export default function Timer(props: TimerProps) {
|
||||
const isNegative =
|
||||
(time.current ?? 0) < 0 && time.timerType !== TimerType.Clock && time.timerType !== TimerType.CountUp;
|
||||
|
||||
const showEndMessage = (time.current ?? 1) < 0 && general.endMessage;
|
||||
const finished = time.playback === Playback.Play && (time.current ?? 0) < 0 && time.startedAt;
|
||||
const showEndMessage = (time.current ?? 1) < 0 && viewSettings.endMessage;
|
||||
const showProgress = time.playback !== Playback.Stop;
|
||||
const showFinished = time.finished && (time.timerType !== TimerType.Clock || showEndMessage);
|
||||
const showFinished = finished && (time.timerType !== TimerType.Clock || showEndMessage);
|
||||
const showWarning = (time.current ?? 1) < viewSettings.warningThreshold;
|
||||
const showDanger = (time.current ?? 1) < viewSettings.dangerThreshold;
|
||||
const timerColor =
|
||||
showProgress && showDanger
|
||||
? viewSettings.dangerColor
|
||||
: showProgress && showWarning
|
||||
? viewSettings.warningColor
|
||||
: viewSettings.normalColor;
|
||||
const showClock = time.timerType !== TimerType.Clock;
|
||||
const baseClasses = `stage-timer ${isMirrored ? 'mirror' : ''}`;
|
||||
|
||||
@@ -95,12 +104,13 @@ export default function Timer(props: TimerProps) {
|
||||
|
||||
<div className='timer-container'>
|
||||
{showEndMessage ? (
|
||||
<div className='end-message'>{general.endMessage}</div>
|
||||
<div className='end-message'>{viewSettings.endMessage}</div>
|
||||
) : (
|
||||
<div
|
||||
className={timerClasses}
|
||||
style={{
|
||||
fontSize: `${timerFontSize}vw`,
|
||||
color: timerColor,
|
||||
}}
|
||||
>
|
||||
{display}
|
||||
@@ -108,15 +118,20 @@ export default function Timer(props: TimerProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ProgressBar
|
||||
<MultiPartProgressBar
|
||||
className={isPlaying ? 'progress-container' : 'progress-container progress-container--paused'}
|
||||
now={time.current || 0}
|
||||
complete={time.duration || 0}
|
||||
normalColor={viewSettings.normalColor}
|
||||
warning={viewSettings.warningThreshold}
|
||||
warningColor={viewSettings.warningColor}
|
||||
danger={viewSettings.dangerThreshold}
|
||||
dangerColor={viewSettings.dangerColor}
|
||||
hidden={!showProgress}
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{title.showNow && !time.finished && (
|
||||
{title.showNow && !finished && (
|
||||
<motion.div
|
||||
className='event now'
|
||||
key='now'
|
||||
|
||||
@@ -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
|
||||
},
|
||||
};
|
||||
@@ -2,7 +2,7 @@ export const ontimeModal = {
|
||||
header: {
|
||||
fontWeight: 400,
|
||||
letterSpacing: '0.3px',
|
||||
padding: '8px 16px',
|
||||
padding: '16px 24px',
|
||||
fontSize: '20px',
|
||||
color: '#202020', // $gray-50
|
||||
},
|
||||
@@ -19,6 +19,9 @@ export const ontimeModal = {
|
||||
closeButton: {
|
||||
color: '#202020', // $gray-50
|
||||
},
|
||||
footer: {
|
||||
padding: '8px',
|
||||
},
|
||||
};
|
||||
|
||||
export const ontimeSmallModal = {
|
||||
|
||||
@@ -13,13 +13,13 @@ export const ontimeSwitch = {
|
||||
|
||||
export const lightSwitch = {
|
||||
track: {
|
||||
border: '2px solid transparent',
|
||||
border: '1px solid transparent',
|
||||
background: '#cfcfcf', // $gray-300
|
||||
_checked: {
|
||||
background: `#578AF4`, // $blue-500
|
||||
},
|
||||
_focus: {
|
||||
border: '2px solid #D2DDFF', // $blue-200
|
||||
border: '1px solid #D2DDFF', // $blue-200
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,4 +13,7 @@ export const ontimeTab = {
|
||||
tablist: {
|
||||
borderBottom: '2px solid #ececec', // $gray-100
|
||||
},
|
||||
tabpanel: {
|
||||
padding: 0,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -23,13 +23,11 @@ export const ontimeInputFilled = {
|
||||
export const ontimeInputFilledOnLight = {
|
||||
field: {
|
||||
backgroundColor: 'white',
|
||||
border: '2px solid transparent',
|
||||
border: '2px solid #f6f6f6', // $gray-50
|
||||
_hover: {
|
||||
backgroundColor: 'white',
|
||||
border: '2px solid #D2DDFF', // $blue-200
|
||||
},
|
||||
_focus: {
|
||||
backgroundColor: 'white',
|
||||
border: '2px solid #578AF4', // $blue-500
|
||||
},
|
||||
},
|
||||
@@ -42,16 +40,15 @@ export const ontimeTextAreaFilled = {
|
||||
export const ontimeTextAreaFilledOnLight = {
|
||||
borderRadius: '3px',
|
||||
fontWeight: '400',
|
||||
backgroundColor: '#ececec', // $gray-100
|
||||
backgroundColor: 'white',
|
||||
color: '#202020', // $gray-1200
|
||||
border: '1px solid transparent',
|
||||
border: '2px solid #f6f6f6', // $gray-50
|
||||
_hover: {
|
||||
backgroundColor: '#cfcfcf', // $gray-300
|
||||
border: '2px solid #D2DDFF', // $blue-200
|
||||
},
|
||||
_focus: {
|
||||
backgroundColor: '#cfcfcf', // $gray-300
|
||||
color: '#101010',
|
||||
border: '1px solid #578AF4', // $blue-500
|
||||
border: '2px solid #578AF4', // $blue-500
|
||||
},
|
||||
_placeholder: { color: '#9d9d9d' }, // $gray-500
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { extendTheme } from '@chakra-ui/react';
|
||||
|
||||
import { ontimeAlertOnLight } from './OntimeAlert';
|
||||
import {
|
||||
ontimeButtonFilled,
|
||||
ontimeButtonOutlined,
|
||||
@@ -26,6 +27,11 @@ import { ontimeTooltip } from './ontimeTooltip';
|
||||
|
||||
const theme = extendTheme({
|
||||
components: {
|
||||
Alert: {
|
||||
variants: {
|
||||
'ontime-on-light-info': { ...ontimeAlertOnLight },
|
||||
},
|
||||
},
|
||||
Button: {
|
||||
baseStyle: {
|
||||
letterSpacing: '0.3px',
|
||||
@@ -88,7 +94,7 @@ const theme = extendTheme({
|
||||
},
|
||||
variants: {
|
||||
'ontime-filled': { ...ontimeTextAreaFilled },
|
||||
'ontime-filled-onlight': { ...ontimeTextAreaFilledOnLight },
|
||||
'ontime-filled-on-light': { ...ontimeTextAreaFilledOnLight },
|
||||
},
|
||||
},
|
||||
Tooltip: {
|
||||
|
||||
@@ -1,49 +1,47 @@
|
||||
import React, { createContext, useCallback, useContext, useState } from 'react';
|
||||
import React, { createContext, useCallback, useContext } from 'react';
|
||||
|
||||
import useSettings from '@/common/hooks-query/useSettings';
|
||||
import { langDe } from '@/translation/languages/de';
|
||||
import { langEn } from '@/translation/languages/en';
|
||||
import { langEs } from '@/translation/languages/es';
|
||||
import { langNo } from '@/translation/languages/no';
|
||||
import { langPt } from '@/translation/languages/pt';
|
||||
import { langSv } from '@/translation/languages/sv';
|
||||
|
||||
const translationsList = {
|
||||
en: langEn,
|
||||
es: langEs,
|
||||
de: langDe,
|
||||
no: langNo,
|
||||
pt: langPt,
|
||||
sv: langSv,
|
||||
};
|
||||
const ALLOWED_LANGUAGES = Object.keys(translationsList);
|
||||
|
||||
const DEFAULT_LANGUAGE = 'en';
|
||||
export const TranslationContext = createContext(undefined);
|
||||
|
||||
export const TranslationProvider = ({ children }) => {
|
||||
const [language, setLanguageState] = useState('en');
|
||||
const { data } = useSettings();
|
||||
|
||||
const getLocalizedString = useCallback(
|
||||
(key, lang = language) => {
|
||||
(key, lang = data.language) => {
|
||||
if (key in translationsList[lang]) {
|
||||
return translationsList[lang][key];
|
||||
} else if (lang !== DEFAULT_LANGUAGE) {
|
||||
return getLocalizedString(key, 'en');
|
||||
}
|
||||
},
|
||||
[language],
|
||||
[data.language],
|
||||
);
|
||||
|
||||
const setLanguage = useCallback((language) => {
|
||||
language = language.toLowerCase();
|
||||
if (ALLOWED_LANGUAGES.includes(language)) {
|
||||
setLanguageState(language);
|
||||
console.info(`Language set to ${language}`);
|
||||
} else {
|
||||
console.warn(`Language code ${language} does not exist.`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const contextValue = {
|
||||
getLocalizedString,
|
||||
setLanguage,
|
||||
};
|
||||
|
||||
return <TranslationContext.Provider value={contextValue}>{children}</TranslationContext.Provider>;
|
||||
};
|
||||
|
||||
export const useTranslation = () => {
|
||||
const { getLocalizedString, setLanguage } = useContext(TranslationContext);
|
||||
return { getLocalizedString, setLanguage };
|
||||
const { getLocalizedString } = useContext(TranslationContext);
|
||||
return { getLocalizedString };
|
||||
};
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { TranslationObject } from '../Translation.types';
|
||||
|
||||
export const langDe: TranslationObject = {
|
||||
'common.end_time': 'Endzeit',
|
||||
'common.expected_finish': 'Voraussichtliches Ende',
|
||||
'common.now': 'Jetzt',
|
||||
'common.next': 'Nächste',
|
||||
'common.public_message': 'Öffentliche Nachricht',
|
||||
'common.start_time': 'Startzeit',
|
||||
'common.stage_timer': 'Bühnen-Timer',
|
||||
'common.started_at': 'Gestartet am',
|
||||
'common.time_now': 'Aktuelle Zeit',
|
||||
'countdown.ended': 'Veranstaltung endete um',
|
||||
'countdown.running': 'Veranstaltung läuft',
|
||||
'countdown.select_event': 'Wählen Sie eine Veranstaltung aus, um sie zu verfolgen',
|
||||
'countdown.to_start': 'Zeit bis zum Start',
|
||||
'countdown.waiting': 'Warten auf den Veranstaltungsbeginn',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { TranslationObject } from '../Translation.types';
|
||||
|
||||
export const langEs: TranslationObject = {
|
||||
'common.end_time': 'Hora de finalización',
|
||||
'common.expected_finish': 'Finalización esperada',
|
||||
'common.now': 'Ahora',
|
||||
'common.next': 'Siguiente',
|
||||
'common.public_message': 'Mensaje público',
|
||||
'common.start_time': 'Hora de inicio',
|
||||
'common.stage_timer': 'Temporizador de presentador',
|
||||
'common.started_at': 'Iniciado en',
|
||||
'common.time_now': 'Hora actual',
|
||||
'countdown.ended': 'Evento finalizado a las',
|
||||
'countdown.running': 'Evento en curso',
|
||||
'countdown.select_event': 'Seleccionar un evento para seguir',
|
||||
'countdown.to_start': 'Tiempo para comenzar',
|
||||
'countdown.waiting': 'Esperando el inicio del evento',
|
||||
};
|
||||
@@ -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 langPt: TranslationObject = {
|
||||
'common.end_time': 'Hora de término',
|
||||
'common.expected_finish': 'Término esperado',
|
||||
'common.now': 'Agora',
|
||||
'common.next': 'Próximo',
|
||||
'common.public_message': 'Mensagem pública',
|
||||
'common.start_time': 'Hora de início',
|
||||
'common.stage_timer': 'Temporizador do presentador',
|
||||
'common.started_at': 'Iniciado em',
|
||||
'common.time_now': 'Hora atual',
|
||||
'countdown.ended': 'Evento encerrado às',
|
||||
'countdown.running': 'Evento em andamento',
|
||||
'countdown.select_event': 'Selecione um evento para acompanhar',
|
||||
'countdown.to_start': 'Tempo para iniciar',
|
||||
'countdown.waiting': 'Aguardando o início do evento',
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "2.0.0-beta5",
|
||||
"version": "2.0.0-beta6",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -96,13 +96,6 @@ function getApplicationMenu(isMac, askToQuit) {
|
||||
await shell.openExternal('http://localhost:4001/lower');
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
label: 'PiP',
|
||||
click: async () => {
|
||||
await shell.openExternal('http://localhost:4001/pip');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Studio Clock',
|
||||
click: async () => {
|
||||
@@ -160,7 +153,7 @@ function getApplicationMenu(isMac, askToQuit) {
|
||||
{
|
||||
label: 'Online documentation',
|
||||
click: async () => {
|
||||
await shell.openExternal('https://cpvalente.gitbook.io/ontime/');
|
||||
await shell.openExternal('https://ontime.gitbook.io/');
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "2.0.0-beta5",
|
||||
"version": "2.0.0-beta6",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"body-parser": "^1.20.0",
|
||||
|
||||
@@ -12,7 +12,7 @@ export class DataProvider {
|
||||
return data;
|
||||
}
|
||||
|
||||
static async setEventData(newData: EventData) {
|
||||
static async setEventData(newData: Partial<EventData>) {
|
||||
data.eventData = { ...data.eventData, ...newData };
|
||||
await this.persist();
|
||||
return data.eventData;
|
||||
@@ -44,8 +44,12 @@ export class DataProvider {
|
||||
}
|
||||
|
||||
static async deleteEvent(eventId) {
|
||||
data.rundown = Array.from(data.rundown).filter((e) => e.id !== eventId);
|
||||
await this.persist();
|
||||
const eventIndex = data.rundown.findIndex((e) => e.id === eventId);
|
||||
|
||||
if (eventIndex !== -1) {
|
||||
data.rundown.splice(eventIndex, 1);
|
||||
await this.persist();
|
||||
}
|
||||
}
|
||||
|
||||
static getRundownLength() {
|
||||
@@ -165,7 +169,6 @@ export class DataProvider {
|
||||
data.settings = mergedData.settings;
|
||||
data.viewSettings = mergedData.viewSettings;
|
||||
data.osc = mergedData.osc;
|
||||
data.http = mergedData.http;
|
||||
data.aliases = mergedData.aliases;
|
||||
data.userFields = mergedData.userFields;
|
||||
data.rundown = mergedData.rundown;
|
||||
|
||||
@@ -8,7 +8,6 @@ describe('safeMerge', () => {
|
||||
publicUrl: 'existing public URL',
|
||||
backstageUrl: 'existing backstageUrl',
|
||||
backstageInfo: 'existing backstageInfo',
|
||||
endMessage: 'existing endMessage',
|
||||
},
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
@@ -20,6 +19,7 @@ describe('safeMerge', () => {
|
||||
},
|
||||
viewSettings: {
|
||||
overrideStyles: false,
|
||||
endMessage: 'existing endMessage',
|
||||
},
|
||||
aliases: [],
|
||||
userFields: {
|
||||
@@ -75,7 +75,6 @@ describe('safeMerge', () => {
|
||||
publicInfo: 'new public info',
|
||||
backstageUrl: 'existing backstageUrl',
|
||||
backstageInfo: 'existing backstageInfo',
|
||||
endMessage: 'existing endMessage',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,7 +144,6 @@ describe('safeMerge', () => {
|
||||
publicInfo: '',
|
||||
backstageUrl: '',
|
||||
backstageInfo: '',
|
||||
endMessage: '',
|
||||
},
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
@@ -157,6 +155,7 @@ describe('safeMerge', () => {
|
||||
},
|
||||
viewSettings: {
|
||||
overrideStyles: false,
|
||||
endMessage: '',
|
||||
},
|
||||
aliases: [],
|
||||
userFields: {
|
||||
|
||||
+3
-3
@@ -1,14 +1,14 @@
|
||||
import { removeUndefined } from '../utils/parserUtils.js';
|
||||
import { failEmptyObjects } from '../utils/routerUtils.js';
|
||||
import { DataProvider } from '../classes/data-provider/DataProvider.ts';
|
||||
import { DataProvider } from '../classes/data-provider/DataProvider.js';
|
||||
|
||||
// Create controller for GET request to 'event'
|
||||
export const getEvent = async (req, res) => {
|
||||
export const getEventData = async (req, res) => {
|
||||
res.json(DataProvider.getEventData());
|
||||
};
|
||||
|
||||
// Create controller for POST request to 'event'
|
||||
export const postEvent = async (req, res) => {
|
||||
export const postEventData = async (req, res) => {
|
||||
if (failEmptyObjects(req.body, res)) {
|
||||
return;
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { body, validationResult } from 'express-validator';
|
||||
|
||||
export const eventSanitizer = [
|
||||
export const eventDataSanitizer = [
|
||||
body('title').optional().isString().trim(),
|
||||
body('publicUrl').optional().isString().trim(),
|
||||
body('publicInfo').optional().isString().trim(),
|
||||
+59
-30
@@ -1,7 +1,7 @@
|
||||
import fs from 'fs';
|
||||
import type { Alias, EventData } from 'ontime-types';
|
||||
import { networkInterfaces } from 'os';
|
||||
import { generateId } from 'ontime-utils';
|
||||
import { fileHandler } from '../utils/parser.ts';
|
||||
import { fileHandler } from '../utils/parser.js';
|
||||
import { DataProvider } from '../classes/data-provider/DataProvider.js';
|
||||
import { failEmptyObjects, failIsNotArray } from '../utils/routerUtils.js';
|
||||
import { mergeObject } from '../utils/parserUtils.js';
|
||||
@@ -10,6 +10,7 @@ import { eventStore } from '../stores/EventStore.js';
|
||||
import { resolveDbPath } from '../setup.js';
|
||||
import { oscIntegration } from '../services/integration-service/OscIntegration.js';
|
||||
import { logger } from '../classes/Logger.js';
|
||||
import { deleteAllEvents } from '../services/RundownService.js';
|
||||
|
||||
// Create controller for GET request to '/ontime/poll'
|
||||
// Returns data for current state
|
||||
@@ -56,9 +57,9 @@ const uploadAndParse = async (file, req, res, options) => {
|
||||
try {
|
||||
const result = await fileHandler(file);
|
||||
|
||||
if (result?.error) {
|
||||
if ('error' in result && result.error) {
|
||||
res.status(400).send({ message: result.message });
|
||||
} else if (result.message === 'success') {
|
||||
} else if ('data' in result && result.message === 'success') {
|
||||
PlaybackService.stop();
|
||||
// explicitly write objects
|
||||
if (typeof result !== 'undefined') {
|
||||
@@ -134,10 +135,9 @@ export const postAliases = async (req, res) => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const newAliases = [];
|
||||
const newAliases: Alias[] = [];
|
||||
req.body.forEach((a) => {
|
||||
newAliases.push({
|
||||
id: generateId(),
|
||||
enabled: a.enabled,
|
||||
alias: a.alias,
|
||||
pathAndParams: a.pathAndParams,
|
||||
@@ -176,16 +176,23 @@ export const postUserFields = async (req, res) => {
|
||||
// Create controller for POST request to '/ontime/settings'
|
||||
// Returns -
|
||||
export const getSettings = async (req, res) => {
|
||||
const { version, serverPort, pinCode, timeFormat } = DataProvider.getSettings();
|
||||
|
||||
res.status(200).send({
|
||||
version,
|
||||
serverPort,
|
||||
pinCode,
|
||||
timeFormat,
|
||||
});
|
||||
const settings = DataProvider.getSettings();
|
||||
res.status(200).send(settings);
|
||||
};
|
||||
|
||||
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'
|
||||
// Returns ACK message
|
||||
export const postSettings = async (req, res) => {
|
||||
@@ -194,26 +201,22 @@ export const postSettings = async (req, res) => {
|
||||
}
|
||||
try {
|
||||
const settings = DataProvider.getSettings();
|
||||
let pin = settings.pinCode;
|
||||
if (typeof req.body?.pinCode === 'string') {
|
||||
if (req.body?.pinCode.length === 0) {
|
||||
pin = null;
|
||||
} else if (req.body?.pinCode.length <= 4) {
|
||||
pin = req.body?.pinCode;
|
||||
}
|
||||
const editorKey = extractPin(req.body?.editorKey, settings.editorKey);
|
||||
const operatorKey = extractPin(req.body?.operatorKey, settings.operatorKey);
|
||||
|
||||
let timeFormat = settings.timeFormat;
|
||||
if (req.body?.timeFormat === '12' || req.body?.timeFormat === '24') {
|
||||
timeFormat = req.body.timeFormat;
|
||||
}
|
||||
|
||||
let format = settings.timeFormat;
|
||||
if (typeof req.body?.timeFormat === 'string') {
|
||||
if (req.body?.timeFormat === '12' || req.body?.timeFormat === '24') {
|
||||
format = req.body.timeFormat;
|
||||
}
|
||||
}
|
||||
const language = req.body?.language || 'en';
|
||||
|
||||
const newData = {
|
||||
...settings,
|
||||
pinCode: pin,
|
||||
timeFormat: format,
|
||||
editorKey,
|
||||
operatorKey,
|
||||
timeFormat,
|
||||
language,
|
||||
};
|
||||
await DataProvider.setSettings(newData);
|
||||
res.status(200).send(newData);
|
||||
@@ -241,7 +244,15 @@ export const postViewSettings = async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const newData = { overrideStyles: req.body.overrideStyles };
|
||||
const newData = {
|
||||
overrideStyles: req.body.overrideStyles,
|
||||
endMessage: req.body?.endMessage || '',
|
||||
normalColor: req.body.normalColor,
|
||||
warningColor: req.body.warningColor,
|
||||
warningThreshold: req.body.warningThreshold,
|
||||
dangerColor: req.body.dangerColor,
|
||||
dangerThreshold: req.body.dangerThreshold,
|
||||
};
|
||||
await DataProvider.setViewSettings(newData);
|
||||
res.status(200).send(newData);
|
||||
} catch (error) {
|
||||
@@ -288,3 +299,21 @@ export const dbUpload = async (req, res) => {
|
||||
const file = req.file.path;
|
||||
await uploadAndParse(file, req, res, options);
|
||||
};
|
||||
|
||||
// Create controller for POST request to '/ontime/new'
|
||||
export const postNew = async (req, res) => {
|
||||
try {
|
||||
const newEventData: Omit<EventData, 'endMessage'> = {
|
||||
title: req.body?.title ?? '',
|
||||
publicUrl: req.body?.publicUrl ?? '',
|
||||
publicInfo: req.body?.publicInfo ?? '',
|
||||
backstageUrl: req.body?.backstageUrl ?? '',
|
||||
backstageInfo: req.body?.backstageInfo ?? '',
|
||||
};
|
||||
const newData = await DataProvider.setEventData(newEventData);
|
||||
await deleteAllEvents();
|
||||
res.status(201).send(newData);
|
||||
} catch (error) {
|
||||
res.status(400).send(error);
|
||||
}
|
||||
};
|
||||
@@ -6,6 +6,12 @@ import { validateOscSubscription } from '../utils/parserFunctions.js';
|
||||
*/
|
||||
export const viewValidator = [
|
||||
check('overrideStyles').isBoolean().withMessage('overrideStyles value must be boolean'),
|
||||
check('endMessage').isString().trim().withMessage('endMessage value must be string'),
|
||||
check('normalColor').isString().trim().withMessage('normalColor value must be string'),
|
||||
check('warningColor').isString().trim().withMessage('warningColor value must be string'),
|
||||
check('dangerColor').isString().trim().withMessage('dangerColor value must be string'),
|
||||
check('warningThreshold').isNumeric().withMessage('warningThreshold value must be a number'),
|
||||
check('dangerThreshold').isNumeric().withMessage('dangerThreshold value must a number'),
|
||||
(req, res, next) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||
@@ -53,8 +59,10 @@ export const validateUserFields = [
|
||||
* @description Validates object for POST /ontime/settings
|
||||
*/
|
||||
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('language').isString(),
|
||||
(req, res, next) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||
|
||||
@@ -8,18 +8,24 @@ export const dbModel: DatabaseModel = {
|
||||
publicInfo: '',
|
||||
backstageUrl: '',
|
||||
backstageInfo: '',
|
||||
endMessage: '',
|
||||
},
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
version: 2,
|
||||
serverPort: 4001,
|
||||
lock: null,
|
||||
pinCode: null,
|
||||
editorKey: null,
|
||||
operatorKey: null,
|
||||
timeFormat: '24',
|
||||
language: 'en',
|
||||
},
|
||||
viewSettings: {
|
||||
overrideStyles: false,
|
||||
normalColor: '#ffffffcc',
|
||||
warningColor: '#FFAB33',
|
||||
warningThreshold: 120000,
|
||||
dangerColor: '#ED3333',
|
||||
dangerThreshold: 60000,
|
||||
endMessage: '',
|
||||
},
|
||||
aliases: [],
|
||||
userFields: {
|
||||
@@ -49,35 +55,4 @@ export const dbModel: DatabaseModel = {
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import express from 'express';
|
||||
// import event controller
|
||||
import { getEventData, postEventData } from '../controllers/eventDataController.ts';
|
||||
import { eventDataSanitizer } from '../controllers/eventDataController.validate.ts';
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
// import event controller
|
||||
import { getEvent, postEvent } from '../controllers/eventDataController.js';
|
||||
import { eventSanitizer } from '../controllers/eventDataController.validate.js';
|
||||
|
||||
// create route between controller and 'GET /event' endpoint
|
||||
router.get('/', getEvent);
|
||||
router.get('/', getEventData);
|
||||
|
||||
// create route between controller and 'POST /event' endpoint
|
||||
router.post('/', eventSanitizer, postEvent);
|
||||
router.post('/', eventDataSanitizer, postEventData);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user