* refactor: cleanup routes

* style: smaller base font

* chore: upgrade dependencies

* chore: lock node version to electron

* refactor: pass HTTP to integration controller (#652)

* refactor: deprecate onair control

* refactor: remove playback router

* Several project files user folder (#617)

* chore: automated screenshots (#667)

* feat: app settings (#658)

* refactor: remove deprecated event data (#674)

* Studio clock (#663)

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* Feat: reorder events with alt+ctrl + arrow up/down (#645)

* Warning and danger per event (#677)

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* refactor: stabilise actionHandler (#683)

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* improvement: hide seconds (#675)

* wip: overview (#688)

* fix: focus cursor (#695)

* refactor: update lower third (#665)

* Refactor/time formatting (#696)

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* feat: multiple selection (#703)

---------

Co-authored-by: asharonbaltazar <asharonbaltazar@outlook.com>
Co-authored-by: Alex <ac@omnivox.dk>

* fix: test - go to `Edit mode` befor tying to click `Event options` button (#708)

* refactor: runtime service (#715)

* fix: issue with loosing cursor position on message (#719)

* remove info panel (#721)

* Event editor continue (#722)

* update API - part  (#709)

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* refactor: update timers (#729)

* feat: many timers (#706)

---------

Co-authored-by: arc-alex <ac@omnivox.dk>

* refactor: excel cleanup (#734)

* refactor: allow import of blocks and skip import (#735)

* Project manager (#697)

* refactor: UI for linking events (#763)

* upgraded pipeline actions (#777)

* Over under (#771)

* custom fields (#744)


---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* Sheets settings (#774)

---------

Co-authored-by: arc-alex <ac@omnivox.dk>

* style: tweaks to lower thirds (#785)

* refactor: delays account for gaps (#784)

* refactor: partial state updates (#780)

* feat: generate crash report (#787)

* Sheet use limited input device auth flow (#782)

---------

Co-authored-by: cv <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* Custom fields views (#789)

* refactor: deprecate presenter and subtitle (#795)

* refactor: organise API around resources (#798)

---------

Co-authored-by: Bianca Procopio <biancahprocopio@gmail.com>

* Time to end (#804)

* Skip fixes (#805)

* fix: onair derives from playback

* Param nav (#822)

---------

Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>

* refactor: download files from interface (#831)

* Quick options (#814)

* End pause (#832)

* chore: bump node version in docker (#834)

* refactor: follow in run mode (#840)

* fix: uncaught error in http integration (#837)

* Apply project (#843)

Co-authored-by: Matteo Gheza <matteo.gheza07@gmail.com>
Co-authored-by: Ary <arylmoraesn@gmail.com>
Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
Co-authored-by: Fabian Posenau <19673098+kellhogs@users.noreply.github.com>
Co-authored-by: Fabian Posenau <fabian@fphome.de>
Co-authored-by: Alex Rohleder <alexrohleder96@gmail.com>
Co-authored-by: asharonbaltazar <asharonbaltazar@outlook.com>
Co-authored-by: Bianca Procopio <biancahprocopio@gmail.com>
Co-authored-by: Fabian Posenau <fabianpos99+github@gmail.com>
This commit is contained in:
Carlos Valente
2024-04-13 10:06:46 +02:00
committed by GitHub
parent debdbd1c5a
commit 55d1aca9b6
640 changed files with 26702 additions and 21623 deletions
@@ -0,0 +1,11 @@
.container {
grid-area: main;
width: 100%;
padding: 1rem;
display: flex;
gap: 0.25rem;
overflow: hidden;
background-color: black;
}
@@ -0,0 +1,40 @@
import { ErrorBoundary } from '@sentry/react';
import { useKeyDown } from '../../common/hooks/useKeyDown';
import AboutPanel from './panel/about-panel/AboutPanel';
import FeatureSettingsPanel from './panel/feature-settings-panel/FeatureSettingsPanel';
import GeneralPanel from './panel/general-panel/GeneralPanel';
import IntegrationsPanel from './panel/integrations-panel/IntegrationsPanel';
import LogPanel from './panel/log-panel/LogPanel';
import ProjectPanel from './panel/project-panel/ProjectPanel';
import ShutdownPanel from './panel/shutdown-panel/ShutdownPanel';
import SourcesPanel from './panel/sources-panel/SourcesPanel';
import PanelContent from './panel-content/PanelContent';
import PanelList from './panel-list/PanelList';
import useAppSettingsNavigation from './useAppSettingsNavigation';
import style from './AppSettings.module.scss';
export default function AppSettings() {
const { close, panel, location } = useAppSettingsNavigation();
useKeyDown(close, 'Escape');
return (
<div className={style.container}>
<ErrorBoundary>
<PanelList selectedPanel={panel} location={location} />
<PanelContent onClose={close}>
{panel === 'project' && <ProjectPanel location={location} />}
{panel === 'general' && <GeneralPanel location={location} />}
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
{panel === 'sources' && <SourcesPanel />}
{panel === 'integrations' && <IntegrationsPanel location={location} />}
{panel === 'about' && <AboutPanel />}
{panel === 'log' && <LogPanel />}
{panel === 'shutdown' && <ShutdownPanel />}
</PanelContent>
</ErrorBoundary>
</div>
);
}
@@ -0,0 +1,21 @@
.corner {
position: absolute;
top: 1rem;
right: 2rem;
z-index: 100;
}
.contentWrapper {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
position: relative;
}
.content {
margin: 1rem;
overflow-y: auto;
flex-grow: 1;
}
@@ -0,0 +1,24 @@
import { PropsWithChildren } from 'react';
import { Button } from '@chakra-ui/react';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import style from './PanelContent.module.scss';
interface PanelContentProps {
onClose: () => void;
}
export default function PanelContent(props: PropsWithChildren<PanelContentProps>) {
const { onClose, children } = props;
return (
<div className={style.contentWrapper}>
<div className={style.corner}>
<Button onClick={onClose} aria-label='close' rightIcon={<IoClose />} variant='ontime-subtle'>
Close settings
</Button>
</div>
<div className={style.content}>{children}</div>
</div>
);
}
@@ -0,0 +1,65 @@
.tabs,
ul {
list-style: none;
padding: 0;
margin: 0;
}
.tabs {
width: min(30vw, 300px);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.primary,
.secondary {
padding: 0.25rem 1rem;
margin-right: 1rem;
&:focus {
background-color: $gray-1000;
outline: 0;
}
&:hover {
background-color: $gray-1000;
cursor: pointer;
}
}
.primary {
font-size: 1rem;
border-radius: 2px;
display: flex;
align-items: center;
gap: 0.5rem;
&.active {
color: $blue-400;
background-color: $gray-1100;
}
&.unsaved::before {
content: '';
width: 6px;
height: 6px;
border-radius: 3px;
background-color: $blue-400;
}
&.split {
margin-top: 1rem;
}
}
.secondary {
margin-left: 1rem;
color: $secondary-text-gray;
border-left: 1px solid $white-10;
font-size: $inner-section-text-size;
&.active {
color: $blue-400;
}
}
@@ -0,0 +1,66 @@
import { Fragment } from 'react';
import { isKeyEnter } from '../../../common/utils/keyEvent';
import { cx } from '../../../common/utils/styleUtils';
import { PanelBaseProps, settingPanels, useSettingsStore } from '../settingsStore';
import useAppSettingsNavigation from '../useAppSettingsNavigation';
import style from './PanelList.module.scss';
interface PanelListProps extends PanelBaseProps {
selectedPanel: string;
}
export default function PanelList({ selectedPanel, location }: PanelListProps) {
const { setLocation } = useAppSettingsNavigation();
const { hasUnsavedChanges } = useSettingsStore();
return (
<ul className={style.tabs}>
{settingPanels.map((panel) => {
const unsaved = hasUnsavedChanges(panel.id);
const classes = cx([
style.primary,
selectedPanel === panel.id ? style.active : null,
panel.split ? style.split : null,
unsaved ? style.unsaved : null,
]);
return (
<Fragment key={panel.id}>
<li
key={panel.id}
onClick={() => setLocation(panel.id)}
onKeyDown={(event) => {
isKeyEnter(event) && setLocation(panel.id);
}}
className={classes}
tabIndex={0}
role='button'
>
{panel.label}
</li>
{panel.secondary?.map((secondary) => {
const id = secondary.id.split('__')[1];
const secondaryClasses = cx([style.secondary, location === id ? style.active : null]);
return (
<li
key={secondary.id}
onClick={() => setLocation(secondary.id)}
onKeyDown={(event) => {
isKeyEnter(event) && setLocation(secondary.id);
}}
className={secondaryClasses}
role='button'
>
{secondary.label}
</li>
);
})}
</Fragment>
);
})}
</ul>
);
}
@@ -0,0 +1,174 @@
$inner-padding: 1rem;
.header {
font-size: 2rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid $white-10;
font-weight: 600;
}
.subheader {
font-size: 1.5rem;
padding-bottom: 0.5rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
font-size: 1.375rem;
padding: 0 2rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: space-between;
color: $gray-300;
}
.section {
position: relative;
margin-top: 2rem;
font-size: calc(1rem - 1px);
max-width: 800px;
display: flex;
flex-direction: column;
gap: 1rem;
color: $ui-white;
}
.paragraph {
padding: 0.5rem 0;
}
.card {
position: relative;
padding: 2rem;
background-color: $white-3;
border: 1px solid $gray-1100;
border-radius: 3px;
}
.error {
font-size: $inner-section-text-size;
display: block;
color: $error-red;
}
.pad {
padding: 0 2rem;
max-height: 550px;
overflow-y: scroll;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: calc(1rem - 2px);
text-align: left;
margin-bottom: 2rem;
thead {
position: sticky;
top: 0;
z-index: 3;
box-shadow: 0 1px $white-10;
}
th {
font-weight: 400;
color: $gray-400;
background-color: $gray-1350;
white-space: nowrap;
text-transform: capitalize;
}
th,
td {
padding: 0.5rem;
}
tr:nth-child(even) {
background-color: $white-1;
}
}
.listGroup {
padding: 0 2rem;
> li:not(:last-child) {
border-bottom: 1px solid $white-10;
}
}
.listItem {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
padding: 0.5rem 0;
}
.fieldTitle {
color: $ui-white;
font-size: 1rem;
}
.fieldDescription {
font-size: calc(1rem - 2px);
color: $gray-400;
}
.fieldError {
font-size: calc(1rem - 2px);
color: $red-500;
}
.divider {
border-top: 1px solid $white-10;
margin: 1rem -2rem;
z-index: 999;
}
.overlay {
position: absolute;
z-index: 10;
width: 100%;
height: 100%;
backdrop-filter: blur(2px);
display: grid;
place-content: center;
background-color: $black-10;
}
.loader {
$loader-size: 4rem;
width: $loader-size;
height: $loader-size;
background: $blue-500;
display: inline-block;
border-radius: 50%;
box-sizing: border-box;
animation: animloader 1s ease-in infinite;
}
@keyframes animloader {
0% {
transform: scale(0);
opacity: 0.6;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@keyframes animloader {
0% {
transform: scale(0);
opacity: 0.6;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@@ -0,0 +1,94 @@
import { HTMLAttributes, ReactNode } from 'react';
import { cx } from '../../../common/utils/styleUtils';
import style from './Panel.module.scss';
export function Header({ children }: { children: ReactNode }) {
return <h2 className={style.header}>{children}</h2>;
}
export function SubHeader({ children }: { children: ReactNode }) {
return <h3 className={style.subheader}>{children}</h3>;
}
export function Title({ children }: { children: ReactNode }) {
return <h4 className={style.title}>{children}</h4>;
}
type AllowedTags = 'div' | 'form';
type SectionProps<C extends AllowedTags> = {
as?: C;
children: ReactNode;
} & JSX.IntrinsicElements[C];
export function Section<C extends AllowedTags = 'div'>({ as, children, ...props }: SectionProps<C>) {
const Element = as ?? 'div';
return (
<Element className={style.section} {...(props as HTMLAttributes<HTMLElement>)}>
{children}
</Element>
);
}
export function Paragraph({ children }: { children: ReactNode }) {
return <p className={style.paragraph}>{children}</p>;
}
export function Card({ children, ...props }: { children: ReactNode } & JSX.IntrinsicElements['div']) {
return (
<div className={style.card} {...props}>
{children}
</div>
);
}
export function Table({ className, children }: { className?: string; children: ReactNode }) {
const classes = cx([style.table, className]);
return (
<div className={style.pad}>
<table className={classes}>{children}</table>
</div>
);
}
export function ListGroup({ children }: { children: ReactNode }) {
return <ul className={style.listGroup}>{children}</ul>;
}
export function ListItem({ children }: { children: ReactNode }) {
return <li className={style.listItem}>{children}</li>;
}
export function Field({ title, description, error }: { title: string; description: string; error?: string }) {
return (
<div className={style.fieldTitle}>
{title}
{error && <Error>{error}</Error>}
{!error && description && <Description>{description}</Description>}
</div>
);
}
export function Description({ children }: { children: ReactNode }) {
return <div className={style.fieldDescription}>{children}</div>;
}
export function Error({ children }: { children: ReactNode }) {
return <div className={style.fieldError}>{children}</div>;
}
export function Divider() {
return <hr className={style.divider} />;
}
export function Loader({ isLoading }: { isLoading: boolean }) {
if (!isLoading) {
return null;
}
return (
<div className={style.overlay}>
<div className={style.loader} />
</div>
);
}
@@ -0,0 +1,31 @@
import { version } from '../../../../../package.json';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import { documentationUrl, githubUrl, websiteUrl } from '../../../../externals';
import * as Panel from '../PanelUtils';
import CheckUpdatesButton from './CheckUpdatesButton';
export default function AboutPanel() {
return (
<>
<Panel.Header>About Ontime</Panel.Header>
<Panel.Section>
<Panel.SubHeader>Ontime</Panel.SubHeader>
<Panel.Paragraph>
Free, open-source software for managing rundowns and event timers
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
</Panel.Paragraph>
</Panel.Section>
<Panel.Section>
<Panel.SubHeader>Links</Panel.SubHeader>
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
</Panel.Section>
<Panel.Section>
<Panel.SubHeader>Current version</Panel.SubHeader>
<Panel.Paragraph>{`You are currently using Ontime ${version}`}</Panel.Paragraph>
<CheckUpdatesButton version={version} />
</Panel.Section>
</>
);
}
@@ -0,0 +1,80 @@
import { useState } from 'react';
import { Button } from '@chakra-ui/react';
import { getLatestVersion, HasUpdate } from '../../../../common/api/external';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import style from '../Panel.module.scss';
type CheckFail = {
error: string;
};
type CheckIsLatest = {
latest: true;
};
type CheckRemote = CheckFail | CheckIsLatest | HasUpdate;
interface CheckUpdatesButtonProps {
version: string;
}
export default function CheckUpdatesButton(props: CheckUpdatesButtonProps) {
const { version } = props;
const [updateMessage, setUpdateMessage] = useState<CheckRemote | null>(null);
const [isFetching, setIsFetching] = useState(false);
/**
* Handles version comparison and returns component with message
*/
const versionCheck = async () => {
setIsFetching(true);
try {
const latest = await getLatestVersion();
if (!latest.version.includes(version)) {
// new version, pass data to component
setUpdateMessage(latest);
} else {
setUpdateMessage({ latest: true });
}
} catch {
setUpdateMessage({ error: 'Error reaching server' });
} finally {
setIsFetching(false);
}
};
const disableButton = Boolean(updateMessage && 'version' in updateMessage);
return (
<>
<Button
onClick={versionCheck}
variant='ontime-filled'
isLoading={isFetching}
isDisabled={disableButton}
size='sm'
maxWidth='max-content'
>
Check for updates
</Button>
<ResolveUpdateMessage updateMessage={updateMessage} />
</>
);
}
function ResolveUpdateMessage(props: { updateMessage: CheckRemote | null }) {
const { updateMessage } = props;
if (updateMessage && 'error' in updateMessage) {
return <span className={style.error}>{updateMessage.error}</span>;
}
if (updateMessage && 'url' in updateMessage) {
return <ExternalLink href={updateMessage?.url}>{`New version available: ${updateMessage.version}`}</ExternalLink>;
}
return null;
}
@@ -0,0 +1,39 @@
.fullWidth {
width: 100%;
}
.actions {
display: flex;
gap: 0.5px;
}
.actionButtons {
display: flex;
gap: 1em;
}
.fieldForm {
padding: 1rem;
background-color: $gray-1350;
display: flex;
flex-direction: column;
gap: 1rem;
}
.buttonRow {
display: flex;
justify-content: flex-end;
gap: 1rem;
}
.fit {
width: fit-content;
}
.aliasConstrain {
min-width: 12em;
}
.flex {
display: flex;
}
@@ -0,0 +1,24 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { PanelBaseProps } from '../../settingsStore';
import * as Panel from '../PanelUtils';
import CustomFields from './custom-fields/CustomFields';
import UrlPresetsForm from './UrlPresetsForm';
export default function FeatureSettingsPanel({ location }: PanelBaseProps) {
const customFieldsRef = useScrollIntoView<HTMLDivElement>('custom', location);
const urlPresetsRef = useScrollIntoView<HTMLDivElement>('urlpresets', location);
return (
<>
<Panel.Header>Feature Settings</Panel.Header>
<div ref={customFieldsRef}>
<CustomFields />
</div>
<div ref={urlPresetsRef}>
<UrlPresetsForm />
</div>
</>
);
}
@@ -0,0 +1,207 @@
import { useEffect } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertIcon, Button, IconButton, Input, Switch } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { URLPreset } from 'ontime-types';
import { postUrlPresets } from '../../../../common/api/urlPresets';
import { maybeAxiosError } from '../../../../common/api/utils';
import TooltipActionBtn from '../../../../common/components/buttons/TooltipActionBtn';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
import { handleLinks } from '../../../../common/utils/linkUtils';
import { validateUrlPresetPath } from '../../../../common/utils/urlPresets';
import * as Panel from '../PanelUtils';
import style from './FeatureSettings.module.scss';
const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/';
type FormData = {
data: URLPreset[];
};
export default function UrlPresetsForm() {
const { data, status, refetch } = useUrlPresets();
const {
control,
handleSubmit,
register,
reset,
setError,
formState: { isSubmitting, isDirty, isValid, errors },
} = useForm<FormData>({
mode: 'onBlur',
defaultValues: { data },
values: { data },
resetOptions: {
keepDirtyValues: true,
},
});
const { fields, prepend, remove } = useFieldArray({
name: 'data',
control,
});
// reset form if we get new data from backend
useEffect(() => {
if (data) {
reset({ data });
}
}, [data, reset]);
const onSubmit = async (formData: FormData) => {
for (let i = 0; i < formData.data.length; i++) {
const preset = formData.data[i];
const { isValid, message } = validateUrlPresetPath(preset.pathAndParams);
if (!isValid) {
setError(`data.${i}.pathAndParams`, { message });
return;
}
}
try {
await postUrlPresets(formData.data);
} catch (error) {
const message = maybeAxiosError(error);
setError('root', { message });
} finally {
await refetch();
}
};
const onReset = () => {
reset({ data });
};
const addNew = () => {
prepend({
enabled: false,
alias: '',
pathAndParams: '',
});
};
const isLoading = status === 'pending';
const canSubmit = !isSubmitting && isDirty && isValid;
return (
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} data-testid='url-preset-form'>
<Panel.Card>
<Panel.SubHeader>
URL presets
<div className={style.actionButtons}>
<Button variant='ontime-ghosted' size='md' onClick={onReset} isDisabled={!canSubmit}>
Revert to saved
</Button>
<Button variant='ontime-filled' size='md' type='submit' isDisabled={!canSubmit} isLoading={isSubmitting}>
Save
</Button>
</div>
</Panel.SubHeader>
<Panel.Divider />
<Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon />
<AlertDescription>
URL Presets
<br />
<br />
Custom presets allow providing a short name for any ontime URL. <br />
- Providing dynamic URLs for automation or unattended screens <br />- Simplifying complex URLs
<br />
<br />
<ExternalLink href={urlPresetsDocs}>See the docs</ExternalLink>
</AlertDescription>
</Alert>
<Panel.Section>
<Panel.Loader isLoading={isLoading} />
<Panel.Title>
Manage presets
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={addNew}>
New
</Button>
</Panel.Title>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
{errors?.data && <Panel.Error>{errors.data.message}</Panel.Error>}
<Panel.Table>
<thead>
<tr>
<th className={style.fit}>Active</th>
<th className={style.aliasConstrain}>Preset</th>
<th className={style.fullWidth}>URL</th>
<th />
</tr>
</thead>
<tbody>
{fields.map((preset, index) => {
const maybeAliasError = errors.data?.[index]?.alias?.message;
const maybeUrlError = errors.data?.[index]?.pathAndParams?.message;
return (
<tr key={preset.id}>
<td className={style.fit}>
<Switch
{...register(`data.${index}.enabled`)}
variant='ontime'
data-testid={`field__enable_${index}`}
/>
</td>
<td className={style.aliasConstrain}>
<Input
{...register(`data.${index}.alias`, {
required: { value: true, message: 'Required field' },
})}
size='sm'
variant='ontime-filled'
placeholder='URL Preset'
data-testid={`field__alias_${index}`}
autoComplete='off'
/>
<Panel.Error>{maybeAliasError}</Panel.Error>
</td>
<td className={style.fullWidth}>
<Input
{...register(`data.${index}.pathAndParams`, {
required: { value: true, message: 'Required field' },
})}
size='sm'
variant='ontime-filled'
placeholder='URL (portion after ontime Port)'
data-testid={`field__url_${index}`}
autoComplete='off'
/>
<Panel.Error>{maybeUrlError}</Panel.Error>
</td>
<td className={style.flex}>
<TooltipActionBtn
size='sm'
clickHandler={(event) => handleLinks(event, preset.alias)}
tooltip='Test preset'
aria-label='Test preset'
variant='ontime-ghosted'
color='#e2e2e2' // $gray-200
icon={<IoOpenOutline />}
data-testid={`field__test_${index}`}
/>
<IconButton
size='sm'
onClick={() => remove(index)}
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
data-testid={`field__delete_${index}`}
/>
</td>
</tr>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -0,0 +1,71 @@
import { useState } from 'react';
import { IconButton } from '@chakra-ui/react';
import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { CustomField, CustomFieldLabel } from 'ontime-types';
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
import CustomFieldForm from './CustomFieldForm';
import style from '../FeatureSettings.module.scss';
interface CustomFieldEntryProps {
field: string;
colour: string;
label: string;
onEdit: (label: CustomFieldLabel, patch: CustomField) => Promise<void>;
onDelete: (label: CustomFieldLabel) => Promise<void>;
}
export default function CustomFieldEntry(props: CustomFieldEntryProps) {
const { colour, label, onEdit, onDelete, field } = props;
const [isEditing, setIsEditing] = useState(false);
const handleEdit = async (patch: CustomField) => {
await onEdit(field, patch);
setIsEditing(false);
};
if (isEditing) {
return (
<tr>
<td colSpan={99}>
<CustomFieldForm
onCancel={() => setIsEditing(false)}
onSubmit={handleEdit}
initialColour={colour}
initialLabel={label}
/>
</td>
</tr>
);
}
return (
<tr>
<td>
<Swatch color={colour} />
</td>
<td className={style.fullWidth}>{label}</td>
<td className={style.actions}>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#e2e2e2' // $gray-200
icon={<IoPencil />}
aria-label='Edit entry'
onClick={() => setIsEditing(true)}
/>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={() => onDelete(field)}
/>
</td>
</tr>
);
}
@@ -0,0 +1,102 @@
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Button, Input } from '@chakra-ui/react';
import { CustomField } from 'ontime-types';
import { isAlphanumeric } from 'ontime-utils';
import { maybeAxiosError } from '../../../../../common/api/utils';
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
import * as Panel from '../../PanelUtils';
import style from '../FeatureSettings.module.scss';
interface CustomFieldsFormProps {
onSubmit: (field: CustomField) => Promise<void>;
onCancel: () => void;
initialColour?: string;
initialLabel?: string;
}
export default function CustomFieldForm(props: CustomFieldsFormProps) {
const { onSubmit, onCancel, initialColour, initialLabel } = props;
// we use this to force an update
const [_, setColour] = useState(initialColour || '');
const {
handleSubmit,
register,
setFocus,
setError,
setValue,
getValues,
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm({
defaultValues: { label: initialLabel || '', colour: initialColour || '' },
resetOptions: {
keepDirtyValues: true,
},
});
const setupSubmit = async (values: { label: string; colour: string }) => {
const { label, colour } = values;
const newField: CustomField = {
type: 'string', // type is not user definable yet
colour,
label,
};
try {
await onSubmit(newField);
} catch (error) {
setError('root', { type: 'custom', message: maybeAxiosError(error) });
}
};
// give initial focus to the label
useEffect(() => {
setFocus('label');
}, [setFocus]);
const handleSelectColour = (colour: string) => {
setColour(colour);
setValue('colour', colour, { shouldDirty: true });
};
const colour = getValues('colour');
const canSubmit = isDirty && isValid;
return (
<form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}>
<div className={style.column}>
<Panel.Description>Label</Panel.Description>
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
<Input
{...register('label', {
required: { value: true, message: 'Required field' },
validate: (value) => {
if (value.trim().length === 0) return 'Required field';
if (!isAlphanumeric(value)) return 'Only alphanumeric characters are allowed';
return true;
},
})}
size='sm'
variant='ontime-filled'
autoComplete='off'
/>
</div>
<div>
<Panel.Description>Colour</Panel.Description>
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
</div>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<div className={style.buttonRow}>
<Button size='sm' variant='ontime-ghosted' onClick={onCancel}>
Cancel
</Button>
<Button size='sm' type='submit' variant='ontime-filled' isDisabled={!canSubmit} isLoading={isSubmitting}>
Save
</Button>
</div>
</form>
);
}
@@ -0,0 +1,98 @@
import { useState } from 'react';
import { Alert, AlertDescription, AlertIcon, Button } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { CustomField, CustomFieldLabel } from 'ontime-types';
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
import ExternalLink from '../../../../../common/components/external-link/ExternalLink';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import * as Panel from '../../PanelUtils';
import CustomFieldEntry from './CustomFieldEntry';
import CustomFieldForm from './CustomFieldForm';
const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
export default function CustomFields() {
const { data, refetch } = useCustomFields();
const [isAdding, setIsAdding] = useState(false);
const handleInitiateCreate = () => {
setIsAdding(true);
};
const handleCancel = () => {
setIsAdding(false);
};
const handleCreate = async (customField: CustomField) => {
await postCustomField(customField);
refetch();
setIsAdding(false);
};
const handleEditField = async (label: CustomFieldLabel, customField: CustomField) => {
await editCustomField(label, customField);
refetch();
};
const handleDelete = async (label: string) => {
try {
await deleteCustomField(label);
refetch();
} catch (_error) {
/** we do not handle errors here */
}
};
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>
Custom fields
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleInitiateCreate}>
New
</Button>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
<Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon />
<AlertDescription>
Custom fields allow for additional information to be added to an event (eg. light, sound, camera). <br />
<br />
This data is not used by Ontime.
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
</AlertDescription>
</Alert>
</Panel.Section>
<Panel.Section>
{isAdding && <CustomFieldForm onSubmit={handleCreate} onCancel={handleCancel} />}
<Panel.Table>
<thead>
<tr>
<th>Colour</th>
<th>Name</th>
<th />
</tr>
</thead>
<tbody>
{Object.entries(data).map(([key, { colour, label }]) => {
return (
<CustomFieldEntry
key={key}
field={key}
colour={colour}
label={label}
onEdit={handleEditField}
onDelete={handleDelete}
/>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -0,0 +1,4 @@
.actionButtons {
display: flex;
gap: 1em;
}
@@ -0,0 +1,28 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { PanelBaseProps } from '../../settingsStore';
import EditorSettingsForm from '../interface-panel/EditorSettingsForm';
import * as Panel from '../PanelUtils';
import GeneralPanelForm from './GeneralPanelForm';
import ViewSettingsForm from './ViewSettingsForm';
export default function GeneralPanel({ location }: PanelBaseProps) {
const generalRef = useScrollIntoView<HTMLDivElement>('settings', location);
const editorRef = useScrollIntoView<HTMLDivElement>('editor', location);
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
return (
<>
<Panel.Header>App Settings</Panel.Header>
<div ref={generalRef}>
<GeneralPanelForm />
</div>
<div ref={editorRef}>
<EditorSettingsForm />
</div>
<div ref={viewRef}>
<ViewSettingsForm />
</div>
</>
);
}
@@ -0,0 +1,163 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Button, Input, Select } from '@chakra-ui/react';
import { Settings } from 'ontime-types';
import { postSettings } from '../../../../common/api/settings';
import { maybeAxiosError } from '../../../../common/api/utils';
import useSettings from '../../../../common/hooks-query/useSettings';
import { isOnlyNumbers } from '../../../../common/utils/regex';
import * as Panel from '../PanelUtils';
import GeneralPinInput from './GeneralPinInput';
import style from './GeneralPanel.module.scss';
export type GeneralPanelFormValues = {
filename: string;
};
export default function GeneralPanelForm() {
const { data, status, refetch } = useSettings();
const {
handleSubmit,
register,
reset,
setError,
formState: { isSubmitting, isDirty, isValid, errors },
} = useForm<Settings>({
defaultValues: data,
values: data,
resetOptions: {
keepDirtyValues: true,
},
});
// update form if we get new data from server
useEffect(() => {
if (data) {
reset(data);
}
}, [data, reset]);
const onSubmit = async (formData: Settings) => {
try {
await postSettings(formData);
} catch (error) {
const message = maybeAxiosError(error);
setError('root', { message });
} finally {
await refetch();
}
};
const disableInputs = status === 'pending';
const disableSubmit = isSubmitting || !isDirty || !isValid;
const submitError = '';
const onReset = () => {
reset(data);
};
const isLoading = status === 'pending';
return (
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} id='app-settings'>
<Panel.Card>
<Panel.SubHeader>
General settings
<div className={style.actionButtons}>
<Button isDisabled={!isDirty || isSubmitting} variant='ontime-ghosted' size='sm' onClick={onReset}>
Revert to saved
</Button>
<Button
type='submit'
form='app-settings'
isLoading={isSubmitting}
isDisabled={disableSubmit}
variant='ontime-filled'
size='sm'
>
Save
</Button>
</div>
</Panel.SubHeader>
{submitError && <Panel.Error>{submitError}</Panel.Error>}
<Panel.Divider />
<Panel.Section>
<Panel.Loader isLoading={isLoading} />
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Ontime server port'
description='Port ontime server listens in. Defaults to 4001 (needs app restart)'
error={errors.serverPort?.message}
/>
<Input
id='serverPort'
size='sm'
type='number'
variant='ontime-filled'
maxLength={5}
width='75px'
{...register('serverPort', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
pattern: {
value: isOnlyNumbers,
message: 'Value should be numeric',
},
})}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Editor pin code'
description='Protect the editor view with a pin code'
error={errors.editorKey?.message}
/>
<GeneralPinInput register={register} formName='editorKey' isDisabled={disableInputs} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Operator pin code'
description='Protect the operator and cuesheet views with a pin code'
error={errors.operatorKey?.message}
/>
<GeneralPinInput register={register} formName='operatorKey' isDisabled={disableInputs} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Time format'
description='Default time format to show in views 12 /24 hours'
error={errors.timeFormat?.message}
/>
<Select variant='ontime' size='sm' width='auto' isDisabled={disableInputs} {...register('timeFormat')}>
<option value='12'>12 hours 11:00:10 PM</option>
<option value='24'>24 hours 23:00:10</option>
</Select>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Views language'
description='Language to be displayed in views'
error={errors.language?.message}
/>
<Select variant='ontime' size='sm' width='auto' isDisabled={disableInputs} {...register('language')}>
<option value='en'>English</option>
<option value='fr'>French</option>
<option value='de'>German</option>
<option value='it'>Italian</option>
<option value='no'>Norwegian</option>
<option value='pt'>Portuguese</option>
<option value='es'>Spanish</option>
<option value='sv'>Swedish</option>
</Select>
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -0,0 +1,39 @@
import { PropsWithChildren, 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';
import { Settings } from 'ontime-types';
interface GeneralPinInputProps {
register: UseFormRegister<Settings>;
formName: keyof Settings;
isDisabled?: boolean;
}
export default function GeneralPinInput(props: PropsWithChildren<GeneralPinInputProps>) {
const { register, formName, isDisabled } = props;
const [isVisible, setVisible] = useState(false);
return (
<InputGroup size='sm' width='100px'>
<Input
variant='ontime-filled'
type={isVisible ? 'text' : 'password'}
maxLength={4}
{...register(formName)}
placeholder='-'
isDisabled={isDisabled}
/>
<InputRightElement>
<IconButton
onMouseDown={() => setVisible(true)}
onMouseUp={() => setVisible(false)}
size='sm'
variant='ontime-ghosted'
icon={<IoEyeOutline />}
aria-label='Show pin code'
/>
</InputRightElement>
</InputGroup>
);
}
@@ -0,0 +1,159 @@
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertIcon, Button, Input, Switch } from '@chakra-ui/react';
import { ViewSettings } from 'ontime-types';
import { maybeAxiosError } from '../../../../common/api/utils';
import { postViewSettings } from '../../../../common/api/viewSettings';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import { PopoverPickerRHF } from '../../../../common/components/input/popover-picker/PopoverPicker';
import useInfo from '../../../../common/hooks-query/useInfo';
import useViewSettings from '../../../../common/hooks-query/useViewSettings';
import * as Panel from '../PanelUtils';
import style from './GeneralPanel.module.scss';
const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/';
export default function ViewSettingsForm() {
const { data, status, refetch } = useViewSettings();
const { data: info, status: infoStatus } = useInfo();
const {
control,
handleSubmit,
register,
reset,
setError,
formState: { isSubmitting, isDirty },
} = useForm<ViewSettings>({
defaultValues: data,
values: data,
resetOptions: {
keepDirtyValues: true,
},
});
// update form if we get new data from server
useEffect(() => {
if (data) {
reset(data);
}
}, [data, reset]);
const onSubmit = async (formData: ViewSettings) => {
const newData = {
...formData,
};
try {
await postViewSettings(newData);
} catch (error) {
const message = maybeAxiosError(error);
setError('root', { message });
} finally {
await refetch();
}
};
const onReset = () => {
reset(data);
};
if (!control) {
return null;
}
const isLoading = status === 'pending' || infoStatus === 'pending';
return (
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} id='view-settings'>
<Panel.Card>
<Panel.SubHeader>
View settings
<div className={style.actionButtons}>
<Button isDisabled={!isDirty} variant='ontime-ghosted' size='sm' onClick={onReset}>
Revert to saved
</Button>
<Button type='submit' isLoading={isSubmitting} isDisabled={!isDirty} variant='ontime-filled' size='sm'>
Save
</Button>
</div>
</Panel.SubHeader>
<Panel.Divider />
<Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon />
<AlertDescription>
You can override the styles of the viewers with a custom CSS file. <br />
{info?.cssOverride && `In your installation the file is at ${info?.cssOverride}`}
<br />
<br />
<ExternalLink href={cssOverrideDocsUrl}>See the docs</ExternalLink>
</AlertDescription>
</Alert>
<Panel.Section>
<Panel.Loader isLoading={isLoading} />
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Override CSS styles'
description='Enables overriding view styles with custom stylesheet'
/>
<Controller
control={control}
name='overrideStyles'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='Timer colour' description='Default colour of a running timer' />
<PopoverPickerRHF name='normalColor' control={control} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Warning colour' description='Colour of a running timer in warning mode' />
<PopoverPickerRHF name='warningColor' control={control} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Danger colour' description='Colour of a running timer in danger mode' />
<PopoverPickerRHF name='dangerColor' control={control} />
</Panel.ListItem>
</Panel.ListGroup>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Freeze timer on end'
description='Timer in views will stop from going negative after reaching'
/>
<Controller
control={control}
name='freezeEnd'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='End message'
description='Message to show on negative timers if not frozen. If not provided, timer will continue'
/>
<Input
size='sm'
autoComplete='off'
variant='ontime-filled'
maxLength={150}
width='275px'
placeholder='Message shown when timer reaches end'
{...register('endMessage')}
/>
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -0,0 +1,189 @@
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { Button, IconButton, Input, Select, Switch } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { HttpSettings } from 'ontime-types';
import { generateId } from 'ontime-utils';
import { maybeAxiosError } from '../../../../common/api/utils';
import { useHttpSettings, usePostHttpSettings } from '../../../../common/hooks-query/useHttpSettings';
import { isKeyEscape } from '../../../../common/utils/keyEvent';
import { startsWithHttp } from '../../../../common/utils/regex';
import * as Panel from '../PanelUtils';
import { cycles } from './integrationUtils';
import style from './IntegrationsPanel.module.css';
export default function HttpIntegrations() {
const { data, status } = useHttpSettings();
const { mutateAsync } = usePostHttpSettings();
const {
control,
handleSubmit,
reset,
register,
setError,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<HttpSettings>({
mode: 'onBlur',
defaultValues: data,
values: data,
resetOptions: {
keepDirtyValues: true,
},
});
const { fields, prepend, remove } = useFieldArray({
name: 'subscriptions',
control,
});
const onSubmit = async (values: HttpSettings) => {
try {
await mutateAsync(values);
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
}
};
const preventEscape = (event: React.KeyboardEvent) => {
if (isKeyEscape(event)) {
event.preventDefault();
event.stopPropagation();
}
};
const handleAddNewSubscription = () => {
prepend({
id: generateId(),
cycle: 'onLoad',
message: '',
enabled: false,
});
};
const handleDeleteSubscription = (index: number) => {
remove(index);
};
const canSubmit = !isSubmitting && isDirty && isValid;
const isLoading = status === 'pending';
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>
HTTP settings
<div className={style.flex}>
<Button variant='ontime-ghosted' size='sm' onClick={() => reset()} isDisabled={!canSubmit}>
Revert to saved
</Button>
<Button
variant='ontime-filled'
size='sm'
type='submit'
form='http-form'
isDisabled={!canSubmit}
isLoading={isSubmitting}
>
Save
</Button>
</div>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section as='form' id='http-form' onSubmit={handleSubmit(onSubmit)} onKeyDown={preventEscape}>
<Panel.Loader isLoading={isLoading} />
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='HTTP Output' description='Provide feedback from Ontime through HTTP' />
<Controller
control={control}
name='enabledOut'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.Divider />
<Panel.Title>
HTTP Integration
<Button variant='ontime-subtle' size='sm' rightIcon={<IoAdd />} onClick={handleAddNewSubscription}>
New
</Button>
</Panel.Title>
{fields.length > 0 && (
<Panel.Table>
<thead>
<tr>
<th>Enabled</th>
<th>Cycle</th>
<th className={style.fullWidth}>Message</th>
<th />
</tr>
</thead>
<tbody>
{fields.map((integration, index) => {
// @ts-expect-error -- not sure why it is not finding the type, it is ok
const maybeError = errors.subscriptions?.[index]?.message?.message;
return (
<tr key={integration.id}>
<td>
<Switch variant='ontime' {...register(`subscriptions.${index}.enabled`)} />
</td>
<td className={style.autoWidth}>
<Select
size='sm'
variant='ontime'
className={style.fitContents}
{...register(`subscriptions.${index}.cycle`)}
>
{cycles.map((cycle) => (
<option key={cycle.id} value={cycle.value}>
{cycle.label}
</option>
))}
</Select>
</td>
<td className={style.fullWidth}>
<Input
size='sm'
variant='ontime-filled'
autoComplete='off'
placeholder='http://third-party/vt1/{{timer.current}}'
{...register(`subscriptions.${index}.message`, {
required: { value: true, message: 'Required field' },
pattern: {
value: startsWithHttp,
message: 'HTTP messages should start with http://',
},
})}
/>
{maybeError && <Panel.Error>{maybeError}</Panel.Error>}
</td>
<td>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={() => handleDeleteSubscription(index)}
/>
</td>
</tr>
);
})}
</tbody>
</Panel.Table>
)}
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -0,0 +1,16 @@
.fullWidth {
width: 100%;
}
.halfWidth {
width: 50%;
}
.fitContents.fitContents {
width: max-content; /* override chakra */
}
.flex {
display: flex;
gap: 1rem;
}
@@ -0,0 +1,42 @@
import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { PanelBaseProps } from '../../settingsStore';
import * as Panel from '../PanelUtils';
import HttpIntegrations from './HttpIntegrations';
import OscIntegrations from './OscIntegrations';
const integrationDocsUrl = 'https://docs.getontime.no/api/integrations/';
export default function IntegrationsPanel({ location }: PanelBaseProps) {
const oscRef = useScrollIntoView<HTMLDivElement>('osc', location);
const httpRef = useScrollIntoView<HTMLDivElement>('http', location);
return (
<>
<Panel.Header>Integration settings</Panel.Header>
<Panel.Section>
<Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon />
<AlertDescription>
Integrations allow Ontime to receive commands or send its data to other systems in your workflow. <br />
<br />
Currently supported protocols are OSC (Open Sound Control), HTTP and Websockets. <br />
WebSockets are used for Ontime and cannot be configured independently. <br />
<ExternalLink href={integrationDocsUrl}>See the docs</ExternalLink>
</AlertDescription>
</Alert>
</Panel.Section>
<Panel.Section>
<div ref={oscRef}>
<OscIntegrations />
</div>
<div ref={httpRef}>
<HttpIntegrations />
</div>
</Panel.Section>
</>
);
}
@@ -0,0 +1,305 @@
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { Button, IconButton, Input, Select, Switch } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { OSCSettings } from 'ontime-types';
import { generateId } from 'ontime-utils';
import { maybeAxiosError } from '../../../../common/api/utils';
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
import { isKeyEscape } from '../../../../common/utils/keyEvent';
import { isASCII, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
import * as Panel from '../PanelUtils';
import { cycles } from './integrationUtils';
import style from './IntegrationsPanel.module.css';
export default function OscIntegrations() {
const { data, status } = useOscSettings();
const { mutateAsync } = useOscSettingsMutation();
const {
control,
handleSubmit,
reset,
register,
setError,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<OSCSettings>({
mode: 'onBlur',
defaultValues: data,
values: data,
resetOptions: {
keepDirtyValues: true,
},
});
const { fields, prepend, remove } = useFieldArray({
name: 'subscriptions',
control,
});
const onSubmit = async (values: OSCSettings) => {
if (values.portIn === values.portOut) {
setError('portIn', { message: 'OSC IN and OUT Ports cant be the same' });
return;
}
const parsedValues = { ...values, portIn: Number(values.portIn), portOut: Number(values.portOut) };
try {
await mutateAsync(parsedValues);
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
}
};
const preventEscape = (event: React.KeyboardEvent) => {
if (isKeyEscape(event)) {
event.preventDefault();
event.stopPropagation();
}
};
const handleAddNewSubscription = () => {
prepend({
id: generateId(),
cycle: 'onLoad',
address: '',
payload: '',
enabled: false,
});
};
const handleDeleteSubscription = (index: number) => {
remove(index);
};
const canSubmit = !isSubmitting && isDirty && isValid;
const isLoading = status === 'pending';
return (
<Panel.Card>
<Panel.SubHeader>
OSC settings
<div className={style.flex}>
<Button variant='ontime-ghosted' size='sm' onClick={() => reset()} isDisabled={!canSubmit}>
Revert to saved
</Button>
<Button
variant='ontime-filled'
size='sm'
type='submit'
form='osc-form'
isDisabled={!canSubmit}
isLoading={isSubmitting}
>
Save
</Button>
</div>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section as='form' id='osc-form' onSubmit={handleSubmit(onSubmit)} onKeyDown={preventEscape}>
<Panel.Loader isLoading={isLoading} />
<Panel.Title>General OSC settings</Panel.Title>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='OSC input' description='Allow control of Ontime through OSC' />
<Controller
control={control}
name='enabledIn'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Listen on port'
description='Port for incoming OSC. Default: 8888'
error={errors.portIn?.message}
/>
<Input
id='portIn'
placeholder='8888'
width='5rem'
maxLength={5}
size='sm'
textAlign='right'
variant='ontime-filled'
type='number'
autoComplete='off'
{...register('portIn', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
pattern: {
value: isOnlyNumbers,
message: 'Value should be numeric',
},
})}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='OSC output' description='Provide feedback from Ontime with OSC' />
<Controller
control={control}
name='enabledOut'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='OSC target IP'
description='IP address Ontime will send OSC messages to'
error={errors.targetIP?.message}
/>
<Input
id='targetIP'
placeholder='127.0.0.1'
width='9rem'
size='sm'
textAlign='right'
variant='ontime-filled'
autoComplete='off'
{...register('targetIP', {
required: { value: true, message: 'Required field' },
pattern: {
value: isIPAddress,
message: 'Invalid IP address',
},
})}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='OSC target port'
description='Port number Ontime will send OSC messages to'
error={errors.portOut?.message}
/>
<Input
id='portOut'
placeholder='8888'
width='75px'
size='sm'
textAlign='right'
variant='ontime-filled'
autoComplete='off'
{...register('portOut', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
pattern: {
value: isOnlyNumbers,
message: 'Value should be numeric',
},
})}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.Divider />
<Panel.Title>
OSC integrations
<Button variant='ontime-subtle' size='sm' rightIcon={<IoAdd />} onClick={handleAddNewSubscription}>
Add
</Button>
</Panel.Title>
{fields.length > 0 && (
<Panel.Table>
<thead>
<tr>
<th>Enabled</th>
<th>Cycle</th>
<th className={style.halfWidth}>Address</th>
<th className={style.halfWidth}>Payload</th>
<th />
</tr>
</thead>
<tbody>
{fields.map((field, index) => {
const maybeAddressError = errors.subscriptions?.[index]?.address?.message;
const maybePayloadError = errors.subscriptions?.[index]?.payload?.message;
return (
<tr key={field.id}>
<td>
<Switch variant='ontime' {...register(`subscriptions.${index}.enabled`)} />
</td>
<td className={style.autoWidth}>
<Select
size='sm'
variant='ontime'
className={style.fitContents}
{...register(`subscriptions.${index}.cycle`)}
>
{cycles.map((cycle) => (
<option key={cycle.id} value={cycle.value}>
{cycle.label}
</option>
))}
</Select>
</td>
<td className={style.halfWidth}>
<Input
key={field.id}
size='sm'
variant='ontime-filled'
autoComplete='off'
placeholder='/from-ontime/'
{...register(`subscriptions.${index}.address`, {
required: { value: true, message: 'Required field' },
validate: {
oscStartsWithSlash: (value) =>
startsWithSlash.test(value) || 'OSC address should start with a forward slash',
oscStringIsAscii: (value) =>
isASCII.test(value) || 'OSC address only allow ASCII characters',
},
})}
/>
{maybeAddressError && <Panel.Error>{maybeAddressError}</Panel.Error>}
</td>
<td className={style.halfWidth}>
<Input
key={field.id}
size='sm'
variant='ontime-filled'
autoComplete='off'
placeholder='{{timer.current}}'
{...register(`subscriptions.${index}.payload`, {
validate: {
oscStringIsAscii: (value) =>
isASCII.test(value) || 'OSC payloads only allow ASCII characters',
},
})}
/>
{maybePayloadError && <Panel.Error>{maybePayloadError}</Panel.Error>}
</td>
<td>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={() => handleDeleteSubscription(index)}
/>
</td>
</tr>
);
})}
</tbody>
</Panel.Table>
)}
</Panel.Section>
</Panel.Card>
);
}
@@ -0,0 +1,17 @@
import { TimerLifeCycle } from 'ontime-types';
type CycleLabel = {
id: number;
label: string;
value: keyof typeof TimerLifeCycle;
};
export const cycles: CycleLabel[] = [
{ id: 1, label: 'On Load', value: 'onLoad' },
{ id: 2, label: 'On Start', value: 'onStart' },
{ id: 3, label: 'On Pause', value: 'onPause' },
{ id: 4, label: 'On Stop', value: 'onStop' },
{ id: 5, label: 'Every second', value: 'onClock' },
{ id: 5, label: 'On Timer Update', value: 'onUpdate' },
{ id: 6, label: 'On Finish', value: 'onFinish' },
];
@@ -0,0 +1,100 @@
import { Switch } from '@chakra-ui/react';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { useEditorSettings } from '../../../../common/stores/editorSettings';
import { forgivingStringToMillis } from '../../../../common/utils/dateConfig';
import * as Panel from '../PanelUtils';
export default function EditorSettingsForm() {
const eventSettings = useEditorSettings((state) => state.eventSettings);
const setLinkPrevious = useEditorSettings((state) => state.setLinkPrevious);
const setDefaultPublic = useEditorSettings((state) => state.setDefaultPublic);
const setDefaultDuration = useEditorSettings((state) => state.setDefaultDuration);
const durationInMs = forgivingStringToMillis(eventSettings.defaultDuration);
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Editor settings</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
<Panel.Title>Rundown options</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Default duration'
description='When creating a new event, what is the default duration'
/>
<TimeInput<'defaultDuration'>
name='defaultDuration'
submitHandler={(_field, value) => setDefaultDuration(value)}
time={durationInMs}
placeholder='00:10:00'
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Link previous'
description='New events start time will be linked to the previous event'
/>
<Switch
variant='ontime'
size='lg'
defaultChecked={eventSettings.linkPrevious}
onChange={(event) => setLinkPrevious(event.target.checked)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Default public' description='New events will be public' />
<Switch
variant='ontime'
size='lg'
defaultChecked={eventSettings.defaultPublic}
onChange={(event) => setDefaultPublic(event.target.checked)}
/>
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
<Panel.Section>
<Panel.Title>Play mode</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Show quick entry'
description='Whether the quick entry buttons show above / under selected event'
/>
<Switch variant='ontime' size='lg' defaultChecked={false} isDisabled />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Follow playback'
description='Whether view automatically follows the event being played'
/>
<Switch variant='ontime' size='lg' defaultChecked isDisabled />
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
<Panel.Section>
<Panel.Title>Edit mode</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Show quick entry'
description='Whether the quick entry buttons show above / under selected event'
/>
<Switch variant='ontime' size='lg' defaultChecked isDisabled />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Follow playback'
description='Whether view automatically follows the event being played'
/>
<Switch variant='ontime' size='lg' defaultChecked={false} isDisabled />
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -0,0 +1,27 @@
import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
import * as Panel from '../PanelUtils';
import EditorSettingsForm from './EditorSettingsForm';
export default function InterfacePanel() {
return (
<>
<Panel.Header>Interface</Panel.Header>
<Panel.Section>
<Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon />
<AlertDescription>
Interface settings
<br />
<br />
These concern settings that are applied to this user in this browser.
<br />
It will not affect other users or other browsers.
</AlertDescription>
</Alert>
</Panel.Section>
<EditorSettingsForm />
</>
);
}
@@ -0,0 +1,3 @@
.iconRotate {
transform: rotate(45deg);
}
@@ -0,0 +1,35 @@
import { MouseEvent } from 'react';
import { Button } from '@chakra-ui/react';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { handleLinks } from '../../../../common/utils/linkUtils';
import Log from '../../../log/Log';
import * as Panel from '../PanelUtils';
import style from './LogExport.module.scss';
export default function LogExport() {
const extract = (event: MouseEvent) => {
handleLinks(event, 'log');
};
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>
Network log
<Button
variant='ontime-subtle'
size='sm'
rightIcon={<IoArrowUp className={style.iconRotate} />}
onClick={extract}
>
Extract
</Button>
</Panel.SubHeader>
<Panel.Divider />
<Log />
</Panel.Card>
</Panel.Section>
);
}
@@ -0,0 +1,17 @@
import * as Panel from '../PanelUtils';
import LogExport from './LogExport';
import InfoNif from './NetworkInterfaces';
export default function LogPanel() {
return (
<>
<Panel.Header>Log</Panel.Header>
<Panel.Section>
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
</Panel.Section>
<InfoNif />
<LogExport />
</>
);
}
@@ -0,0 +1,6 @@
.interfaces {
display: flex;
flex-wrap: wrap;
gap: $section-spacing;
row-gap: $element-inner-spacing;
}
@@ -0,0 +1,19 @@
import { serverPort } from '../../../../common/api/constants';
import AppLink from '../../../../common/components/app-link/AppLink';
import useInfo from '../../../../common/hooks-query/useInfo';
import style from './NetworkInterfaces.module.scss';
export default function InfoNif() {
const { data } = useInfo();
return (
<div className={style.interfaces}>
{data?.networkInterfaces?.map((nif) => (
<AppLink key={nif.address} href={`http://${nif.address}:${serverPort}`}>
{`${nif.name} - ${nif.address}`}
</AppLink>
))}
</div>
);
}
@@ -0,0 +1,96 @@
import { ChangeEvent, useRef, useState } from 'react';
import { Button, Input } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { uploadProjectFile } from '../../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
import { validateProjectFile } from '../../../../common/utils/uploadUtils';
import * as Panel from '../PanelUtils';
import ProjectCreateForm from './ProjectCreateForm';
import ProjectList from './ProjectList';
import style from './ProjectPanel.module.scss';
export default function ManageProjects() {
const [isCreatingProject, setIsCreatingProject] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState<'import' | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleToggleCreate = () => {
setIsCreatingProject((prev) => !prev);
};
const handleSelectFile = () => {
fileInputRef.current?.click();
};
const handleImport = async (event: ChangeEvent<HTMLInputElement>) => {
const selectedFile = event.target?.files?.[0];
if (!selectedFile) {
return;
}
setLoading('import');
try {
validateProjectFile(selectedFile);
await uploadProjectFile(selectedFile);
} catch (error) {
const errorMessage = maybeAxiosError(error);
setError(`Error uploading file: ${errorMessage}`);
} finally {
invalidateAllCaches();
}
setLoading(null);
};
const handleCloseForm = () => {
setIsCreatingProject(false);
};
return (
<Panel.Section>
<Input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
onChange={handleImport}
accept='.json'
data-testid='file-input'
/>
<Panel.Card>
<Panel.SubHeader>
Manage projects
<div className={style.headerButtons}>
<Button
variant='ontime-subtle'
onClick={handleSelectFile}
size='sm'
isDisabled={Boolean(loading) || isCreatingProject}
isLoading={loading === 'import'}
>
Import
</Button>
<Button
variant='ontime-subtle'
onClick={handleToggleCreate}
size='sm'
isDisabled={Boolean(loading) || isCreatingProject}
rightIcon={<IoAdd />}
>
New
</Button>
</div>
</Panel.SubHeader>
{error && <Panel.Error>{error}</Panel.Error>}
<Panel.Divider />
{isCreatingProject && <ProjectCreateForm onClose={handleCloseForm} />}
<ProjectList />
</Panel.Card>
</Panel.Section>
);
}
@@ -0,0 +1,150 @@
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Button, Input, Textarea } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { PROJECT_LIST } from '../../../../common/api/constants';
import { createProject } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils';
import * as Panel from '../PanelUtils';
import style from './ProjectPanel.module.scss';
interface ProjectCreateFromProps {
onClose: () => void;
}
type ProjectCreateFormValues = {
title?: string;
description?: string;
publicInfo?: string;
publicUrl?: string;
backstageInfo?: string;
backstageUrl?: string;
};
export default function ProjectCreateForm(props: ProjectCreateFromProps) {
const { onClose } = props;
const [error, setError] = useState<string | null>(null);
const queryClient = useQueryClient();
const {
handleSubmit,
register,
formState: { isSubmitting, isValid },
setFocus,
} = useForm<ProjectCreateFormValues>({
defaultValues: { title: '' },
values: { title: '' },
resetOptions: {
keepDirtyValues: true,
},
});
// set focus to first field
useEffect(() => {
setFocus('title');
}, [setFocus]);
const handleSubmitCreate = async (values: ProjectCreateFormValues) => {
try {
setError(null);
const filename = values.title?.trim();
await createProject({
...values,
filename,
});
await queryClient.invalidateQueries({ queryKey: PROJECT_LIST });
onClose();
} catch (error) {
setError(maybeAxiosError(error));
}
};
return (
<Panel.Section as='form' onSubmit={handleSubmit(handleSubmitCreate)}>
<Panel.Title>
Create new project
<div className={style.createActionButtons}>
<Button onClick={onClose} variant='ontime-ghosted' size='sm' isDisabled={isSubmitting}>
Cancel
</Button>
<Button isDisabled={!isValid} type='submit' isLoading={isSubmitting} variant='ontime-filled' size='sm'>
Create
</Button>
</div>
</Panel.Title>
{error && <Panel.Error>{error}</Panel.Error>}
<div className={style.innerColumn}>
<label>
Project title
<Input
variant='ontime-filled'
size='sm'
maxLength={50}
placeholder='Your project name'
autoComplete='off'
{...register('title')}
/>
</label>
<label>
Project description
<Input
variant='ontime-filled'
size='sm'
maxLength={100}
placeholder='Euro Love, Malmö 2024'
autoComplete='off'
{...register('description')}
/>
</label>
<label>
Public info
<Textarea
variant='ontime-filled'
size='sm'
maxLength={150}
placeholder='Shows always start ontime'
autoComplete='off'
resize='none'
{...register('publicInfo')}
/>
</label>
<label>
Public QR code Url
<Input
variant='ontime-filled'
size='sm'
placeholder='www.getontime.no'
autoComplete='off'
{...register('publicUrl')}
/>
</label>
<label>
Backstage info
<Textarea
variant='ontime-filled'
size='sm'
maxLength={150}
placeholder='Wi-Fi password: 1234'
autoComplete='off'
resize='none'
{...register('backstageInfo')}
/>
</label>
<label>
Backstage QR code Url
<Input
variant='ontime-filled'
size='sm'
placeholder='http://docs.getontime.no'
autoComplete='off'
{...register('backstageUrl')}
/>
</label>
</div>
</Panel.Section>
);
}
@@ -0,0 +1,148 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Button, Input, Textarea } from '@chakra-ui/react';
import { type ProjectData } from 'ontime-types';
import { postProjectData } from '../../../../common/api/project';
import { maybeAxiosError } from '../../../../common/api/utils';
import useProjectData from '../../../../common/hooks-query/useProjectData';
import * as Panel from '../PanelUtils';
import style from './ProjectPanel.module.scss';
export default function ProjectData() {
const { data, status, refetch } = useProjectData();
const {
handleSubmit,
register,
reset,
formState: { isSubmitting, isValid, isDirty },
setError,
} = useForm({
defaultValues: data,
values: data,
resetOptions: {
keepDirtyValues: true,
},
});
// reset form values if data changes
useEffect(() => {
if (data) {
reset(data);
}
}, [data, reset]);
const onSubmit = async (formData: ProjectData) => {
try {
await postProjectData(formData);
} catch (error) {
const message = maybeAxiosError(error);
setError('root', { message });
} finally {
await refetch();
}
};
// populate with new data if we get an update
const onReset = () => {
reset(data);
};
const isLoading = status === 'pending';
return (
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)}>
<Panel.Card>
<Panel.SubHeader>
Project data
<div className={style.headerButtons}>
<Button variant='ontime-ghosted' size='sm' onClick={onReset} isDisabled={isSubmitting || !isDirty}>
Revert to saved
</Button>
<Button
variant='ontime-filled'
size='sm'
type='submit'
isDisabled={!isDirty || !isValid}
isLoading={isSubmitting}
>
Save
</Button>
</div>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
<Panel.Loader isLoading={isLoading} />
<label>
Project title
<Input
variant='ontime-filled'
size='sm'
maxLength={50}
placeholder='Your project name'
autoComplete='off'
{...register('title')}
/>
</label>
<label>
Project description
<Input
variant='ontime-filled'
size='sm'
maxLength={100}
placeholder='Euro Love, Malmö 2024'
autoComplete='off'
{...register('description')}
/>
</label>
<label>
Public info
<Textarea
variant='ontime-filled'
size='sm'
maxLength={150}
placeholder='Shows always start ontime'
autoComplete='off'
resize='none'
{...register('publicInfo')}
/>
</label>
<label>
Public QR code URL
<Input
variant='ontime-filled'
size='sm'
placeholder='www.getontime.no'
autoComplete='off'
{...register('publicUrl')}
/>
</label>
<label>
Backstage info
<Textarea
variant='ontime-filled'
size='sm'
maxLength={150}
placeholder='Wi-Fi password: 1234'
autoComplete='off'
resize='none'
{...register('backstageInfo')}
/>
</label>
<label>
Backstage QR code URL
<Input
variant='ontime-filled'
size='sm'
placeholder='http://docs.getontime.no'
autoComplete='off'
{...register('backstageUrl')}
/>
</label>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -0,0 +1,70 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Button, Input } from '@chakra-ui/react';
import * as Panel from '../PanelUtils';
import style from './ProjectPanel.module.scss';
export type ProjectFormValues = {
filename: string;
};
interface ProjectFormProps {
action: 'duplicate' | 'rename';
filename: string;
onCancel: () => void;
onSubmit: (values: ProjectFormValues) => Promise<void>;
submitError: string | null;
}
export default function ProjectForm({ action, filename, onSubmit, onCancel, submitError }: ProjectFormProps) {
const {
handleSubmit,
register,
formState: { isSubmitting, isDirty, isValid },
setFocus,
} = useForm<ProjectFormValues>({
defaultValues: { filename },
values: { filename },
resetOptions: {
keepDirtyValues: true,
},
});
useEffect(() => {
setFocus('filename');
}, [setFocus]);
return (
<>
<form onSubmit={handleSubmit(onSubmit)} className={style.form}>
<Input
className={style.formInput}
id='filename'
size='sm'
type='text'
variant='ontime-filled'
placeholder='Enter new name'
autoComplete='off'
{...register('filename')}
/>
<div className={style.actionButtons}>
<Button onClick={onCancel} size='sm' variant='ontime-ghosted' disabled={isSubmitting}>
Cancel
</Button>
<Button
size='sm'
variant='ontime-filled'
isDisabled={!isDirty || !isValid || isSubmitting}
type='submit'
className={style.saveButton}
>
{action}
</Button>
</div>
</form>
{submitError && <Panel.Error>{submitError}</Panel.Error>}
</>
);
}
@@ -0,0 +1,67 @@
import { useMemo, useState } from 'react';
import { useProjectList } from '../../../../common/hooks-query/useProjectList';
import * as Panel from '../PanelUtils';
import ProjectListItem, { EditMode } from './ProjectListItem';
import style from './ProjectPanel.module.scss';
export default function ProjectList() {
const { data, refetch } = useProjectList();
const { files, lastLoadedProject } = data;
const [editingMode, setEditingMode] = useState<EditMode | null>(null);
const [editingFilename, setEditingFilename] = useState<string | null>(null);
const handleToggleEditMode = (editMode: EditMode, filename: string | null) => {
setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode));
setEditingFilename(filename);
};
const handleClear = () => {
setEditingMode(null);
setEditingFilename(null);
};
const handleRefetch = async () => {
await refetch();
};
const reorderedProjectFiles = useMemo(() => {
if (!data.files?.length) return [];
const currentlyLoadedIndex = files.findIndex((project) => project.filename === lastLoadedProject);
const projectFiles = [...files];
const current = projectFiles.splice(currentlyLoadedIndex, 1)?.[0];
return [current, ...projectFiles];
}, [data.files?.length, files, lastLoadedProject]);
return (
<Panel.Table>
<thead>
<tr>
<th className={style.containCell}>Project Name</th>
<th>Last Used</th>
<th />
</tr>
</thead>
<tbody>
{reorderedProjectFiles.map((project) => (
<ProjectListItem
key={project.filename}
filename={project.filename}
updatedAt={project.updatedAt}
onToggleEditMode={handleToggleEditMode}
onSubmit={handleClear}
onRefetch={handleRefetch}
editingFilename={editingFilename}
editingMode={editingMode}
current={project.filename === lastLoadedProject}
/>
))}
</tbody>
</Panel.Table>
);
}
@@ -0,0 +1,179 @@
import { useState } from 'react';
import { IconButton, Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react';
import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHorizontal';
import {
deleteProject,
downloadCSV,
downloadProject,
duplicateProject,
loadProject,
renameProject,
} from '../../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
import ProjectForm, { ProjectFormValues } from './ProjectForm';
import style from './ProjectPanel.module.scss';
export type EditMode = 'rename' | 'duplicate' | null;
interface ProjectListItemProps {
current?: boolean;
filename: string;
updatedAt: string;
onToggleEditMode: (editMode: EditMode, filename: string | null) => void;
onSubmit: () => void;
onRefetch: () => Promise<void>;
editingFilename: string | null;
editingMode: EditMode | null;
}
export default function ProjectListItem({
current,
updatedAt,
editingFilename,
editingMode,
filename,
onRefetch,
onSubmit,
onToggleEditMode,
}: ProjectListItemProps) {
const [submitError, setSubmitError] = useState<string | null>(null);
const handleSubmitRename = async (values: ProjectFormValues) => {
try {
setSubmitError(null);
if (!values.filename) {
setSubmitError('Filename cannot be blank');
return;
}
await renameProject(filename, values.filename);
await onRefetch();
onSubmit();
} catch (error) {
setSubmitError(maybeAxiosError(error));
}
};
const handleSubmitDuplicate = async (values: ProjectFormValues) => {
try {
setSubmitError(null);
if (!values.filename) {
setSubmitError('Filename cannot be blank');
return;
}
await duplicateProject(filename, values.filename);
await onRefetch();
onSubmit();
} catch (error) {
setSubmitError(maybeAxiosError(error));
}
};
const handleToggleEditMode = (editMode: EditMode, filename: string | null) => {
setSubmitError(null);
onToggleEditMode(editMode, filename);
};
const handleCancel = () => {
handleToggleEditMode(null, null);
};
const isCurrentlyBeingEdited = editingMode && filename === editingFilename;
const classes = current && !isCurrentlyBeingEdited ? style.current : undefined;
return (
<tr key={filename} className={classes}>
{isCurrentlyBeingEdited ? (
<td colSpan={99}>
<ProjectForm
action={editingMode}
filename={filename}
onSubmit={editingMode === 'duplicate' ? handleSubmitDuplicate : handleSubmitRename}
onCancel={handleCancel}
submitError={submitError}
/>
</td>
) : (
<>
<td className={style.containCell}>{filename}</td>
<td>{new Date(updatedAt).toLocaleString()}</td>
<td className={style.actionButton}>
<ActionMenu
current={current}
filename={filename}
onChangeEditMode={handleToggleEditMode}
onRefetch={onRefetch}
/>
</td>
</>
)}
</tr>
);
}
function ActionMenu({
current,
filename,
onChangeEditMode,
onRefetch,
}: {
current?: boolean;
filename: string;
onChangeEditMode: (editMode: EditMode, filename: string) => void;
onRefetch: () => Promise<void>;
}) {
const handleLoad = async () => {
await loadProject(filename);
await invalidateAllCaches();
};
const handleRename = () => {
onChangeEditMode('rename', filename);
};
const handleDuplicate = () => {
onChangeEditMode('duplicate', filename);
};
const handleDelete = async () => {
await deleteProject(filename);
await onRefetch();
};
const handleDownload = async () => {
await downloadProject(filename);
};
const handleExportCSV = async () => {
await downloadCSV(filename);
};
return (
<Menu variant='ontime-on-dark' size='sm'>
<MenuButton
as={IconButton}
aria-label='Options'
icon={<IoEllipsisHorizontal />}
color='#e2e2e2' // $gray-200
variant='ontime-ghosted'
size='sm'
/>
<MenuList>
<MenuItem onClick={handleLoad} isDisabled={current}>
Load
</MenuItem>
<MenuItem onClick={handleRename}>Rename</MenuItem>
<MenuItem onClick={handleDuplicate}>Duplicate</MenuItem>
<MenuItem onClick={handleDownload}>Download</MenuItem>
{current && <MenuItem onClick={handleExportCSV}>Export CSV Rundown</MenuItem>}
<MenuItem isDisabled={current} onClick={handleDelete}>
Delete
</MenuItem>
</MenuList>
</Menu>
);
}
@@ -0,0 +1,52 @@
.current {
background-color: $blue-1100;
}
.actionButton {
flex: 1;
text-align: right;
}
.form {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.formInput {
flex: 2;
}
.headerButtons,
.actionButtons,
.createActionButtons {
display: flex;
align-items: center;
gap: 1rem;
}
.actionButtons {
margin-left: 1rem;
}
.createActionButtons {
margin-left: 1rem;
justify-content: flex-end;
}
.saveButton {
text-transform: capitalize;
}
.containCell {
max-width: 400px;
}
.innerColumn {
margin: 0 2rem;
margin-bottom: 2rem;
display: flex;
flex-direction: column;
gap: 1em;
}
@@ -0,0 +1,23 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { PanelBaseProps } from '../../settingsStore';
import * as Panel from '../PanelUtils';
import ManageProjects from './ManageProjects';
import ProjectData from './ProjectData';
export default function ProjectPanel({ location }: PanelBaseProps) {
const projectRef = useScrollIntoView<HTMLDivElement>('data', location);
const manageRef = useScrollIntoView<HTMLDivElement>('manage', location);
return (
<>
<Panel.Header>Project</Panel.Header>
<div ref={projectRef}>
<ProjectData />
</div>
<div ref={manageRef}>
<ManageProjects />
</div>
</>
);
}
@@ -0,0 +1,27 @@
import { Button } from '@chakra-ui/react';
import useElectronEvent from '../../../../common/hooks/useElectronEvent';
import * as Panel from '../PanelUtils';
export default function ShutdownPanel() {
const { isElectron, sendToElectron } = useElectronEvent();
const sendShutdown = () => {
sendToElectron('shutdown', 'now');
};
return (
<>
<Panel.Header>Shutdown Ontime</Panel.Header>
<Panel.Section>
<Panel.Paragraph>
This will shutdown the Ontime server. <br />
The runtime state will be lost, but your project is kept for next time.
</Panel.Paragraph>
<Button colorScheme='red' onClick={sendShutdown} isDisabled={!isElectron}>
Shutdown ontime
</Button>
</Panel.Section>
</>
);
}
@@ -0,0 +1,21 @@
import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadsheet-gsheet/';
export default function GSheetInfo() {
return (
<Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon />
<AlertDescription>
Ontime allows you to synchronize your rundown with a Google Sheet.
<br />
<br />
To enable this feature, you will need to generate tokens in your Google account and provide them to Ontime.
<br />
Once set up, you will be able to synchronize data between Ontime and your Google Sheet. <br />
<ExternalLink href={googleSheetDocsUrl}>See the docs</ExternalLink>
</AlertDescription>
</Alert>
);
}
@@ -0,0 +1,211 @@
import { ChangeEvent, useEffect, useState } from 'react';
import { Button, Input, Spinner } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { IoShieldCheckmarkOutline } from '@react-icons/all-files/io5/IoShieldCheckmarkOutline';
import { getWorksheetNames } from '../../../../common/api/sheets';
import { maybeAxiosError } from '../../../../common/api/utils';
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import { openLink } from '../../../../common/utils/linkUtils';
import * as Panel from '../PanelUtils';
import useGoogleSheet from './useGoogleSheet';
import { useSheetStore } from './useSheetStore';
import style from './SourcesPanel.module.scss';
interface GSheetSetupProps {
onCancel: () => void;
}
export default function GSheetSetup(props: GSheetSetupProps) {
const { onCancel } = props;
const { revoke, connect, verifyAuth } = useGoogleSheet();
const [file, setFile] = useState<File | null>(null);
const [authKey, setAuthKey] = useState<string | null>(null);
const [loading, setLoading] = useState<'' | 'cancel' | 'connect' | 'authenticate'>('');
const [authLink, setAuthLink] = useState('');
const sheetId = useSheetStore((state) => state.sheetId);
const setSheetId = useSheetStore((state) => state.setSheetId);
const setWorksheets = useSheetStore((state) => state.setWorksheets);
const patchStepData = useSheetStore((state) => state.patchStepData);
const authenticationStatus = useSheetStore((state) => state.authenticationStatus);
const setAuthenticationStatus = useSheetStore((state) => state.setAuthenticationStatus);
/** Check if we are authenticated */
const getAuthStatus = async () => {
const result = await verifyAuth();
if (result) {
setAuthenticationStatus(result.authenticated);
}
};
/** check if the current session has been authenticated */
useEffect(() => {
untilAuthenticated();
}, []);
// user cancels the flow
const handleRevoke = async () => {
setLoading('cancel');
await revoke();
await getAuthStatus();
setLoading('');
};
const handleCancelFlow = async () => {
onCancel();
};
/**
* Gets file from input
* @param event
*/
const handleClientSecret = async (event: ChangeEvent<HTMLInputElement>) => {
if (!event.target.files?.length) {
return;
}
setFile(event.target.files[0]);
};
/**
* Requests connection to google auth
*/
const handleConnect = async () => {
if (!file) return;
if (!sheetId) return;
setLoading('connect');
const result = await connect(file, sheetId);
if (result) {
setAuthLink(result.verification_url);
setAuthKey(result.user_code);
}
setLoading('');
};
const untilAuthenticated = async (attempts: number = 0) => {
const result = await verifyAuth();
if (result?.authenticated) {
setAuthenticationStatus(result.authenticated);
if (result.authenticated !== 'pending') {
if (result.authenticated == 'authenticated') {
try {
const names = await getWorksheetNames(result.sheetId);
setWorksheets(names);
} catch (error) {
const message = maybeAxiosError(error);
patchStepData({ worksheet: { available: false, error: message } });
}
}
setLoading('');
return;
}
}
if (attempts <= 10) {
setTimeout(() => untilAuthenticated(attempts + 1), 2000);
return;
}
setLoading('');
};
/**
* Open google auth
*/
const handleAuthenticate = async () => {
setLoading('authenticate');
// open link and schedule a check for when the user focuses again
openLink(authLink);
window.addEventListener(
'focus',
async () => {
untilAuthenticated();
},
{ once: true },
);
};
const canConnect = file && sheetId;
const canAuthenticate = Boolean(authKey) && Boolean(authLink);
const isLoading = Boolean(loading);
const isAuthenticated = authenticationStatus === 'authenticated';
const isAuthenticating = authenticationStatus === 'pending';
return (
<Panel.Section>
<Panel.Title>
Sync with Google Sheet (experimental)
{isAuthenticated ? (
<Button variant='ontime-subtle' size='sm' onClick={handleRevoke} isLoading={loading === 'cancel'}>
Revoke Authentication
</Button>
) : (
<Button variant='ontime-subtle' size='sm' onClick={handleCancelFlow}>
Go Back
</Button>
)}
</Panel.Title>
<Panel.ListGroup>
<Panel.Description>Upload Client Secret provided by Google</Panel.Description>
<Panel.Error>{undefined}</Panel.Error>
<Input
type='file'
onChange={handleClientSecret}
accept='.json'
size='sm'
variant='ontime-filled'
isDisabled={isLoading || canAuthenticate}
/>
</Panel.ListGroup>
<Panel.ListGroup>
<Panel.Description>Enter ID of sheet to synchronise</Panel.Description>
<Panel.Error>{undefined}</Panel.Error>
<Input
size='sm'
variant='ontime-filled'
autoComplete='off'
placeholder='Sheet ID'
onChange={(event) => setSheetId(event.target.value)}
isDisabled={isLoading || canAuthenticate}
/>
</Panel.ListGroup>
{!canAuthenticate ? (
<Panel.ListGroup>
<div className={style.buttonRow}>
<Button
variant='ontime-subtle'
size='sm'
leftIcon={<IoCheckmark />}
onClick={handleConnect}
isDisabled={!canConnect || isLoading}
isLoading={loading === 'connect'}
>
Connect
</Button>
</div>
</Panel.ListGroup>
) : (
<Panel.ListGroup>
<div className={style.buttonRow}>
{isAuthenticating && <Spinner />}
<CopyTag label='Google Auth Key' disabled={!canAuthenticate} size='sm'>
{authKey ? authKey : 'Upload files to generate Auth Key'}
</CopyTag>
<Button
variant='ontime-filled'
size='sm'
leftIcon={<IoShieldCheckmarkOutline />}
onClick={handleAuthenticate}
isDisabled={!canAuthenticate}
>
Authenticate
</Button>
</div>
</Panel.ListGroup>
)}
</Panel.Section>
);
}
@@ -0,0 +1,55 @@
import { useState } from 'react';
import { Button } from '@chakra-ui/react';
import { CustomFields, OntimeRundown } from 'ontime-types';
import * as Panel from '../PanelUtils';
import PreviewSpreadsheet from './preview/PreviewRundown';
import useGoogleSheet from './useGoogleSheet';
import { useSheetStore } from './useSheetStore';
import style from './SourcesPanel.module.scss';
interface ImportReviewProps {
rundown: OntimeRundown;
customFields: CustomFields;
onFinished: () => void;
onCancel: () => void;
}
export default function ImportReview(props: ImportReviewProps) {
const { rundown, customFields, onFinished, onCancel } = props;
const [loading, setLoading] = useState(false);
const { importRundown } = useGoogleSheet();
const resetPreview = useSheetStore((state) => state.resetPreview);
const handleCancel = () => {
resetPreview();
onCancel();
};
const applyImport = async () => {
setLoading(true);
await importRundown(rundown, customFields);
setLoading(false);
onFinished();
};
return (
<Panel.Section>
<Panel.Title>
Review Rundown
<div className={style.buttonRow}>
<Button onClick={handleCancel} variant='ontime-ghosted' size='sm' isDisabled={loading}>
Cancel
</Button>
<Button onClick={applyImport} variant='ontime-filled' size='sm' isLoading={loading}>
Apply
</Button>
</div>
</Panel.Title>
<PreviewSpreadsheet rundown={rundown} customFields={customFields} />
</Panel.Section>
);
}
@@ -0,0 +1,40 @@
.uploadSection,
.successSection {
margin-top: 1rem;
display: flex;
padding: 3rem 1rem;
align-items: center;
justify-content: center;
background-color: $gray-1350;
border: 1px solid $white-10;
border-radius: 3px;
}
.uploadSection {
flex-direction: row;
gap: 2rem;
}
.successSection {
color: $green-500;
font-size: 1.5rem;
text-align: center;
flex-direction: column;
gap: 1rem;
}
.buttonRow {
display: flex;
gap: 1rem;
align-items: center;
justify-content: end;
}
.inputContainer {
flex: 1;
}
.singleActionCell {
width: 50px;
text-align: center;
}
@@ -0,0 +1,216 @@
import { ChangeEvent, useRef, useState } from 'react';
import { Button, Input } from '@chakra-ui/react';
import { IoCloudOutline } from '@react-icons/all-files/io5/IoCloudOutline';
import { IoDownloadOutline } from '@react-icons/all-files/io5/IoDownloadOutline';
import { getErrorMessage, ImportMap } from 'ontime-utils';
import {
getWorksheetNames as getWorksheetNamesExcel,
importRundownPreview as importRundownPreviewExcel,
upload as uploadExcel,
} from '../../../../common/api/excel';
import { getWorksheetNames } from '../../../../common/api/sheets';
import { maybeAxiosError } from '../../../../common/api/utils';
import { validateExcelImport } from '../../../../common/utils/uploadUtils';
import * as Panel from '../PanelUtils';
import ImportMapForm from './import-map/ImportMapForm';
import GSheetInfo from './GSheetInfo';
import GSheetSetup from './GSheetSetup';
import ImportReview from './ImportReview';
import useGoogleSheet from './useGoogleSheet';
import { useSheetStore } from './useSheetStore';
import style from './SourcesPanel.module.scss';
export default function SourcesPanel() {
const [importFlow, setImportFlow] = useState<'none' | 'excel' | 'gsheet' | 'finished'>('none');
const [error, setError] = useState('');
const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none');
const { exportRundown, importRundownPreview, verifyAuth } = useGoogleSheet();
const setWorksheets = useSheetStore((state) => state.setWorksheets);
const authenticationStatus = useSheetStore((state) => state.authenticationStatus);
const setAuthenticationStatus = useSheetStore((state) => state.setAuthenticationStatus);
const rundown = useSheetStore((state) => state.rundown);
const setRundown = useSheetStore((state) => state.setRundown);
const customFields = useSheetStore((state) => state.customFields);
const setCustomFields = useSheetStore((state) => state.setCustomFields);
const setSheetId = useSheetStore((state) => state.setSheetId);
const sheetId = useSheetStore((state) => state.sheetId);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFile = async (event: ChangeEvent<HTMLInputElement>) => {
const fileToUpload = event.target.files?.[0];
if (!fileToUpload) {
setWorksheets(null);
setHasFile('none');
return;
}
try {
setHasFile('loading');
validateExcelImport(fileToUpload);
await uploadExcel(fileToUpload);
const names = await getWorksheetNamesExcel();
setWorksheets(names);
setImportFlow('excel');
setHasFile('done');
} catch (error) {
const errorMessage = getErrorMessage(error);
setError(`Error uploading file: ${errorMessage}`);
setWorksheets(null);
setHasFile('none');
}
};
const handleUpload = () => {
fileInputRef.current?.click();
};
const openGSheetFlow = async () => {
const result = await verifyAuth();
if (result) {
setAuthenticationStatus(result.authenticated);
setSheetId(result.sheetId);
if (result.authenticated === 'authenticated' && result.sheetId) {
const names = await getWorksheetNames(result.sheetId);
setWorksheets(names);
}
}
setImportFlow('gsheet');
};
const cancelGSheetFlow = () => {
setImportFlow('none');
};
const handleSubmitImportPreview = async (importMap: ImportMap) => {
if (importFlow === 'excel') {
try {
const previewData = await importRundownPreviewExcel(importMap);
setRundown(previewData.rundown);
setCustomFields(previewData.customFields);
} catch (error) {
setError(maybeAxiosError(error));
}
}
if (importFlow === 'gsheet') {
if (!sheetId) return;
await importRundownPreview(sheetId, importMap);
}
};
const cancelImportMap = async () => {
setImportFlow('none');
setHasFile('none');
setWorksheets(null);
if (authenticationStatus === 'authenticated') {
const result = await verifyAuth();
if (result) {
setAuthenticationStatus(result.authenticated);
}
}
};
const handleFinished = () => {
setImportFlow('finished');
setRundown(null);
setHasFile('none');
setWorksheets(null);
setCustomFields(null);
};
const handleSubmitExport = async (importMap: ImportMap) => {
if (!sheetId) return;
await exportRundown(sheetId, importMap);
};
const isExcelFlow = importFlow === 'excel';
const isGSheetFlow = importFlow === 'gsheet';
const isAuthenticated = authenticationStatus === 'authenticated';
const showInput = importFlow === 'none';
const showSuccess = importFlow === 'finished';
const showAuth = isGSheetFlow && !isAuthenticated;
const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile === 'done');
const showReview = rundown !== null && customFields !== null;
return (
<>
<Panel.Header>Data sources</Panel.Header>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Synchronise your rundown with an external source</Panel.SubHeader>
{error && <Panel.Error>{error}</Panel.Error>}
{showInput && (
<>
<GSheetInfo />
<Input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
onChange={handleFile}
accept='.xlsx'
data-testid='file-input'
/>
<div className={style.uploadSection}>
<div>
<Button
variant='ontime-filled'
size='sm'
leftIcon={<IoDownloadOutline />}
onClick={handleUpload}
isLoading={hasFile === 'loading'}
>
Import from spreadsheet
</Button>
<Panel.Description>Accepts .xlsx files</Panel.Description>
</div>
<div>
<Button
variant='ontime-filled'
size='sm'
leftIcon={<IoCloudOutline />}
onClick={openGSheetFlow}
isDisabled={hasFile !== 'none'}
>
Synchronise with Google
</Button>
<Panel.Description>Start authentication process</Panel.Description>
</div>
</div>
</>
)}
{showSuccess && (
<div className={style.successSection}>
<span>Import successful</span>
<Button variant='ontime-filled' size='sm' onClick={() => setImportFlow('none')}>
Return
</Button>
</div>
)}
{showAuth && <GSheetSetup onCancel={cancelGSheetFlow} />}
{showImportMap && !showReview && (
<ImportMapForm
isSpreadsheet={isExcelFlow}
onCancel={cancelImportMap}
onSubmitExport={handleSubmitExport}
onSubmitImport={handleSubmitImportPreview}
/>
)}
{showReview && (
<ImportReview
rundown={rundown}
customFields={customFields}
onFinished={handleFinished}
onCancel={cancelImportMap}
/>
)}
</Panel.Card>
</Panel.Section>
</>
);
}
@@ -0,0 +1,238 @@
import { useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { ImportMap } from 'ontime-utils';
import { isAlphanumeric } from '../../../../../common/utils/regex';
import * as Panel from '../../PanelUtils';
import useGoogleSheet from '../useGoogleSheet';
import { useSheetStore } from '../useSheetStore';
import { convertToImportMap, getPersistedOptions, NamedImportMap, persistImportMap } from './importMapUtils';
import style from '../SourcesPanel.module.scss';
interface ImportMapFormProps {
isSpreadsheet?: boolean;
onCancel: () => void;
onSubmitExport: (importMap: ImportMap) => Promise<void>;
onSubmitImport: (importMap: ImportMap) => Promise<void>;
}
export default function ImportMapForm(props: ImportMapFormProps) {
const { isSpreadsheet, onCancel, onSubmitExport, onSubmitImport } = props;
const namedImportMap = getPersistedOptions();
const { revoke } = useGoogleSheet();
const {
control,
handleSubmit,
register,
formState: { errors, isValid },
} = useForm<NamedImportMap>({
mode: 'onBlur',
defaultValues: namedImportMap,
values: namedImportMap,
});
const { fields, append, remove } = useFieldArray({
control,
name: 'custom',
});
const stepData = useSheetStore((state) => state.stepData);
const worksheetNames = useSheetStore((state) => state.worksheetNames);
const [loading, setLoading] = useState<'' | 'export' | 'import'>('');
const handleExport = async (values: NamedImportMap) => {
setLoading('export');
const importMap = convertToImportMap(values);
await onSubmitExport(importMap);
setLoading('');
};
const handleRevoke = async () => {
await revoke();
onCancel();
};
const handleImportPreview = async (values: NamedImportMap) => {
setLoading('import');
const importMap = convertToImportMap(values);
persistImportMap(values);
await onSubmitImport(importMap);
setLoading('');
};
const deleteCustomImport = (index: number) => {
remove(index);
};
const addCustomImport = () => {
append({});
};
const isLoading = Boolean(loading);
const canSubmitSpreadsheet = isSpreadsheet && !isLoading;
const canSubmitGSheet = !isLoading;
const canSubmit = isValid && (canSubmitSpreadsheet || canSubmitGSheet);
return (
<Panel.Section as='form' id='import-map'>
<Panel.Title>
Import options
<div className={style.buttonRow}>
{!isSpreadsheet && (
<Tooltip label='Revoke the google authentication'>
<Button variant='ontime-subtle' size='sm' onClick={handleRevoke} isDisabled={isLoading}>
Revoke
</Button>
</Tooltip>
)}
<Button variant='ontime-subtle' size='sm' onClick={onCancel} isDisabled={isLoading}>
Cancel
</Button>
{!isSpreadsheet && (
<Button
variant='ontime-filled'
size='sm'
onClick={handleSubmit(handleExport)}
isDisabled={!canSubmitGSheet}
isLoading={loading === 'export'}
>
Export
</Button>
)}
<Button
variant='ontime-filled'
size='sm'
onClick={handleSubmit(handleImportPreview)}
isDisabled={!canSubmit}
isLoading={loading === 'import'}
>
Import preview
</Button>
</div>
</Panel.Title>
<Panel.Table>
<thead>
<tr>
<th>Ontime field</th>
<th>From spreadsheet name</th>
<th className={style.singleActionCell} />
</tr>
</thead>
<tbody>
{Object.entries(namedImportMap).map(([label, importName]) => {
if (label === 'custom') {
return null;
}
if (label === 'Worksheet') {
return (
<tr key={importName as string}>
<td>{label}</td>
<td>
<Select
variant='ontime'
id={importName as string}
size='sm'
{...register(label as keyof NamedImportMap)}
>
{worksheetNames?.map((name) => {
return (
<option key={name} value={name}>
{name}
</option>
);
})}
</Select>
</td>
<td className={style.singleActionCell} />
</tr>
);
}
return (
<tr key={importName as string}>
<td>{label}</td>
<td>
<Input
id={importName as string}
size='sm'
variant='ontime-filled'
autoComplete='off'
maxLength={25}
defaultValue={importName as string}
placeholder='Use default column name'
{...register(label as keyof NamedImportMap)}
/>
</td>
<td className={style.singleActionCell} />
</tr>
);
})}
{fields.map((field, index) => {
const ontimeName = field.ontimeName;
const importName = field.importName;
const maybeOntimeError = errors.custom?.[index]?.ontimeName?.message;
const key = `custom.${index}.ontimeName`;
return (
<tr key={key}>
<td>
<Input
size='sm'
variant='ontime-filled'
autoComplete='off'
maxLength={25}
defaultValue={ontimeName}
placeholder='Name of the field as shown in Ontime'
{...register(`custom.${index}.ontimeName`, {
pattern: {
value: isAlphanumeric,
message: 'Custom field name must be alphanumeric',
},
})}
/>
{maybeOntimeError && <Panel.Error>{maybeOntimeError}</Panel.Error>}
</td>
<td>
<Input
size='sm'
variant='ontime-filled'
autoComplete='off'
maxLength={25}
defaultValue={importName}
placeholder='Name of the column in the spreadsheet'
{...register(`custom.${index}.importName`)}
/>
</td>
<td className={style.singleActionCell}>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={() => deleteCustomImport(index)}
/>
</td>
</tr>
);
})}
<tr>
<td />
<td className={style.buttonRow} colSpan={99}>
<Button size='sm' variant='ontime-subtle' rightIcon={<IoAdd />} onClick={addCustomImport}>
Add custom field
</Button>
</td>
<td />
</tr>
</tbody>
</Panel.Table>
<Panel.Error>{stepData.worksheet.error}</Panel.Error>
</Panel.Section>
);
}
@@ -0,0 +1,38 @@
import { ImportCustom } from 'ontime-utils';
import { convertToImportMap } from '../importMapUtils';
describe('convertToImportMap', () => {
it('converts a namedImportMap to a importMap', () => {
const defaultNamedImporMap = {
Worksheet: 'event schedule',
Start: 'time start',
End: 'time end',
Duration: 'duration',
Cue: 'cue',
Title: 'title',
'Is Public': 'public',
Skip: 'skip',
Note: 'notes',
Colour: 'colour',
'End action': 'end action',
'Timer type': 'timer type',
'Time warning': 'warning time',
'Time danger': 'danger time',
custom: [
{ ontimeName: 'Custom1 ', importName: 'custom1' },
{ ontimeName: 'Custom2', importName: 'custom2' },
{ ontimeName: 'Custom3', importName: 'custom3' },
{ ontimeName: 'EmptyImportName', importName: '' },
{ ontimeName: '', importName: 'EmptyOntimeName' },
] as ImportCustom[],
};
const importMap = convertToImportMap(defaultNamedImporMap);
expect(importMap.custom).toStrictEqual({
Custom1: 'custom1',
Custom2: 'custom2',
Custom3: 'custom3',
});
});
});
@@ -0,0 +1,61 @@
import { ImportCustom, ImportMap } from 'ontime-utils';
export type NamedImportMap = typeof namedImportMap;
// Record of label and import name
export const namedImportMap = {
Worksheet: 'event schedule',
Start: 'time start',
End: 'time end',
Duration: 'duration',
Cue: 'cue',
Title: 'title',
'Is Public': 'public',
Skip: 'skip',
Note: 'notes',
Colour: 'colour',
'End action': 'end action',
'Timer type': 'timer type',
'Time warning': 'warning time',
'Time danger': 'danger time',
custom: [] as ImportCustom[],
};
export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
const custom = namedImportMap.custom.reduce((accumulator, { ontimeName, importName }) => {
if (ontimeName && importName) {
accumulator[ontimeName.trim()] = importName.trim();
}
return accumulator;
}, {});
return {
worksheet: namedImportMap.Worksheet,
timeStart: namedImportMap.Start,
timeEnd: namedImportMap.End,
duration: namedImportMap.Duration,
cue: namedImportMap.Cue,
title: namedImportMap.Title,
isPublic: namedImportMap['Is Public'],
skip: namedImportMap.Skip,
note: namedImportMap.Note,
colour: namedImportMap.Colour,
endAction: namedImportMap['End action'],
timerType: namedImportMap['Timer type'],
timeWarning: namedImportMap['Time warning'],
timeDanger: namedImportMap['Time danger'],
custom,
};
}
export function persistImportMap(options: NamedImportMap) {
localStorage.setItem('ontime-import-options', JSON.stringify(options));
}
export function getPersistedOptions(): NamedImportMap {
const options = localStorage.getItem('ontime-import-options');
if (!options) {
return namedImportMap;
}
return JSON.parse(options);
}
@@ -0,0 +1,12 @@
.center {
text-align: center;
}
.nowrap {
white-space: nowrap;
}
tr .secondaryRow {
background-color: $white-7;
padding-left: 2em;
}
@@ -0,0 +1,122 @@
import { Fragment } from 'react';
import { CustomFields, isOntimeBlock, isOntimeEvent, OntimeRundown } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import Tag from '../../../../../common/components/tag/Tag';
import { getAccessibleColour } from '../../../../../common/utils/styleUtils';
import * as Panel from '../../PanelUtils';
import style from './PreviewRundown.module.scss';
interface PreviewRundownProps {
rundown: OntimeRundown;
customFields: CustomFields;
}
function booleanToText(value?: boolean) {
return value ? 'Yes' : undefined;
}
export default function PreviewRundown(props: PreviewRundownProps) {
const { rundown, customFields } = props;
// we only count Ontime Events which are 1 based in client
let eventIndex = 0;
const fieldHeaders = Object.keys(customFields);
return (
<Panel.Table>
<thead>
<tr>
<th>#</th>
<th>Type</th>
<th>Cue</th>
<th>Title</th>
<th>Time Start</th>
<th>Time End</th>
<th>Duration</th>
<th>Warning Time</th>
<th>Danger Time</th>
<th>Is Public</th>
<th>Skip</th>
<th>Colour</th>
<th>Timer Type</th>
<th>End Action</th>
{fieldHeaders.map((field) => (
<th key={field}>{field}</th>
))}
</tr>
</thead>
<tbody>
{rundown.map((event) => {
if (isOntimeBlock(event)) {
return (
<tr key={event.id}>
<td className={style.center}>
<Tag>-</Tag>
</td>
<td className={style.center}>
<Tag>{event.type}</Tag>
</td>
<td />
<td colSpan={99}>{event.title}</td>
</tr>
);
}
if (!isOntimeEvent(event)) {
return null;
}
eventIndex += 1;
const colour = event.colour ? getAccessibleColour(event.colour) : {};
const isPublic = booleanToText(event.isPublic);
const skip = booleanToText(event.skip);
return (
<Fragment key={event.id}>
<tr>
<td className={style.center}>
<Tag>{eventIndex}</Tag>
</td>
<td className={style.center}>
<Tag>{event.type}</Tag>
</td>
<td className={style.nowrap}>{event.cue}</td>
<td>{event.title}</td>
<td>{millisToString(event.timeStart)}</td>
<td>{millisToString(event.timeEnd)}</td>
<td>{millisToString(event.duration)}</td>
<td>{millisToString(event.timeWarning)}</td>
<td>{millisToString(event.timeDanger)}</td>
<td className={style.center}>{isPublic && <Tag>{isPublic}</Tag>}</td>
<td>{skip && <Tag>{skip}</Tag>}</td>
<td style={{ ...colour }}>{event.colour}</td>
<td className={style.center}>
<Tag>{event.timerType}</Tag>
</td>
<td className={style.center}>
<Tag>{event.endAction}</Tag>
</td>
{isOntimeEvent(event) &&
fieldHeaders.map((field) => {
let value = '';
if (field in event.custom) {
value = event.custom[field].value;
}
return <td key={field}>{value}</td>;
})}
</tr>
{event.note && (
<tr>
<td colSpan={99} className={style.secondaryRow}>
Note: {event.note}
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</Panel.Table>
);
}
@@ -0,0 +1,100 @@
import { useQueryClient } from '@tanstack/react-query';
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { CUSTOM_FIELDS, RUNDOWN } from '../../../../common/api/constants';
import { patchData } from '../../../../common/api/db';
import {
previewRundown,
requestConnection,
revokeAuthentication,
uploadRundown,
verifyAuthenticationStatus,
} from '../../../../common/api/sheets';
import { maybeAxiosError } from '../../../../common/api/utils';
import { useSheetStore } from './useSheetStore';
export default function useGoogleSheet() {
const queryClient = useQueryClient();
// functions push data to store
const patchStepData = useSheetStore((state) => state.patchStepData);
const setRundown = useSheetStore((state) => state.setRundown);
const setCustomFields = useSheetStore((state) => state.setCustomFields);
/** whether the current session has been authenticated */
const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus; sheetId: string } | void> => {
try {
return verifyAuthenticationStatus();
} catch (_error) {
/** we do not handle errors here */
}
};
/** requests connection to a google sheet */
const connect = async (
file: File,
sheetId: string,
): Promise<{ verification_url: string; user_code: string } | void> => {
try {
return requestConnection(file, sheetId);
} catch (_error) {
/** we do not handle errors here */
}
};
/** requests the revoking of an existing authenticated session */
const revoke = async (): Promise<{ authenticated: AuthenticationStatus } | void> => {
try {
return revokeAuthentication();
} catch (_error) {
/** we do not handle errors here */
}
};
/** fetches data from a worksheet by its ID */
const importRundownPreview = async (sheetId: string, fileOptions: ImportMap) => {
try {
const data = await previewRundown(sheetId, fileOptions);
setRundown(data.rundown);
setCustomFields(data.customFields);
} catch (error) {
patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } });
}
};
/** writes data to a worksheet by its ID */
const exportRundown = async (sheetId: string, fileOptions: ImportMap) => {
try {
// write data to google
await uploadRundown(sheetId, fileOptions);
patchStepData({ pullPush: { available: false, error: '' } });
} catch (error) {
patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } });
}
};
/** applies rundown and customFields to current project */
const importRundown = async (rundown: OntimeRundown, customFields: CustomFields) => {
try {
await patchData({ rundown, customFields });
// we are unable to optimistically set the rundown since we need
// it to be normalised
await queryClient.invalidateQueries({
queryKey: [RUNDOWN, CUSTOM_FIELDS],
});
} catch (error) {
patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } });
}
};
return {
connect,
revoke,
verifyAuth,
importRundownPreview,
importRundown,
exportRundown,
};
}
@@ -0,0 +1,76 @@
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types';
import { defaultImportMap, ImportMap } from 'ontime-utils';
import { create } from 'zustand';
type SheetStore = {
stepData: typeof initialStepData;
patchStepData: (patch: Partial<typeof initialStepData>) => void;
setWorksheets: (worksheetNames: string[] | null) => void;
worksheetNames: string[] | null;
//gSheet
sheetId: string | null;
setSheetId: (sheetId: string | null) => void;
authenticationStatus: AuthenticationStatus;
setAuthenticationStatus: (status: AuthenticationStatus) => void;
// we get this from a preview response
rundown: OntimeRundown | null;
setRundown: (rundown: OntimeRundown | null) => void;
// we get this from a preview response
customFields: CustomFields | null;
setCustomFields: (customFields: CustomFields | null) => void;
spreadsheetImportMap: ImportMap;
patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => void;
reset: () => void;
resetPreview: () => void;
};
const initialStepData = {
authenticate: { available: false, error: '' },
sheetId: { available: false, error: '' },
worksheet: { available: false, error: '' },
pullPush: { available: false, error: '' },
};
const initialState = {
stepData: initialStepData,
worksheetNames: null,
sheetId: null,
authenticationStatus: 'not_authenticated' as AuthenticationStatus,
rundown: null,
customFields: null,
spreadsheetImportMap: defaultImportMap,
};
export const useSheetStore = create<SheetStore>((set, get) => ({
...initialState,
patchStepData: (patch: Partial<typeof initialStepData>) => {
const stepData = get().stepData;
set({ stepData: { ...stepData, ...patch } });
},
setWorksheets: (worksheetNames: string[] | null) => set({ worksheetNames }),
setSheetId: (sheetId: string | null) => set({ sheetId }),
setAuthenticationStatus: (status: AuthenticationStatus) => set({ authenticationStatus: status }),
setRundown: (rundown: OntimeRundown | null) => set({ rundown }),
setCustomFields: (customFields: CustomFields | null) => set({ customFields }),
patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => {
const currentImportMap = get().spreadsheetImportMap;
if (currentImportMap[field] !== value) {
currentImportMap[field] = value;
}
},
reset: () => set(initialState),
resetPreview: () => set({ rundown: null, customFields: null }),
}));
@@ -0,0 +1,92 @@
import { create } from 'zustand';
export type SettingsOption = {
id: string;
label: string;
secondary?: Readonly<SettingsOption[]>;
split?: boolean;
};
export const settingPanels: Readonly<SettingsOption[]> = [
{
id: 'project',
label: 'Project',
secondary: [
{ id: 'project__data', label: 'Project data' },
{ id: 'project__manage', label: 'Manage projects' },
],
},
{
id: 'general',
label: 'App Settings',
secondary: [
{ id: 'general__settings', label: 'General settings' },
{ id: 'general__editor', label: 'Editor settings' },
{ id: 'general__view', label: 'View settings' },
],
},
{
id: 'feature_settings',
label: 'Feature Settings',
secondary: [
{ id: 'feature_settings__custom', label: 'Custom fields' },
{ id: 'feature_settings__urlpresets', label: 'URL Presets' },
],
},
{
id: 'sources',
label: 'Data Sources',
secondary: [
{ id: 'sources__xlsx', label: 'Import spreadsheet' },
{ id: 'sources__gsheet', label: 'Sync with Google Sheet' },
],
split: true,
},
{
id: 'integrations',
label: 'Integrations',
secondary: [
{ id: 'integrations__osc', label: 'OSC settings' },
{ id: 'integrations__http', label: 'HTTP settings' },
],
},
{ id: 'log', label: 'Log', split: true },
{
id: 'about',
label: 'About',
split: true,
},
{
id: 'shutdown',
label: 'Shutdown',
split: true,
},
] as const;
export type SettingsOptionId = (typeof settingPanels)[number]['id'];
export interface PanelBaseProps {
location?: string;
}
type SettingsStore = {
unsavedChanges: Set<SettingsOptionId>;
hasUnsavedChanges: (panelId: SettingsOptionId) => boolean;
addUnsavedChanges: (panelId: SettingsOptionId) => void;
removeUnsavedChanges: (panelId: SettingsOptionId) => void;
};
export const useSettingsStore = create<SettingsStore>((set, get) => ({
unsavedChanges: new Set(),
hasUnsavedChanges: (panelId: SettingsOptionId) => get().unsavedChanges.has(panelId),
addUnsavedChanges: (panelId: SettingsOptionId) =>
set((state) => {
state.unsavedChanges.add(panelId);
return { unsavedChanges: new Set(state.unsavedChanges) };
}),
removeUnsavedChanges: (panelId: SettingsOptionId) =>
set((state) => {
state.unsavedChanges.delete(panelId);
return { unsavedChanges: new Set(state.unsavedChanges) };
}),
}));
@@ -0,0 +1,32 @@
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { SettingsOptionId } from './settingsStore';
const settingsKey = 'settings';
export default function useAppSettingsNavigation() {
const [searchParams, setSearchParams] = useSearchParams();
const selectedPanel = useMemo(
() => (searchParams.get(settingsKey) as SettingsOptionId | null) ?? 'project',
[searchParams],
);
const isOpen = useMemo(() => Boolean(searchParams.get(settingsKey)), [searchParams]);
const [panel, location] = selectedPanel.split('__');
const close = useCallback(() => {
searchParams.delete(settingsKey);
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
const setLocation = useCallback(
(panelId: SettingsOptionId) => {
searchParams.set(settingsKey, panelId);
setSearchParams(searchParams);
},
[searchParams, setSearchParams],
);
return { isOpen, panel, location, setLocation, close };
}