Compare commits

..

1 Commits

Author SHA1 Message Date
arc-alex 905db382cd refactor: titlecard component 2026-07-17 19:37:42 +02:00
196 changed files with 4508 additions and 7000 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.12.0",
"version": "4.10.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+7 -8
View File
@@ -1,10 +1,10 @@
{
"name": "ontime-ui",
"version": "4.12.0",
"version": "4.10.0",
"private": true,
"type": "module",
"dependencies": {
"@base-ui/react": "1.7.0",
"@base-ui/react": "1.6.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/state": "^6.0.0",
@@ -13,7 +13,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.7",
"@mantine/hooks": "^9.5.1",
"@mantine/hooks": "^8.3.7",
"@sentry/react": "^10.59.0",
"@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.101.0",
@@ -29,7 +29,7 @@
"react-dom": "^19.2.7",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.80.0",
"react-icons": "5.7.0",
"react-icons": "5.6.0",
"react-router": "^8.0.1",
"react-virtuoso": "^4.18.7",
"zustand": "^5.0.14"
@@ -60,8 +60,7 @@
]
},
"devDependencies": {
"@sentry/vite-plugin": "5.4.0",
"@types/node": "catalog:",
"@sentry/vite-plugin": "5.1.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
@@ -73,8 +72,8 @@
"ontime-utils": "workspace:*",
"sass": "^1.57.1",
"typescript": "catalog:",
"vite": "8.2.1",
"vite-plugin-compression2": "2.5.3",
"vite": "8.0.1",
"vite-plugin-compression2": "2.5.1",
"vite-plugin-svgr": "4.5.0",
"vitest": "catalog:"
}
-17
View File
@@ -6,7 +6,6 @@ import {
ProjectRundownsList,
RenumberCues,
Rundown,
RundownImportPayload,
TransientEventPayload,
} from 'ontime-types';
@@ -83,15 +82,6 @@ export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse
return axios.delete(`${rundownPath}/${rundownId}`);
}
/**
* HTTP request to apply an imported rundown using a merge strategy or into a new rundown
*/
export async function importRundownWithOptions(
payload: RundownImportPayload,
): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/import`, payload);
}
// #endregion operations on project rundowns ======================
// #region operations on rundown entries ==========================
@@ -176,13 +166,6 @@ export async function postCloneEntry(
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
}
/**
* HTTP request events duration to fit inside the group target
*/
export async function requestFitGroupTarget(rundownId: RundownId, eventId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/${eventId}/fit-group-duration`);
}
/**
* HTTP request for grouping a list of entries into a group
*/
@@ -1,82 +1,24 @@
.section {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem;
background-color: $gray-1300;
border: 1px solid $gray-1100;
border-radius: $component-border-radius-md;
}
.sectionHeader {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.separator {
display: flex;
align-items: center;
gap: 0.75rem;
color: $gray-500;
font-size: $inner-section-text-size;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
&::before,
&::after {
content: '';
flex: 1;
border-top: 1px solid $gray-1100;
}
}
.textEntry {
grid-area: input;
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
color: $label-gray;
}
.inlineEntry {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.75rem;
grid-template-areas:
'label label'
'input btn';
grid-template-columns: 1fr auto;
column-gap: 0.5rem;
}
.redirect {
margin-left: auto;
grid-area: btn;
}
.label {
color: $gray-200;
font-size: $text-body-size;
font-weight: 600;
}
.description {
color: $gray-500;
font-size: $inner-section-text-size;
}
.origin {
flex: 0 0 auto;
color: $gray-500;
font-size: $inner-section-text-size;
white-space: nowrap;
}
@media (max-width: $min-tablet) {
.inlineEntry {
grid-template-columns: 1fr;
align-items: stretch;
}
.redirect {
align-self: flex-end;
}
color: $label-gray;
}
@@ -1,7 +1,6 @@
import { useState } from 'react';
import { IoArrowForward } from 'react-icons/io5';
import { baseURI } from '../../../externals';
import { navigatorConstants } from '../../../viewerConfig';
import useUrlPresets from '../../hooks-query/useUrlPresets';
import { setClientRemote } from '../../hooks/useSocket';
@@ -9,7 +8,7 @@ import Button from '../buttons/Button';
import Dialog from '../dialog/Dialog';
import Info from '../info/Info';
import Input from '../input/input/Input';
import ExternalLink from '../link/external-link/ExternalLink';
import AppLink from '../link/app-link/AppLink';
import Select from '../select/Select';
import style from './RedirectClientModal.module.scss';
@@ -45,7 +44,6 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
};
const enabledPresets = data.filter((preset) => preset.enabled);
const settingsUrl = `${window.location.origin}${baseURI}/?settings=sharing__presets`;
const viewOptions = [
...navigatorConstants.map((view) => ({
@@ -68,26 +66,21 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
bodyElements={
<>
<Info>
<Info.Body>
Remotely redirect the client to a different URL. Choose a custom path or select a URL Preset.
</Info.Body>
<Info.Footer>
<ExternalLink href={settingsUrl}>Manage URL Presets</ExternalLink>
</Info.Footer>
Remotely redirect the client to a different URL. <br />
Either by entering a custom path or selecting a URL Preset.
<br />
<br />
<AppLink search='settings=sharing__presets'>Manage URL Presets</AppLink>
</Info>
<section className={style.section}>
<div className={style.sectionHeader}>
<span className={style.label}>Enter custom path</span>
<span className={style.description}>Type the path the client should open.</span>
</div>
<div className={style.inlineEntry}>
<span className={style.label}>Enter custom path</span>
<label className={style.textEntry}>
<span className={style.origin}>{origin}</span>
{origin}
<Input placeholder='eg. /timer' fluid value={path} onChange={(event) => setPath(event.target.value)} />
</label>
<Button
variant='primary'
aria-label='Redirect to custom path'
aria-label='Redirect'
disabled={path === currentPath || path === ''}
className={style.redirect}
onClick={() => handleRedirect(path)}
@@ -96,18 +89,11 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
<IoArrowForward />
</Button>
</div>
</section>
<div className={style.separator} role='separator'>
<span>Or</span>
</div>
<section className={style.section}>
<div className={style.sectionHeader}>
<span className={style.label}>Select view or URL Preset</span>
<span className={style.description}>Choose from the available destinations.</span>
</div>
<div>
<span className={style.label}>Select View or URL Preset</span>
<div className={style.inlineEntry}>
<label className={style.textEntry}>
<span className={style.origin}>{origin}</span>
{origin}
<Select
fluid
options={viewOptions}
@@ -129,7 +115,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
Redirect <IoArrowForward />
</Button>
</div>
</section>
</div>
</>
}
/>
@@ -1,13 +0,0 @@
@use '../../../theme/ontimeStyles' as *;
.form {
padding: 0.25rem 0.5rem 0.5rem;
}
.label {
display: flex;
flex-direction: column;
gap: 0.5rem;
color: $label-gray;
font-size: $inner-section-text-size;
}
@@ -5,8 +5,6 @@ import { setClientRemote } from '../../hooks/useSocket';
import Dialog from '../dialog/Dialog';
import Input from '../input/input/Input';
import style from './RenameClientModal.module.scss';
interface RenameClientModalProps {
id: string;
name?: string;
@@ -33,22 +31,9 @@ export function RenameClientModal({ id, name: currentName = '', isOpen, onClose
isOpen={isOpen}
title={`Rename Client: ${currentName}`}
showCloseButton
showBackdrop
onClose={onClose}
bodyElements={
<div className={style.form}>
<label className={style.label}>
Client name
<Input
height='large'
fluid
autoComplete='off'
placeholder='New name'
value={name}
onChange={(event) => setName(event.target.value)}
/>
</label>
</div>
<Input height='large' placeholder='New name' value={name} onChange={(event) => setName(event.target.value)} />
}
footerElements={
<>
@@ -1,16 +1,12 @@
.dialog {
position: fixed;
top: 10vh;
top: 10%;
left: 50%;
transform: translateX(-50%);
box-sizing: border-box;
width: min(600px, 90vw);
max-height: calc(90vh - 1rem);
display: flex;
flex-direction: column;
padding-inline: 1rem;
min-width: min(600px, 90vw);
background-color: $gray-1250;
color: $ui-white;
@@ -47,8 +43,7 @@
flex-direction: column;
gap: 0.5rem;
flex: 1;
min-height: 0;
max-height: min(80vh, 600px);
overflow-y: auto;
}
@@ -58,5 +53,4 @@
align-items: center;
justify-content: end;
gap: 1rem;
flex-wrap: wrap;
}
@@ -21,15 +21,4 @@
outline: none;
box-shadow: 0 1px 0 0 currentColor;
}
&:hover .icon,
&:focus-visible .icon {
transform: translateX(0.25rem);
}
}
.icon {
flex: 0 0 1.5em;
font-size: 1em;
transition: transform $transition-time-action;
}
@@ -1,5 +1,5 @@
import { MouseEvent, ReactNode } from 'react';
import { HiArrowLongRight } from 'react-icons/hi2';
import { IoOpenOutline } from 'react-icons/io5';
import { openLink } from '../../../utils/linkUtils';
import { cx } from '../../../utils/styleUtils';
@@ -10,10 +10,9 @@ interface ExternalLinkProps {
href: string;
children: ReactNode;
inline?: boolean;
className?: string;
}
export default function ExternalLink({ href, inline, className, children }: ExternalLinkProps) {
export default function ExternalLink({ href, inline, children }: ExternalLinkProps) {
const handleClick = (event: MouseEvent) => {
event.preventDefault();
openLink(href);
@@ -24,11 +23,10 @@ export default function ExternalLink({ href, inline, className, children }: Exte
href='#!'
target='_blank'
rel='noreferrer'
className={cx([style.link, inline && style.inline, className])}
className={cx([style.link, inline && style.inline])}
onClick={handleClick}
>
{children}
<HiArrowLongRight className={style.icon} />
{children} <IoOpenOutline style={{ fontSize: '1em' }} />
</a>
);
}
@@ -4,13 +4,10 @@
left: 50%;
transform: translateX(-50%);
box-sizing: border-box;
width: min(880px, 90vw);
padding-inline: 1rem;
min-width: min(880px, 90vw);
min-height: min(200px, 10vh);
max-height: calc(90vh - 1rem);
display: flex;
flex-direction: column;
max-width: min(1200px, 90vw);
background-color: $gray-1250;
color: $ui-white;
@@ -20,21 +17,14 @@
outline: none;
}
.compact {
min-width: 0;
width: min(680px, 90vw);
}
.small {
min-width: 0;
width: min(480px, 90vw);
}
.wide {
top: 4vh;
width: min(1800px, 98vw);
height: min(88vh, calc(96vh - 1rem));
min-height: 0;
min-width: min(1280px, 96vw);
max-width: min(1600px, 96vw);
height: 88vh;
max-height: 88vh;
display: flex;
flex-direction: column;
}
.backdrop {
@@ -51,19 +41,15 @@
.title {
padding-block: 1rem;
padding-inline: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.titleText {
min-width: 0;
font-size: 1rem;
font-weight: 600;
overflow-wrap: anywhere;
}
.body {
@@ -71,23 +57,21 @@
flex-direction: column;
gap: 0.5rem;
flex: 1;
min-height: 0;
padding: 0.5rem 1rem;
max-height: 60vh;
overflow-y: auto;
scroll-padding-block: 0.5rem;
}
.wide .body {
flex: 1;
min-height: 0;
max-height: none;
overflow: hidden;
}
.footer {
padding-block: 1rem;
padding-inline: 1rem;
display: flex;
align-items: center;
justify-content: end;
gap: 1rem;
flex-wrap: wrap;
}
@@ -12,7 +12,7 @@ interface ModalProps {
title?: string;
showCloseButton?: boolean;
showBackdrop?: boolean;
size?: 'small' | 'compact' | 'default' | 'wide';
size?: 'default' | 'wide';
bodyElements: ReactNode;
footerElements?: ReactNode;
onClose: () => void;
@@ -38,7 +38,7 @@ export default function Modal({
>
<BaseDialog.Portal>
{showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />}
<BaseDialog.Popup aria-label={title} className={cx([style.modal, style[size]])}>
<BaseDialog.Popup aria-label={title} className={cx([style.modal, size === 'wide' && style.wide])}>
<div className={style.title}>
{title ? <BaseDialog.Title className={style.titleText}>{title}</BaseDialog.Title> : <div />}
{showCloseButton && (
@@ -9,7 +9,6 @@ import './MultiPartProgressBar.scss';
interface MultiPartProgressBar {
now: MaybeNumber;
complete: MaybeNumber;
eventId?: string | null;
normalColor: string;
warning?: MaybeNumber;
warningColor: string;
@@ -25,7 +24,6 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
const {
now,
complete,
eventId,
normalColor,
warning,
warningColor,
@@ -37,7 +35,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
className = '',
} = props;
const percentRemaining = 100 - useAnimatedProgress(now, complete, eventId);
const percentRemaining = 100 - useAnimatedProgress(now, complete);
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
const isOvertime = now !== null && now < 0;
@@ -1,5 +1,5 @@
import { Dialog } from '@base-ui/react/dialog';
import { useDisclosure, useFullscreenDocument } from '@mantine/hooks';
import { useDisclosure, useFullscreen } from '@mantine/hooks';
import { memo } from 'react';
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
import { LuCoffee } from 'react-icons/lu';
@@ -33,7 +33,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
const isSmallScreen = useIsSmallScreen();
const [isRenameOpen, handlers] = useDisclosure(false);
const { fullscreen, toggle } = useFullscreenDocument();
const { fullscreen, toggle } = useFullscreen();
const { mirror, toggleMirror } = useViewOptionsStore();
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
const location = useLocation();
@@ -2,7 +2,7 @@ import { useDisclosure, useHotkeys } from '@mantine/hooks';
import { memo } from 'react';
import { useSearchParams } from 'react-router';
import { hasCustomParams } from '../../stores/savedViewParams';
import { hasCustomParams, useSavedViewParams } from '../../stores/savedViewParams';
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
import FloatingNavigation from './floating-navigation/FloatingNavigation';
import NavigationMenu from './NavigationMenu';
@@ -20,7 +20,8 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
const [isMenuOpen, menuHandler] = useDisclosure();
const { open: showEditFormDrawer } = useViewParamsEditorStore();
const [searchParams] = useSearchParams();
const hasSavedChanges = hasCustomParams(searchParams);
const savedParams = useSavedViewParams((store) => store.params);
const hasSavedChanges = hasCustomParams(searchParams) || Object.keys(savedParams).length > 0;
useHotkeys([
[
@@ -7,12 +7,12 @@ import './ProgressBar.scss';
interface ProgressBarProps {
current: MaybeNumber;
duration: MaybeNumber;
eventId?: string | null;
className?: string;
}
export default function ProgressBar({ current, duration, eventId, className }: ProgressBarProps) {
const progress = useAnimatedProgress(current, duration, eventId);
export default function ProgressBar(props: ProgressBarProps) {
const { current, duration, className } = props;
const progress = useAnimatedProgress(current, duration);
return (
<div className={`progress-bar__bg ${className}`}>
@@ -1,43 +1,20 @@
@use '@/theme/viewerDefs' as *;
.emptyContainer {
width: 100%;
text-align: center;
color: var(--secondary-color-override, $viewer-secondary-color);
color: $white-10;
.empty {
display: block;
width: min(100%, 14rem);
margin: 0 auto -1.5rem;
opacity: 0.6;
width: min(100%, 24rem);
margin-inline: auto;
opacity: 0.8;
}
.text {
display: block;
margin-inline: auto;
font-weight: 400;
font-size: clamp(1rem, 1.55vw, 1.5rem);
line-height: 1.35;
max-width: min(100%, 40rem);
}
&.error {
color: $error-red;
.empty {
opacity: 0.35;
filter: grayscale(1);
}
.text {
margin-top: 0.5rem;
}
}
.errorIcon {
display: block;
width: 1.5rem;
height: 1.5rem;
margin: -0.125rem auto 0;
font-weight: 600;
font-size: 2em;
max-width: min(100%, 600px);
}
}
@@ -1,5 +1,4 @@
import { CSSProperties } from 'react';
import { IoWarningOutline } from 'react-icons/io5';
import EmptyImage from '../../../assets/images/empty.svg?react';
import { cx } from '../../utils/styleUtils';
@@ -10,18 +9,12 @@ interface EmptyProps {
text?: string;
injectedStyles?: CSSProperties;
className?: string;
variant?: 'error';
}
export default function Empty({ text, className, injectedStyles, variant }: EmptyProps) {
export default function Empty({ text, className, injectedStyles }: EmptyProps) {
return (
<div
className={cx([style.emptyContainer, variant === 'error' && style.error, className])}
style={injectedStyles}
role={variant === 'error' ? 'alert' : undefined}
>
<div className={cx([style.emptyContainer, className])} style={injectedStyles}>
<EmptyImage className={style.empty} />
{variant === 'error' && <IoWarningOutline className={style.errorIcon} aria-hidden />}
{text && <span className={style.text}>{text}</span>}
</div>
);
@@ -1,11 +0,0 @@
.fill {
box-sizing: border-box;
width: 100%;
height: 100%;
min-height: 0;
flex: 1;
display: grid;
place-items: start center;
padding: clamp(4rem, 20dvh, 12rem) 1.5rem 1rem;
}
@@ -1,19 +0,0 @@
import { cx } from '../../utils/styleUtils';
import Empty from './Empty';
import style from './EmptyFill.module.scss';
interface EmptyFillProps {
text?: string;
/** placed on the fill wrapper — e.g. to assign a grid-area in a grid parent */
className?: string;
}
/** Container-filling empty/loading state for panels and grid/flex cells. */
export default function EmptyFill({ text, className }: EmptyFillProps) {
return (
<div className={cx([style.fill, className])}>
<Empty text={text} />
</div>
);
}
@@ -6,7 +6,7 @@
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100dvh;
height: 100vh;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color);
@@ -16,6 +16,5 @@
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-block: 5rem;
padding-top: 5rem;
}
@@ -7,13 +7,12 @@ import style from './EmptyPage.module.scss';
interface EmptyPageProps {
text?: string;
injectedStyles?: CSSProperties;
variant?: 'error';
}
export default function EmptyPage({ text, injectedStyles, variant }: EmptyPageProps) {
export default function EmptyPage({ text, injectedStyles }: EmptyPageProps) {
return (
<div className={style.page}>
<Empty text={text} injectedStyles={injectedStyles} variant={variant} />
<Empty text={text} injectedStyles={injectedStyles} />
</div>
);
}
@@ -15,4 +15,9 @@
gap: 1rem;
margin-top: 1em;
}
.text {
font-weight: 600;
font-size: 2em;
}
}
@@ -18,7 +18,8 @@ export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
<tbody className={style.emptyContainer}>
<tr>
<td colSpan={99} className={style.emptyCell}>
<Empty text={text} injectedStyles={{ marginTop: '5vh' }} />
<Empty injectedStyles={{ marginTop: '5vh' }} />
<span className={style.text}>{text}</span>
{handleAddNew && (
<div className={style.inline}>
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
@@ -11,11 +11,6 @@
color: $ui-white;
}
.active {
border: 1px solid $green-700;
color: $green-400;
}
.warning {
background-color: $orange-1300;
color: $orange-300;
@@ -6,7 +6,7 @@ import style from './Tag.module.scss';
interface TagProps {
className?: string;
variant?: 'default' | 'active' | 'warning';
variant?: 'default' | 'warning';
}
export default function Tag({ className, variant = 'default', children }: PropsWithChildren<TagProps>) {
@@ -4,31 +4,50 @@
position: relative;
display: flex;
flex-direction: column;
}
.title-card__title,
.title-card__placeholder {
font-weight: 600;
font-size: $title-font-size;
line-height: 1.2em;
background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding: $view-card-padding;
border-radius: $element-border-radius;
border-left: 1vw solid;
.title-card__title:empty::before {
color: var(--label-color-override, $viewer-label-color);
content: attr(data-placeholder);
}
.title-card__title {
color: var(--color-override, $viewer-color);
font-weight: 600;
line-height: 1.4em;
padding-right: 1em;
min-height: 1.2em;
}
.title-card__placeholder {
color: var(--label-color-override, $viewer-label-color);
color: var(--color-override, $viewer-color);
}
.title-card__secondary {
font-size: $base-font-size;
color: var(--secondary-color-override, $viewer-secondary-color);
line-height: 1.2em;
}
&.md {
.title-card__title {
font-size: $title-font-size;
}
.title-card__secondary {
font-size: $base-font-size;
}
}
&.lg {
.title-card__title {
font-size: $large-font-size;
}
.title-card__secondary {
font-size: $title-font-size;
}
.schedule__ {
font-size: $base-font-size;
}
}
.title-card__label {
position: absolute;
right: 1rem;
@@ -41,3 +60,4 @@
color: var(--accent-color-override, $accent-color);
}
}
}
@@ -1,33 +1,60 @@
import { ForwardedRef, forwardRef } from 'react';
import { OntimeEvent } from 'ontime-types';
import { useTranslation } from '../../../translation/TranslationProvider';
import { cx } from '../../utils/styleUtils';
import { ExtendedEntry } from '../../utils/rundownMetadata';
import { cx, enDash } from '../../utils/styleUtils';
import './TitleCard.scss';
interface TitleCardProps {
type TitleCardMainProps = {
title?: string;
label?: 'now' | 'next';
secondary?: string;
className?: string;
}
colour?: string;
textAlign?: 'left' | 'right' | 'center';
size?: 'md' | 'lg';
placeholder?: string;
};
type TitleCardExpectedProps = TitleCardMainProps & {
event: ExtendedEntry<OntimeEvent>;
expectedStart: number;
showExpected: boolean;
};
type TitleCardNoExpectedProps = TitleCardMainProps & {
event?: undefined;
expectedStart?: undefined;
showExpected?: false;
};
type TitleCardProps = TitleCardExpectedProps | TitleCardNoExpectedProps;
export default function TitleCard({
label,
title,
secondary,
className = '',
colour = 'transparent',
textAlign = 'left',
size = 'md',
placeholder = enDash,
}: TitleCardProps) {
'use memo';
const TitleCard = forwardRef((props: TitleCardProps, ref: ForwardedRef<HTMLDivElement>) => {
const { label, title, secondary, className = '' } = props;
const { getLocalizedString } = useTranslation();
const accent = label === 'now';
return (
<div className={cx(['title-card', className])} ref={ref}>
<span className='title-card__title'>{title}</span>
<div className={cx(['title-card', className, size])} style={{ borderColor: colour }}>
<span className='title-card__title' style={{ textAlign }} data-placeholder={placeholder}>
{title === '' ? null : title}
</span>
<span className={cx(['title-card__label', accent && 'title-card__label--accent'])}>
{label && getLocalizedString(`common.${label}`)}
</span>
<div className='title-card__secondary'>{secondary}</div>
</div>
);
});
TitleCard.displayName = 'TitleCard';
export default TitleCard;
}
@@ -1,34 +1,26 @@
import { EntryId, MaybeNumber, Playback } from 'ontime-types';
import { MaybeNumber, Playback } from 'ontime-types';
import { useEffect, useRef, useState } from 'react';
import { getProgress } from '../utils/getProgress';
import { useIsOnline, usePlayback } from './useSocket';
import { usePlayback } from './useSocket';
/**
* Returns the live completion percentage (0100) of a countdown, interpolated locally.
*/
export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber, eventId?: EntryId | null): number {
export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber): number {
const playback = usePlayback();
const isOnline = useIsOnline();
const isRunning = playback === Playback.Play || playback === Playback.Roll;
const baseline = useRef({ current, duration, eventId, playback, at: performance.now() });
const baseline = useRef({ current, at: performance.now() });
const [, setTick] = useState(0);
const now = performance.now();
const hasAuthoritativeUpdate =
baseline.current.current !== current || // handle timer updates
baseline.current.duration !== duration || // handle duration changes
baseline.current.eventId !== eventId || // handle event changing
baseline.current.playback !== playback; // handle playback changes
// there is only something to animate while a running timer is counting down towards 0
const shouldAnimate = isRunning && current !== null && current > 0 && duration !== null;
if (hasAuthoritativeUpdate) {
// Reset during render so an event change is reflected in this very paint.
baseline.current = { current, duration, eventId, playback, at: now };
}
// There is only something to animate while a connected timer is counting down towards 0.
const shouldAnimate = isOnline && isRunning && current !== null && current > 0 && duration !== null;
// re-anchor to the authoritative value whenever the server pushes a new timer update
useEffect(() => {
baseline.current = { current, at: performance.now() };
}, [current, duration, playback]);
// while counting down, re-render every animation frame so the derived progress stays smooth
useEffect(() => {
@@ -42,8 +34,8 @@ export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber,
return () => cancelAnimationFrame(frame);
}, [shouldAnimate]);
// Derive from the anchor plus elapsed time at render; freeze while disconnected or not running.
// derive from the anchor plus elapsed time at render; frozen to the anchor when not running
const anchored = baseline.current.current;
const value = isOnline && isRunning && anchored !== null ? anchored - (now - baseline.current.at) : anchored;
const value = isRunning && anchored !== null ? anchored - (performance.now() - baseline.current.at) : anchored;
return getProgress(value, duration);
}
+8 -37
View File
@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
EntryId,
InsertOptions,
MaybeString,
OntimeDelay,
OntimeEntry,
@@ -49,7 +50,6 @@ import {
requestEventSwap,
requestGroupEntries,
requestUngroup,
requestFitGroupTarget,
} from '../api/rundown';
import { logAxiosError } from '../api/utils';
import { useEditorSettings } from '../stores/editorSettings';
@@ -60,15 +60,9 @@ export type EventOptions = Partial<{
before: MaybeString;
// options of entries of type OntimeEvent
linkPrevious: boolean;
/** the timing reference for the new entry, usually the same as after */
lastEventId: MaybeString;
}>;
type ClientInsertOptions = {
after?: EntryId;
before?: EntryId;
};
/**
* Gather utilities for actions on entries in the loaded rundown.
*/
@@ -126,7 +120,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
* @private
*/
const { mutateAsync: addEntryMutation } = useMutation({
mutationFn: ([rundownId, entry]: [string, PatchWithId & ClientInsertOptions]) => postAddEntry(rundownId, entry),
mutationFn: ([rundownId, entry]: [string, PatchWithId & InsertOptions]) => postAddEntry(rundownId, entry),
onMutate: async ([_rundownId, entry]) => {
const queryKey = resolveCurrentRundownQueryKey();
await queryClient.cancelQueries({ queryKey });
@@ -155,9 +149,8 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
addToRundown(
newRundown,
optimisticEntry,
parent ? (newRundown.entries[parent.id] as OntimeGroup) : null,
afterId,
entry.before ?? null,
parent ? (newRundown.entries[parent.id] as OntimeGroup) : null,
);
queryClient.setQueryData<Rundown>(queryKey, newRundown);
@@ -200,7 +193,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
throw new Error('Rundown not initialised');
}
const newEntry: PatchWithId & ClientInsertOptions = { ...entry, id: generateId() };
const newEntry: PatchWithId & InsertOptions = { ...entry, id: generateId() };
// handle adding options that concern all event types
if (options?.after) {
@@ -212,8 +205,8 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
// ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEntry)) {
// last event id is the timing reference of the previous OntimeEvent in rundown
if (options?.lastEventId) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
const previousEvent = rundownData.entries[options?.lastEventId];
if (isOntimeEvent(previousEvent)) {
newEntry.timeStart = previousEvent.timeEnd;
@@ -291,7 +284,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
* Clone an entry
*/
const clone = useCallback(
async (entryId: EntryId, options?: ClientInsertOptions) => {
async (entryId: EntryId, options?: InsertOptions) => {
try {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
@@ -467,27 +460,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
return previousEnd;
}
},
[getCurrentRundownData, updateEntryMutation, queryClient, resolveCurrentRundownQueryKey],
);
/**
* Updates time of existing event so it satisfies the group target duration
* @param eventId {EntryId} - id of the event
*/
const matchGroupDuration = useCallback(
async (eventId: EntryId) => {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
try {
await requestFitGroupTarget(rundownId, eventId);
} catch (error) {
logAxiosError('Error updating event', error);
}
},
[getCurrentRundownData],
[getCurrentRundownData, updateEntryMutation, queryClient],
);
/**
@@ -1030,7 +1003,6 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
swapEvents,
updateEntry,
updateTimer,
matchGroupDuration,
}),
[
addEntry,
@@ -1048,7 +1020,6 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
swapEvents,
updateEntry,
updateTimer,
matchGroupDuration,
],
);
}
@@ -1086,7 +1057,7 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
/**
* Utility to create an optimistic entry for immediate cache insertion
*/
function createOptimisticEntry(payload: PatchWithId & ClientInsertOptions): OntimeEntry {
function createOptimisticEntry(payload: PatchWithId & InsertOptions): OntimeEntry {
const { after: _after, before: _before, ...entryData } = payload;
const id = entryData.id;
let parent: EntryId | null = null;
-12
View File
@@ -139,17 +139,6 @@ export const setEventPlayback = {
pause: () => sendSocket('pause', undefined),
};
export const useTimerProgress = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
phase: state.timer.phase,
addedTime: state.timer.addedTime,
secondaryTimer: state.timer.secondaryTimer,
current: state.timer.current,
expectedFinish: state.timer.expectedFinish,
startedAt: state.timer.startedAt,
isCountToEnd: state.eventNow?.countToEnd ?? false,
}));
export const useTimer = createSelector((state: RuntimeStore) => ({
...state.timer,
}));
@@ -163,7 +152,6 @@ export const useNextFlag = createSelector((state: RuntimeStore) => ({
export const useProgressData = createSelector((state: RuntimeStore) => ({
current: state.timer.current,
duration: state.timer.duration,
eventId: state.eventNow?.id ?? null,
timeWarning: state.eventNow?.timeWarning ?? null,
timeDanger: state.eventNow?.timeDanger ?? null,
}));
@@ -1,25 +0,0 @@
import { describe, expect, it } from 'vitest';
import { removeFileExtension } from '../uploadUtils';
describe('removeFileExtension()', () => {
it('removes a trailing extension', () => {
expect(removeFileExtension('show.xlsx')).toBe('show');
});
it('only removes the last extension', () => {
expect(removeFileExtension('my.show.xlsx')).toBe('my.show');
});
it('returns the name unchanged when there is no extension', () => {
expect(removeFileExtension('rundown')).toBe('rundown');
});
it('does not treat a leading dot as an extension', () => {
expect(removeFileExtension('.gitignore')).toBe('.gitignore');
});
it('handles an empty string', () => {
expect(removeFileExtension('')).toBe('');
});
});
+4 -9
View File
@@ -3,9 +3,7 @@ import {
MILLIS_PER_HOUR,
MILLIS_PER_MINUTE,
MILLIS_PER_SECOND,
dayInMs,
formatFromMillis,
getExpectedEnd,
getExpectedStart,
} from 'ontime-utils';
@@ -31,11 +29,6 @@ export function nowInMillis(): number {
return elapsed;
}
export function normaliseWallClock(time: number): number {
const timeOfDay = time % dayInMs;
return timeOfDay < 0 ? timeOfDay + dayInMs : timeOfDay;
}
/**
* @description Resolves format from url and store
* @return {string|null} A format string like "hh:mm:ss a" or null
@@ -187,13 +180,15 @@ export function getExpectedTimesFromExtendedEvent(
...state,
},
);
const expectedEnd = getExpectedEnd(event, expectedStart, state.currentDay);
const plannedEnd = event.timeStart + event.duration + event.delay;
return {
expectedStart,
timeToStart: expectedStart - state.clock,
expectedEnd,
expectedEnd: event.countToEnd
? Math.max(expectedStart + event.duration, plannedEnd)
: expectedStart + event.duration,
plannedEnd,
};
}
@@ -38,18 +38,6 @@ export function validateProjectFile(file: File) {
}
}
/**
* Removes a trailing file extension from a file name (e.g. "show.xlsx" -> "show")
* A leading dot (dotfiles like ".gitignore") is not treated as an extension
*/
export function removeFileExtension(fileName: string): string {
const lastDot = fileName.lastIndexOf('.');
if (lastDot <= 0) {
return fileName;
}
return fileName.slice(0, lastDot);
}
export function isExcelFile(file: File | null) {
return file?.name.endsWith('.xlsx');
}
@@ -1,21 +0,0 @@
import { describe, expect, it } from 'vitest';
import { matchesSettingsOptionQuery } from '../useAppSettingsMenu';
describe('matchesSettingsOptionQuery', () => {
const option = {
id: 'settings__general',
label: 'General settings',
keywords: ['pin', 'time zone'],
};
it('does not match an empty or whitespace-only query', () => {
expect(matchesSettingsOptionQuery(option, '')).toBe(false);
expect(matchesSettingsOptionQuery(option, ' ')).toBe(false);
});
it('matches keywords case-insensitively', () => {
expect(matchesSettingsOptionQuery(option, 'PIN')).toBe(true);
expect(matchesSettingsOptionQuery(option, 'TIME ZONE')).toBe(true);
});
});
@@ -1,4 +1,9 @@
$content-max-width: 1280px;
.corner {
position: fixed;
top: 6rem;
right: 4rem;
z-index: $zindex-floating;
}
.contentWrapper {
display: flex;
@@ -9,29 +14,9 @@ $content-max-width: 1280px;
position: relative;
}
.corner {
flex: 0 0 auto;
display: flex;
justify-content: flex-end;
// width is needed so the button can align to the right of the capped
// column, auto margins alone would shrink this to fit and centre it
width: 100%;
max-width: $content-max-width;
margin-inline: auto;
padding: 0 1rem 0.5rem;
box-sizing: border-box;
}
.content {
box-sizing: border-box;
// as with .corner, the width is needed to fill the capped column, otherwise
// auto margins shrink this to fit and each panel ends up its own width
width: 100%;
max-width: $content-max-width;
margin: 0 auto 1rem;
padding-inline: 1rem;
margin: 1rem;
overflow-y: auto;
flex-grow: 1;
// room for the last section to scroll to the top of the viewport
padding-bottom: 40vh;
padding-bottom: 300px;
}
@@ -12,12 +12,12 @@ interface PanelContentProps {
export default function PanelContent({ onClose, children }: PropsWithChildren<PanelContentProps>) {
return (
<div className={style.contentWrapper}>
<div className={style.content}>{children}</div>
<div className={style.corner}>
<Button size='large' onClick={onClose}>
Close settings <IoClose />
</Button>
</div>
<div className={style.content}>{children}</div>
</div>
);
}
@@ -1,13 +1,3 @@
.container {
width: min(30vw, 300px);
flex: 0 0 min(30vw, 300px);
min-width: min(30vw, 300px);
display: flex;
flex-direction: column;
gap: 0.75rem;
overflow-y: auto;
}
.tabs,
ul {
list-style: none;
@@ -16,20 +6,22 @@ ul {
}
.tabs {
width: min(30vw, 300px);
flex: 0 0 min(30vw, 300px);
min-width: min(30vw, 300px);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.primary,
.secondary {
padding: 0.25rem 1rem;
margin-right: 1rem;
border-radius: 2px;
&:focus-visible {
&:focus {
background-color: $gray-1000;
outline: 2px solid $blue-500;
outline-offset: -2px;
outline: 0;
}
&:hover {
@@ -40,21 +32,14 @@ ul {
.primary {
font-size: 1rem;
border-radius: 2px;
display: flex;
align-items: center;
gap: 0.5rem;
border-left: 3px solid transparent;
&.groupActive {
color: $blue-400;
font-weight: 600;
}
&.active {
color: $ui-white;
border-left-color: $blue-400;
background-color: rgba($blue-500, 0.16);
font-weight: 600;
color: $blue-400;
background-color: $gray-1100;
}
&.highlight {
@@ -77,9 +62,6 @@ ul {
font-size: $inner-section-text-size;
&.active {
color: $ui-white;
border-left-color: $blue-400;
background-color: rgba($blue-500, 0.16);
font-weight: 600;
color: $blue-400;
}
}
@@ -1,18 +1,10 @@
import { Fragment, useState } from 'react';
import { Fragment } from 'react';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { isKeyEnter } from '../../../common/utils/keyEvent';
import { cx } from '../../../common/utils/styleUtils';
import * as Panel from '../panel-utils/PanelUtils';
import {
filterSettingsOptions,
matchesSettingsOptionQuery,
SettingsOption,
SettingsOptionId,
useAppSettingsMenu,
} from '../useAppSettingsMenu';
import { SettingsOption, SettingsOptionId, useAppSettingsMenu } from '../useAppSettingsMenu';
import useAppSettingsNavigation from '../useAppSettingsNavigation';
import SettingsSearch from './SettingsSearch';
import style from './PanelList.module.scss';
@@ -24,44 +16,12 @@ interface PanelListProps extends PanelBaseProps {
selectedPanel: string;
}
/** Returns the first matching setting, preferring a matching child over its non-matching group. */
function getFirstResultId(results: SettingsOption[], query: string): SettingsOptionId | null {
const firstResult = results[0];
if (!firstResult) {
return null;
}
if (matchesSettingsOptionQuery(firstResult, query.trim().toLowerCase())) {
return firstResult.id as SettingsOptionId;
}
return (firstResult.secondary?.[0]?.id as SettingsOptionId | undefined) ?? null;
}
export default function PanelList({ selectedPanel, location }: PanelListProps) {
const { options } = useAppSettingsMenu();
const { setLocation } = useAppSettingsNavigation();
const [query, setQuery] = useState('');
const results = filterSettingsOptions(options, query);
const handleSearchSubmit = () => {
const target = getFirstResultId(results, query);
if (target) {
setLocation(target);
setQuery('');
}
};
return (
<div className={style.container}>
<SettingsSearch query={query} onQueryChange={setQuery} onSubmit={handleSearchSubmit} />
{results.length === 0 ? (
<Panel.EmptyState title='No settings match' description={`Nothing found for "${query.trim()}"`} />
) : (
<ul className={style.tabs}>
{results.map((panel) => {
{options.map((panel) => {
const isSelected = selectedPanel === panel.id;
if (panel.highlight) {
return (
@@ -73,8 +33,6 @@ export default function PanelList({ selectedPanel, location }: PanelListProps) {
return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />;
})}
</ul>
)}
</div>
);
}
@@ -86,15 +44,7 @@ interface PanelListItemProps {
function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
const { setLocation } = useAppSettingsNavigation();
const hasSelectedChild = Boolean(
isSelected && panel.secondary?.some((secondary) => secondary.id.split('__')[1] === location),
);
const classes = cx([
style.primary,
isSelected && !hasSelectedChild && style.active,
hasSelectedChild && style.groupActive,
panel.highlight && style.highlight,
]);
const classes = cx([style.primary, isSelected && style.active, panel.highlight && style.highlight]);
return (
<Fragment key={panel.id}>
@@ -125,7 +75,6 @@ function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
}
}}
className={secondaryClasses}
tabIndex={0}
role='button'
>
{secondary.label}
@@ -1,24 +0,0 @@
.search {
position: relative;
display: flex;
align-items: center;
flex: 0 0 auto;
margin: 0.25rem 1rem 0.25rem 0.25rem;
}
.icon {
position: absolute;
left: 0.5rem;
color: $gray-400;
pointer-events: none;
}
.input {
padding-left: 2rem;
padding-right: 2rem;
}
.clear {
position: absolute;
right: 0.25rem;
}
@@ -1,60 +0,0 @@
import { KeyboardEvent, useRef } from 'react';
import { IoClose, IoSearch } from 'react-icons/io5';
import IconButton from '../../../common/components/buttons/IconButton';
import Input from '../../../common/components/input/input/Input';
import style from './SettingsSearch.module.scss';
interface SettingsSearchProps {
query: string;
onQueryChange: (query: string) => void;
onSubmit: () => void;
}
export default function SettingsSearch({ query, onQueryChange, onSubmit }: SettingsSearchProps) {
const searchRef = useRef<HTMLInputElement>(null);
const isSearching = query.trim().length > 0;
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape' && isSearching) {
// Do not let the settings panel close while the user is clearing a search.
event.stopPropagation();
onQueryChange('');
return;
}
if (event.key === 'Enter') {
onSubmit();
}
};
return (
<div className={style.search}>
<IoSearch className={style.icon} />
<Input
ref={searchRef}
value={query}
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={handleKeyDown}
placeholder='Search settings'
className={style.input}
fluid
/>
{isSearching && (
<IconButton
variant='ghosted-white'
size='small'
aria-label='Clear search'
className={style.clear}
onClick={() => {
onQueryChange('');
searchRef.current?.focus();
}}
>
<IoClose />
</IconButton>
)}
</div>
);
}
@@ -1,26 +1,24 @@
$inner-padding: 1rem;
$card-padding: 2rem;
.header {
font-size: 1.5rem;
font-size: 2rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid $white-10;
font-weight: 600;
}
.subheader {
font-size: 1.125rem;
font-size: 1.5rem;
padding-bottom: 0.5rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.title {
font-size: 1rem;
padding: 0 var(--panel-card-padding, #{$card-padding});
font-size: 1.375rem;
padding: 0 2rem;
font-weight: 600;
display: flex;
align-items: center;
@@ -37,12 +35,10 @@ $card-padding: 2rem;
color: $ui-white;
}
// width is owned by the panel content column, which caps and centres itself.
// sections must stay full width so top level cards line up with Panel.Header
// and nested sections fill their card rather than shrinking to fit
.section {
position: relative;
margin-top: 2rem;
max-width: 1024px;
}
.indent {
@@ -55,10 +51,8 @@ $card-padding: 2rem;
}
.card {
--panel-card-padding: #{$card-padding};
position: relative;
padding: var(--panel-card-padding);
padding: 2rem;
background-color: $white-3;
border: 1px solid $gray-1100;
border-radius: 3px;
@@ -82,10 +76,10 @@ $card-padding: 2rem;
color: $error-red;
}
// tables scroll with the panel rather than owning a nested scroll area,
// which keeps the sticky table head anchored to the panel viewport
.pad {
padding: 0 var(--panel-card-padding, #{$card-padding});
padding: 0 2rem;
max-height: 550px;
overflow-y: scroll;
}
.table {
@@ -103,13 +97,10 @@ $card-padding: 2rem;
}
th {
font-weight: 600;
color: $gray-300;
font-size: calc(1rem - 3px);
letter-spacing: 0.02em;
vertical-align: bottom;
font-weight: 400;
color: $gray-400;
white-space: nowrap;
text-transform: uppercase;
text-transform: capitalize;
}
th,
@@ -121,17 +112,6 @@ $card-padding: 2rem;
background-color: $white-1;
}
// active rows use a panel-local class today; keep the treatment here so all
// settings tables share the same state without each panel owning its colours
tr[class*='current'] {
background-color: rgba($blue-500, 0.16);
> td:first-child {
box-shadow: inset 3px 0 $blue-400;
font-weight: 500;
}
}
// allow highlighting table elements
tr[data-warn='true'],
td[data-warn='true'] {
@@ -140,7 +120,7 @@ $card-padding: 2rem;
}
.listGroup {
padding: 0 var(--panel-card-padding, #{$card-padding});
padding: 0 2rem;
> li:not(:last-child) {
border-bottom: 1px solid $white-10;
@@ -153,17 +133,6 @@ $card-padding: 2rem;
align-items: center;
gap: 1rem;
padding: 0.5rem 0;
&.interactive {
margin-inline: calc(var(--panel-card-padding, #{$card-padding}) * -1);
padding: 0.75rem var(--panel-card-padding, #{$card-padding});
transition: background-color $transition-time-action;
&:hover,
&:focus-within {
background-color: $white-3;
}
}
}
.fieldTitle {
@@ -194,13 +163,14 @@ $card-padding: 2rem;
.divider {
border: none;
border-top: 1px solid $white-10;
margin: 1rem calc(var(--panel-card-padding, #{$card-padding}) * -1);
margin: 1rem -2rem;
}
.overlay {
position: absolute;
z-index: $zindex-backdrop;
inset: 0;
width: 100%;
height: 100%;
backdrop-filter: blur(2px);
display: grid;
place-content: center;
@@ -218,35 +188,19 @@ $card-padding: 2rem;
animation: animloader 1s ease-in infinite;
}
.emptyState {
padding: 3rem 1.5rem;
.empty {
background-color: $black-10;
text-align: center;
color: $muted-gray;
td {
padding-block: 5rem;
}
.emptyMessage {
width: min(30rem, 100%);
button {
margin-top: 1rem;
margin-inline: auto;
}
.emptyTitle {
margin-bottom: 0.25rem;
color: rgba($gray-200, 0.72);
font-size: calc(1rem + 2px);
font-weight: 400;
}
.emptyBody {
color: rgba($gray-200, 0.55);
font-size: calc(1rem - 3px);
line-height: 1.5;
}
.emptyAction {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-top: 1rem;
}
.inlineElements {
@@ -285,3 +239,14 @@ $card-padding: 2rem;
opacity: 0;
}
}
@keyframes animloader {
0% {
transform: scale(0);
opacity: 0.6;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@@ -1,5 +1,7 @@
import { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
import { IoAdd } from 'react-icons/io5';
import Button from '../../../common/components/buttons/Button';
import { cx } from '../../../common/utils/styleUtils';
import style from './PanelUtils.module.scss';
@@ -60,29 +62,16 @@ export function Table({ className, children }: { className?: string; children: R
);
}
export interface EmptyStateProps {
title: string;
description?: ReactNode;
action?: ReactNode;
}
export function EmptyState({ title, description, action }: EmptyStateProps) {
export function TableEmpty({ label, handleClick }: { label?: string; handleClick?: () => void }) {
return (
<div className={style.emptyState}>
<div className={style.emptyMessage}>
<div className={style.emptyTitle}>{title}</div>
{description && <div className={style.emptyBody}>{description}</div>}
{action && <div className={style.emptyAction}>{action}</div>}
</div>
</div>
);
}
export function TableEmpty(props: EmptyStateProps) {
return (
<tr>
<tr className={style.empty}>
<td colSpan={99}>
<EmptyState {...props} />
<div>{label ?? 'No data yet'}</div>
{handleClick && (
<Button onClick={handleClick} disabled={!handleClick} variant='primary'>
New <IoAdd />
</Button>
)}
</td>
</tr>
);
@@ -92,8 +81,8 @@ export function ListGroup({ className, children }: { className?: string; childre
return <ul className={cx([style.listGroup, className])}>{children}</ul>;
}
export function ListItem({ children, interactive = false }: { children: ReactNode; interactive?: boolean }) {
return <li className={cx([style.listItem, interactive && style.interactive])}>{children}</li>;
export function ListItem({ children }: { children: ReactNode }) {
return <li className={style.listItem}>{children}</li>;
}
export function Field({
@@ -1,4 +1,4 @@
import Info from '../../../../common/components/info/Info';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import {
buyMeACoffeeUrl,
discordUrl,
@@ -11,8 +11,6 @@ import {
} from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import AppVersion from './AppVersion';
import CloudPanel from './CloudPanel';
import ExternalLinkRow from './ExternalLinkRow';
export default function AboutPanel() {
return (
@@ -21,49 +19,28 @@ export default function AboutPanel() {
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Ontime</Panel.SubHeader>
<Panel.Divider />
<Info>Free, open-source software for managing rundowns and event timers.</Info>
<Panel.ListGroup>
<ExternalLinkRow href={websiteUrl} title='www.getontime.no' description='App site and downloads' />
<ExternalLinkRow
href={githubSponsorUrl}
title='GitHub Sponsors'
description='Support development through GitHub'
/>
<ExternalLinkRow
href={buyMeACoffeeUrl}
title='Buy Me a Coffee'
description='Make a one-time contribution'
/>
</Panel.ListGroup>
<Panel.Paragraph>
Free, open-source software for managing rundowns and event timers
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
</Panel.Paragraph>
<Panel.Paragraph>
Considering sponsoring our work
<ExternalLink href={githubSponsorUrl}>GitHub Sponsors</ExternalLink>
<ExternalLink href={buyMeACoffeeUrl}>Buy Me a Coffee</ExternalLink>
</Panel.Paragraph>
<Panel.Paragraph>
If you are looking for an online version of Ontime, consider trying out our cloud service
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
</Panel.Paragraph>
</Panel.Card>
</Panel.Section>
<CloudPanel />
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Current version</Panel.SubHeader>
<Panel.Divider />
<Panel.ListGroup>
<AppVersion />
</Panel.ListGroup>
</Panel.Card>
</Panel.Section>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Helpful links</Panel.SubHeader>
<Panel.Divider />
<Panel.ListGroup>
<ExternalLinkRow
href={documentationUrl}
title='Read the docs'
description='Setup guides and feature documentation'
/>
<ExternalLinkRow href={githubUrl} title='GitHub' description='Report issues or contribute to Ontime' />
<ExternalLinkRow href={youtubeUrl} title='YouTube' description='Watch tutorials and feature walkthroughs' />
<ExternalLinkRow href={discordUrl} title='Discord' description='Get help and share suggestions' />
<ExternalLinkRow href={subredditUrl} title='Reddit' description='Join the Ontime community discussion' />
</Panel.ListGroup>
</Panel.Card>
<Panel.SubHeader>Links</Panel.SubHeader>
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
<ExternalLink href={youtubeUrl}>YouTube channel</ExternalLink>
<ExternalLink href={subredditUrl}>Subreddit</ExternalLink>
</Panel.Section>
</>
);
@@ -1,7 +0,0 @@
.updateIndicator {
width: 0.5em;
height: 0.5em;
flex: 0 0 auto;
border-radius: 99px;
background-color: $red-400;
}
@@ -1,47 +1,35 @@
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import useAppVersion from '../../../../common/hooks-query/useAppVersion';
import { appVersion, isOntimeCloud, websiteUrl } from '../../../../externals';
import { appVersion, isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './AppVersion.module.scss';
export default function AppVersion() {
const { data, isError } = useAppVersion();
if (isError) {
return (
<Panel.ListItem>
<Panel.Field title={`Ontime ${appVersion}`} description='' error='Could not fetch version information' />
</Panel.ListItem>
<Panel.Paragraph>
{`You are currently using Ontime version ${appVersion}`}
<Panel.Error>Could not fetch version information</Panel.Error>
</Panel.Paragraph>
);
}
if (data.hasUpdates) {
return (
<Panel.ListItem>
<Panel.Field
title={
<>
<span className={style.updateIndicator} aria-hidden='true' />
{`Ontime ${appVersion}`}
</>
}
description={
isOntimeCloud
? `Version ${data.version} is available. Restart your stage to update.`
: `Version ${data.version} is available.`
}
/>
{!isOntimeCloud && (
<ExternalLink href={websiteUrl}>Download the latest version from Ontime's page</ExternalLink>
<Panel.Paragraph>
{`You are currently using Ontime version ${appVersion}.`}
<br />
<br />
{`A new version ${data.version} is available.`} <br />
{isOntimeCloud ? (
'You can restart your stage to get the latest available version.'
) : (
<ExternalLink href={data.url}>Please visit the release page to download</ExternalLink>
)}
</Panel.ListItem>
</Panel.Paragraph>
);
}
return (
<Panel.ListItem>
<Panel.Field title={`Ontime ${appVersion}`} description='You are using the latest version.' />
</Panel.ListItem>
);
return <Panel.Paragraph>{`You are currently using the latest version of Ontime: ${appVersion}`}</Panel.Paragraph>;
}
@@ -1,74 +0,0 @@
.card {
padding: 1.5rem;
border-color: rgba($action-blue, 0.42);
background-color: rgba($action-blue, 0.06);
}
.eyebrow {
color: $action-text-color;
font-size: calc(1rem - 3px);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.title {
margin-top: 0.35rem;
font-size: 1.5rem;
font-weight: 600;
line-height: 1.15;
}
.description {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 33rem;
margin-top: 0.9rem;
color: $gray-300;
line-height: 1.5;
}
.portability {
padding-top: 0.75rem;
border-top: 1px solid $white-10;
color: $gray-400;
font-size: calc(1rem - 2px);
}
.benefits {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem 1rem;
margin-top: 1.5rem;
padding: 0;
list-style: none;
li {
display: flex;
align-items: flex-start;
gap: 0.5rem;
line-height: 1.4;
}
svg {
flex: 0 0 auto;
margin-top: 0.1rem;
color: $action-text-color;
font-size: 1.1rem;
}
}
.actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
margin-top: 1.5rem;
}
@media (max-width: $min-tablet) {
.benefits {
grid-template-columns: 1fr;
}
}
@@ -1,52 +0,0 @@
import { IoCheckmarkCircle } from 'react-icons/io5';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import { isOntimeCloud, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './CloudPanel.module.scss';
export default function CloudPanel() {
if (isOntimeCloud) {
return null;
}
return (
<Panel.Section>
<Panel.Card className={style.card}>
<span className={style.eyebrow}>Ontime Cloud</span>
<h3 className={style.title}>Run Ontime online with zero setup</h3>
<p className={style.description}>
<span>
Ontime Cloud lets you run and share Ontime online with your team. Subscriptions support continued
development while Ontime remains fully open-source and self-hostable.
</span>
<span className={style.portability}>
Project files work locally and in Cloud, so your shows can move freely.
</span>
</p>
<ul className={style.benefits}>
<li>
<IoCheckmarkCircle />
Start in minutes, with no setup or maintenance
</li>
<li>
<IoCheckmarkCircle />
Coordinate multi-track rundowns with Overview views
</li>
<li>
<IoCheckmarkCircle />
Manage every instance from one dashboard
</li>
<li>
<IoCheckmarkCircle />
Share secure read-only views with collaborators
</li>
</ul>
<div className={style.actions}>
<ExternalLink href={websiteUrl}>Learn more about Ontime Cloud</ExternalLink>
</div>
</Panel.Card>
</Panel.Section>
);
}
@@ -1,15 +0,0 @@
.link {
width: calc(100% + (var(--panel-card-padding, 2rem) * 2));
margin: -0.75rem calc(var(--panel-card-padding, 2rem) * -1);
padding: 0.75rem var(--panel-card-padding, 2rem);
> :first-child {
flex: 1;
min-width: 0;
}
}
.action {
color: $action-text-color;
font-size: calc(1rem - 2px);
}
@@ -1,21 +0,0 @@
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './ExternalLinkRow.module.scss';
interface ExternalLinkRowProps {
href: string;
title: string;
description: string;
}
export default function ExternalLinkRow({ href, title, description }: ExternalLinkRowProps) {
return (
<Panel.ListItem interactive>
<ExternalLink href={href} className={style.link}>
<Panel.Field title={title} description={description} />
<span className={style.action}>Open</span>
</ExternalLink>
</Panel.ListItem>
);
}
@@ -1,13 +1,6 @@
.outerColumn {
display: flex;
flex-direction: column;
gap: 2rem;
font-size: calc(1rem - 1px);
color: $ui-white;
// the shared modal body owns scrolling for this regular form modal
min-height: 100%;
padding-block: 0.5rem;
margin-bottom: 2rem;
h3 {
font-size: 1rem;
@@ -19,12 +19,12 @@ import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import Modal from '../../../../common/components/modal/Modal';
import RadioGroup from '../../../../common/components/radio-group/RadioGroup';
import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { startsWithHttp } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils';
import { isAutomation, makeFieldList } from './automationUtils';
@@ -34,7 +34,6 @@ import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss';
const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation';
const formId = 'automation-form';
interface AutomationFormProps {
automation: Automation | AutomationDTO;
@@ -186,15 +185,15 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
const canSubmit = !isSubmitting && isDirty && isValid;
return (
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
title={isEdit ? 'Edit automation' : 'Create automation'}
bodyElements={
<form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.outerColumn}>
<div className={style.innerColumn}>
<Panel.Indent
as='form'
name='automation-form'
onSubmit={handleSubmit(onSubmit)}
className={style.outerColumn}
onKeyDown={(event) => preventEscape(event, onClose)}
>
<Panel.SubHeader>{isEdit ? 'Edit automation' : 'Create automation'}</Panel.SubHeader>
<div className={style.innerSection}>
<h3>Automation options</h3>
<div className={style.titleSection}>
<label>
@@ -209,7 +208,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
</div>
</div>
<div className={style.innerColumn}>
<div className={style.innerSection}>
<h3>Filters (optional)</h3>
<div className={style.ruleSection}>
<label>
@@ -254,13 +253,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
if (value === null) return;
setValue(
`filters.${index}.operator`,
value as
| 'equals'
| 'not_equals'
| 'greater_than'
| 'less_than'
| 'contains'
| 'not_contains',
value as 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains' | 'not_contains',
{ shouldDirty: true },
);
}}
@@ -280,11 +273,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<div>
<span>&nbsp;</span>
<div>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeFilter(index)}
>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeFilter(index)}>
<IoTrash />
</IconButton>
</div>
@@ -363,12 +352,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
</label>
<label>
Arguments
<TemplateInput
{...register(`outputs.${index}.args`)}
value={output.args}
fluid
placeholder='1'
/>
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} fluid placeholder='1' />
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label>
<div>
@@ -377,11 +361,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<Button variant='ghosted-white' onClick={() => handleTestOSCOutput(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeOutput(index)}
>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
<IoTrash />
</IconButton>
</Panel.InlineElements>
@@ -422,11 +402,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<Button variant='ghosted-white' onClick={() => handleTestHTTPOutput(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeOutput(index)}
>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
<IoTrash />
</IconButton>
</Panel.InlineElements>
@@ -462,11 +438,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<Button variant='ghosted-white' onClick={() => handleTestOntimeAction(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeOutput(index)}
>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
<IoTrash />
</IconButton>
</Panel.InlineElements>
@@ -475,6 +447,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
);
}
// there should be no other output types
return null;
})}
<Panel.InlineElements relation='inner'>
@@ -489,17 +462,14 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
</Button>
</Panel.InlineElements>
</div>
</form>
}
footerElements={
<>
<Panel.InlineElements align='end'>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' form={formId} disabled={!canSubmit} loading={isSubmitting}>
<Button variant='primary' type='submit' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</>
}
/>
</Panel.InlineElements>
</Panel.Indent>
);
}
@@ -19,6 +19,8 @@ export default function AutomationPanel({ location }: PanelBaseProps) {
return (
<>
<Panel.Header>Automation</Panel.Header>
<Panel.Section>
<Panel.Loader isLoading={isLoading} />
<div ref={settingsRef}>
<AutomationSettingsForm
enabledAutomations={data.enabledAutomations}
@@ -26,20 +28,15 @@ export default function AutomationPanel({ location }: PanelBaseProps) {
oscPortIn={data.oscPortIn}
automationState={automationState}
oscInputState={oscInputState}
isLoading={isLoading}
/>
</div>
<div ref={automationsRef}>
<AutomationsList automations={data.automations} enabledAutomations={automationState} isLoading={isLoading} />
<AutomationsList automations={data.automations} enabledAutomations={automationState} />
</div>
<div ref={triggersRef}>
<TriggersList
triggers={data.triggers}
automations={data.automations}
enabledAutomations={automationState}
isLoading={isLoading}
/>
<TriggersList triggers={data.triggers} automations={data.automations} enabledAutomations={automationState} />
</div>
</Panel.Section>
</>
);
}
@@ -21,7 +21,6 @@ interface AutomationSettingsProps {
oscPortIn: number;
automationState?: boolean;
oscInputState?: boolean;
isLoading?: boolean;
}
export default function AutomationSettingsForm({
@@ -30,7 +29,6 @@ export default function AutomationSettingsForm({
oscPortIn,
automationState,
oscInputState,
isLoading,
}: AutomationSettingsProps) {
const {
handleSubmit,
@@ -67,9 +65,7 @@ export default function AutomationSettingsForm({
const oscInputEnabled = watch('enabledOscIn');
return (
<Panel.Section>
<Panel.Card>
<Panel.Loader isLoading={Boolean(isLoading)} />
<Panel.SubHeader>
Automation settings
<Panel.InlineElements>
@@ -106,6 +102,8 @@ export default function AutomationSettingsForm({
onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onReset)}
>
<Panel.Loader isLoading={false} />
<Panel.Title>Automation</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
@@ -136,9 +134,7 @@ export default function AutomationSettingsForm({
<Panel.Title>OSC Input</Panel.Title>
<Panel.ListGroup>
{isOntimeCloud && (
<Info>For security reasons OSC integrations are not available in the cloud service.</Info>
)}
{isOntimeCloud && <Info>For security reasons OSC integrations are not available in the cloud service.</Info>}
<Panel.ListItem>
<Panel.Field
title={
@@ -190,6 +186,5 @@ export default function AutomationSettingsForm({
</Panel.ListGroup>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -22,10 +22,10 @@ const automationPlaceholder: AutomationDTO = {
interface AutomationsListProps {
automations: NormalisedAutomation;
enabledAutomations?: boolean;
isLoading: boolean;
}
export default function AutomationsList({ automations, enabledAutomations, isLoading }: AutomationsListProps) {
export default function AutomationsList(props: AutomationsListProps) {
const { automations, enabledAutomations } = props;
const { refetch } = useAutomationSettings();
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -44,21 +44,20 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
const arrayAutomations = Object.keys(automations);
return (
<Panel.Section>
<Panel.Card>
{automationFormData !== null && (
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
)}
<Panel.SubHeader>
Manage automations
<Button onClick={() => setAutomationFormData(automationPlaceholder)}>
<Button
type='submit'
disabled={Boolean(automationFormData)}
onClick={() => setAutomationFormData(automationPlaceholder)}
>
New <IoAdd />
</Button>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
{enabledAutomations === false && (
<Info>
Automations are disabled. You can still manage automation definitions here, but they will not run until
@@ -66,6 +65,10 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
</Info>
)}
{automationFormData !== null && (
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
)}
<Panel.Table>
<thead>
<tr>
@@ -77,15 +80,9 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
</tr>
</thead>
<tbody>
{!isLoading && arrayAutomations.length === 0 && (
{arrayAutomations.length === 0 && (
<Panel.TableEmpty
title='No automations yet'
description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires.'
action={
<Button variant='primary' onClick={() => setAutomationFormData(automationPlaceholder)}>
Create automation <IoAdd />
</Button>
}
handleClick={!automationFormData ? () => setAutomationFormData(automationPlaceholder) : undefined}
/>
)}
{arrayAutomations.map((automationId) => {
@@ -130,8 +127,6 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
)}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -1,4 +1,4 @@
import { NormalisedAutomation, TimerLifeCycle, Trigger, TriggerDTO } from 'ontime-types';
import { NormalisedAutomation, TimerLifeCycle, TriggerDTO } from 'ontime-types';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
@@ -6,21 +6,30 @@ import { addTrigger, editTrigger } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import Modal from '../../../../common/components/modal/Modal';
import Select from '../../../../common/components/select/Select';
import { preventEscape } from '../../../../common/utils/keyEvent';
import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './automationUtils';
const formId = 'trigger-form';
interface TriggerFormProps {
automations: NormalisedAutomation;
trigger: Trigger | null;
initialId?: string;
initialTitle?: string;
initialAutomationId?: string;
initialTrigger?: TimerLifeCycle;
onCancel: () => void;
postSubmit: () => void;
}
export default function TriggerForm({ automations, trigger, onCancel, postSubmit }: TriggerFormProps) {
export default function TriggerForm({
automations,
initialId,
initialTitle,
initialAutomationId,
initialTrigger,
onCancel,
postSubmit,
}: TriggerFormProps) {
const {
handleSubmit,
register,
@@ -31,9 +40,9 @@ export default function TriggerForm({ automations, trigger, onCancel, postSubmit
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<TriggerDTO>({
defaultValues: {
title: trigger?.title,
trigger: trigger?.trigger ?? (cycles[0].value as TimerLifeCycle | undefined),
automationId: trigger?.automationId ?? automations?.[Object.keys(automations)[0]]?.id,
title: initialTitle,
trigger: initialTrigger ?? (cycles[0].value as TimerLifeCycle | undefined),
automationId: initialAutomationId ?? automations?.[Object.keys(automations)[0]]?.id,
},
resetOptions: {
keepDirtyValues: true,
@@ -46,9 +55,10 @@ export default function TriggerForm({ automations, trigger, onCancel, postSubmit
}, [setFocus]);
const onSubmit = async (values: TriggerDTO) => {
if (trigger) {
// if we were passed an ID we are editing a Trigger
if (initialId) {
try {
await editTrigger(trigger.id, { id: trigger.id, ...values });
await editTrigger(initialId, { id: initialId, ...values });
postSubmit();
} catch (error) {
setError('root', { message: `Failed to save changes to trigger ${maybeAxiosError(error)}` });
@@ -75,21 +85,19 @@ export default function TriggerForm({ automations, trigger, onCancel, postSubmit
const canSubmit = isDirty && isValid;
return (
<Modal
isOpen
onClose={onCancel}
showBackdrop
showCloseButton
size='compact'
title={trigger ? 'Edit trigger' : 'Create trigger'}
bodyElements={
<form id={formId} onSubmit={handleSubmit(onSubmit)}>
<Panel.Indent
as='form'
name='trigger-form'
onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onCancel)}
>
<Panel.SubHeader>{initialId ? 'Edit trigger' : 'Create trigger'}</Panel.SubHeader>
<label>
Title
<Input
{...register('title', { required: { value: true, message: 'Required field' } })}
fluid
defaultValue={trigger?.title}
defaultValue={initialTitle}
/>
<Panel.Error>{errors.title?.message}</Panel.Error>
</label>
@@ -119,19 +127,14 @@ export default function TriggerForm({ automations, trigger, onCancel, postSubmit
/>
<Panel.Error>{errors.automationId?.message}</Panel.Error>
</label>
</form>
}
footerElements={
<>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.InlineElements align='end'>
<Button disabled={isSubmitting} onClick={onCancel}>
Cancel
</Button>
<Button type='submit' form={formId} variant='primary' disabled={!canSubmit} loading={isSubmitting}>
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</>
}
/>
</Panel.InlineElements>
</Panel.Indent>
);
}
@@ -9,33 +9,23 @@ import Info from '../../../../common/components/info/Info';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import * as Panel from '../../panel-utils/PanelUtils';
import { checkDuplicates } from './automationUtils';
import TriggerForm from './TriggerForm';
import AutomationForm from './TriggerForm';
import TriggersListItem from './TriggersListItem';
type FormState = {
isOpen: boolean;
trigger?: Trigger;
};
interface TriggersListProps {
triggers: Trigger[];
automations: NormalisedAutomation;
enabledAutomations?: boolean;
isLoading: boolean;
}
export default function TriggersList({ triggers, automations, enabledAutomations, isLoading }: TriggersListProps) {
const [formState, setFormState] = useState<FormState>({ isOpen: false, trigger: undefined });
export default function TriggersList(props: TriggersListProps) {
const { triggers, automations, enabledAutomations } = props;
const [showForm, setShowForm] = useState(false);
const { refetch } = useAutomationSettings();
const [deleteError, setDeleteError] = useState<string | null>(null);
const openNewForm = () => setFormState({ isOpen: true });
const openEditForm = (trigger: Trigger) => setFormState({ isOpen: true, trigger });
const closeForm = () => setFormState({ isOpen: false, trigger: undefined });
const handleDelete = async (id: string) => {
try {
setDeleteError(null);
await deleteTrigger(id);
} catch (error) {
setDeleteError(maybeAxiosError(error));
@@ -45,7 +35,7 @@ export default function TriggersList({ triggers, automations, enabledAutomations
};
const postSubmit = () => {
closeForm();
setShowForm(false);
refetch();
};
@@ -55,19 +45,10 @@ export default function TriggersList({ triggers, automations, enabledAutomations
const canAdd = Object.keys(automations).length > 0;
return (
<Panel.Section>
<Panel.Card>
{formState.isOpen && (
<TriggerForm
automations={automations}
trigger={formState.trigger ?? null}
onCancel={closeForm}
postSubmit={postSubmit}
/>
)}
<Panel.SubHeader>
Manage triggers
<Button disabled={!canAdd} onClick={openNewForm}>
<Button type='submit' form='trigger-form' disabled={!canAdd} loading={false} onClick={() => setShowForm(true)}>
New <IoAdd />
</Button>
</Panel.SubHeader>
@@ -80,10 +61,12 @@ export default function TriggersList({ triggers, automations, enabledAutomations
)}
{duplicates && (
<Panel.Error>
You have created multiple links between the same trigger and automation which can cause performance
issues.
You have created multiple links between the same trigger and automation which can performance issues.
</Panel.Error>
)}
{showForm && (
<AutomationForm automations={automations} onCancel={() => setShowForm(false)} postSubmit={postSubmit} />
)}
<Panel.Table>
<thead>
<tr>
@@ -94,21 +77,10 @@ export default function TriggersList({ triggers, automations, enabledAutomations
</tr>
</thead>
<tbody>
{!isLoading && triggers.length === 0 && (
{!showForm && triggers.length === 0 && (
<Panel.TableEmpty
title='No triggers yet'
description={
canAdd
? 'Triggers run an automation at a given point of the timer lifecycle, like when an event starts or finishes.'
: 'Create an automation first, then add a trigger to decide when it should run.'
}
action={
canAdd && (
<Button variant='primary' onClick={openNewForm}>
Create trigger <IoAdd />
</Button>
)
}
label='Create a trigger to run an automation'
handleClick={canAdd ? () => setShowForm(true) : undefined}
/>
)}
{triggers.map((trigger, index) => {
@@ -116,14 +88,14 @@ export default function TriggersList({ triggers, automations, enabledAutomations
<Fragment key={trigger.id}>
<TriggersListItem
automations={automations}
trigger={trigger}
id={trigger.id}
title={trigger.title}
trigger={trigger.trigger}
automationId={trigger.automationId}
duplicate={duplicates?.includes(index)}
handleEdit={() => openEditForm(trigger)}
handleDelete={() => handleDelete(trigger.id)}
postSubmit={postSubmit}
/>
</Fragment>
);
})}
{deleteError && (
<tr>
<td colSpan={5}>
@@ -131,10 +103,12 @@ export default function TriggersList({ triggers, automations, enabledAutomations
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -1,21 +1,48 @@
import { NormalisedAutomation, Trigger } from 'ontime-types';
import { NormalisedAutomation, TimerLifeCycle } from 'ontime-types';
import { useState } from 'react';
import { IoPencil, IoTrash, IoWarningOutline } from 'react-icons/io5';
import IconButton from '../../../../common/components/buttons/IconButton';
import Tag from '../../../../common/components/tag/Tag';
import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './automationUtils';
import AutomationForm from './TriggerForm';
interface TriggersListItemProps {
automations: NormalisedAutomation;
trigger: Trigger;
id: string;
title: string;
trigger: TimerLifeCycle;
automationId: string;
duplicate?: boolean;
handleEdit: () => void;
handleDelete: () => void;
postSubmit: () => void;
}
export default function TriggersListItem(props: TriggersListItemProps) {
const { automations, trigger, duplicate, handleEdit, handleDelete } = props;
const { automations, id, title, trigger, automationId, duplicate, handleDelete, postSubmit } = props;
const [isEditing, setIsEditing] = useState(false);
if (isEditing) {
return (
<tr>
<td colSpan={99}>
<AutomationForm
automations={automations}
initialId={id}
initialTitle={title}
initialTrigger={trigger}
initialAutomationId={automationId}
onCancel={() => setIsEditing(false)}
postSubmit={() => {
setIsEditing(false);
postSubmit();
}}
/>
</td>
</tr>
);
}
return (
<tr data-warn={duplicate}>
@@ -25,16 +52,16 @@ export default function TriggersListItem(props: TriggersListItemProps) {
color='#FFBC56' // $orange-500
/>
)}
{trigger.title}
{title}
</Panel.InlineElements>
<td>
<Tag>{cycles.find((cycle) => cycle.value === trigger.trigger)?.label}</Tag>
<Tag>{cycles.find((cycle) => cycle.value === trigger)?.label}</Tag>
</td>
<td>
<Tag>{automations?.[trigger.automationId]?.title}</Tag>
<Tag>{automations?.[automationId]?.title}</Tag>
</td>
<Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={handleEdit}>
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={() => setIsEditing(true)}>
<IoPencil />
</IconButton>
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={handleDelete}>
@@ -66,10 +66,7 @@ export default function ReportSettings() {
</thead>
<tbody>
{combinedReport.length === 0 && (
<Panel.TableEmpty
title='No report data yet'
description='Reports are generated as you run through the show, comparing scheduled times against what actually happened.'
/>
<Panel.TableEmpty label='Reports are generated when running through the show.' />
)}
{combinedReport.map((entry) => {
@@ -43,7 +43,6 @@ export default function URLPresets() {
return (
<Panel.Section>
<Panel.Card>
{formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />}
<Panel.SubHeader>
URL presets
<Button onClick={openNewForm}>
@@ -64,6 +63,7 @@ export default function URLPresets() {
</Panel.Section>
<Panel.Section>
<Panel.Loader isLoading={status === 'pending'} />
{formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />}
{actionError && <Panel.Error>{actionError}</Panel.Error>}
<Panel.Table>
<thead>
@@ -76,17 +76,7 @@ export default function URLPresets() {
</tr>
</thead>
<tbody>
{data.length === 0 && (
<Panel.TableEmpty
title='No URL presets yet'
description='A preset gives one of your views a short, memorable address, with all of its options baked in.'
action={
<Button variant='primary' onClick={openNewForm}>
Create preset <IoAdd />
</Button>
}
/>
)}
{data.length === 0 && <Panel.TableEmpty handleClick={openNewForm} />}
{data.map((preset, index) => {
const isCuesheet = preset.target === OntimeView.Cuesheet;
return (
@@ -6,9 +6,9 @@ import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils';
import Button from '../../../../../common/components/buttons/Button';
import Input from '../../../../../common/components/input/input/Input';
import Textarea from '../../../../../common/components/input/textarea/Textarea';
import Modal from '../../../../../common/components/modal/Modal';
import Select, { SelectOption } from '../../../../../common/components/select/Select';
import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets';
import { preventEscape } from '../../../../../common/utils/keyEvent';
import { isUrlSafe } from '../../../../../common/utils/regex';
import { enDash } from '../../../../../common/utils/styleUtils';
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
@@ -28,8 +28,6 @@ const targetOptions: SelectOption<OntimeViewPresettable>[] = [
{ value: OntimeView.ProjectInfo, label: 'Project Info' },
];
const formId = 'url-preset-form';
const defaultValues: URLPreset = {
alias: '',
target: OntimeView.Timer,
@@ -136,15 +134,12 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
};
return (
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
size='compact'
title={urlPreset ? 'Edit URL preset' : 'Create URL preset'}
bodyElements={
<form id={formId} onSubmit={handleSubmit(setupSubmit)} className={style.column}>
<Panel.Indent
as='form'
onSubmit={handleSubmit(setupSubmit)}
onKeyDown={(event) => preventEscape(event, onClose)}
className={style.column}
>
<input hidden name='enabled' value='true' />
<div>1. Enter URL and let Ontime generate the preset options</div>
@@ -214,23 +209,20 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
/>
</div>
)}
</form>
}
footerElements={
<>
<div>
<Panel.Error>{errors.root?.message}</Panel.Error>
<Panel.InlineElements align='end'>
<Button onClick={onClose}>Cancel</Button>
<Button
variant='primary'
type='submit'
form={formId}
disabled={!isValid || (!isDirty && !permissionsDirty) || noReadAccess}
loading={isSubmitting || isMutating}
>
Save
</Button>
</>
}
/>
</Panel.InlineElements>
</div>
</Panel.Indent>
);
}
@@ -13,30 +13,27 @@ import * as Panel from '../../panel-utils/PanelUtils';
import CustomFieldEntry from './composite/CustomFieldEntry';
import CustomFieldForm from './composite/CustomFieldForm';
type CustomFieldEntity = CustomField & { key: CustomFieldKey };
type FormState = {
isOpen: boolean;
field?: CustomFieldEntity;
};
export default function CustomFieldSettings() {
const { data, refetch } = useCustomFields();
const [formState, setFormState] = useState<FormState>({ isOpen: false, field: undefined });
const [isAdding, setIsAdding] = useState(false);
const openNewForm = () => setFormState({ isOpen: true });
const openEditForm = (field: CustomFieldEntity) => setFormState({ isOpen: true, field });
const closeForm = () => setFormState({ isOpen: false, field: undefined });
const handleInitiateCreate = () => {
setIsAdding(true);
};
const handleSubmit = async (customField: CustomField) => {
const editing = formState.field;
if (editing) {
await editCustomField(editing.key, customField);
} else {
const handleCancel = () => {
setIsAdding(false);
};
const handleCreate = async (customField: CustomField) => {
await postCustomField(customField);
}
refetch();
closeForm();
setIsAdding(false);
};
const handleEditField = async (key: CustomFieldKey, customField: CustomField) => {
await editCustomField(key, customField);
refetch();
};
const handleDelete = async (key: CustomFieldKey) => {
@@ -48,24 +45,12 @@ export default function CustomFieldSettings() {
}
};
const entries = Object.entries(data);
return (
<Panel.Section>
<Panel.Card>
{formState.isOpen && (
<CustomFieldForm
onSubmit={handleSubmit}
onCancel={closeForm}
initialColour={formState.field?.colour}
initialLabel={formState.field?.label}
initialKey={formState.field?.key}
initialType={formState.field?.type}
/>
)}
<Panel.SubHeader>
Custom fields
<Button onClick={openNewForm}>
<Button onClick={handleInitiateCreate}>
New <IoAdd />
</Button>
</Panel.SubHeader>
@@ -81,6 +66,7 @@ export default function CustomFieldSettings() {
</Info>
</Panel.Section>
<Panel.Section>
{isAdding && <CustomFieldForm onSubmit={handleCreate} onCancel={handleCancel} />}
<Panel.Table>
<thead>
<tr>
@@ -92,18 +78,7 @@ export default function CustomFieldSettings() {
</tr>
</thead>
<tbody>
{entries.length === 0 && (
<Panel.TableEmpty
title='No custom fields yet'
description='Custom fields add your own columns to the rundown, and can be shown in views or used in automations.'
action={
<Button variant='primary' onClick={openNewForm}>
Create custom field <IoAdd />
</Button>
}
/>
)}
{entries.map(([key, { colour, label, type }]) => {
{Object.entries(data).map(([key, { colour, label, type }]) => {
return (
<CustomFieldEntry
key={key}
@@ -111,7 +86,7 @@ export default function CustomFieldSettings() {
colour={colour}
label={label}
type={type}
onEdit={() => openEditForm({ key, colour, label, type })}
onEdit={handleEditField}
onDelete={handleDelete}
/>
);
@@ -5,14 +5,11 @@ import { uploadCustomView } from '../../../../common/api/customViews';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import Modal from '../../../../common/components/modal/Modal';
import * as Panel from '../../panel-utils/PanelUtils';
import { getFileError, getSlugError, getViewUrl, maxUploadLabel } from './customViews.utils';
import style from './CustomViews.module.scss';
const formId = 'custom-view-form';
interface CustomViewFormProps {
onComplete: () => void;
onClose: () => void;
@@ -56,15 +53,7 @@ export default function CustomViewForm({ onComplete, onClose }: CustomViewFormPr
};
return (
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
size='compact'
title='Upload custom view'
bodyElements={
<form id={formId} onSubmit={handleUpload} className={style.uploadForm}>
<Panel.Indent as='form' onSubmit={handleUpload} className={style.uploadForm}>
<input
ref={fileInputRef}
style={{ display: 'none' }}
@@ -111,17 +100,15 @@ export default function CustomViewForm({ onComplete, onClose }: CustomViewFormPr
<Panel.Description>Accepted: index.html only, maximum {maxUploadLabel}.</Panel.Description>
{fileDirty && fileError && <Panel.Error>{fileError}</Panel.Error>}
</div>
</form>
}
footerElements={
<>
{error && <Panel.Error>{error}</Panel.Error>}
<Panel.InlineElements align='end'>
<Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' form={formId} loading={isUploading} disabled={!canUpload}>
<Button variant='primary' type='submit' loading={isUploading} disabled={!canUpload}>
Upload view <IoCloudUploadOutline />
</Button>
</>
}
/>
</Panel.InlineElements>
</Panel.Indent>
);
}
@@ -37,14 +37,13 @@ export default function CustomViews() {
return (
<Panel.Section>
<Panel.Card>
{isUploadOpen && <CustomViewForm onComplete={handleUploadComplete} onClose={() => setIsUploadOpen(false)} />}
<Panel.SubHeader>
Custom views
<Panel.InlineElements>
<Button variant='ghosted' onClick={handleRestoreDemo} disabled={hasDemoView}>
Restore demo
</Button>
<Button onClick={() => setIsUploadOpen(true)}>
<Button onClick={() => setIsUploadOpen(true)} disabled={isUploadOpen}>
New <IoAdd />
</Button>
</Panel.InlineElements>
@@ -64,6 +63,8 @@ export default function CustomViews() {
<Panel.Section>
<Panel.Loader isLoading={status === 'pending'} />
{isUploadOpen && <CustomViewForm onComplete={handleUploadComplete} onClose={() => setIsUploadOpen(false)} />}
{actionError && <Panel.Error>{actionError}</Panel.Error>}
<CustomViewsList
@@ -1,6 +1,5 @@
import { CustomViewSummary } from 'ontime-types';
import { useState } from 'react';
import { IoCloudUploadOutline } from 'react-icons/io5';
import { deleteCustomView } from '../../../../common/api/customViews';
import { maybeAxiosError } from '../../../../common/api/utils';
@@ -54,17 +53,7 @@ export default function CustomViewsList({ views, onOpenUpload, onMutate, onError
</tr>
</thead>
<tbody>
{views.length === 0 && (
<Panel.TableEmpty
title='No custom views yet'
description='Upload a self-contained index.html to serve your own view alongside the built-in ones.'
action={
<Button variant='primary' onClick={onOpenUpload}>
Upload view <IoCloudUploadOutline />
</Button>
}
/>
)}
{views.length === 0 && <Panel.TableEmpty handleClick={onOpenUpload} label='No custom views yet' />}
{views.map((view, index) => (
<CustomViewsListItem
key={view.slug}
@@ -12,13 +12,6 @@
gap: 1rem;
}
.formColumn {
display: flex;
flex-direction: column;
gap: 1rem;
}
.current {
// marker consumed by the shared Panel.Table active-row treatment
background-color: inherit;
background-color: $blue-1100 !important; // fighting zebra styles
}
@@ -3,12 +3,9 @@ import { useForm } from 'react-hook-form';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import Modal from '../../../../common/components/modal/Modal';
import { useMutateProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import * as Panel from '../../panel-utils/PanelUtils';
const formId = 'new-rundown-form';
type NewRundownFormState = {
title: string;
};
@@ -45,32 +42,22 @@ export function ManageRundownForm({ onClose }: ManageRundownForm) {
}, [setFocus]);
return (
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
size='small'
title='Create rundown'
bodyElements={
<form id={formId} onSubmit={handleSubmit(createRundown)}>
<Panel.Indent as='form' onSubmit={handleSubmit(createRundown)}>
<Panel.Section>
<label>
<Panel.Description>Rundown title</Panel.Description>
<Input {...register('title')} fluid placeholder='Your rundown name' maxLength={64} />
<Input {...register('title')} fluid placeholder='Your rundown name' />
</label>
</form>
}
footerElements={
<>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
</Panel.Section>
<Panel.InlineElements relation='inner' align='end'>
<Button variant='ghosted' disabled={isSubmitting} onClick={onClose}>
Cancel
</Button>
<Button type='submit' form={formId} variant='primary' disabled={isSubmitting}>
<Button type='submit' variant='primary' disabled={isSubmitting}>
Create rundown
</Button>
</>
}
/>
</Panel.InlineElements>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
</Panel.Indent>
);
}
@@ -153,7 +153,7 @@ export default function ManageRundowns() {
<tr key={id} className={cx([isLoaded && style.current])}>
<td>{numEntries}</td>
<td>
{title} {isLoaded && <Tag variant='active'>Loaded</Tag>}
{title} {isLoaded && <Tag>Loaded</Tag>}
</td>
<td>
<DropdownMenu
@@ -1,4 +1,5 @@
import { CustomField, CustomFieldKey } from 'ontime-types';
import { useState } from 'react';
import { IoPencil, IoTrash } from 'react-icons/io5';
import IconButton from '../../../../../common/components/buttons/IconButton';
@@ -6,6 +7,7 @@ import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
import Tag from '../../../../../common/components/tag/Tag';
import * as Panel from '../../../panel-utils/PanelUtils';
import CustomFieldForm from './CustomFieldForm';
import style from '../ManagePanel.module.scss';
@@ -14,12 +16,35 @@ interface CustomFieldEntryProps {
label: string;
fieldKey: string;
type: CustomField['type'];
onEdit: () => void;
onEdit: (key: CustomFieldKey, patch: CustomField) => Promise<void>;
onDelete: (key: CustomFieldKey) => Promise<void>;
}
export default function CustomFieldEntry(props: CustomFieldEntryProps) {
const { colour, label, fieldKey, type, onEdit, onDelete } = props;
const [isEditing, setIsEditing] = useState(false);
const handleEdit = async (patch: CustomField) => {
await onEdit(fieldKey, patch);
setIsEditing(false);
};
if (isEditing) {
return (
<tr>
<td colSpan={99}>
<CustomFieldForm
onCancel={() => setIsEditing(false)}
onSubmit={handleEdit}
initialColour={colour}
initialLabel={label}
initialKey={fieldKey}
initialType={type}
/>
</td>
</tr>
);
}
return (
<tr>
@@ -36,7 +61,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
</CopyTag>
</td>
<Panel.InlineElements relation='inner' as='td'>
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={onEdit}>
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={() => setIsEditing(true)}>
<IoPencil />
</IconButton>
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={() => onDelete(fieldKey)}>
@@ -8,15 +8,13 @@ import Button from '../../../../../common/components/buttons/Button';
import Info from '../../../../../common/components/info/Info';
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
import Input from '../../../../../common/components/input/input/Input';
import Modal from '../../../../../common/components/modal/Modal';
import RadioGroup from '../../../../../common/components/radio-group/RadioGroup';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { preventEscape } from '../../../../../common/utils/keyEvent';
import * as Panel from '../../../panel-utils/PanelUtils';
import style from '../ManagePanel.module.scss';
const formId = 'custom-field-form';
interface CustomFieldsFormProps {
onSubmit: (field: CustomField) => Promise<void>;
onCancel: () => void;
@@ -87,15 +85,7 @@ export default function CustomFieldForm({
const isEditMode = initialKey !== undefined;
return (
<Modal
isOpen
onClose={onCancel}
showBackdrop
showCloseButton
size='compact'
title={isEditMode ? 'Edit custom field' : 'Create custom field'}
bodyElements={
<form id={formId} onSubmit={handleSubmit(setupSubmit)} className={style.formColumn}>
<Panel.Indent as='form' onSubmit={handleSubmit(setupSubmit)} onKeyDown={(event) => preventEscape(event, onCancel)}>
<Info>
Please note that images can quickly deteriorate your app&apos;s performance.
<br />
@@ -126,8 +116,8 @@ export default function CustomFieldForm({
if (value.trim().length === 0) return 'Required field';
if (!checkRegex.isAlphanumericWithSpace(value))
return 'Only alphanumeric characters and space are allowed';
if (!isEditMode && Object.hasOwn(data, customFieldLabelToKey(value) ?? '')) {
return 'Custom fields must be unique';
if (!isEditMode) {
if (isEditMode && Object.keys(data).includes(value)) return 'Custom fields must be unique';
}
return true;
},
@@ -145,19 +135,15 @@ export default function CustomFieldForm({
<Panel.Description>Colour</Panel.Description>
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
</label>
</form>
}
footerElements={
<>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.InlineElements relation='inner' align='end'>
<Button variant='ghosted' onClick={onCancel}>
Cancel
</Button>
<Button type='submit' form={formId} variant='primary' disabled={!canSubmit} loading={isSubmitting}>
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</>
}
/>
</Panel.InlineElements>
</Panel.Indent>
);
}
@@ -59,7 +59,6 @@ export default function RundownRenameForm({ onSubmit, onCancel, initialTitle }:
return true;
},
})}
maxLength={64}
fluid
/>
{errors.title && <Panel.Error>{errors.title.message}</Panel.Error>}
@@ -25,13 +25,6 @@
margin-top: 1rem;
}
.finishActions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
}
.sourceGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1,6 +1,4 @@
import type {
ImportedFields,
RundownImportMode,
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
SpreadsheetWorksheetOptions,
@@ -8,7 +6,6 @@ import type {
import { getErrorMessage, ImportMap } from 'ontime-utils';
import { ChangeEvent, useCallback, useRef, useState } from 'react';
import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
import { useNavigate } from 'react-router';
import {
getWorksheetMetadata as getExcelWorksheetMetadata,
@@ -25,7 +22,7 @@ import Info from '../../../../../common/components/info/Info';
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
import Modal from '../../../../../common/components/modal/Modal';
import useRundown from '../../../../../common/hooks-query/useRundown';
import { removeFileExtension, validateExcelImport } from '../../../../../common/utils/uploadUtils';
import { validateExcelImport } from '../../../../../common/utils/uploadUtils';
import * as Panel from '../../../panel-utils/PanelUtils';
import GSheetSetup from './GSheetSetup';
import SheetImportEditor from './sheet-import/SheetImportEditor';
@@ -38,7 +35,6 @@ const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadshee
type ActiveSource =
| {
kind: 'excel';
fileName: string;
worksheetNames: string[];
initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null;
closedByUser: boolean;
@@ -57,11 +53,9 @@ export default function SourcesPanel() {
const [error, setError] = useState('');
const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none');
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
const [completedRundownTitle, setCompletedRundownTitle] = useState('');
const { data: currentRundown } = useRundown();
const { applyImport } = useSpreadsheetImport();
const navigate = useNavigate();
const { importRundown } = useSpreadsheetImport();
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -80,7 +74,6 @@ export default function SourcesPanel() {
const worksheetOptions = await uploadExcel(fileToUpload);
setActiveSource({
kind: 'excel',
fileName: fileToUpload.name,
worksheetNames: worksheetOptions.worksheets,
initialWorksheetMetadata: worksheetOptions.metadata,
closedByUser: false,
@@ -104,7 +97,6 @@ export default function SourcesPanel() {
setHasFile('none');
setActiveSource(null);
setError('');
setCompletedRundownTitle('');
};
const openGSheetFlow = () => {
@@ -127,41 +119,29 @@ export default function SourcesPanel() {
}
};
const handleFinished = (rundownTitle: string) => {
const handleFinished = () => {
setImportFlow('finished');
setHasFile('none');
setActiveSource(null);
setError('');
setCompletedRundownTitle(rundownTitle);
};
const handleApplyImport = async (
preview: SpreadsheetPreviewResponse,
mode: RundownImportMode,
newRundownTitle: string,
providedFields: ImportedFields,
) => {
if (mode === 'new') {
const title = newRundownTitle.trim() || preview.rundown.title;
await applyImport({ mode: 'new', rundown: { ...preview.rundown, title }, customFields: preview.customFields });
handleFinished(title);
return;
}
const handleApplyImport = async (preview: SpreadsheetPreviewResponse) => {
if (!currentRundown) {
throw new Error('No current rundown loaded');
}
// override or merge into the current rundown; merge uses providedFields to know which columns
// the sheet supplied so matched events keep the rest (e.g. automations)
await applyImport({
mode,
targetRundownId: currentRundown.id,
rundown: preview.rundown,
customFields: preview.customFields,
providedFields,
});
handleFinished(currentRundown.title);
await importRundown(
{
[currentRundown.id]: {
...preview.rundown,
id: currentRundown.id,
title: currentRundown.title,
},
},
preview.customFields,
);
handleFinished();
};
const loadWorksheetMetadata = useCallback(
@@ -228,13 +208,6 @@ export default function SourcesPanel() {
if (activeSource.kind === 'excel') return 'excel';
return `gsheet:${activeSource.sheetId}`;
})();
// suggested name when importing into a new rundown: the spreadsheet file name (without extension)
// for Excel, or the document title for Google Sheets
const spreadsheetName = (() => {
if (!activeSource) return '';
if (activeSource.kind === 'excel') return removeFileExtension(activeSource.fileName);
return activeSource.title;
})();
return (
<Panel.Section>
@@ -294,21 +267,12 @@ export default function SourcesPanel() {
{showCompleted && (
<div className={style.finishSection}>
<span className={style.finishBadge}>Import complete</span>
<div className={style.finishTitle}>
Spreadsheet data applied to {completedRundownTitle || 'your rundown'}.
</div>
<div className={style.finishDescription}>
Review the imported rundown in the editor or start another import.
</div>
<div className={style.finishActions}>
<Button variant='primary' onClick={() => navigate('/editor')}>
Open editor
</Button>
<div className={style.finishTitle}>Spreadsheet data applied.</div>
<div className={style.finishDescription}>You can close this flow or start another import.</div>
<Button variant='subtle-white' onClick={resetFlow}>
Import another
Reset flow
</Button>
</div>
</div>
)}
{isGSheetFlow && (
<GSheetSetup onCancel={cancelGSheetFlow} onSheetLoaded={handleSheetLoaded} closedByUser={closedByUser} />
@@ -323,7 +287,6 @@ export default function SourcesPanel() {
bodyElements={
<SheetImportEditor
sourceKey={sourceKey ?? 'spreadsheet'}
defaultRundownName={spreadsheetName}
worksheetNames={activeSource?.worksheetNames ?? []}
initialMetadata={activeSource?.initialWorksheetMetadata ?? null}
loadMetadata={loadWorksheetMetadata}
@@ -1,55 +0,0 @@
import type { RundownImportMode, SpreadsheetPreviewResponse } from 'ontime-types';
import { isOntimeEvent, isPlayableEvent, Playback } from 'ontime-types';
import { useEffect, useState } from 'react';
import Button from '../../../../../../common/components/buttons/Button';
import { usePlayback, useSelectedEventId } from '../../../../../../common/hooks/useSocket';
interface ApplyImportButtonProps {
preview: SpreadsheetPreviewResponse | null;
mode: RundownImportMode;
disabled: boolean;
loading: boolean;
onApply: () => void;
}
/**
* Apply action for the spreadsheet import.
* Subscribes to playback state on its own so playback updates do not re-render the whole editor.
* Requires a second click to confirm when applying would stop a running playback.
*/
export default function ApplyImportButton({ preview, mode, disabled, loading, onApply }: ApplyImportButtonProps) {
const playback = usePlayback();
const loadedEventId = useSelectedEventId();
// the loaded (playing) event loses its playback unless it still exists as a playable event after the import
const loadedEntry = loadedEventId ? preview?.rundown.entries[loadedEventId] : undefined;
const willLoadedEventBeOverriden = !(
loadedEntry !== undefined &&
isOntimeEvent(loadedEntry) &&
isPlayableEvent(loadedEntry)
);
// applying stops playback when creating a new rundown, or when the playing event does not survive
const willStopPlayback = playback !== Playback.Stop && (mode === 'new' || willLoadedEventBeOverriden);
// two-step confirmation before applying an import that stops playback
const [confirmStop, setConfirmStop] = useState(false);
useEffect(() => {
setConfirmStop(false);
}, [mode, preview]);
const handleClick = () => {
if (willStopPlayback && !confirmStop) {
setConfirmStop(true);
return;
}
onApply();
};
return (
<Button variant='primary' onClick={handleClick} disabled={disabled} loading={loading}>
{willStopPlayback && confirmStop ? 'Confirm — stop playback & apply import' : 'Apply import'}
</Button>
);
}
@@ -33,11 +33,6 @@
white-space: nowrap;
}
.importModeTrigger {
min-width: 12rem;
justify-content: space-between;
}
.addColumnTrigger {
justify-content: center;
white-space: nowrap;
@@ -92,10 +87,6 @@
font-weight: 600;
}
.toolbarWarning {
color: $orange-400;
}
.mappingPaneTitle {
align-self: center;
}
@@ -1,20 +1,10 @@
import type {
ImportedFields,
RundownImportMode,
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
} from 'ontime-types';
import type { SpreadsheetPreviewResponse, SpreadsheetWorksheetMetadata } from 'ontime-types';
import type { ImportMap } from 'ontime-utils';
import { useMemo } from 'react';
import { IoArrowUpOutline, IoCheckmark, IoChevronDown, IoEye, IoWarningOutline } from 'react-icons/io5';
import { IoArrowUpOutline, IoEye } from 'react-icons/io5';
import Button from '../../../../../../common/components/buttons/Button';
import { DropdownMenu, DropdownMenuOption } from '../../../../../../common/components/dropdown-menu/DropdownMenu';
import Input from '../../../../../../common/components/input/input/Input';
import Select from '../../../../../../common/components/select/Select';
import * as Panel from '../../../../panel-utils/PanelUtils';
import ApplyImportButton from './ApplyImportButton';
import { isIdColumnMapped } from './importMapUtils';
import PreviewTable from './preview/PreviewTable';
import SheetImportMappingPane from './SheetImportMappingPane';
import { useSheetImportForm } from './useSheetImportForm';
@@ -23,46 +13,17 @@ import style from './SheetImportEditor.module.scss';
interface SheetImportEditorProps {
sourceKey: string;
defaultRundownName: string;
worksheetNames: string[];
initialMetadata: SpreadsheetWorksheetMetadata | null;
loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>;
previewImport: (importMap: ImportMap) => Promise<SpreadsheetPreviewResponse>;
onApply: (
preview: SpreadsheetPreviewResponse,
mode: RundownImportMode,
newRundownTitle: string,
providedFields: ImportedFields,
) => Promise<void>;
onApply: (preview: SpreadsheetPreviewResponse) => Promise<void>;
onCancel: () => void;
onExport?: (importMap: ImportMap) => Promise<void>;
}
const importModeOptions: Array<{
value: RundownImportMode;
label: string;
description: string;
}> = [
{
value: 'override',
label: 'Replace current rundown',
description: 'Spreadsheet data completely replaces current rundown',
},
{
value: 'merge',
label: 'Merge with current rundown',
description: 'Merge entries referencing their IDs, entries not present in spreadsheet rundown are deleted',
},
{
value: 'new',
label: 'New rundown',
description: 'Create a new rundown to import the data into. Loads the new rundown',
},
];
export default function SheetImportEditor({
sourceKey,
defaultRundownName,
worksheetNames,
initialMetadata,
loadMetadata,
@@ -88,16 +49,11 @@ export default function SheetImportEditor({
isBusy,
canPreview,
displayError,
importMode,
setImportMode,
newRundownTitle,
setNewRundownTitle,
handlePreviewSubmit,
handleExportSubmit,
handleApply,
} = useSheetImportForm({
sourceKey,
defaultRundownName,
worksheetNames,
initialMetadata,
loadMetadata,
@@ -106,19 +62,6 @@ export default function SheetImportEditor({
onExport,
});
const selectedImportMode = importModeOptions.find((option) => option.value === importMode) ?? importModeOptions[0];
const importModeItems = useMemo<DropdownMenuOption[]>(
() =>
importModeOptions.map((option) => ({
type: 'item',
label: option.label,
description: option.description,
icon: importMode === option.value ? IoCheckmark : undefined,
onClick: () => setImportMode(option.value),
})),
[importMode, setImportMode],
);
return (
<Panel.Section as='form' id='spreadsheet-import-workspace' className={style.editor} onSubmit={handlePreviewSubmit}>
<Panel.InlineElements align='apart' wrap='wrap' className={style.editorToolbar}>
@@ -132,16 +75,7 @@ export default function SheetImportEditor({
}
/>
</label>
{toolbarStatus && (
<Panel.Description>
{toolbarStatus.entries === ''
? 'No import preview yet'
: `${toolbarStatus.entries} entries · ${toolbarStatus.groups} groups · ${toolbarStatus.milestones} milestones · ${toolbarStatus.start}${toolbarStatus.end} · ${toolbarStatus.duration}`}
{toolbarStatus.warnings > 0 && (
<span className={style.toolbarWarning}> · {toolbarStatus.warnings} warnings</span>
)}
</Panel.Description>
)}
{toolbarStatus && <Panel.Description>{toolbarStatus}</Panel.Description>}
</Panel.InlineElements>
<div className={style.editorBody}>
@@ -159,19 +93,13 @@ export default function SheetImportEditor({
<section className={style.previewPane}>
<div className={style.previewPaneHeader}>
<div className={style.previewPaneHeading}>
<span className={style.previewPaneTitle}>Import preview</span>
</div>
</div>
<div className={style.tableShell}>
<PreviewTable
preview={state.preview}
columnLabels={columnLabels}
canRefresh={canPreview}
isLoadingMetadata={isLoadingMetadata}
isRefreshing={state.loading === 'preview'}
needsPreviewRefresh={state.needsPreviewRefresh}
onRefresh={handlePreviewSubmit}
worksheetHeaders={worksheetHeaders}
/>
</div>
@@ -179,37 +107,7 @@ export default function SheetImportEditor({
</div>
{displayError && <Panel.Error>{displayError}</Panel.Error>}
{importMode === 'merge' && !isIdColumnMapped(values) && (
<Panel.Description tone='warning'>
<IoWarningOutline /> No ID column mapped merge matches entries by ID, so it will behave like Replace. Export
your rundown to a spreadsheet first to keep its IDs.
</Panel.Description>
)}
<Panel.InlineElements align='apart' wrap='wrap' className={style.editorFooter}>
<Panel.InlineElements wrap='wrap'>
<label className={style.worksheetControl}>
<span className={style.worksheetLabel}>Import mode</span>
<DropdownMenu
render={<Button className={style.importModeTrigger} variant='subtle-white' />}
items={importModeItems}
>
{selectedImportMode.label}
<IoChevronDown />
</DropdownMenu>
</label>
{importMode === 'new' && (
<label className={style.worksheetControl}>
<span className={style.worksheetLabel}>New rundown name</span>
<Input
value={newRundownTitle}
onChange={(event) => setNewRundownTitle(event.target.value)}
placeholder={state.preview?.rundown.title || 'Imported rundown'}
aria-label='New rundown name'
/>
</label>
)}
</Panel.InlineElements>
<Panel.InlineElements wrap='wrap'>
<Panel.InlineElements align='end' wrap='wrap' className={style.editorFooter}>
<Button onClick={onCancel} disabled={isBusy}>
Cancel
</Button>
@@ -228,14 +126,14 @@ export default function SheetImportEditor({
<IoEye />
Preview import
</Button>
<ApplyImportButton
preview={state.preview}
mode={importMode}
<Button
variant='primary'
onClick={handleApply}
disabled={!state.preview || isBusy}
loading={state.loading === 'apply'}
onApply={handleApply}
/>
</Panel.InlineElements>
>
Apply import
</Button>
</Panel.InlineElements>
</Panel.Section>
);
@@ -1,21 +1,15 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { describe, expect, it } from 'vitest';
import {
builtInFieldDefs,
convertToImportMap,
createDefaultFormValues,
defaultImportMode,
getImportWarnings,
getPersistedImportMode,
getProvidedImportFields,
getResolvedCustomFields,
isIdColumnMapped,
persistImportMode,
} from '../importMapUtils';
const cueIndex = builtInFieldDefs.findIndex((field) => field.label === 'Cue');
const titleIndex = builtInFieldDefs.findIndex((field) => field.label === 'Title');
const idIndex = builtInFieldDefs.findIndex((field) => field.importKey === 'id');
describe('getImportWarnings()', () => {
it('warns when two mappings target the same spreadsheet column', () => {
@@ -134,64 +128,3 @@ describe('convertToImportMap()', () => {
});
});
});
describe('getProvidedImportFields()', () => {
it('reports the mapped built-in and custom fields the sheet supplies', () => {
const values = createDefaultFormValues();
values.builtIn[titleIndex] = { header: 'title', enabled: true };
values.builtIn[cueIndex] = { header: '', enabled: false };
values.custom = [{ ontimeName: 'ignored', importName: 'FOH/Monitor' }];
const provided = getProvidedImportFields(convertToImportMap(values));
// enabled built-in mappings are reported as event fields, disabled ones are not
expect(provided.event).toContain('title');
expect(provided.event).not.toContain('cue');
// the id column is only used for matching, never overwritten
expect(provided.event).not.toContain('id');
// custom fields are reported symmetrically by their resolved Ontime name
expect(provided.custom).toStrictEqual(['FOH Monitor']);
});
});
describe('isIdColumnMapped()', () => {
it('is true when the ID field is enabled with a header', () => {
// the default form maps the ID column
expect(isIdColumnMapped(createDefaultFormValues())).toBe(true);
});
it('is false when the ID field is disabled', () => {
const values = createDefaultFormValues();
values.builtIn[idIndex] = { header: 'id', enabled: false };
expect(isIdColumnMapped(values)).toBe(false);
});
it('is false when the ID field header is blank', () => {
const values = createDefaultFormValues();
values.builtIn[idIndex] = { header: ' ', enabled: true };
expect(isIdColumnMapped(values)).toBe(false);
});
});
describe('import mode persistence', () => {
const sourceKey = 'excel-test';
beforeEach(() => {
localStorage.clear();
});
it('defaults to override when nothing is persisted', () => {
expect(getPersistedImportMode(sourceKey)).toBe('override');
expect(defaultImportMode).toBe('override');
});
it('round-trips a persisted value', () => {
persistImportMode(sourceKey, 'merge');
expect(getPersistedImportMode(sourceKey)).toBe('merge');
});
it('falls back to the default when the persisted value is invalid', () => {
persistImportMode(sourceKey, 'nonsense' as never);
expect(getPersistedImportMode(sourceKey)).toBe('override');
});
});
@@ -1,4 +1,3 @@
import type { ImportedFields, RundownImportMode } from 'ontime-types';
import type { ImportMap } from 'ontime-utils';
import { makeStageKey } from '../../../../../../common/utils/localStorage';
@@ -50,16 +49,6 @@ export function createDefaultFormValues(): ImportFormValues {
};
}
/**
* Whether the mapping supplies an ID column. Merge matches entries by ID, so without one every
* imported entry gets a fresh id and nothing can reconcile with the current rundown.
*/
export function isIdColumnMapped(values: ImportFormValues): boolean {
const idIndex = builtInFieldDefs.findIndex((def) => def.importKey === 'id');
const field = values.builtIn[idIndex];
return Boolean(field?.enabled && field.header.trim());
}
function sanitiseOntimeCustomFieldLabel(importName: string): string {
// Replace punctuation with spaces, then collapse repeated whitespace into single spaces.
const sanitised = importName
@@ -92,24 +81,6 @@ export function getResolvedCustomFields(customFields: ImportFormValues['custom']
});
}
/**
* Returns the fields the import map supplies the complete description of what the incoming data
* provides, for both built-in and custom fields. A merge uses this to patch exactly these fields
* onto a matched event and keep everything else (e.g. automations) untouched.
* Import-map keys are OntimeEvent field names; `worksheet`/`custom` are meta and `id` is only used
* for matching, not overwritten.
*/
export function getProvidedImportFields(importMap: ImportMap): ImportedFields {
const event: string[] = [];
for (const [key, value] of Object.entries(importMap)) {
if (key === 'worksheet' || key === 'custom' || key === 'id') continue;
if (typeof value === 'string' && value.trim() !== '') {
event.push(key);
}
}
return { event, custom: Object.keys(importMap.custom) };
}
export function convertToImportMap(values: ImportFormValues): ImportMap {
const custom = getResolvedCustomFields(values.custom).reduce<Record<string, string>>(
(accumulator, { ontimeName, importName }) => {
@@ -159,12 +130,12 @@ function isPersistedFormValues(obj: unknown): obj is ImportFormValues {
export function getPersistedImportState(sourceKey: string): { values: ImportFormValues; isPersisted: boolean } {
const storageKey = getImportMapKey(sourceKey);
try {
const persistedData = localStorage.getItem(storageKey);
if (!persistedData) {
const raw = localStorage.getItem(storageKey);
if (!raw) {
return { values: createDefaultFormValues(), isPersisted: false };
}
const parsed: unknown = JSON.parse(persistedData);
const parsed: unknown = JSON.parse(raw);
if (isPersistedFormValues(parsed)) {
return { values: parsed, isPersisted: true };
}
@@ -179,32 +150,6 @@ export function getPersistedImportState(sourceKey: string): { values: ImportForm
}
}
/**
* The import mode (new / merge / override) is persisted separately from the field mapping
* so the mapping schema guard stays untouched.
*/
/** Default import mode: replace matched elements in the current rundown */
export const defaultImportMode: RundownImportMode = 'override';
function getImportModeKey(sourceKey: string) {
return makeStageKey(`import-mode:${sourceKey}`);
}
/** Persists the import mode for a given source */
export function persistImportMode(sourceKey: string, mode: RundownImportMode) {
localStorage.setItem(getImportModeKey(sourceKey), mode);
}
/** Reads the persisted import mode for a source, falling back to the default when absent or invalid */
export function getPersistedImportMode(sourceKey: string): RundownImportMode {
const persisted = localStorage.getItem(getImportModeKey(sourceKey));
if (persisted === 'new' || persisted === 'merge' || persisted === 'override') {
return persisted;
}
return defaultImportMode;
}
/**
* Validates import mappings and generates warnings for duplicate or missing spreadsheet columns.
*/
@@ -1,8 +1,27 @@
.emptyState {
height: 100%;
min-height: 16rem;
display: grid;
place-content: center;
gap: 0.35rem;
padding: 1.5rem;
text-align: center;
}
.emptyTitle {
color: $ui-white;
font-size: 1rem;
font-weight: 600;
}
.emptyBody {
color: $gray-400;
font-size: 0.95rem;
}
.table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
color: $ui-white;
border-collapse: collapse;
font-size: calc(1rem - 2px);
text-align: left;
table-layout: auto;
@@ -15,101 +34,31 @@
}
th {
color: $gray-300;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
vertical-align: bottom;
font-weight: 400;
color: $gray-400;
text-transform: capitalize;
vertical-align: top;
white-space: normal;
}
th,
td {
box-sizing: border-box;
padding: 0.5rem;
min-width: 8rem;
max-width: 20rem;
padding: 0.55rem 0.65rem;
border-bottom: 1px solid $white-10;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
vertical-align: top;
}
tbody tr {
--entry-colour: transparent;
background-color: color-mix(in srgb, $gray-1300 96%, var(--entry-colour) 4%);
box-shadow: inset 3px 0 var(--entry-colour);
tr:nth-child(even) {
background-color: $white-1;
}
td[data-empty='true'] {
color: $gray-600;
}
}
.rowNumber,
.rowType {
position: sticky;
z-index: 1;
background-color: inherit;
}
.rowNumber {
left: 0;
width: 3.25rem;
min-width: 3.25rem !important;
color: $gray-400;
text-align: right;
width: 4.5rem;
min-width: 4.5rem;
}
.rowType {
left: 3.25rem;
width: 6.25rem;
min-width: 6.25rem !important;
color: $gray-400;
font-size: 0.8rem;
box-shadow: 1px 0 $white-10;
}
thead .rowNumber,
thead .rowType {
z-index: 2;
background-color: $gray-1350;
}
.numericCell {
font-variant-numeric: tabular-nums;
text-align: right;
}
.multilineCell {
max-width: 30rem !important;
overflow-wrap: anywhere;
text-overflow: clip !important;
white-space: pre-wrap !important;
}
.eventRow {
.rowNumber {
font-variant-numeric: tabular-nums;
}
}
.groupRow {
background-color: color-mix(in srgb, $gray-1300 88%, var(--entry-colour) 12%) !important;
box-shadow: inset 4px 0 var(--entry-colour) !important;
font-weight: 600;
td {
min-height: 3.25rem;
border-top: 0.75rem solid $gray-1350;
}
}
.milestoneRow {
background-color: color-mix(in srgb, $gray-1300 93%, var(--entry-colour) 7%) !important;
box-shadow: inset 3px 0 var(--entry-colour) !important;
color: $gray-300;
font-style: italic;
width: 7rem;
min-width: 7rem;
}
@@ -1,12 +1,7 @@
import type { CustomField, CustomFieldKey, SpreadsheetPreviewResponse } from 'ontime-types';
import { isOntimeDelay, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import type { CSSProperties } from 'react';
import { isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { useMemo } from 'react';
import Button from '../../../../../../../common/components/buttons/Button';
import Tag from '../../../../../../../common/components/tag/Tag';
import { getRundownMetadata } from '../../../../../../../common/utils/rundownMetadata';
import * as Panel from '../../../../../panel-utils/PanelUtils';
import { getCellValue } from './previewTableUtils';
import style from './PreviewTable.module.scss';
@@ -14,85 +9,14 @@ import style from './PreviewTable.module.scss';
interface PreviewTableProps {
preview: SpreadsheetPreviewResponse | null;
columnLabels: string[];
canRefresh: boolean;
isLoadingMetadata: boolean;
isRefreshing: boolean;
needsPreviewRefresh: boolean;
onRefresh: () => void;
worksheetHeaders: string[];
}
const priorityColumns = ['Title', 'Cue', 'Start', 'End', 'Duration'];
const numericColumns = new Set(['Start', 'End', 'Duration', 'Time warning', 'Time danger']);
const transparentColour = 'transparent';
type PreviewEntry = SpreadsheetPreviewResponse['rundown']['entries'][string];
function getEntryDisplay(entry: PreviewEntry, groupColour?: string) {
if (isOntimeGroup(entry)) {
return {
rowClassName: style.groupRow,
entryColour: entry.colour,
entryType: 'Group',
};
}
const entryColour = groupColour ?? transparentColour;
if (isOntimeMilestone(entry)) {
return {
rowClassName: style.milestoneRow,
entryColour,
entryType: 'Milestone',
};
}
if (isOntimeDelay(entry)) {
return {
rowClassName: style.eventRow,
entryColour,
entryType: 'Delay',
};
}
return {
rowClassName: style.eventRow,
entryColour,
entryType: 'Event',
};
}
function getCellClassName(label: string, value: string) {
if (value.includes('\n')) {
return style.multilineCell;
}
if (numericColumns.has(label)) {
return style.numericCell;
}
return undefined;
}
function getDisplayColumns(columnLabels: string[]) {
return [...columnLabels].sort((left, right) => {
const leftPriority = priorityColumns.indexOf(left);
const rightPriority = priorityColumns.indexOf(right);
return (
(leftPriority === -1 ? priorityColumns.length : leftPriority) -
(rightPriority === -1 ? priorityColumns.length : rightPriority)
);
});
}
export default function PreviewTable({
preview,
columnLabels,
canRefresh,
isLoadingMetadata,
isRefreshing,
needsPreviewRefresh,
onRefresh,
worksheetHeaders,
}: PreviewTableProps) {
const customFieldKeyByLabel = useMemo(() => {
@@ -100,51 +24,33 @@ export default function PreviewTable({
return new Map(Object.entries(preview.customFields).map(([fieldId, field]) => [field.label, fieldId]));
}, [preview]);
const displayColumns = useMemo(() => getDisplayColumns(columnLabels), [columnLabels]);
const previewMetadata = useMemo(() => {
if (!preview) return null;
return getRundownMetadata(preview.rundown, null);
}, [preview]);
if (!preview) {
let emptyTitle = 'Preview not generated';
let emptyContent = 'Select the fields you want to import, then click Preview import.';
if (isLoadingMetadata) {
emptyTitle = 'Loading worksheet';
emptyContent = 'Loading worksheet metadata...';
} else if (worksheetHeaders.length === 0) {
emptyTitle = 'No headers found';
emptyContent =
'No column headers detected in this worksheet. Try a different worksheet or ensure the first row contains column headers.';
} else if (needsPreviewRefresh) {
emptyTitle = 'Preview needs updating';
emptyContent = 'Your column mapping changed. Preview the import again to update this table.';
}
return (
<Panel.EmptyState
title={emptyTitle}
description={emptyContent}
action={
needsPreviewRefresh && (
<Button variant='primary' onClick={onRefresh} disabled={!canRefresh} loading={isRefreshing}>
Refresh preview
</Button>
)
}
/>
<div className={style.emptyState}>
<div className={style.emptyTitle}>Preview not generated</div>
<div className={style.emptyBody}>{emptyContent}</div>
</div>
);
}
let eventIndex = 0;
return (
<table className={style.table}>
<thead>
<tr>
<th className={style.rowNumber}>#</th>
<th className={style.rowType}>Type</th>
{displayColumns.map((label, index) => (
{columnLabels.map((label, index) => (
<th key={`${label}-${index}`}>{label}</th>
))}
</tr>
@@ -153,29 +59,16 @@ export default function PreviewTable({
{preview.rundown.flatOrder.map((entryId) => {
const entry = preview.rundown.entries[entryId];
const isEvent = isOntimeEvent(entry);
const entryMetadata = previewMetadata?.[entryId];
const { rowClassName, entryColour, entryType } = getEntryDisplay(entry, entryMetadata?.groupColour);
if (isEvent) eventIndex++;
const hasType = isEvent || isOntimeGroup(entry) || isOntimeMilestone(entry);
return (
<tr key={entryId} className={rowClassName} style={{ '--entry-colour': entryColour } as CSSProperties}>
<td className={style.rowNumber}>{isEvent ? entryMetadata?.eventIndex : ''}</td>
<td className={style.rowType}>
<Tag>{entryType}</Tag>
</td>
{displayColumns.map((label, colIndex) => {
const value = getCellValue(label, entry, customFieldKeyByLabel);
const cellClassName = getCellClassName(label, value);
return (
<td
key={`${entryId}-${colIndex}`}
className={cellClassName}
data-empty={value === ''}
title={value || undefined}
>
{value}
</td>
);
})}
<tr key={entryId}>
<td className={style.rowNumber}>{isEvent ? eventIndex : ''}</td>
<td className={style.rowType}>{hasType ? entry.type : ''}</td>
{columnLabels.map((label, colIndex) => (
<td key={`${entryId}-${colIndex}`}>{getCellValue(label, entry, customFieldKeyByLabel)}</td>
))}
</tr>
);
})}
@@ -1,13 +1,7 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type {
ImportedFields,
RundownImportMode,
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
} from 'ontime-types';
import { isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { millisToString, removeTrailingZero } from 'ontime-utils';
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import type { SpreadsheetPreviewResponse, SpreadsheetWorksheetMetadata } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { maybeAxiosError } from '../../../../../../common/api/utils';
@@ -17,11 +11,8 @@ import {
builtInFieldDefs,
convertToImportMap,
getImportWarnings,
getPersistedImportMode,
getPersistedImportState,
getProvidedImportFields,
getResolvedCustomFields,
persistImportMode,
persistImportState,
} from './importMapUtils';
import { deriveHeaderOptionsState } from './spreadsheetImportUtils';
@@ -33,7 +24,7 @@ type ImportAction =
| { type: 'previewSuccess'; preview: SpreadsheetPreviewResponse }
| { type: 'applySuccess' }
| { type: 'exportSuccess' }
| { type: 'clearPreview'; error?: string; needsRefresh?: boolean }
| { type: 'clearPreview'; error?: string }
| { type: 'failure'; error: string }
| { type: 'reset' };
@@ -41,14 +32,12 @@ type ImportState = {
loading: '' | 'preview' | 'apply' | 'export';
error: string;
preview: SpreadsheetPreviewResponse | null;
needsPreviewRefresh: boolean;
};
const initialImportState: ImportState = {
loading: '',
error: '',
preview: null,
needsPreviewRefresh: false,
};
function importReducer(state: ImportState, action: ImportAction): ImportState {
@@ -60,15 +49,15 @@ function importReducer(state: ImportState, action: ImportAction): ImportState {
case 'startExport':
return { ...state, loading: 'export', error: '' };
case 'previewSuccess':
return { loading: '', error: '', preview: action.preview, needsPreviewRefresh: false };
return { loading: '', error: '', preview: action.preview };
case 'applySuccess':
case 'exportSuccess':
return { ...state, loading: '' };
case 'clearPreview':
return { ...state, error: action.error ?? '', preview: null, needsPreviewRefresh: action.needsRefresh ?? false };
return { ...state, error: action.error ?? '', preview: null };
case 'failure': {
if (state.loading === 'preview') {
return { loading: '', error: action.error, preview: null, needsPreviewRefresh: false };
return { loading: '', error: action.error, preview: null };
}
return { ...state, loading: '', error: action.error };
}
@@ -111,23 +100,16 @@ function buildColumnLabels(values: ImportFormValues): string[] {
interface UseSheetImportFormProps {
sourceKey: string;
defaultRundownName: string;
worksheetNames: string[];
initialMetadata: SpreadsheetWorksheetMetadata | null;
loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>;
previewImport: (importMap: ReturnType<typeof convertToImportMap>) => Promise<SpreadsheetPreviewResponse>;
onApply: (
preview: SpreadsheetPreviewResponse,
mode: RundownImportMode,
newRundownTitle: string,
providedFields: ImportedFields,
) => Promise<void>;
onApply: (preview: SpreadsheetPreviewResponse) => Promise<void>;
onExport?: (importMap: ReturnType<typeof convertToImportMap>) => Promise<void>;
}
export function useSheetImportForm({
sourceKey,
defaultRundownName,
worksheetNames,
initialMetadata,
loadMetadata,
@@ -188,8 +170,6 @@ export function useSheetImportForm({
const columnLabels = buildColumnLabels(values);
const [state, dispatch] = useReducer(importReducer, initialImportState);
const [importMode, setImportMode] = useState<RundownImportMode>(() => getPersistedImportMode(sourceKey));
const [newRundownTitle, setNewRundownTitle] = useState(defaultRundownName);
const warnings = getImportWarnings(values, headers);
const warningCount = Object.values(warnings).filter(Boolean).length;
const previewRef = useRef<SpreadsheetPreviewResponse | null>(null);
@@ -202,12 +182,6 @@ export function useSheetImportForm({
dispatch({ type: 'reset' });
}, [initialFormValues, reset]);
// Update import mode and rundown name if the source changes
useEffect(() => {
setImportMode(getPersistedImportMode(sourceKey));
setNewRundownTitle(defaultRundownName);
}, [sourceKey, defaultRundownName]);
// Keep the worksheet selection valid if the available worksheets change underneath the form.
useEffect(() => {
if (worksheetNames.length === 0) return;
@@ -224,7 +198,7 @@ export function useSheetImportForm({
const sub = watch(() => {
if (!previewRef.current) return;
previewRef.current = null;
dispatch({ type: 'clearPreview', needsRefresh: true });
dispatch({ type: 'clearPreview' });
});
return () => sub.unsubscribe();
}, [watch]);
@@ -267,15 +241,13 @@ export function useSheetImportForm({
try {
dispatch({ type: 'startApply' });
const providedFields = getProvidedImportFields(convertToImportMap(getValues()));
await onApply(state.preview, importMode, newRundownTitle, providedFields);
await onApply(state.preview);
persistImportState(sourceKey, getValues());
persistImportMode(sourceKey, importMode);
dispatch({ type: 'applySuccess' });
} catch (error) {
dispatch({ type: 'failure', error: maybeAxiosError(error) });
}
}, [getValues, importMode, newRundownTitle, onApply, sourceKey, state.preview]);
}, [getValues, onApply, sourceKey, state.preview]);
const handleExport = useCallback(
async (formValues: ImportFormValues) => {
@@ -300,37 +272,15 @@ export function useSheetImportForm({
}, [append]);
const toolbarStatus = (() => {
const warningText = warningCount > 0 ? ` | warnings: ${warningCount}` : '';
if (!state.preview) {
return {
entries: '',
groups: '',
milestones: '',
start: '',
end: '',
duration: '',
warnings: warningCount,
};
return `entries: | start: | end: | duration: ${warningText}`;
}
const { entries, flatOrder } = state.preview.rundown;
const { flatOrder } = state.preview.rundown;
const { start, end, duration } = state.preview.summary;
let groups = 0;
let milestones = 0;
for (const entryId of flatOrder) {
const entry = entries[entryId];
if (isOntimeGroup(entry)) groups++;
else if (isOntimeMilestone(entry)) milestones++;
}
return {
entries: flatOrder.length,
groups,
milestones,
start: removeTrailingZero(millisToString(start)),
end: removeTrailingZero(millisToString(end)),
duration: formatDuration(duration),
warnings: warningCount,
};
return `entries: ${flatOrder.length} | start: ${millisToString(start)} | end: ${millisToString(end)} | duration: ${formatDuration(duration)}${warningText}`;
})();
return {
@@ -350,10 +300,6 @@ export function useSheetImportForm({
isBusy,
canPreview,
displayError,
importMode,
setImportMode,
newRundownTitle,
setNewRundownTitle,
handlePreviewSubmit: handleSubmit(handlePreview),
handleExportSubmit: handleSubmit(handleExport),
handleApply,
@@ -1,17 +1,30 @@
import { RundownImportPayload } from 'ontime-types';
import { useQueryClient } from '@tanstack/react-query';
import { CustomFields, ProjectRundowns } from 'ontime-types';
import { useCallback } from 'react';
import { importRundownWithOptions } from '../../../../../common/api/rundown';
import { CUSTOM_FIELDS, RUNDOWN } from '../../../../../common/api/constants';
import { patchData } from '../../../../../common/api/db';
export default function useSpreadsheetImport() {
/** applies a spreadsheet import: override or merge into the current rundown, or create a new one */
const applyImport = useCallback(async (payload: RundownImportPayload) => {
// the backend broadcasts a refetch once the rundown is parsed and applied, so the caches update
// through that single path rather than racing it with an optimistic write from here
await importRundownWithOptions(payload);
}, []);
const queryClient = useQueryClient();
/** applies rundown and customFields to current project */
const importRundown = useCallback(
async (rundowns: ProjectRundowns, customFields: CustomFields) => {
await patchData({ rundowns, customFields });
// we are unable to optimistically set the rundown since we need
// it to be normalised
await queryClient.invalidateQueries({
queryKey: RUNDOWN,
});
await queryClient.invalidateQueries({
queryKey: CUSTOM_FIELDS,
});
},
[queryClient],
);
return {
applyImport,
importRundown,
};
}
@@ -16,22 +16,6 @@
user-select: text;
}
.actionsCell {
width: 1%;
white-space: nowrap;
text-align: right;
}
.actionsHeader {
text-align: right;
}
.actionsGroup {
width: 100%;
justify-content: flex-end;
}
.current {
// marker consumed by the shared Panel.Table active-row treatment
background-color: inherit;
.self {
background-color: $blue-1100;
}
@@ -64,28 +64,21 @@ export default function ClientList() {
<tr>
<td style={{ width: '20%' }}>Client Name</td>
<td>Path</td>
<td className={style.actionsHeader} />
<td />
</tr>
</thead>
<tbody>
{ontimeClients.length === 0 && (
<Panel.TableEmpty
title='No Ontime clients connected'
description='Editor, cuesheet and operator windows appear here as they connect.'
/>
)}
{ontimeClients.map(([key, client]) => {
const { identify, name, path } = client;
const isCurrent = id === key;
return (
<tr key={key} className={cx([isCurrent && style.current])}>
<tr key={key} className={cx([isCurrent && style.self])}>
<Panel.InlineElements relation='inner' as='td'>
{isCurrent && <Tag variant='active'>SELF</Tag>}
{isCurrent && <Tag>SELF</Tag>}
{name}
</Panel.InlineElements>
<td className={style.copiable}>{path}</td>
<td className={style.actionsCell}>
<Panel.InlineElements relation='inner' align='end' className={style.actionsGroup}>
<Panel.InlineElements relation='inner' as='td'>
<Button
size='small'
className={`${identify ? style.blink : ''}`}
@@ -115,7 +108,6 @@ export default function ClientList() {
Redirect
</Button>
</Panel.InlineElements>
</td>
</tr>
);
})}
@@ -133,12 +125,6 @@ export default function ClientList() {
</tr>
</thead>
<tbody>
{otherClients.length === 0 && (
<Panel.TableEmpty
title='No other clients connected'
description='Views and integrations connected to this server are listed here.'
/>
)}
{otherClients.map(([key, client]) => {
const { name, type } = client;
@@ -1,21 +1,29 @@
import { ChangeEvent, useRef, useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import { useSearchParams } from 'react-router';
import { uploadProjectFile } from '../../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import { validateProjectFile } from '../../../../common/utils/uploadUtils';
import * as Panel from '../../panel-utils/PanelUtils';
import useAppSettingsNavigation from '../../useAppSettingsNavigation';
import ProjectCreateForm from './ProjectCreateForm';
import ProjectList from './ProjectList';
export default function ManageProjects() {
const { setLocation } = useAppSettingsNavigation();
const [searchParams, setSearchParams] = useSearchParams();
const [error, setError] = useState('');
const [loading, setLoading] = useState<'import' | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const isCreatingProject = searchParams.get('new') === 'true';
const handleToggleCreate = () => {
searchParams.set('new', isCreatingProject ? 'false' : 'true');
setSearchParams(searchParams);
};
const handleSelectFile = () => {
fileInputRef.current?.click();
};
@@ -41,6 +49,11 @@ export default function ManageProjects() {
setLoading(null);
};
const handleCloseForm = () => {
searchParams.delete('new');
setSearchParams(searchParams);
};
return (
<Panel.Section>
<input
@@ -55,16 +68,21 @@ export default function ManageProjects() {
<Panel.SubHeader>
Manage projects
<Panel.InlineElements>
<Button onClick={handleSelectFile} disabled={Boolean(loading)} loading={loading === 'import'}>
<Button
onClick={handleSelectFile}
disabled={Boolean(loading) || isCreatingProject}
loading={loading === 'import'}
>
Import
</Button>
<Button onClick={() => setLocation('project__create')} disabled={Boolean(loading)}>
<Button onClick={handleToggleCreate} disabled={Boolean(loading) || isCreatingProject}>
New <IoAdd />
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
{error && <Panel.Error>{error}</Panel.Error>}
<Panel.Divider />
{isCreatingProject && <ProjectCreateForm onClose={handleCloseForm} />}
<ProjectList />
</Panel.Card>
</Panel.Section>
@@ -0,0 +1,84 @@
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { createProject } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import { preventEscape } from '../../../../common/utils/keyEvent';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './ProjectPanel.module.scss';
interface ProjectCreateFromProps {
onClose: () => void;
}
type ProjectCreateFormValues = {
title?: string;
description?: string;
info?: string;
url?: string;
custom?: { title: string; value: string }[];
};
export default function ProjectCreateForm({ onClose }: ProjectCreateFromProps) {
const [error, setError] = useState<string | null>(null);
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 ?? 'untitled';
await createProject({ filename });
onClose();
} catch (error) {
setError(maybeAxiosError(error));
}
};
return (
<Panel.Indent
as='form'
onSubmit={handleSubmit(handleSubmitCreate)}
onKeyDown={(event) => preventEscape(event, onClose)}
>
<Panel.Title>
Create new project
<Panel.InlineElements>
<Button onClick={onClose} variant='ghosted' disabled={isSubmitting}>
Cancel
</Button>
<Button disabled={!isValid} type='submit' loading={isSubmitting} variant='primary'>
Create project
</Button>
</Panel.InlineElements>
</Panel.Title>
{error && <Panel.Error>{error}</Panel.Error>}
<Panel.Section className={style.innerColumn}>
<Panel.Description>Project title</Panel.Description>
<Input fluid placeholder='Your project name' {...register('title')} />
</Panel.Section>
</Panel.Indent>
);
}
@@ -40,7 +40,7 @@ export default function ProjectList() {
if (status === 'pending') {
return (
<div className={style.loaderBox}>
<div className={style.empty}>
<Panel.Loader isLoading />
</div>
);
@@ -74,12 +74,6 @@ export default function ProjectList() {
</tr>
</thead>
<tbody>
{numProjects === 0 && (
<Panel.TableEmpty
title='No projects yet'
description='Projects are the show files Ontime saves your rundown and settings into. Create one to get started.'
/>
)}
{data.reorderedProjectFiles.map((project) => (
<ProjectListItem
key={project.filename}
@@ -20,7 +20,6 @@ import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import Dialog from '../../../../common/components/dialog/Dialog';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import Tag from '../../../../common/components/tag/Tag';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils';
import ProjectForm, { ProjectFormValues } from './ProjectForm';
@@ -144,7 +143,7 @@ export default function ProjectListItem({
) : (
<>
<td className={style.containCell}>{filename}</td>
<td>{current ? <Tag variant='active'>Loaded</Tag> : new Date(updatedAt).toLocaleString()}</td>
<td>{current ? 'Currently loaded' : new Date(updatedAt).toLocaleString()}</td>
<td>
<ActionMenu
current={current}
@@ -159,7 +158,13 @@ export default function ProjectListItem({
</>
)}
</tr>
{showMergeForm && <ProjectMergeForm onClose={handleCancel} fileName={filename} />}
{showMergeForm && (
<tr>
<td colSpan={99}>
<ProjectMergeForm onClose={handleCancel} fileName={filename} />
</td>
</tr>
)}
<Dialog
isOpen={isDeleteOpen}
onClose={() => setDeleteOpen(false)}
@@ -7,15 +7,13 @@ import { getDb, patchData } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import Modal from '../../../../common/components/modal/Modal';
import Switch from '../../../../common/components/switch/Switch';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils';
import { makeProjectPatch } from './project.utils';
import style from './ProjectPanel.module.scss';
const formId = 'project-merge-form';
interface ProjectMergeFromProps {
onClose: () => void;
fileName: string;
@@ -79,122 +77,68 @@ export default function ProjectMergeForm({ onClose, fileName }: ProjectMergeFrom
};
return (
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
size='compact'
title='Partial project merge'
bodyElements={
<form id={formId} onSubmit={handleSubmit(handleSubmitCreate)}>
<Panel.Section className={style.mergeBody}>
<div className={style.mergeIntro}>
<Panel.Section as='form' onSubmit={handleSubmit(handleSubmitCreate)}>
<Panel.Title>
Partial project merge
<Panel.InlineElements>
<Button onClick={onClose} variant='ghosted' disabled={isSubmitting}>
Cancel
</Button>
<Button type='submit' disabled={!isValid || !isDirty} loading={isSubmitting} variant='primary'>
Merge
</Button>
</Panel.InlineElements>
</Panel.Title>
{error && <Panel.Error>{error}</Panel.Error>}
<Panel.Section className={cx([style.innerColumn, style.inlineLabels])}>
<Panel.Description>
Select the sections from <span className={style.sourceFile}>{`"${fileName}"`}</span> that you want to
merge into the current project.
Select data from <i>{`"${fileName}"`}</i> to merge into the current project.
</Panel.Description>
<div className={style.mergeSummary}>
Only the enabled sections are copied. Existing data stays intact.
</div>
</div>
<Info type='warning' className={style.mergeWarningInfo}>
<Info.Title>Back up before merging</Info.Title>
<Info.Body>
This action can overwrite project data. Create a duplicate project first if you want a recovery point.
</Info.Body>
<Info type='warning'>
This process is irreversible and can result in data loss. <br />
You may want to create a duplicate backup beforehand.
</Info>
<Panel.ListGroup className={style.optionList}>
<Panel.ListItem>
<label className={style.optionRow}>
<div className={style.optionCopy}>
<span className={style.optionTitle}>Project data</span>
<span className={style.optionBody}>Core project metadata and settings.</span>
</div>
<label>
<Switch
size='large'
checked={watch('project')}
onCheckedChange={(value: boolean) => setValue('project', value, { shouldDirty: true })}
/>
Project data
</label>
</Panel.ListItem>
<Panel.ListItem>
<label className={style.optionRow}>
<div className={style.optionCopy}>
<span className={style.optionTitle}>Rundown + Custom Fields</span>
<span className={style.optionBody}>Copies all rundowns and any associated custom fields.</span>
</div>
<label>
<Switch
size='large'
checked={watch('rundowns')}
onCheckedChange={(value: boolean) => setValue('rundowns', value, { shouldDirty: true })}
/>
Rundown + Custom Fields
</label>
</Panel.ListItem>
<Panel.ListItem>
<label className={style.optionRow}>
<div className={style.optionCopy}>
<span className={style.optionTitle}>View Settings</span>
<span className={style.optionBody}>View configuration, and display preferences.</span>
</div>
<label>
<Switch
size='large'
checked={watch('viewSettings')}
onCheckedChange={(value: boolean) => setValue('viewSettings', value, { shouldDirty: true })}
/>
View Settings
</label>
</Panel.ListItem>
<Panel.ListItem>
<label className={style.optionRow}>
<div className={style.optionCopy}>
<span className={style.optionTitle}>URL Presets</span>
<span className={style.optionBody}>Saved links and preset launch parameters.</span>
</div>
<label>
<Switch
size='large'
checked={watch('urlPresets')}
onCheckedChange={(value: boolean) => setValue('urlPresets', value, { shouldDirty: true })}
/>
URL Presets
</label>
</Panel.ListItem>
<Panel.ListItem>
<label className={style.optionRow}>
<div className={style.optionCopy}>
<span className={style.optionTitle}>Automation Settings</span>
<span className={style.optionBody}>Triggers and automation configuration.</span>
</div>
<label>
<Switch
size='large'
checked={watch('automation')}
onCheckedChange={(value: boolean) => setValue('automation', value, { shouldDirty: true })}
/>
Automation Settings
</label>
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
</form>
}
footerElements={
<div className={style.footerContent}>
{error && <Panel.Error>{error}</Panel.Error>}
<div className={style.footerActions}>
<Button onClick={onClose} variant='ghosted' disabled={isSubmitting}>
Cancel
</Button>
<Button
type='submit'
form={formId}
disabled={!isValid || !isDirty}
loading={isSubmitting}
variant='primary'
>
Merge
</Button>
</div>
</div>
}
/>
</Panel.Section>
);
}
@@ -1,6 +1,5 @@
.current {
// marker consumed by the shared Panel.Table active-row treatment
background-color: inherit;
background-color: $blue-1100;
}
.isEditing {
@@ -56,90 +55,7 @@
margin-bottom: 1rem;
}
.loaderBox {
.empty {
height: 300px;
position: relative;
}
.mergeBody {
gap: 1rem;
}
.mergeIntro {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.sourceFile {
padding: 0.1rem 0.45rem;
border-radius: 999px;
background-color: $white-3;
color: $orange-400;
white-space: nowrap;
}
.mergeSummary {
color: $gray-400;
font-size: calc(1rem - 2px);
line-height: 1.5;
}
.mergeWarningInfo {
margin-bottom: 0.25rem;
}
.optionList {
padding: 0;
gap: 0;
}
.optionList > li:not(:last-child) {
border-bottom: 1px solid $white-10;
}
.optionRow {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 1rem;
align-items: center;
width: 100%;
min-width: 0;
cursor: pointer;
}
.optionCopy {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.optionTitle {
color: $ui-white;
font-size: 0.95rem;
font-weight: 600;
line-height: 1.35;
}
.optionBody {
color: $gray-400;
font-size: calc(1rem - 3px);
line-height: 1.45;
}
.footerContent {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.footerActions {
width: 100%;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
flex-wrap: wrap;
}
@@ -1,5 +1,5 @@
import { type ProjectData } from 'ontime-types';
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import { ChangeEvent, useEffect, useRef } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoDownloadOutline, IoTrash } from 'react-icons/io5';
@@ -17,16 +17,9 @@ import * as Panel from '../../panel-utils/PanelUtils';
import style from './SettingsPanel.module.scss';
type PendingLogo = {
file: File;
previewUrl: string;
uploadedFilename?: string;
};
export default function ProjectData() {
const { data, status } = useProjectData();
const { updateProjectData } = useUpdateProjectData();
const [pendingLogo, setPendingLogo] = useState<PendingLogo | null>(null);
const {
handleSubmit,
@@ -52,22 +45,13 @@ export default function ProjectData() {
});
// reset form values if data changes
// Release the browser-managed preview URL when the selection changes or this form unmounts.
useEffect(() => {
if (data) {
reset(data);
}
}, [data, reset]);
useEffect(() => {
return () => {
if (pendingLogo) {
URL.revokeObjectURL(pendingLogo.previewUrl);
}
};
}, [pendingLogo]);
const handleUploadProjectLogo = (event: ChangeEvent<HTMLInputElement>) => {
const handleUploadProjectLogo = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
clearErrors('logo');
@@ -77,16 +61,14 @@ export default function ProjectData() {
try {
validateLogo(file);
setPendingLogo({
file,
previewUrl: URL.createObjectURL(file),
const response = await uploadProjectLogo(file);
setValue('logo', response.data.logoFilename, {
shouldDirty: true,
});
} catch (error) {
const message = maybeAxiosError(error);
setError('logo', { message });
} finally {
// Allow selecting the same local file again after an upload attempt.
event.target.value = '';
}
};
@@ -101,7 +83,6 @@ export default function ProjectData() {
const handleDeleteLogo = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setPendingLogo(null);
setValue('logo', null, {
shouldDirty: true,
});
@@ -111,29 +92,10 @@ export default function ProjectData() {
append({ title: '', value: '', url: '' });
};
const uploadPendingLogo = async () => {
if (!pendingLogo) {
return null;
}
if (pendingLogo.uploadedFilename) {
return pendingLogo.uploadedFilename;
}
const response = await uploadProjectLogo(pendingLogo.file);
const uploadedFilename = response.data.logoFilename;
setPendingLogo((currentPendingLogo) =>
currentPendingLogo?.file === pendingLogo.file ? { ...currentPendingLogo, uploadedFilename } : currentPendingLogo,
);
return uploadedFilename;
};
const onSubmit = async (formData: ProjectData) => {
try {
clearErrors();
const logo = (await uploadPendingLogo()) ?? formData.logo;
await updateProjectData({ ...formData, logo });
setPendingLogo(null);
await updateProjectData(formData);
} catch (error) {
const message = maybeAxiosError(error);
setError('root', { message });
@@ -142,12 +104,10 @@ export default function ProjectData() {
// reset data to the initial state
const onReset = () => {
setPendingLogo(null);
reset(data);
};
const isLoading = status === 'pending';
const logo = watch('logo');
return (
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} onKeyDown={(event) => preventEscape(event, onReset)}>
@@ -158,12 +118,7 @@ export default function ProjectData() {
<Button onClick={onReset} disabled={isSubmitting || !isDirty}>
Revert to saved
</Button>
<Button
variant='primary'
type='submit'
disabled={(!isDirty && !pendingLogo) || !isValid}
loading={isSubmitting}
>
<Button variant='primary' type='submit' disabled={!isDirty || !isValid} loading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
@@ -195,24 +150,17 @@ export default function ProjectData() {
onChange={handleUploadProjectLogo}
/>
<Panel.Card className={style.uploadLogoCard}>
{logo || pendingLogo ? (
{watch('logo') ? (
<>
<img src={pendingLogo ? pendingLogo.previewUrl : `${projectLogoPath}/${logo}`} />
<div className={style.logoActions}>
<Button disabled={isSubmitting} onClick={handleClickUpload} type='button'>
<IoDownloadOutline />
Replace logo
</Button>
<img src={`${projectLogoPath}/${watch('logo')}`} />
<Button
variant='subtle-destructive'
disabled={isSubmitting || (!logo && !pendingLogo)}
disabled={isSubmitting || !watch('logo')}
onClick={handleDeleteLogo}
type='button'
>
<IoTrash />
Delete
</Button>
</div>
</>
) : (
<Button disabled={isSubmitting} onClick={handleClickUpload} type='button'>
@@ -85,10 +85,10 @@ export default function ServerPortSettings() {
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
<Panel.Loader isLoading={status === 'pending'} />
{rootError && <Panel.Error>{rootError}</Panel.Error>}
<Panel.Divider />
<Panel.Section>
<Panel.Loader isLoading={status === 'pending'} />
{data.pendingRestart && (
<Info type='warning'>A port change is pending and will happen on the next restart.</Info>
)}
@@ -15,11 +15,6 @@
}
}
.logoActions {
display: flex;
gap: 0.5rem;
}
.customDataItem {
width: 100%;
display: flex;
@@ -90,13 +90,16 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
<div className={style.footer}>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.InlineElements align='apart'>
<Button variant='ghosted' onClick={resetToEnglish} disabled={isSubmitting}>
<Button variant='ghosted' size='large' onClick={resetToEnglish} disabled={isSubmitting}>
Reset to English
</Button>
<Panel.InlineElements>
<Button onClick={onClose}>Cancel</Button>
<Button size='large' onClick={onClose}>
Cancel
</Button>
<Button
variant='primary'
size='large'
type='submit'
form='custom-translations-form'
disabled={isSubmitting || !isDirty || !isValid}
@@ -109,16 +109,19 @@ export default function CodeEditorModal({ isOpen, onClose }: CodeEditorModalProp
<Info>Invalid CSS will be refused by the browser</Info>
{error && <Panel.Error className={style.right}>{`Error: ${error}`}</Panel.Error>}
<Panel.InlineElements align='apart' className={style.editorActions}>
<Button variant='ghosted' onClick={handleRestore} disabled={saveLoading || resetLoading}>
<Button variant='ghosted' size='large' onClick={handleRestore} disabled={saveLoading || resetLoading}>
Reset to example
</Button>
<Panel.InlineElements>
<Button variant='ghosted' onClick={clear}>
<Button variant='ghosted' size='large' onClick={clear}>
Clear
</Button>
<Button onClick={onClose}>Cancel</Button>
<Button size='large' onClick={onClose}>
Cancel
</Button>
<Button
variant='primary'
size='large'
onClick={handleSave}
disabled={saveLoading || resetLoading || !isDirty}
loading={saveLoading}
@@ -3,7 +3,6 @@ import { useRef } from 'react';
import Button from '../../../../common/components/buttons/Button';
import Dialog from '../../../../common/components/dialog/Dialog';
import Info from '../../../../common/components/info/Info';
import { useElectronEvent } from '../../../../common/hooks/useElectronEvent';
import { isLocalhost, isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -24,29 +23,22 @@ export default function ShutdownPanel() {
<>
<Panel.Header>Shutdown Ontime</Panel.Header>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Shutdown</Panel.SubHeader>
<Panel.Divider />
{isOntimeCloud ? (
<Info type='warning'>
<Panel.Highlight>
For security reasons, shutting down the server must be done from the Ontime Cloud dashboard.
</Info>
</Panel.Highlight>
) : (
<Panel.Field
title='Stop the Ontime server'
description='The runtime state will be lost, but your project is kept for next time.'
/>
<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>
)}
{!isOntimeCloud && (
<Panel.Section>
<Button variant='destructive' onClick={handler.open} disabled={!canShutdown}>
<Button variant='destructive' onClick={handler.open} disabled={!(isElectron || isLocalhost)}>
Shutdown ontime
</Button>
{!canShutdown && <Panel.Description>Only available from the machine running Ontime.</Panel.Description>}
</Panel.Section>
{!canShutdown && (
<Panel.Description>Note: Ontime can only be shutdown from the machine it is running in.</Panel.Description>
)}
</Panel.Card>
</Panel.Section>
<Dialog
isOpen={isOpen}
title='Shutdown Ontime'
@@ -68,6 +60,7 @@ export default function ShutdownPanel() {
</>
}
/>
</Panel.Section>
</>
);
}
@@ -3,107 +3,64 @@ import { useMemo } from 'react';
import useAppVersion from '../../common/hooks-query/useAppVersion';
import { isDocker } from '../../externals';
type SettingsOptionBase = {
export type SettingsOption = {
id: string;
label: string;
secondary?: Readonly<SettingsOption[]>;
highlight?: string;
};
export type SettingsOption =
| (SettingsOptionBase & { secondary: Readonly<SettingsOption[]>; keywords?: never })
| (SettingsOptionBase & { secondary?: never; keywords: Readonly<string[]> });
const staticOptions = [
{
id: 'settings',
label: 'Settings',
secondary: [
{ id: 'settings__data', label: 'Project data', keywords: ['title', 'description', 'logo', 'url', 'info'] },
{
id: 'settings__general',
label: 'General settings',
keywords: ['pin', 'password', 'lock', 'language', 'time format', 'timezone'],
},
{
id: 'settings__view',
label: 'View settings',
keywords: ['css', 'style', 'theme', 'translation', 'freeze', 'overtime'],
},
{ id: 'settings__custom-views', label: 'Custom views', keywords: ['html', 'upload', 'external', 'embed'] },
{ id: 'settings__mcp', label: 'MCP Server', keywords: ['ai', 'agent', 'model'] },
...(isDocker ? [] : [{ id: 'settings__port', label: 'Server port', keywords: ['http', 'network', 'address'] }]),
{ id: 'settings__data', label: 'Project data' },
{ id: 'settings__general', label: 'General settings' },
{ id: 'settings__view', label: 'View settings' },
{ id: 'settings__custom-views', label: 'Custom views' },
{ id: 'settings__mcp', label: 'MCP Server' },
...(isDocker ? [] : [{ id: 'settings__port', label: 'Server port' }]),
],
},
{
id: 'project',
label: 'Project',
secondary: [
{ id: 'project__create', label: 'Create...', keywords: ['new project', 'quick start', 'wizard'] },
{
id: 'project__list',
label: 'Manage projects',
keywords: ['load', 'open', 'rename', 'duplicate', 'delete', 'download', 'import', 'backup', 'merge'],
},
{ id: 'project__create', label: 'Create...' },
{ id: 'project__list', label: 'Manage projects' },
],
},
{
id: 'manage',
label: 'Project settings',
secondary: [
{
id: 'manage__defaults',
label: 'Rundown defaults',
keywords: ['duration', 'warning', 'danger', 'defaults'],
},
{
id: 'manage__custom',
label: 'Custom fields',
keywords: ['metadata', 'columns', 'extra data', 'image field'],
},
{
id: 'manage__rundowns',
label: 'Manage rundowns',
keywords: ['rundown', 'xlsx', 'excel', 'load', 'export'],
},
{
id: 'manage__sheets',
label: 'Import spreadsheet',
keywords: ['google sheet', 'sync', 'spreadsheet', 'xlsx', 'excel', 'csv', 'export'],
},
{ id: 'manage__defaults', label: 'Rundown defaults' },
{ id: 'manage__custom', label: 'Custom fields' },
{ id: 'manage__rundowns', label: 'Manage rundowns' },
{ id: 'manage__sheets', label: 'Import spreadsheet' },
{ id: 'manage__sheets', label: 'Sync with Google Sheet' },
],
},
{
id: 'automation',
label: 'Automation',
secondary: [
{
id: 'automation__settings',
label: 'Automation settings',
keywords: ['osc input', 'port', 'enable', 'remote control'],
},
{
id: 'automation__automations',
label: 'Manage automations',
keywords: ['osc', 'http', 'webhook', 'integration', 'api', 'output', 'action'],
},
{
id: 'automation__triggers',
label: 'Manage triggers',
keywords: ['lifecycle', 'on load', 'on start', 'on finish', 'on update'],
},
{ id: 'automation__settings', label: 'Automation settings' },
{ id: 'automation__automations', label: 'Manage automations' },
{ id: 'automation__triggers', label: 'Manage triggers' },
],
},
{
id: 'sharing',
label: 'Sharing and reporting',
secondary: [
{ id: 'sharing__presets', label: 'URL Presets', keywords: ['alias', 'link', 'url', 'shortcut'] },
{ id: 'sharing__presets', label: 'URL Presets' },
{
id: 'sharing__link',
label: 'Share link',
keywords: ['qr code', 'guest', 'cuesheet link', 'permissions', 'read only'],
},
{ id: 'sharing__report', label: 'Runtime report', keywords: ['actual times', 'csv', 'export', 'history'] },
{ id: 'sharing__report', label: 'Runtime report' },
],
},
{
@@ -113,24 +70,20 @@ const staticOptions = [
{
id: 'network__log',
label: 'Event log',
keywords: ['debug', 'errors', 'console', 'export log'],
},
{
id: 'network__clients',
label: 'Manage clients',
keywords: ['redirect', 'identify', 'rename client', 'connected'],
},
],
},
{
id: 'about',
label: 'About',
keywords: ['version', 'update', 'licence', 'license', 'credits'],
},
{
id: 'shutdown',
label: 'Shutdown',
keywords: ['quit', 'exit', 'close ontime'],
},
] as const;
@@ -139,51 +92,6 @@ export type SettingsOptionId =
| (typeof staticOptions)[number]['id']
| Extract<(typeof staticOptions)[number], { secondary: object }>['secondary'][number]['id'];
function sanitiseSearchInput(query: string): string {
return query.trim().toLowerCase();
}
export function matchesSettingsOptionQuery(option: SettingsOption, query: string): boolean {
const sanitisedQuery = sanitiseSearchInput(query);
if (!sanitisedQuery) {
return false;
}
const sanitisedLabel = sanitiseSearchInput(option.label);
if (sanitisedLabel.includes(sanitisedQuery)) {
return true;
}
// check keywords
return Boolean(option.keywords?.some((keyword) => keyword.includes(sanitisedQuery)));
}
/**
* Filters the settings menu against a user query.
* A group is kept if it matches itself (with all its children) or if any of its children match.
*/
export function filterSettingsOptions(options: Readonly<SettingsOption[]>, query: string): SettingsOption[] {
const sanitisedQuery = sanitiseSearchInput(query);
if (!sanitisedQuery) {
return [...options];
}
return options.reduce<SettingsOption[]>((accumulator, option) => {
if (matchesSettingsOptionQuery(option, sanitisedQuery)) {
accumulator.push(option);
return accumulator;
}
if (option.secondary) {
const secondary = option.secondary.filter((child) => matchesSettingsOptionQuery(child, sanitisedQuery));
if (secondary.length) {
accumulator.push({ ...option, secondary });
}
}
return accumulator;
}, []);
}
export function useAppSettingsMenu() {
const { data } = useAppVersion();
@@ -73,10 +73,6 @@
margin-right: 0.25rem;
}
.tagOvertime {
color: $playback-over;
}
.time {
color: $section-white;
font-size: $text-body-size;
@@ -1,13 +1,12 @@
import { MaybeNumber, Playback, TimerPhase } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { dayInMs, millisToString } from 'ontime-utils';
import { PropsWithChildren } from 'react';
import AppLink from '../../../../common/components/link/app-link/AppLink';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import useReport from '../../../../common/hooks-query/useReport';
import { useTimerProgress } from '../../../../common/hooks/useSocket';
import { cx } from '../../../../common/utils/styleUtils';
import { formatDuration, normaliseWallClock } from '../../../../common/utils/time';
import { useTimer } from '../../../../common/hooks/useSocket';
import { formatDuration } from '../../../../common/utils/time';
import TimerDisplay from '../timer-display/TimerDisplay';
import style from './PlaybackTimer.module.scss';
@@ -25,14 +24,15 @@ function resolveAddedTimeLabel(addedTime: number) {
}
export default function PlaybackTimer({ children }: PropsWithChildren) {
'use memo';
const timer = useTimerProgress();
const timer = useTimer();
const isRolling = timer.playback === Playback.Roll;
const isWaiting = timer.phase === TimerPhase.Pending;
const isOvertime = timer.phase === TimerPhase.Overtime;
const hasAddedTime = Boolean(timer.addedTime);
const rollLabel = isRolling ? 'Roll mode active' : '';
const addedTimeLabel = resolveAddedTimeLabel(timer.addedTime);
return (
@@ -51,13 +51,7 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
{isWaiting ? (
<span className={style.rolltag}>Roll: Countdown to start</span>
) : (
<RunningStatus
startedAt={timer.startedAt}
expectedFinish={timer.expectedFinish}
isStopped={timer.playback === Playback.Stop}
isCountToEnd={timer.isCountToEnd}
isOvertime={isOvertime}
/>
<RunningStatus startedAt={timer.startedAt} expectedFinish={timer.expectedFinish} playback={timer.playback} />
)}
</div>
{children}
@@ -68,18 +62,16 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
interface RunningStatusProps {
startedAt: MaybeNumber;
expectedFinish: MaybeNumber;
isStopped: boolean;
isCountToEnd: boolean;
isOvertime: boolean;
playback: Playback;
}
function RunningStatus({ startedAt, expectedFinish, isStopped, isCountToEnd, isOvertime }: RunningStatusProps) {
if (isStopped) {
function RunningStatus({ startedAt, expectedFinish, playback }: RunningStatusProps) {
if (playback === Playback.Stop) {
return <StoppedStatus />;
}
const started = millisToString(startedAt);
const finish = millisToString(expectedFinish === null ? null : normaliseWallClock(expectedFinish));
const finishedMs = expectedFinish !== null ? expectedFinish % dayInMs : null;
const finish = millisToString(finishedMs);
return (
<>
@@ -88,9 +80,7 @@ function RunningStatus({ startedAt, expectedFinish, isStopped, isCountToEnd, isO
<span className={style.time}>{started}</span>
</span>
<span className={style.finish}>
<span className={cx([style.tag, isOvertime && style.tagOvertime])}>
{isCountToEnd ? 'Scheduled end' : 'Expected end'}
</span>
<span className={style.tag}>Expect end</span>
<span className={style.time}>{finish}</span>
</span>
</>
+2 -45
View File
@@ -1,54 +1,20 @@
$info-gray: $secondary-text-gray;
$info-hover: $section-white;
.container {
display: flex;
flex-direction: column;
min-height: 0;
}
.extracted {
height: 100%;
padding: 1.5rem;
background-color: $white-3;
border: 1px solid $gray-1100;
border-radius: $component-border-radius-md;
.log {
flex: 1;
height: auto;
min-height: 0;
max-height: none;
}
}
.log {
font-size: $inner-section-text-size;
overflow-y: auto;
user-select: text;
height: 40vh;
min-height: 40vh;
min-height: 20vh;
max-height: 40vh;
margin: 0;
padding: 0.5rem;
background-color: $gray-1350;
border: 1px solid $gray-1100;
border-radius: $component-border-radius-md;
box-shadow: inset 0 1px 0 $white-3;
padding: 0.5rem;
}
.apart {
margin-left: auto;
}
.filterLabel {
color: $gray-400;
font-size: $aux-text-size;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.logEntry {
display: flex;
@@ -78,19 +44,10 @@ $info-hover: $section-white;
.msg {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
}
}
.empty {
padding: 2.5rem 1rem;
color: $gray-600;
text-align: center;
}
.buttonBar {
flex-wrap: wrap;
gap: 0.5rem;
padding-bottom: 0.5rem;
}

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