* 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
@@ -1,8 +1,6 @@
@use '../theme/v2Styles' as *;
.wrapper {
background: $bg-container-l1;
width: 100%;
height: 100%;
padding: max(16px, 2vh);
}
}
@@ -2,12 +2,12 @@
import { ComponentType, useEffect } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import useAliases from '../common/hooks-query/useAliases';
import { getAliasRoute } from '../common/utils/aliases';
import useUrlPresets from '../common/hooks-query/useUrlPresets';
import { getRouteFromPreset } from '../common/utils/urlPresets';
const withAlias = <P extends object>(Component: ComponentType<P>) => {
const withPreset = <P extends object>(Component: ComponentType<P>) => {
return (props: Partial<P>) => {
const { data } = useAliases();
const { data } = useUrlPresets();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const location = useLocation();
@@ -15,7 +15,7 @@ const withAlias = <P extends object>(Component: ComponentType<P>) => {
// navigate if is alias route
useEffect(() => {
if (!data) return;
const url = getAliasRoute(location, data, searchParams);
const url = getRouteFromPreset(location, data, searchParams);
// navigate to this route if its not empty
if (url) {
navigate(url);
@@ -26,4 +26,4 @@ const withAlias = <P extends object>(Component: ComponentType<P>) => {
};
};
export default withAlias;
export default withPreset;
@@ -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>
</>
);
}
@@ -1,14 +1,10 @@
import { useState } from 'react';
import { Button } from '@chakra-ui/react';
import { getLatestVersion, HasUpdate } from '../../../common/api/ontimeApi';
import ModalLink from '../ModalLink';
import { getLatestVersion, HasUpdate } from '../../../../common/api/external';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import styles from '../Modal.module.scss';
interface UpdateCheckerProps {
version: string;
}
import style from '../Panel.module.scss';
type CheckFail = {
error: string;
@@ -20,8 +16,13 @@ type CheckIsLatest = {
type CheckRemote = CheckFail | CheckIsLatest | HasUpdate;
export default function UpdateChecker(props: UpdateCheckerProps) {
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);
@@ -50,12 +51,19 @@ export default function UpdateChecker(props: UpdateCheckerProps) {
const disableButton = Boolean(updateMessage && 'version' in updateMessage);
return (
<div className={styles.updateSection}>
<Button onClick={versionCheck} variant='ontime-filled' isLoading={isFetching} isDisabled={disableButton}>
<>
<Button
onClick={versionCheck}
variant='ontime-filled'
isLoading={isFetching}
isDisabled={disableButton}
size='sm'
maxWidth='max-content'
>
Check for updates
</Button>
<ResolveUpdateMessage updateMessage={updateMessage} />
</div>
</>
);
}
@@ -63,10 +71,10 @@ function ResolveUpdateMessage(props: { updateMessage: CheckRemote | null }) {
const { updateMessage } = props;
if (updateMessage && 'error' in updateMessage) {
return <span className={styles.error}>{updateMessage.error}</span>;
return <span className={style.error}>{updateMessage.error}</span>;
}
if (updateMessage && 'url' in updateMessage) {
return <ModalLink href={updateMessage?.url}>{`New version available: ${updateMessage.version}`}</ModalLink>;
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>
);
}
@@ -1,23 +1,23 @@
import { useState } from 'react';
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 FormInput {
[key: string]: string;
}
interface ModalPinInputProps {
register: UseFormRegister<FormInput>;
formName: string;
interface GeneralPinInputProps {
register: UseFormRegister<Settings>;
formName: keyof Settings;
isDisabled?: boolean;
}
export default function ModalPinInput({ register, formName, isDisabled }: ModalPinInputProps) {
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)}
@@ -29,7 +29,7 @@ export default function ModalPinInput({ register, formName, isDisabled }: ModalP
onMouseDown={() => setVisible(true)}
onMouseUp={() => setVisible(false)}
size='sm'
variant='ontime-ghost-on-light'
variant='ontime-ghosted'
icon={<IoEyeOutline />}
aria-label='Show pin code'
/>
@@ -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 };
}
@@ -1,5 +1,3 @@
@use '../../../theme/v2Styles' as *;
.inputItems {
display: grid;
grid-template-columns: 1fr auto;
@@ -1,3 +1,4 @@
import { useEffect, useRef } from 'react';
import { IconButton, Input } from '@chakra-ui/react';
import { IoEye } from '@react-icons/all-files/io5/IoEye';
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
@@ -22,9 +23,22 @@ interface InputRowProps {
export default function InputRow(props: InputRowProps) {
const { label, placeholder, text, visible, actionHandler, changeHandler, className, readonly } = props;
const handleInputChange = (newValue: string) => {
changeHandler(newValue);
const inputRef = useRef<HTMLInputElement>(null);
const cursorPositionRef = useRef(0);
// sync cursor position with text
useEffect(() => {
if (inputRef.current) {
inputRef.current.selectionStart = cursorPositionRef.current;
inputRef.current.selectionEnd = cursorPositionRef.current;
}
}, [text]);
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
cursorPositionRef.current = event.target.selectionStart ?? 0;
changeHandler(event.target.value);
};
const classes = cx([style.inputRow, className]);
return (
@@ -32,12 +46,13 @@ export default function InputRow(props: InputRowProps) {
<label className={`${style.label} ${visible ? style.active : ''}`}>{label}</label>
<div className={style.inputItems}>
<Input
ref={inputRef}
size='sm'
variant='ontime-filled'
readOnly={readonly}
disabled={readonly}
value={text}
onChange={(event) => handleInputChange(event.target.value)}
onChange={handleInputChange}
placeholder={placeholder}
/>
{readonly ? (
@@ -1,29 +1,12 @@
@use '../../../theme/v2Styles' as *;
.messageContainer {
display: flex;
flex-direction: column;
gap: $section-spacing;
}
.onAirSection {
display: flex;
flex-direction: column;
gap: $element-spacing;
}
.buttonSection {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $element-spacing;
margin-top: -0.5rem;
}
.label {
font-size: $inner-section-text-size;
color: $label-gray;
&.active {
color: $action-text-color;
}
}
@@ -1,89 +1,79 @@
import { Button } from '@chakra-ui/react';
import { IoEye } from '@react-icons/all-files/io5/IoEye';
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
import { IoMicOffOutline } from '@react-icons/all-files/io5/IoMicOffOutline';
import { IoMicSharp } from '@react-icons/all-files/io5/IoMicSharp';
import { IoSunny } from '@react-icons/all-files/io5/IoSunny';
import { IoSunnyOutline } from '@react-icons/all-files/io5/IoSunnyOutline';
import { setMessage, useMessageControl } from '../../../common/hooks/useSocket';
import { enDash } from '../../../common/utils/styleUtils';
import InputRow from './InputRow';
import style from './MessageControl.module.scss';
export default function MessageControl() {
const data = useMessageControl();
const noop = () => undefined;
export default function MessageControl() {
const message = useMessageControl();
const blink = message.timer.blink;
const blackout = message.timer.blackout;
return (
<div className={style.messageContainer}>
<InputRow
label='Public / Backstage screen message'
placeholder='Shown in public and backstage screens'
text={data.publicMessage.text || ''}
visible={data.publicMessage.visible || false}
text={message.public.text || ''}
visible={message.public.visible || false}
changeHandler={(newValue) => setMessage.publicText(newValue)}
actionHandler={() => setMessage.publicVisible(!data.publicMessage.visible)}
actionHandler={() => setMessage.publicVisible(!message.public.visible)}
/>
<InputRow
label='Lower third message'
placeholder='Shown in lower third'
text={data.lowerMessage.text || ''}
visible={data.lowerMessage.visible || false}
text={message.lower.text || ''}
visible={message.lower.visible || false}
changeHandler={(newValue) => setMessage.lowerText(newValue)}
actionHandler={() => setMessage.lowerVisible(!data.lowerMessage.visible)}
actionHandler={() => setMessage.lowerVisible(!message.lower.visible)}
/>
<InputRow
label='Timer'
placeholder='Message shown in stage timer'
text={data.timerMessage.text || ''}
visible={data.timerMessage.visible || false}
changeHandler={(newValue) => setMessage.presenterText(newValue)}
actionHandler={() => setMessage.presenterVisible(!data.timerMessage.visible)}
text={message.timer.text || ''}
visible={message.timer.visible || false}
changeHandler={(newValue) => setMessage.timerText(newValue)}
actionHandler={() => setMessage.timerVisible(!message.timer.visible)}
/>
<div className={style.buttonSection}>
<Button
size='sm'
className={`${data.timerMessage.timerBlink ? style.blink : ''}`}
variant={data.timerMessage.timerBlink ? 'ontime-filled' : 'ontime-subtle'}
leftIcon={data.timerMessage.timerBlink ? <IoSunny size='1rem' /> : <IoSunnyOutline size='1rem' />}
onClick={() => setMessage.timerBlink(!data.timerMessage.timerBlink)}
className={`${blink ? style.blink : ''}`}
variant={blink ? 'ontime-filled' : 'ontime-subtle'}
leftIcon={blink ? <IoSunny size='1rem' /> : <IoSunnyOutline size='1rem' />}
onClick={() => setMessage.timerBlink(!blink)}
data-testid='toggle timer blink'
>
Blink message
Blink
</Button>
<Button
size='sm'
className={style.blackoutButton}
variant={data.timerMessage.timerBlackout ? 'ontime-filled' : 'ontime-subtle'}
leftIcon={data.timerMessage.timerBlackout ? <IoEye size='1rem' /> : <IoEyeOffOutline size='1rem' />}
onClick={() => setMessage.timerBlackout(!data.timerMessage.timerBlackout)}
variant={blackout ? 'ontime-filled' : 'ontime-subtle'}
leftIcon={blackout ? <IoEye size='1rem' /> : <IoEyeOffOutline size='1rem' />}
onClick={() => setMessage.timerBlackout(!blackout)}
data-testid='toggle timer blackout'
>
Blackout screen
</Button>
</div>
<InputRow
label='External Message'
placeholder='-'
label='External Message (read only)'
placeholder={enDash}
readonly
text={data.externalMessage.text || ''}
visible={data.externalMessage.visible || false}
changeHandler={() => undefined}
actionHandler={() => undefined}
text={message.external.text || ''}
visible={message.external.visible || false}
changeHandler={noop}
actionHandler={noop}
/>
<div className={style.onAirSection}>
<label className={style.label}>Toggle On Air state</label>
<Button
size='sm'
variant={data.onAir ? 'ontime-filled' : 'ontime-subtle'}
leftIcon={data.onAir ? <IoMicSharp size='24px' /> : <IoMicOffOutline size='24px' />}
onClick={() => setMessage.onAir(!data.onAir)}
data-testid='toggle on air'
>
{data?.onAir ? 'Ontime is On Air' : 'Ontime is Off Air'}
</Button>
</div>
</div>
);
}
@@ -2,6 +2,7 @@ import { Playback } from 'ontime-types';
import { usePlaybackControl } from '../../../common/hooks/useSocket';
import { ExtraTimer } from './extra-timer/ExtraTimer';
import PlaybackButtons from './playback-buttons/PlaybackButtons';
import PlaybackTimer from './playback-timer/PlaybackTimer';
@@ -18,6 +19,7 @@ export default function PlaybackControl() {
numEvents={data.numEvents}
selectedEventIndex={data.selectedEventIndex}
/>
<ExtraTimer />
</div>
);
}
@@ -0,0 +1,5 @@
.extraRow {
display: flex;
gap: 0.5rem;
margin-top: 2rem;
}
@@ -0,0 +1,74 @@
import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { Playback, SimpleDirection, SimplePlayback } from 'ontime-types';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { setExtraTimer, useExtraTimerControl, useExtraTimerTime } from '../../../../common/hooks/useSocket';
import { forgivingStringToMillis } from '../../../../common/utils/dateConfig';
import TapButton from '../tap-button/TapButton';
import style from './ExtraTimer.module.scss';
export function ExtraTimer() {
const { playback, direction } = useExtraTimerControl();
const { start, pause, stop, setDirection } = setExtraTimer;
const toggleDirection = () => {
const newDirection = direction === SimpleDirection.CountDown ? SimpleDirection.CountUp : SimpleDirection.CountDown;
setDirection(newDirection);
};
const userCan = {
start: playback !== SimplePlayback.Start,
pause: playback === SimplePlayback.Start,
stop: playback !== SimplePlayback.Stop,
};
return (
<div className={style.extraRow}>
<ExtraTimeInput />
<TapButton onClick={toggleDirection} aspect='tight'>
{direction === SimpleDirection.CountDown && <IoArrowDown data-testid='aux-timer-direction' />}
{direction === SimpleDirection.CountUp && <IoArrowUp data-testid='aux-timer-direction' />}
</TapButton>
<TapButton
onClick={start}
theme={Playback.Play}
active={playback === SimplePlayback.Start}
disabled={!userCan.start}
>
<IoPlay data-testid='aux-timer-start' />
</TapButton>
<TapButton
onClick={pause}
theme={Playback.Pause}
active={playback === SimplePlayback.Pause}
disabled={!userCan.pause}
>
<IoPause data-testid='aux-timer-pause' />
</TapButton>
<TapButton onClick={stop} theme={Playback.Stop} disabled={!userCan.stop}>
<IoStop data-testid='aux-timer-stop' />
</TapButton>
</div>
);
}
function ExtraTimeInput() {
const time = useExtraTimerTime();
const { setDuration } = setExtraTimer;
const handleTimeUpdate = (_field: string, value: string) => {
const newTime = forgivingStringToMillis(value);
setDuration(newTime / 1000); //frontend api is seconds based;
};
return (
<TimeInput<'auxTimer'> submitHandler={handleTimeUpdate} name='auxTimer' time={time} placeholder='Aux Timer 1' />
);
}
@@ -1,5 +1,3 @@
@use '../../../../theme/v2Styles' as *;
.buttonContainer {
padding-top: $element-spacing;
display: grid;
@@ -28,20 +28,21 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
const isPlaying = playback === Playback.Play;
const isPaused = playback === Playback.Pause;
const isArmed = playback === Playback.Armed;
const isStopped = playback === Playback.Stop;
const isFirst = selectedEventIndex === 0;
const isLast = selectedEventIndex === numEvents - 1;
const noEvents = numEvents === 0;
const disableGo = isRolling || noEvents || (isLast && !isArmed);
const disablePrev = noEvents || isFirst;
const disableNext = isRolling || noEvents || isLast;
const disablePrev = isRolling || noEvents || isFirst;
const playbackCan = validatePlayback(playback);
const disableStart = !playbackCan.start;
const disablePause = !playbackCan.pause;
const disableRoll = !playbackCan.roll || noEvents;
const disableStop = !playbackCan.stop;
const disableReload = !playbackCan.reload;
const goModeText = selectedEventIndex === null || isArmed ? 'Start' : 'Next';
const goModeAction = () => {
@@ -73,7 +74,7 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
</TapButton>
</Tooltip>
<Tooltip label='Next event' openDelay={tooltipDelayMid}>
<TapButton onClick={setPlayback.next} disabled={disableGo}>
<TapButton onClick={setPlayback.next} disabled={disableNext}>
<IoPlaySkipForward />
</TapButton>
</Tooltip>
@@ -83,7 +84,7 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
<IoTime />
</TapButton>
<Tooltip label='Reload event' openDelay={tooltipDelayMid}>
<TapButton onClick={setPlayback.reload} disabled={isStopped}>
<TapButton onClick={setPlayback.reload} disabled={disableReload}>
<IoReload className={style.invertX} />
</TapButton>
</Tooltip>
@@ -1,6 +1,3 @@
@use '../../../../theme/v2Styles' as *;
@use '../../../../theme/ontimeColours' as *;
.timeContainer {
display: grid;
grid-template-areas:
@@ -12,12 +9,6 @@
justify-items: start;
}
.timer {
grid-area: clk;
white-space: nowrap;
max-width: 18.75rem;
}
.indicators {
grid-area: ind;
width: 100%;
@@ -95,16 +86,17 @@
grid-area: 2 / 2 / 2 / 4 ;
}
.tag {
color: $label-gray;
font-size: calc(1rem - 2px);
margin-right: 0.25rem;
}
.time {
color: $section-white;
font-size: $text-body-size;
}
.tag {
color: $label-gray;
font-size: 13px;
}
.rolltag {
color: $ontime-roll;
font-size: $text-body-size;
@@ -1,12 +1,11 @@
import { Tooltip } from '@chakra-ui/react';
import { Playback } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { dayInMs, millisToMinutes, millisToSeconds, millisToString } from 'ontime-utils';
import TimerDisplay from '../../../../common/components/timer-display/TimerDisplay';
import { setPlayback, useTimer } from '../../../../common/hooks/useSocket';
import { millisToMinutes, millisToSeconds } from '../../../../common/utils/dateConfig';
import { tooltipDelayMid } from '../../../../ontimeConfig';
import TapButton from '../tap-button/TapButton';
import TimerDisplay from '../timer-display/TimerDisplay';
import style from './PlaybackTimer.module.scss';
@@ -18,9 +17,10 @@ export default function PlaybackTimer(props: PlaybackTimerProps) {
const { playback } = props;
const timer = useTimer();
// TODO: checkout typescript in utilities
const started = millisToString(timer.startedAt);
const finish = millisToString(timer.expectedFinish);
const expectedFinish = timer.expectedFinish !== null ? timer.expectedFinish % dayInMs : null;
const finish = millisToString(expectedFinish);
const isRolling = playback === Playback.Roll;
const isStopped = playback === Playback.Stop;
const isWaiting = timer.secondaryTimer !== null && timer.secondaryTimer > 0 && timer.current === null;
@@ -65,9 +65,7 @@ export default function PlaybackTimer(props: PlaybackTimerProps) {
<div className={hasAddedTime ? style.indDelayActive : style.indDelay} />
</Tooltip>
</div>
<div className={style.timer}>
<TimerDisplay time={isWaiting ? timer.secondaryTimer : timer.current} />
</div>
<TimerDisplay time={isWaiting ? timer.secondaryTimer : timer.current} />
{isWaiting ? (
<div className={style.roll}>
<span className={style.rolltag}>Roll: Countdown to start</span>
@@ -75,11 +73,11 @@ export default function PlaybackTimer(props: PlaybackTimerProps) {
) : (
<>
<div className={style.start}>
<span className={style.tag}>Started at </span>
<span className={style.tag}>Started at</span>
<span className={style.time}>{started}</span>
</div>
<div className={style.finish}>
<span className={style.tag}>Finish at </span>
<span className={style.tag}>Expect end</span>
<span className={style.time}>{finish}</span>
</div>
</>
@@ -1,6 +1,3 @@
@use '../../../../theme/v2Styles' as *;
@use '../../../../theme/ontimeColours' as *;
$button-bg-gray: $gray-1050;
$button-color-white: $gray-50;
@@ -82,6 +79,11 @@ $button-color-white: $gray-50;
font-size: calc(1rem - 2px);
}
.tapButton.tight {
padding-inline: 0.5rem;
width: fit-content
}
.tapButton.fill {
aspect-ratio: unset;
height: 100%;
@@ -7,8 +7,7 @@ import style from './TapButton.module.scss';
interface TapButtonProps {
disabled?: boolean;
aspect?: 'normal' | 'square' | 'fill';
square?: boolean;
aspect?: 'normal' | 'square' | 'fill' | 'tight';
free?: boolean;
onClick: () => void;
theme?: Playback | 'neutral';
@@ -0,0 +1,20 @@
@use '../../../../theme/viewerDefs' as *;
.timer {
grid-area: clk;
white-space: nowrap;
max-width: 18.75rem;
min-width: 5em;
font-family: var(--font-family-override, $viewer-font-family);
color: var(--timer-color-override, $timer-color);
line-height: 0.9em;
text-align: center;
letter-spacing: 0.1em;
font-weight: 600;
font-size: 3.5rem;
&.finished {
color: $timer-finished-color;
}
}
@@ -0,0 +1,28 @@
import { MaybeNumber } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { cx, timerPlaceholder } from '../../../../common/utils/styleUtils';
import style from './TimerDisplay.module.scss';
interface TimerDisplayProps {
time: MaybeNumber;
}
/**
* Displays time in ms in formatted timetag
* Used in editor
*/
export default function TimerDisplay(props: TimerDisplayProps) {
const { time } = props;
if (time == null) {
return <div className={style.timer}>{timerPlaceholder}</div>;
}
const isNegative = time < 0;
const display = millisToString(Math.abs(time), { fallback: timerPlaceholder });
const classes = cx([style.timer, isNegative ? style.finished : null]);
return <div className={classes}>{display}</div>;
}
@@ -1,15 +1,6 @@
@use '../../theme/ontimeColours' as *;
@use '../../theme/v2Styles' as *;
$table-font-size: calc(1rem - 2px);
$table-header-font-size: calc(1rem - 3px);
@mixin ellipsis-overflow() {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cuesheetContainer {
grid-area: table;
display: flex;
@@ -28,7 +19,8 @@ $table-header-font-size: calc(1rem - 3px);
display: flex;
}
th, td {
th,
td {
margin: 1px;
font-weight: inherit;
font-size: inherit;
@@ -105,6 +97,9 @@ $table-header-font-size: calc(1rem - 3px);
position: sticky;
left: 47.5%; // center of the screen, ish
padding: 0.5rem 0;
&:first-letter {
text-transform: uppercase;
}
}
}
@@ -1,24 +1,14 @@
@use '../../theme/v2Styles' as *;
@use '../../theme/ontimeColours' as *;
.tableWrapper {
width: 100%;
height: 100vh;
padding: 1rem;
padding: 1rem 0.5rem;
display: grid;
grid-template-rows: auto auto 1fr;
grid-template-rows: 3rem auto 1fr;
grid-template-areas:
'header'
'overview'
'settings'
'table';
gap: 1rem;
background-color: $gray-1300;
color: white;
& > * {
border: 1px solid $white-10;
border-radius: 3px;
}
color: $ui-white;
}
@@ -1,38 +1,45 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { OntimeRundownEntry, ProjectData } from 'ontime-types';
import { useCallback, useMemo } from 'react';
import { IconButton, useDisclosure } from '@chakra-ui/react';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { CustomFieldLabel, isOntimeEvent } from 'ontime-types';
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
import Empty from '../../common/components/state/Empty';
import { useEventAction } from '../../common/hooks/useEventAction';
import { useCuesheet } from '../../common/hooks/useSocket';
import useRundown from '../../common/hooks-query/useRundown';
import useUserFields from '../../common/hooks-query/useUserFields';
import ExportModal, { ExportType } from '../modals/export-modal/ExportModal';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useFlatRundown } from '../../common/hooks-query/useRundown';
import { CuesheetOverview } from '../overview/Overview';
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
import CuesheetTableHeader from './cuesheet-table-header/CuesheetTableHeader';
import { useCuesheetSettings } from './store/CuesheetSettings';
import Cuesheet from './Cuesheet';
import { makeCuesheetColumns } from './cuesheetCols';
import { makeCSV, makeTable } from './cuesheetUtils';
import styles from './CuesheetWrapper.module.scss';
export default function CuesheetWrapper() {
const { data: rundown } = useRundown();
const { data: userFields } = useUserFields();
const { updateEvent } = useEventAction();
// TODO: can we use the normalised rundown for the table?
const { data: flatRundown, status: rundownStatus } = useFlatRundown();
const { data: customFields } = useCustomFields();
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
const { updateCustomField } = useEventAction();
const featureData = useCuesheet();
const columns = useMemo(() => makeCuesheetColumns(userFields), [userFields]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [headerData, setheaderData] = useState<ProjectData | null>(null);
const columns = useMemo(() => makeCuesheetColumns(customFields), [customFields]);
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
// Set window title
useEffect(() => {
document.title = 'ontime - Cuesheet';
}, []);
useWindowTitle('Cuesheet');
/**
* Handles updating a field
* Currently, only custom fields can be updated from the cuesheet
*/
const handleUpdate = useCallback(
async (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: unknown) => {
if (!rundown) {
async (rowIndex: number, accessor: CustomFieldLabel, payload: unknown) => {
if (!flatRundown || rundownStatus !== 'success') {
return;
}
@@ -41,109 +48,66 @@ export default function CuesheetWrapper() {
}
// check if value is the same
const event = rundown[rowIndex];
if (!event) {
const event = flatRundown[rowIndex];
if (!event || !isOntimeEvent(event)) {
return;
}
if (event[accessor] === payload) {
const previousValue = event.custom[accessor]?.value;
if (previousValue === payload) {
return;
}
// check if value is valid
// as of now, the fields do not have any validation
// in anticipation to different types of event here
if (typeof payload !== 'string') {
return;
}
// cleanup
const cleanVal = payload.trim();
const mutationObject = {
id: event.id,
[accessor]: cleanVal,
};
// submit
try {
await updateEvent(mutationObject);
await updateCustomField(event.id, accessor, cleanVal);
} catch (error) {
console.error(error);
}
},
[updateEvent, rundown],
[flatRundown, rundownStatus, updateCustomField],
);
const exportHandler = useCallback(
(headerData: ProjectData, exportType: ExportType) => {
if (!headerData || !rundown || !userFields) {
return;
}
let fileName = '';
let url = '';
if (exportType === 'json') {
const jsonContent = JSON.stringify({
headerData,
rundown,
userFields,
});
fileName = 'ontime export.json';
const blob = new Blob([jsonContent], { type: 'application/json;charset=utf-8;' });
url = URL.createObjectURL(blob);
} else if (exportType === 'csv') {
const sheetData = makeTable(headerData, rundown, userFields);
const csvContent = makeCSV(sheetData);
fileName = 'ontime export.csv';
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
url = URL.createObjectURL(blob);
} else {
console.error('Invalid export type: ', exportType);
return;
}
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
// Clean up the URL.createObjectURL to release resources
URL.revokeObjectURL(url);
return;
},
[rundown, userFields],
);
const onModalClose = (exportType?: ExportType) => {
setIsModalOpen(false);
if (!exportType) {
return;
}
if (headerData) {
exportHandler(headerData, exportType);
}
};
const handleOpenModal = (projectData: ProjectData) => {
setheaderData(projectData);
setIsModalOpen(true);
};
if (!rundown || !userFields) {
if (!customFields || !flatRundown || rundownStatus !== 'success') {
return <Empty text='Loading...' />;
}
return (
<div className={styles.tableWrapper} data-testid='cuesheet'>
<CuesheetTableHeader handleExport={handleOpenModal} featureData={featureData} />
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
<CuesheetOverview>
<IconButton
aria-label='Toggle settings'
variant='ontime-subtle-white'
size='lg'
icon={<IoApps />}
onClick={onOpen}
/>
<IconButton
aria-label='Toggle navigation'
variant='ontime-subtle-white'
size='lg'
icon={<IoSettingsOutline />}
onClick={() => toggleSettings()}
/>
</CuesheetOverview>
<CuesheetProgress />
<Cuesheet data={rundown} columns={columns} handleUpdate={handleUpdate} selectedId={featureData.selectedEventId} />
<ExportModal isOpen={isModalOpen} onClose={onModalClose} />
<Cuesheet
data={flatRundown}
columns={columns}
handleUpdate={handleUpdate}
selectedId={featureData.selectedEventId}
/>
</div>
);
}
@@ -3,61 +3,39 @@
exports[`makeTable() > returns array of arrays with given fields 1`] = `
[
[
"Ontime · Schedule Template",
"Ontime · Rundown export",
],
[
"Project Title",
"",
"Project title: test title",
],
[
"Project Description",
"",
"Project description: test description",
],
[
"Public URL",
"",
],
[
"Backstage URL",
"",
],
[],
[
"Time Start",
"Time End",
"Event Title",
"Presenter Name",
"Event Subtitle",
"Is Public? (x)",
"Note",
"Duration",
"ID",
"Colour",
"End Action",
"Timer Type",
"Cue",
"Title",
"Note",
"Is Public? (x)",
"Skip?",
"user0:test",
"lighting",
],
[
"00:00:00",
"00:00:00",
"...",
"",
"",
"",
"test title 1",
"",
"",
"x",
"",
"",
"",
"",
"",
"test",
"test",
"",
"",
"",
"",
"",
"",
"",
"",
],
]
`;
@@ -1,12 +1,13 @@
import { makeCSV, makeTable, parseField } from '../cuesheetUtils';
describe('parseField()', () => {
it('returns a string from given millis on timeStart and TimeEnd', () => {
it('returns a string from given millis on timeStart, TimeEnd and duration', () => {
const testData1 = 1000;
const testData2 = 60000;
const testData3 = 600000;
expect(parseField('timeStart', testData1)).toBe('00:00:01');
expect(parseField('timeEnd', testData2)).toBe('00:01:00');
expect(parseField('timeEnd', testData2)).toBe('00:01:00');
expect(parseField('duration', testData3)).toBe('00:10:00');
});
describe('returns an x when isPublic is truthy, empty string otherwise', () => {
@@ -26,27 +27,15 @@ describe('parseField()', () => {
});
it('returns an empty string on undefined fields', () => {
expect(parseField('presenter')).toBe('');
expect(parseField('title')).toBe('');
});
describe('simply returns any other value in any other field', () => {
const testFields = [
{ field: 'nothing', value: 123 },
{ field: 'nothing', value: '123' },
{ field: 'title', value: 'test' },
{ field: 'presenter', value: 'test' },
{ field: 'subtitle', value: 'test' },
{ field: 'note', value: 'test' },
{ field: 'colour', value: 'test' },
{ field: 'user0', value: 'test' },
{ field: 'user1', value: 'test' },
{ field: 'user2', value: 'test' },
{ field: 'user3', value: 'test' },
{ field: 'user4', value: 'test' },
{ field: 'user5', value: 'test' },
{ field: 'user6', value: 'test' },
{ field: 'user7', value: 'test' },
{ field: 'user8', value: 'test' },
{ field: 'user9', value: 'test' },
];
testFields.forEach((testCase) => {
@@ -59,23 +48,25 @@ describe('parseField()', () => {
describe('makeTable()', () => {
it('returns array of arrays with given fields', () => {
const headerData = {};
const headerData = {
title: 'test title',
description: 'test description',
};
const tableData = [
{
title: 'test title 1',
presenter: '',
timeStart: 0,
timeEnd: 0,
isPublic: 'x',
user0: 'test',
user1: 'test',
lighting: { value: 'test lighting' },
sound: { value: 'test sound' },
},
];
const userFields = {
user0: 'test',
const customFields = {
lighting: { label: 'test' },
};
const table = makeTable(headerData, tableData, userFields);
const table = makeTable(headerData, tableData, customFields);
expect(table).toMatchSnapshot();
});
});
@@ -1,22 +1,22 @@
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import { useTimer } from '../../../common/hooks/useSocket';
import { useProgressData } from '../../../common/hooks/useSocket';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
import styles from "./CuesheetProgress.module.scss"
import styles from './CuesheetProgress.module.scss';
export default function CuesheetProgress() {
const { data } = useViewSettings();
const timer = useTimer();
const totalTime = (timer.duration ?? 0) + (timer.addedTime ?? 0);
const { addedTime, current, duration, timeWarning, timeDanger } = useProgressData();
const totalTime = (duration ?? 0) + (addedTime ?? 0);
return (
<MultiPartProgressBar
now={timer.current}
now={current}
complete={totalTime}
normalColor={data!.normalColor}
warning={data!.warningThreshold}
warning={timeWarning}
warningColor={data!.warningColor}
danger={data!.dangerThreshold}
danger={timeDanger}
dangerColor={data!.dangerColor}
className={styles.progressOverride}
/>
@@ -1,4 +1,4 @@
import { memo } from 'react';
import { memo, useEffect } from 'react';
import { Tooltip } from '@chakra-ui/react';
import {
closestCenter,
@@ -15,6 +15,7 @@ import { flexRender, HeaderGroup } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
import { useLocalStorage } from '../../../common/hooks/useLocalStorage';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { initialColumnOrder } from '../cuesheetCols';
@@ -30,6 +31,12 @@ function CuesheetHeader(props: CuesheetHeaderProps) {
const { headerGroups } = props;
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>('table-order', initialColumnOrder);
useEffect(() => {
if (!localStorage.getItem('table-order')) {
saveColumnOrder(initialColumnOrder);
}
}, [saveColumnOrder]);
const handleOnDragEnd = (event: DragEndEvent) => {
const { delta, active, over } = event;
@@ -89,9 +96,17 @@ function CuesheetHeader(props: CuesheetHeaderProps) {
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
{headerGroup.headers.map((header) => {
const width = header.getSize();
// @ts-expect-error -- we inject this into react-table
const customBackground = header.column.columnDef?.meta?.colour;
let customStyles = {};
if (customBackground) {
const customColour = getAccessibleColour(customBackground);
customStyles = { backgroundColor: customColour.backgroundColor, color: customColour.color };
}
return (
<SortableCell key={header.column.columnDef.id} header={header} style={{ width }}>
<SortableCell key={header.column.columnDef.id} header={header} style={{ width, ...customStyles }}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</SortableCell>
);
@@ -10,7 +10,7 @@ interface DelayRowProps {
function DelayRow(props: DelayRowProps) {
const { duration } = props;
const delayTime = millisToDelayString(duration);
const delayTime = millisToDelayString(duration, 'expanded');
return (
<tr className={style.delayRow}>
@@ -1,6 +1,3 @@
@use '../../../theme/ontimeColours' as *;
@use '../../../theme/v2Styles' as *;
$label-colour: $gray-700;
$active-colour: $gray-500;
@@ -12,7 +9,7 @@ $active-colour: $gray-500;
}
@mixin time {
font-family: "Open Sans Light", $ontime-font-family;
font-family: 'Open Sans Light', $ontime-font-family;
font-size: 2rem;
text-align: center;
}
@@ -25,8 +22,7 @@ $active-colour: $gray-500;
height: max-content;
column-gap: 2rem;
grid-template-areas:
'event playback timer clock actions';
grid-template-areas: 'event playback timer clock actions';
grid-template-columns: 1fr auto auto auto auto;
align-items: center;
justify-items: center;
@@ -94,11 +90,12 @@ $active-colour: $gray-500;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.125rem;
color: $label-colour;
height: 100%;
font-size: 1rem;
.actionIcon {
.actionIcon,
.actionText {
cursor: pointer;
&.enabled {
@@ -109,6 +106,10 @@ $active-colour: $gray-500;
color: $active-colour;
}
}
.actionIcon {
font-size: 1.25rem;
}
}
@media (min-width: 1200px) {
@@ -1,13 +1,10 @@
import { Tooltip } from '@chakra-ui/react';
import { IoContract } from '@react-icons/all-files/io5/IoContract';
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { Playback, ProjectData } from 'ontime-types';
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
import useFullscreen from '../../../common/hooks/useFullscreen';
import useProjectData from '../../../common/hooks-query/useProjectData';
import { cx, enDash } from '../../../common/utils/styleUtils';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { useCuesheetSettings } from '../store/CuesheetSettings';
@@ -27,10 +24,7 @@ interface CuesheetTableHeaderProps {
export default function CuesheetTableHeader({ handleExport, featureData }: CuesheetTableHeaderProps) {
const followSelected = useCuesheetSettings((state) => state.followSelected);
const showSettings = useCuesheetSettings((state) => state.showSettings);
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
const toggleFollow = useCuesheetSettings((state) => state.toggleFollow);
const { isFullScreen, toggleFullScreen } = useFullscreen();
const { data: project } = useProjectData();
const exportProject = () => {
@@ -41,15 +35,15 @@ export default function CuesheetTableHeader({ handleExport, featureData }: Cuesh
const selected = !featureData.numEvents
? 'No events'
: `Event ${featureData.selectedEventIndex != null ? featureData.selectedEventIndex + 1 : '-'}/${
featureData.numEvents ? featureData.numEvents : '-'
: `Event ${featureData.selectedEventIndex != null ? featureData.selectedEventIndex + 1 : enDash}/${
featureData.numEvents ? featureData.numEvents : enDash
}`;
return (
<div className={style.header}>
<div className={style.event}>
<div className={style.title}>{project?.title || '-'}</div>
<div className={style.eventNow}>{featureData?.titleNow || '-'}</div>
<div className={style.title}>{project?.title || enDash}</div>
<div className={style.eventNow}>{featureData?.titleNow || enDash}</div>
</div>
<div className={style.playback}>
<div className={style.playbackLabel}>{selected}</div>
@@ -58,23 +52,16 @@ export default function CuesheetTableHeader({ handleExport, featureData }: Cuesh
<CuesheetTableHeaderTimers />
<div className={style.headerActions}>
<Tooltip openDelay={tooltipDelayFast} label='Toggle follow'>
<span onClick={() => toggleFollow()} className={`${style.actionIcon} ${followSelected ? style.enabled : ''}`}>
<span
onClick={() => toggleFollow()}
className={cx([style.actionIcon, followSelected ? style.enabled : null])}
>
<IoLocate />
</span>
</Tooltip>
<Tooltip openDelay={tooltipDelayFast} label='Toggle settings'>
<span onClick={() => toggleSettings()} className={`${style.actionIcon} ${showSettings ? style.enabled : ''}`}>
<IoSettingsOutline />
</span>
</Tooltip>
<Tooltip openDelay={tooltipDelayFast} label='Toggle Fullscreen'>
<span onClick={() => toggleFullScreen()} className={style.actionIcon}>
{isFullScreen ? <IoContract /> : <IoExpand />}
</span>
</Tooltip>
<Tooltip openDelay={tooltipDelayFast} label='Export rundown'>
<span className={style.actionIcon} onClick={exportProject}>
Export
<span className={style.actionText} onClick={exportProject}>
Export CSV
</span>
</Tooltip>
</div>
@@ -1,30 +1,22 @@
import { formatDisplay } from 'ontime-utils';
import { useTimer } from '../../../common/hooks/useSocket';
import { formatTime } from '../../../common/utils/time';
import { useClock, useTimer } from '../../../common/hooks/useSocket';
import ClockTime from '../../viewers/common/clock-time/ClockTime';
import RunningTime from '../../viewers/common/running-time/RunningTime';
import style from './CuesheetTableHeader.module.scss';
export default function CuesheetTableHeaderTimers() {
const timer = useTimer();
// prepare presentation variables
const isOvertime = (timer.current ?? 0) < 0;
const timerNow = timer.current == null ? '-' : `${isOvertime ? '-' : ''}${formatDisplay(timer.current)}`;
const timeNow = formatTime(timer.clock, {
showSeconds: true,
format: 'hh:mm:ss a',
});
const { current } = useTimer();
const { clock } = useClock();
return (
<>
<div className={style.timer}>
<div className={style.timerLabel}>Running Timer</div>
<div className={style.value}>{timerNow}</div>
<RunningTime className={style.value} value={current} hideLeadingZero />
</div>
<div className={style.clock}>
<div className={style.clockLabel}>Time Now</div>
<div className={style.value}>{timeNow}</div>
<ClockTime className={style.value} value={clock} />
</div>
</>
);
@@ -1,6 +1,3 @@
@use '../../../theme/v2Styles' as *;
@use '../../../theme/ontimeColours' as *;
.tableSettings {
grid-area: settings;
padding: 1rem;
@@ -23,12 +23,14 @@ interface CuesheetTableSettingsProps {
function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
const {
followSelected,
toggleFollow,
showPrevious,
toggleDelayVisibility,
togglePreviousVisibility,
showDelayBlock,
showDelayedTimes,
toggleDelayedTimes,
togglePreviousVisibility,
toggleDelayVisibility,
} = useCuesheetSettings();
return (
@@ -53,6 +55,10 @@ function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
</div>
<div className={style.sectionTitle}>Table Options</div>
<div className={style.options}>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={followSelected} onChange={() => toggleFollow()} />
Follow selected event
</label>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={showPrevious} onChange={() => togglePreviousVisibility()} />
Show past events
@@ -1,10 +1,11 @@
import { useCallback } from 'react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { CellContext, ColumnDef } from '@tanstack/react-table';
import { OntimeEvent, OntimeRundownEntry, UserFields } from 'ontime-types';
import { CustomFields, isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator';
import RunningTime from '../viewers/common/running-time/RunningTime';
import EditableCell from './cuesheet-table-elements/EditableCell';
import { useCuesheetSettings } from './store/CuesheetSettings';
@@ -24,30 +25,45 @@ function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEnt
return (
<span className={style.time}>
<DelayIndicator delayValue={delayValue} />
{millisToString(cellValue)}
<RunningTime value={cellValue} />
{delayValue !== 0 && showDelayedTimes && (
<span className={style.delayedTime}>{` ${millisToString(cellValue + delayValue)}`}</span>
<RunningTime className={style.delayedTime} value={cellValue + delayValue} />
)}
</span>
);
}
function MakeUserField({ getValue, row: { index }, column: { id }, table }: CellContext<OntimeRundownEntry, unknown>) {
function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
const update = useCallback(
(newValue: string) => {
// @ts-expect-error -- we inject this into react-table
table.options.meta?.handleUpdate(index, id, newValue);
table.options.meta?.handleUpdate(row.index, column.id, newValue);
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
[id, index],
[column.id, row.index],
);
const initialValue = getValue() as string;
const event = row.original;
if (!isOntimeEvent(event)) {
return null;
}
// events dont necessarily contain all custom fields
const initialValue = event.custom[column.id]?.value ?? '';
return <EditableCell value={initialValue} handleUpdate={update} />;
}
export function makeCuesheetColumns(userFields?: UserFields): ColumnDef<OntimeRundownEntry>[] {
export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<OntimeRundownEntry>[] {
const dynamicCustomFields = Object.keys(customFields).map((key) => ({
accessorKey: key,
id: key,
header: customFields[key].label,
meta: { colour: customFields[key].colour },
cell: MakeCustomField,
size: 250,
}));
return [
{
accessorKey: 'cue',
@@ -89,86 +105,17 @@ export function makeCuesheetColumns(userFields?: UserFields): ColumnDef<OntimeRu
id: 'title',
header: 'Title',
cell: (row) => row.getValue(),
},
{
accessorKey: 'subtitle',
id: 'subtitle',
header: 'Subtitle',
cell: (row) => row.getValue(),
},
{
accessorKey: 'presenter',
id: 'presenter',
header: 'Presenter',
cell: (row) => row.getValue(),
size: 250,
},
{
accessorKey: 'note',
id: 'note',
header: 'Note',
cell: (row) => row.getValue(),
size: 250,
},
{
accessorKey: 'user0',
id: 'user0',
header: userFields?.user0 || 'User 0',
cell: MakeUserField,
},
{
accessorKey: 'user1',
id: 'user1',
header: userFields?.user1 || 'User 1',
cell: MakeUserField,
},
{
accessorKey: 'user2',
id: 'user2',
header: userFields?.user2 || 'User 2',
cell: MakeUserField,
},
{
accessorKey: 'user3',
id: 'user3',
header: userFields?.user3 || 'User 3',
cell: MakeUserField,
},
{
accessorKey: 'user4',
id: 'user4',
header: userFields?.user4 || 'User 4',
cell: MakeUserField,
},
{
accessorKey: 'user5',
id: 'user5',
header: userFields?.user5 || 'User 5',
cell: MakeUserField,
},
{
accessorKey: 'user6',
id: 'user6',
header: userFields?.user6 || 'User 6',
cell: MakeUserField,
},
{
accessorKey: 'user7',
id: 'user7',
header: userFields?.user7 || 'User 7',
cell: MakeUserField,
},
{
accessorKey: 'user8',
id: 'user8',
header: userFields?.user8 || 'User 8',
cell: MakeUserField,
},
{
accessorKey: 'user9',
id: 'user9',
header: userFields?.user9 || 'User 9',
cell: MakeUserField,
},
...dynamicCustomFields,
];
}
export const initialColumnOrder: string[] = makeCuesheetColumns().map((column) => column.id as string);
export const initialColumnOrder: string[] = makeCuesheetColumns({}).map((column) => column.id as string);
@@ -1,7 +1,17 @@
import { stringify } from 'csv-stringify/browser/esm/sync';
import { OntimeEntryCommonKeys, OntimeRundown, ProjectData, UserFields } from 'ontime-types';
import {
CustomFields,
isOntimeDelay,
isOntimeEvent,
MaybeNumber,
OntimeEntryCommonKeys,
OntimeRundown,
ProjectData,
} from 'ontime-types';
import { millisToString } from 'ontime-utils';
type CsvHeaderKey = OntimeEntryCommonKeys | keyof CustomFields;
/**
* @description parses a field for export
* @param {string} field
@@ -9,95 +19,85 @@ import { millisToString } from 'ontime-utils';
* @return {string}
*/
export const parseField = <T extends OntimeEntryCommonKeys>(field: T, data: unknown): string => {
let val;
switch (field) {
case 'timeStart':
case 'timeEnd':
val = millisToString(data as number | null);
break;
case 'isPublic':
case 'skip':
val = data ? 'x' : '';
break;
default:
val = data;
break;
export const parseField = (field: CsvHeaderKey, data: unknown): string => {
if (field === 'timeStart' || field === 'timeEnd' || field === 'duration') {
return millisToString(data as MaybeNumber);
}
if (typeof data === 'undefined') {
return '';
if (field === 'isPublic' || field === 'skip') {
return data ? 'x' : '';
}
// all other values are strings
return val as string;
return String(data ?? '');
};
/**
* @description Creates an array of arrays usable by xlsx for export
* @param {object} headerData
* @param {array} rundown
* @param {object} userFields
* @param {ProjectData} headerData
* @param {OntimeRundown} rundown
* @param {CustomFields} customFields
* @return {(string[])[]}
*/
export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, userFields: UserFields): string[][] => {
const data = [
['Ontime · Schedule Template'],
['Project Title', headerData?.title || ''],
['Project Description', headerData?.description || ''],
['Public URL', headerData?.publicUrl || ''],
['Backstage URL', headerData?.backstageUrl || ''],
[],
];
export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, customFields: CustomFields): string[][] => {
// create metadata header row
const data = [['Ontime · Rundown export']];
if (headerData.title) data.push([`Project title: ${headerData.title}`]);
if (headerData.description) data.push([`Project description: ${headerData.description}`]);
const fieldOrder: OntimeEntryCommonKeys[] = [
const customFieldKeys = Object.keys(customFields).map((key) => `custom-${key}`);
const customFieldLabels = Object.keys(customFields);
// we chose not to expose internals of the application
const fieldOrder: CsvHeaderKey[] = [
'timeStart',
'timeEnd',
'title',
'presenter',
'subtitle',
'isPublic',
'note',
'duration',
'id',
'colour',
'endAction',
'timerType',
'cue',
'title',
'note',
'isPublic',
'skip',
'user0',
'user1',
'user2',
'user3',
'user4',
'user5',
'user6',
'user7',
'user8',
'user9',
...customFieldKeys,
];
const fieldTitles = [
'Time Start',
'Time End',
'Event Title',
'Presenter Name',
'Event Subtitle',
'Is Public? (x)',
'Note',
'Duration',
'ID',
'Colour',
'End Action',
'Timer Type',
'Cue',
'Title',
'Note',
'Is Public? (x)',
'Skip?',
...customFieldLabels,
];
for (const field in userFields) {
const fieldValue = userFields[field as keyof UserFields];
const displayName = `${field}${fieldValue !== field && fieldValue !== '' ? `:${fieldValue}` : ''}`;
fieldTitles.push(displayName);
}
// add header row to data
data.push(fieldTitles);
rundown.forEach((entry) => {
if (isOntimeDelay(entry)) return;
const row: string[] = [];
// @ts-expect-error -- not sure how to type this
fieldOrder.forEach((field) => row.push(parseField(field, entry[field])));
fieldOrder.forEach((field) => {
if (isOntimeEvent(entry)) {
// for custom fields, we need to extract the value from the custom object
if (field.startsWith('custom-')) {
const fieldLabel = field.split('custom-')[1];
const value = entry.custom[fieldLabel]?.value;
row.push(parseField(fieldLabel, value));
} else {
// @ts-expect-error -- it is ok, we will just not have the data for other fields
row.push(parseField(field, entry[field]));
}
return;
}
// @ts-expect-error -- it is ok, we will just not have the data for other fields
row.push(parseField(field, entry[field]));
});
data.push(row);
});
@@ -106,10 +106,10 @@ export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, userF
/**
* @description Converts an array of arrays to a csv file
* @param {array[]} arrayOfArrays
* @param {string[][]} arrayOfArrays
* @return {string}
*/
export const makeCSV = (arrayOfArrays: string[][]) => {
export const makeCSV = (arrayOfArrays: string[][]): string => {
const stringifiedData = stringify(arrayOfArrays);
return stringifiedData;
};
+1 -24
View File
@@ -10,33 +10,10 @@ export const defaultColumnOrder: OntimeEntryCommonKeys[] = [
'timeEnd',
'duration',
'title',
'subtitle',
'presenter',
'note',
'user0',
'user1',
'user2',
'user3',
'user4',
'user5',
'user6',
'user7',
'user8',
'user9',
];
/**
* @description set default hidden columns
*/
export const defaultHiddenColumns: (keyof OntimeEvent)[] = [
'user0',
'user1',
'user2',
'user3',
'user4',
'user5',
'user6',
'user7',
'user8',
'user9',
];
export const defaultHiddenColumns: (keyof OntimeEvent)[] = [];
@@ -1,231 +1,64 @@
@use '../../theme/ontimeColours' as *;
@use '../../theme/v2Styles' as *;
@use './EditorMixin' as editor;
$menu-width: 2.75rem;
$rundown-width: 44.5rem;
$playback-width: 26rem;
@mixin absolute-top-right($distance) {
position: absolute;
top: $distance;
right: $distance;
cursor: pointer;
color: $ui-white;
transition-property: color;
transition-duration: $transition-time-action;
&:hover {
color: $ontime-color;
}
}
$min-playback-width: 27rem;
$max-playback-width: 35rem;
$panel-gap: 0.5rem;
.corner {
display: none;
@include absolute-top-right(0.5rem);
transform: rotate(45deg);
@include editor.corner;
}
.mainContainer {
background: $ui-black;
width: 100%;
height: 100%;
margin: auto;
color: $ui-white;
padding: 1rem 0.5rem;
font-family: $ontime-font-family;
display: grid;
grid-template-rows: auto 1fr;
grid-template-columns: $menu-width $rundown-width $playback-width auto;
grid-template-columns: auto;
grid-template-rows: 3rem 1fr;
grid-template-areas:
'sett rundown play info'
'sett rundown mess info';
gap: 0.5rem;
'overview'
'main';
gap: $panel-gap;
}
.panelContainer {
grid-area: main;
display: flex;
gap: $panel-gap;
overflow: hidden;
.corner {
/* we show this if the component hasnt been extracted */
display: inline;
}
.rundown,
.playback,
.messages,
.info,
.settings {
.messages {
position: relative;
.corner {
display: inline;
}
}
}
/* 2/3 window, hide info */
@media (max-width: 1450px) and (min-height: 700px) {
.mainContainer {
height: 100%;
grid-template-rows: auto 1fr;
grid-template-columns: $menu-width 1fr $playback-width;
.info {
visibility: hidden;
}
}
}
/* 1/2 window, event list only */
@media (max-width: 1100px) {
.mainContainer {
height: 100%;
grid-template-rows: 100%;
grid-template-columns: $menu-width $rundown-width;
grid-template-areas:
'sett rundown';
.info,
.messages,
.playback {
visibility: hidden;
}
}
}
/* 1/3 window, show control only */
@media (max-width: 850px) and (min-height: 500px) {
.mainContainer {
grid-template-rows: auto 1fr;
grid-template-columns: 100%;
grid-template-areas:
'play'
'mess';
.playback,
.messages {
visibility: visible;
}
.rundown,
.info,
.settings {
visibility: hidden;
}
}
}
/* 1/3 corner window, playback only */
@media (max-width: 850px) and (max-height: 500px) {
.mainContainer {
grid-template-rows: 100%;
grid-template-columns: 100%;
grid-template-areas: 'play';
.playback {
visibility: visible;
}
.rundown,
.messages,
.info,
.settings {
visibility: hidden;
}
}
}
.mainContainer {
.settings,
.rundown,
.messages,
.playback,
.info {
border-radius: 8px;
height: 100%;
background-color: $bg-container-l2;
padding: 1rem;
display: flex;
flex-direction: column;
}
}
.eventEditor {
border-radius: 8px 8px 0 0;
background-color: $bg-container-l2;
box-shadow: $large-bottom-drawer-shadow;
border-top: 1px solid $white-20;
position: absolute;
bottom: 0;
width: 100vw;
left: 0;
z-index: 10;
color: white;
transition: bottom $transition-time-feedback;
&.noEvent {
bottom: -500px;
transition: bottom 0.7s;
}
.eventEditorLayout {
display: flex;
}
.header {
background-color: $gray-1250;
padding: 0.5rem;
border-left: 1px solid $white-10;
border-radius: 0 8px 0 0;
}
}
.rundown {
grid-area: rundown;
height: 100%;
.content {
height: calc(100% - 1.5rem);
overflow: hidden;
}
}
.info {
grid-area: info;
min-width: 17rem;
max-width: 800px;
.content {
display: flex;
flex-direction: column;
overflow: hidden;
}
.left {
flex: 1 2 auto; /* flex-grow: 1, flex-shrink: 2, flex-basis: auto */
min-width: $min-playback-width;
max-width: $max-playback-width;
display: flex;
flex-direction: column;
gap: $panel-gap;
}
.messages {
grid-area: mess;
min-width: 24rem;
}
.playback {
grid-area: play;
max-height: 380px;
min-width: 26rem;
}
.mainContainer > .settings {
grid-area: sett;
background-color: transparent;
margin: 0;
padding: 0 0.5rem 0 0;
width: fit-content;
display: flex;
flex-direction: column;
}
.mainContainer > .rundown {
padding: 1rem 0;
flex: 1;
}
.content {
padding-top: 1.5rem;
}
.rundown {
.content {
padding-top: 0.5rem;
}
}
+81 -61
View File
@@ -1,77 +1,97 @@
import { lazy, useEffect } from 'react';
import { useDisclosure } from '@chakra-ui/react';
import { lazy, useCallback, useEffect } from 'react';
import { IconButton, useDisclosure } from '@chakra-ui/react';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import MenuBar from '../menu/MenuBar';
import AboutModal from '../modals/about-modal/AboutModal';
import QuickStart from '../modals/quick-start/QuickStart';
import SheetsModal from '../modals/sheets-modal/SheetsModal';
import UploadModal from '../modals/upload-modal/UploadModal';
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
import useElectronEvent from '../../common/hooks/useElectronEvent';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import AppSettings from '../app-settings/AppSettings';
import useAppSettingsNavigation from '../app-settings/useAppSettingsNavigation';
import { EditorOverview } from '../overview/Overview';
import styles from './Editor.module.scss';
const Rundown = lazy(() => import('../rundown/RundownExport'));
const TimerControl = lazy(() => import('../control/playback/TimerControlExport'));
const MessageControl = lazy(() => import('../control/message/MessageControlExport'));
const Info = lazy(() => import('../info/InfoExport'));
const EventEditor = lazy(() => import('../event-editor/EventEditorExport'));
const IntegrationModal = lazy(() => import('../modals/integration-modal/IntegrationModal'));
const SettingsModal = lazy(() => import('../modals/settings-modal/SettingsModal'));
export default function Editor() {
const { isOpen: isSettingsOpen, onOpen: onSettingsOpen, onClose: onSettingsClose } = useDisclosure();
const { isOpen: isUploadModalOpen, onOpen: onUploadModalOpen, onClose: onUploadModalClose } = useDisclosure();
const {
isOpen: isIntegrationModalOpen,
onOpen: onIntegrationModalOpen,
onClose: onIntegrationModalClose,
} = useDisclosure();
const { isOpen: isAboutModalOpen, onOpen: onAboutModalOpen, onClose: onAboutModalClose } = useDisclosure();
const { isOpen: isQuickStartOpen, onOpen: onQuickStartOpen, onClose: onQuickStartClose } = useDisclosure();
const { isOpen: isSheetsOpen, onOpen: onSheetsOpen, onClose: onSheetsClose } = useDisclosure();
const { isOpen: isSettingsOpen, setLocation, close } = useAppSettingsNavigation();
const { isElectron } = useElectronEvent();
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
// Set window title
const toggleSettings = useCallback(() => {
if (isSettingsOpen) {
close();
} else {
setLocation('project');
}
}, [close, isSettingsOpen, setLocation]);
// Handle keyboard shortcuts
const handleKeyPress = useCallback(
(event: KeyboardEvent) => {
// handle held key
if (event.repeat) return;
// check if the ctrl key is pressed
if (event.ctrlKey || event.metaKey) {
// ctrl + , (settings)
if (event.key === ',') {
toggleSettings();
event.preventDefault();
event.stopPropagation();
}
}
},
[toggleSettings],
);
// register ctrl + , to open settings
useEffect(() => {
document.title = 'ontime - Editor';
}, []);
if (isElectron) {
document.addEventListener('keydown', handleKeyPress);
}
return () => {
if (isElectron) {
document.removeEventListener('keydown', handleKeyPress);
}
};
}, [handleKeyPress, isElectron]);
useWindowTitle('Editor');
return (
<>
<ErrorBoundary>
<QuickStart onClose={onQuickStartClose} isOpen={isQuickStartOpen} />
<UploadModal onClose={onUploadModalClose} isOpen={isUploadModalOpen} />
<IntegrationModal onClose={onIntegrationModalClose} isOpen={isIntegrationModalOpen} />
<AboutModal onClose={onAboutModalClose} isOpen={isAboutModalOpen} />
<SettingsModal isOpen={isSettingsOpen} onClose={onSettingsClose} />
<SheetsModal onClose={onSheetsClose} isOpen={isSheetsOpen} />
</ErrorBoundary>
<div className={styles.mainContainer} data-testid='event-editor'>
<div id='settings' className={styles.settings}>
<ErrorBoundary>
<MenuBar
onSettingsOpen={onSettingsOpen}
isSettingsOpen={isSettingsOpen}
onSettingsClose={onSettingsClose}
isUploadOpen={isUploadModalOpen}
onUploadOpen={onUploadModalOpen}
isIntegrationOpen={isIntegrationModalOpen}
onIntegrationOpen={onIntegrationModalOpen}
isAboutOpen={isAboutModalOpen}
onAboutOpen={onAboutModalOpen}
isQuickStartOpen={isQuickStartOpen}
onQuickStartOpen={onQuickStartOpen}
isSheetsOpen={isSheetsOpen}
onSheetsOpen={onSheetsOpen}
/>
</ErrorBoundary>
<div className={styles.mainContainer} data-testid='event-editor'>
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
<EditorOverview>
<IconButton
aria-label='Toggle navigation'
variant='ontime-subtle-white'
size='lg'
icon={<IoApps />}
onClick={onOpen}
/>
<IconButton
aria-label='Toggle settings'
variant='ontime-subtle-white'
size='lg'
icon={<IoSettingsOutline />}
onClick={toggleSettings}
/>
</EditorOverview>
{isSettingsOpen ? (
<AppSettings />
) : (
<div id='panels' className={styles.panelContainer}>
<div className={styles.left}>
<TimerControl />
<MessageControl />
</div>
<Rundown />
</div>
<Rundown />
<MessageControl />
<TimerControl />
<Info />
</div>
<EventEditor />
</>
)}
</div>
);
}
@@ -0,0 +1,37 @@
@use '../../theme/ontimeColours' as *;
@use '../../theme/ontimeStyles' as *;
@mixin absolute-top-right($distance) {
position: absolute;
top: $distance;
right: $distance;
cursor: pointer;
color: $ui-white;
transition-property: color;
transition-duration: $transition-time-action;
&:hover {
color: $ontime-color;
}
}
@mixin corner() {
display: none;
@include absolute-top-right(0.5rem);
transform: rotate(45deg);
}
@mixin panel() {
display: flex;
position: relative;
border-radius: 8px;
height: 100%;
background-color: $bg-container-l2;
padding: 1rem;
.corner {
/* we show this if the component hasnt been extracted */
display: inline;
}
}
@@ -1,107 +0,0 @@
@use '../../theme/v2Styles' as *;
.eventEditor {
padding: 1rem 2rem 2rem 2rem;
width: 100%;
gap: max(1rem, 2vh);
display: grid;
grid-template-areas: 'time left right';
grid-template-columns: auto 1fr 1fr;
}
.timeOptions {
grid-area: time;
display: flex;
gap: 1.5rem;
.timers,
.timeSettings {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
}
.left,
.right {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.left {
grid-area: left;
padding: 0 1rem;
border-left: 1px solid $border-color-ondark;
}
.right {
padding-left: 1rem;
grid-area: right;
border-left: 1px solid $border-color-ondark;
}
@mixin input-label() {
font-size: calc(1rem - 3px);
color: $label-gray;
}
.countedInput {
display: flex;
justify-content: space-between;
@include input-label;
}
.inputLabel {
display: block;
@include input-label;
&.delayLabel {
color: $ontime-delay-text;
}
&.publicToggle {
height: 2rem;
display: flex;
align-items: center;
justify-items: center;
gap: 0.5rem;
}
}
.eventActions {
margin-left: auto;
display: flex;
gap: 0.5rem;
}
.spacer {
height: 1.25rem;
}
.inline {
display: flex;
align-items: center;
gap: 1rem;
}
.splitTwo {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.column {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.padTop {
margin-top: 0.5rem;
}
.fullHeight {
height: 100%
}
@@ -1,81 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { isOntimeEvent, OntimeEvent } from 'ontime-types';
import CopyTag from '../../common/components/copy-tag/CopyTag';
import { useEventAction } from '../../common/hooks/useEventAction';
import useRundown from '../../common/hooks-query/useRundown';
import { useAppMode } from '../../common/stores/appModeStore';
import EventEditorDataLeft from './composite/EventEditorDataLeft';
import EventEditorDataRight from './composite/EventEditorDataRight';
import EventEditorTimes from './composite/EventEditorTimes';
import style from './EventEditor.module.scss';
export type EventEditorSubmitActions = keyof OntimeEvent;
export type EditorUpdateFields = 'cue' | 'title' | 'presenter' | 'subtitle' | 'note' | 'colour';
export default function EventEditor() {
const openId = useAppMode((state) => state.editId);
const { data } = useRundown();
const { updateEvent } = useEventAction();
const [event, setEvent] = useState<OntimeEvent | null>(null);
useEffect(() => {
if (!data || !openId) {
setEvent(null);
return;
}
const event = data.find((event) => event.id === openId);
if (event && isOntimeEvent(event)) {
setEvent(event);
}
}, [data, openId]);
const handleSubmit = useCallback(
(field: EditorUpdateFields, value: string) => {
updateEvent({ id: event?.id, [field]: value });
},
[event?.id, updateEvent],
);
if (!event) {
return <span>Loading...</span>;
}
return (
<div className={style.eventEditor}>
<EventEditorTimes
eventId={event.id}
timeStart={event.timeStart}
timeEnd={event.timeEnd}
duration={event.duration}
delay={event.delay ?? 0}
isPublic={event.isPublic}
endAction={event.endAction}
timerType={event.timerType}
/>
<EventEditorDataLeft
key={`${event.id}-left`}
eventId={event.id}
cue={event.cue}
title={event.title}
presenter={event.presenter}
subtitle={event.subtitle}
handleSubmit={handleSubmit}
/>
<EventEditorDataRight
key={`${event.id}-right`}
note={event.note}
colour={event.colour}
handleSubmit={handleSubmit}
>
<CopyTag label='Event ID'>{event.id}</CopyTag>
<CopyTag label='OSC trigger by id'>{`/ontime/gotoid "${event.id}"`}</CopyTag>
<CopyTag label='OSC trigger by cue'>{`/ontime/gotocue "${event.cue}"`}</CopyTag>
</EventEditorDataRight>
</div>
);
}
@@ -1,42 +0,0 @@
import { memo } from 'react';
import { IconButton } from '@chakra-ui/react';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import { useAppMode } from '../../common/stores/appModeStore';
import { cx } from '../../common/utils/styleUtils';
import EventEditor from './EventEditor';
import style from '../editors/Editor.module.scss';
/* Styling for action buttons */
const closeBtnStyle = {
size: 'md',
variant: 'ghost',
colorScheme: 'white',
_hover: { bg: '#ebedf0', color: '#333' },
};
const EventEditorExport = () => {
const editId = useAppMode((state) => state.editId);
const setEditId = useAppMode((state) => state.setEditId);
const editorStyle = cx([style.eventEditor, !editId ? style.noEvent : null]);
const removeOpenEvent = () => setEditId(null);
return (
<div className={editorStyle}>
<ErrorBoundary>
<div className={style.eventEditorLayout}>
<EventEditor />
<div className={style.header}>
<IconButton aria-label='Close Menu' icon={<IoClose />} onClick={removeOpenEvent} {...closeBtnStyle} />
</div>
</div>
</ErrorBoundary>
</div>
);
};
export default memo(EventEditorExport);
@@ -1,54 +0,0 @@
import { memo } from 'react';
import { Input } from '@chakra-ui/react';
import { sanitiseCue } from 'ontime-utils';
import { type EditorUpdateFields } from '../EventEditor';
import CountedTextInput from './CountedTextInput';
import style from '../EventEditor.module.scss';
interface EventEditorLeftProps {
eventId: string;
cue: string;
title: string;
presenter: string;
subtitle: string;
handleSubmit: (field: EditorUpdateFields, value: string) => void;
}
const EventEditorDataLeft = (props: EventEditorLeftProps) => {
const { eventId, cue, title, presenter, subtitle, handleSubmit } = props;
const cueSubmitHandler = (_field: string, newValue: string) => {
handleSubmit('cue', sanitiseCue(newValue));
};
return (
<div className={style.left}>
<div className={style.splitTwo}>
<div className={style.column}>
<div className={style.countedInput}>
<label className={style.inputLabel} htmlFor='eventId'>
Event ID (read only)
</label>
</div>
<Input
id='eventId'
size='sm'
variant='ontime-filled'
data-testid='input-textfield'
value={eventId}
readOnly
/>
</div>
<CountedTextInput field='cue' label='Cue' initialValue={cue} submitHandler={cueSubmitHandler} maxLength={10} />
</div>
<CountedTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} />
<CountedTextInput field='presenter' label='Presenter' initialValue={presenter} submitHandler={handleSubmit} />
<CountedTextInput field='subtitle' label='Subtitle' initialValue={subtitle} submitHandler={handleSubmit} />
</div>
);
};
export default memo(EventEditorDataLeft);
@@ -1,33 +0,0 @@
import { memo, PropsWithChildren } from 'react';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import { EditorUpdateFields } from '../EventEditor';
import CountedTextArea from './CountedTextArea';
import style from '../EventEditor.module.scss';
interface EventEditorRightProps {
note: string;
colour: string;
handleSubmit: (field: EditorUpdateFields, value: string) => void;
}
const EventEditorDataRight = (props: PropsWithChildren<EventEditorRightProps>) => {
const { children, note, colour, handleSubmit } = props;
return (
<div className={style.right}>
<div className={style.column}>
<label className={style.inputLabel}>Colour</label>
<div className={style.inline}>
<SwatchSelect name='colour' value={colour} handleChange={handleSubmit} />
</div>
</div>
<CountedTextArea field='note' label='Note' initialValue={note} submitHandler={handleSubmit} />
<div className={style.eventActions}>{children}</div>
</div>
);
};
export default memo(EventEditorDataRight);
@@ -1,144 +0,0 @@
import { memo } from 'react';
import { Select, Switch } from '@chakra-ui/react';
import { EndAction, OntimeEvent, TimerType } from 'ontime-types';
import { calculateDuration, dayInMs, millisToString } from 'ontime-utils';
import TimeInput from '../../../common/components/input/time-input/TimeInput';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { millisToDelayString } from '../../../common/utils/dateConfig';
import { cx } from '../../../common/utils/styleUtils';
import style from '../EventEditor.module.scss';
interface EventEditorTimesProps {
eventId: string;
timeStart: number;
timeEnd: number;
duration: number;
delay: number;
isPublic: boolean;
endAction: EndAction;
timerType: TimerType;
}
type TimeActions = 'timeStart' | 'timeEnd' | 'durationOverride' | 'timerType' | 'endAction' | 'isPublic';
// Todo: add previous end to TimeInput fields
const EventEditorTimes = (props: EventEditorTimesProps) => {
const { eventId, timeStart, timeEnd, duration, delay, isPublic, endAction, timerType } = props;
const { updateEvent } = useEventAction();
const handleSubmit = (field: TimeActions, value: number | string | boolean) => {
const newEventData: Partial<OntimeEvent> = { id: eventId };
switch (field) {
case 'durationOverride': {
// duration defines timeEnd
newEventData.duration = value as number;
newEventData.timeEnd = timeStart + ((value as number) % dayInMs);
break;
}
case 'timeStart': {
newEventData.duration = calculateDuration(value as number, timeEnd);
newEventData.timeStart = value as number;
break;
}
case 'timeEnd': {
newEventData.duration = calculateDuration(timeStart, value as number);
newEventData.timeEnd = value as number;
break;
}
case 'isPublic': {
updateEvent({ id: eventId, isPublic: !(value as boolean) });
break;
}
default: {
if (field === 'timerType' || field === 'endAction') {
// @ts-expect-error -- not sure how to typecheck here
newEventData[field as keyof OntimeEvent] = value as string;
} else {
return;
}
}
}
updateEvent(newEventData);
};
const delayTime = delay !== 0 ? millisToDelayString(delay) : null;
const startLabel = delayTime ? `New start ${millisToString(timeStart + delay)}` : 'Start time';
const endLabel = delayTime ? `New end ${millisToString(timeEnd + delay)}` : 'End time';
const inputTimeLabels = cx([style.inputLabel, delayTime ? style.delayLabel : null]);
return (
<div className={style.timeOptions}>
<div className={style.timers}>
<label className={inputTimeLabels} htmlFor='timeStart'>
{startLabel}
</label>
<TimeInput
id='timeStart'
name='timeStart'
submitHandler={handleSubmit}
time={timeStart}
delay={delay}
placeholder='Start'
/>
<label className={inputTimeLabels} htmlFor='timeEnd'>
{endLabel}
</label>
<TimeInput
id='timeEnd'
name='timeEnd'
submitHandler={handleSubmit}
time={timeEnd}
delay={delay}
placeholder='End'
/>
<label className={style.inputLabel} htmlFor='durationOverride'>
Duration
</label>
<TimeInput
id='durationOverride'
name='durationOverride'
submitHandler={handleSubmit}
time={duration}
placeholder='Duration'
/>
</div>
<div className={style.timeSettings}>
<label className={style.inputLabel}>Timer Type</label>
<Select
size='sm'
name='timerType'
value={timerType}
onChange={(event) => handleSubmit('timerType', event.target.value)}
variant='ontime'
>
<option value={TimerType.CountDown}>Count down</option>
<option value={TimerType.CountUp}>Count up</option>
<option value={TimerType.TimeToEnd}>Time to end</option>
<option value={TimerType.Clock}>Clock</option>
</Select>
<label className={style.inputLabel}>End Action</label>
<Select
size='sm'
name='endAction'
value={endAction}
onChange={(event) => handleSubmit('endAction', event.target.value)}
variant='ontime'
>
<option value={EndAction.None}>None</option>
<option value={EndAction.Stop}>Stop</option>
<option value={EndAction.LoadNext}>Load Next</option>
<option value={EndAction.PlayNext}>Play Next</option>
</Select>
<span className={style.spacer} />
<label className={`${style.inputLabel} ${style.publicToggle}`}>
<Switch isChecked={isPublic} onChange={() => handleSubmit('isPublic', isPublic)} variant='ontime' />
Event is public
</label>
</div>
</div>
);
};
export default memo(EventEditorTimes);
@@ -1,19 +0,0 @@
import { PropsWithChildren, useState } from 'react';
import CollapseBar from '../../common/components/collapse-bar/CollapseBar';
interface CollapsableInfoProps {
title: string;
}
export default function CollapsableInfo(props: PropsWithChildren<CollapsableInfoProps>) {
const { title, children } = props;
const [collapsed, setCollapsed] = useState(false);
return (
<>
<CollapseBar title={title} isCollapsed={collapsed} onClick={() => setCollapsed((prev) => !prev)} />
{!collapsed && children}
</>
);
}
@@ -1,65 +0,0 @@
@use '../../theme/mixins' as *;
@use '../../theme/v2Styles' as *;
.panelHeader {
display: flex;
justify-content: space-between;
gap: 1rem;
font-size: $inner-section-text-size;
.title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.selected {
min-width: max-content;
color: $label-gray;
}
}
.description {
font-size: $inner-section-text-size;
color: $label-gray;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.labels {
font-size: $inner-section-text-size;
display: flex;
flex-direction: column;
gap: $element-inner-spacing;
}
.label {
color: $section-white;
}
.content {
color: $secondary-text-gray;
margin-left: $element-inner-spacing;
font-size: $text-body-size;
}
.interfaceList {
display: flex;
flex-wrap: wrap;
gap: $section-spacing;
row-gap: $element-inner-spacing;
.interface {
@include action-link;
font-size: $inner-section-text-size;
white-space: nowrap;
}
.linkIcon {
margin-left: $element-inner-spacing;
display: inline-block;
transform: rotate(45deg);
}
}

Some files were not shown because too many files have changed in this diff Show More