Compare commits

..

6 Commits

Author SHA1 Message Date
Carlos Valente 672267c0b3 v2 beta6 (#379)
* feat: operator key

* chore: update demo

* chore: version bump

* fix: prevent stale keys

* refactor: add new fields to validation

* style: tweaks to modal arrangement

* refactor: prevent parsing http

* ux: click anywhere in event to edit

* refactor: performance improvements to time input

* refactor: performance improvements menu
2023-05-13 23:36:19 +02:00
Carlos Valente ca65d31071 Ux language (#378)
* ux: language from menu

* feat: add extra support for languages

german
portuguese
spanish

* refactor: type improvements
2023-05-12 23:16:20 +02:00
Carlos Valente 305d6b6476 v2 beta 5 (#375)
* chore: update documentation links

* fix: sentry has no access to error context

* style: clarify event history

* fix: issue with clipboard write in safari

* style: clarify event history

* refactor: improvements to follow logic in rundown

* refactor: improvements in go mode

* several style tweaks and small improvements
2023-05-12 22:31:26 +02:00
Carlos Valente 139b667e20 v2 beta5 modals (#377)
* refactor: folder structure

* style: modal wrapper

* feat: add nordic translations
2023-05-12 22:16:33 +02:00
Fabian Posenau 29a9167d61 Feat/timer/stylechange (#341)
* feat: multi color progress bar

* add color support to minimal timer

Co-Authored-By: Fabian Posenau <19673098+kellhogs@users.noreply.github.com>

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>
Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Fabian Posenau <19673098+kellhogs@users.noreply.github.com>
2023-05-03 20:16:35 +02:00
Carlos Valente 38654f8981 Feature: quick start (#369)
* feat: quick start modal

* refactor: end message is part of view settings

* refactor: small tweaks and type improvements
2023-04-29 21:20:25 +02:00
113 changed files with 2179 additions and 2055 deletions
+1 -1
View File
@@ -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.
![App Window](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/app.jpg)
+2 -1
View File
@@ -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",
+10 -22
View File
@@ -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 -1
View File
@@ -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';
+6 -2
View File
@@ -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: '',
};
+7 -1
View File
@@ -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
},
},
});
+12 -1
View File
@@ -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,
+1 -1
View File
@@ -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 -1
View File
@@ -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';
@@ -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>
+7 -2
View File
@@ -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>
@@ -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>
+22 -7
View File
@@ -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;
}
+14 -3
View File
@@ -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;
}
@@ -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>
);
}
+27 -15
View File
@@ -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} />
@@ -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'
+11
View File
@@ -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
},
};
+4 -1
View File
@@ -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 = {
+2 -2
View File
@@ -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
},
},
};
+3
View File
@@ -13,4 +13,7 @@ export const ontimeTab = {
tablist: {
borderBottom: '2px solid #ececec', // $gray-100
},
tabpanel: {
padding: 0,
},
};
+5 -8
View File
@@ -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
};
+7 -1
View File
@@ -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 -1
View File
@@ -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",
+1 -8
View File
@@ -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/');
},
},
],
+1 -1
View File
@@ -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: {
@@ -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,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(),
@@ -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() });
+9 -34
View File
@@ -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,
},
};
+6 -6
View File
@@ -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