Compare commits

...

18 Commits

Author SHA1 Message Date
Carlos Valente 24be49ef66 refactor(settings): polish settings UI 2026-08-02 13:44:28 +02:00
Carlos Valente f0c284b708 refactor(link): add row presentation variant 2026-08-02 13:44:28 +02:00
Carlos Valente 9e42f18299 feat(settings): add searchable navigation 2026-08-02 13:44:28 +02:00
Carlos Valente bdd815678b refactor(ui): extend link and modal variants 2026-08-02 13:44:28 +02:00
Carlos Valente 12fc76b8ff refactor(op): simplify UI elements 2026-08-02 12:55:49 +02:00
Carlos Valente 8ed0a1689e refactor(op): clarify settings text 2026-08-02 12:55:49 +02:00
Carlos Valente ba18b8dba7 refactor(ui): rundown title UI polish 2026-08-02 12:55:49 +02:00
Carlos Valente 608247157b refactor(rundown): cap rundown names to 64 characters 2026-08-02 12:55:49 +02:00
Carlos Valente 514b6c3a02 refactor(navigation): custom params indicators only for current view 2026-08-02 12:55:49 +02:00
Carlos Valente 2050735014 refactor(ui): title alignment is logo aware 2026-08-02 12:55:49 +02:00
Carlos Valente 76b1341432 refactor(logo): upload on submit form 2026-08-02 10:39:58 +02:00
Carlos Valente c7422fc7c7 refactor(logo): improve flow for managing logo 2026-08-02 10:39:58 +02:00
Carlos Valente c93b10cea9 chore: bump deepmerge-ts package 2026-08-02 10:39:58 +02:00
Carlos Valente 8a7f9530d2 chore: bump nanoid package 2026-08-02 10:39:58 +02:00
Carlos Valente 94d54529ee refactor(views): consistent loading and empty states 2026-08-01 16:25:14 +02:00
Carlos Valente f7535651f6 refactor(info): improve UI consistency and polish 2026-08-01 14:41:39 +02:00
Carlos Valente 5cf36f049a refactor(modal): allow wider modals 2026-07-25 14:28:45 +02:00
Carlos Valente c6248b0c72 refactor(import): improve preview UI 2026-07-25 14:28:45 +02:00
109 changed files with 3228 additions and 1802 deletions
@@ -1,24 +1,82 @@
.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-areas:
'label label'
'input btn';
grid-template-columns: 1fr auto;
column-gap: 0.5rem;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.75rem;
}
.redirect {
grid-area: btn;
margin-left: auto;
}
.label {
color: $label-gray;
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;
}
}
@@ -1,6 +1,7 @@
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';
@@ -8,7 +9,7 @@ import Button from '../buttons/Button';
import Dialog from '../dialog/Dialog';
import Info from '../info/Info';
import Input from '../input/input/Input';
import AppLink from '../link/app-link/AppLink';
import ExternalLink from '../link/external-link/ExternalLink';
import Select from '../select/Select';
import style from './RedirectClientModal.module.scss';
@@ -44,6 +45,7 @@ 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) => ({
@@ -66,34 +68,46 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
bodyElements={
<>
<Info>
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.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>
</Info>
<div className={style.inlineEntry}>
<span className={style.label}>Enter custom path</span>
<label className={style.textEntry}>
{origin}
<Input placeholder='eg. /timer' fluid value={path} onChange={(event) => setPath(event.target.value)} />
</label>
<Button
variant='primary'
aria-label='Redirect'
disabled={path === currentPath || path === ''}
className={style.redirect}
onClick={() => handleRedirect(path)}
>
Redirect
<IoArrowForward />
</Button>
</div>
<div>
<span className={style.label}>Select View or URL Preset</span>
<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}>
<label className={style.textEntry}>
{origin}
<span className={style.origin}>{origin}</span>
<Input placeholder='eg. /timer' fluid value={path} onChange={(event) => setPath(event.target.value)} />
</label>
<Button
variant='primary'
aria-label='Redirect to custom path'
disabled={path === currentPath || path === ''}
className={style.redirect}
onClick={() => handleRedirect(path)}
>
Redirect
<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 className={style.inlineEntry}>
<label className={style.textEntry}>
<span className={style.origin}>{origin}</span>
<Select
fluid
options={viewOptions}
@@ -115,7 +129,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
Redirect <IoArrowForward />
</Button>
</div>
</div>
</section>
</>
}
/>
@@ -0,0 +1,13 @@
@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,6 +5,8 @@ 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;
@@ -31,9 +33,22 @@ export function RenameClientModal({ id, name: currentName = '', isOpen, onClose
isOpen={isOpen}
title={`Rename Client: ${currentName}`}
showCloseButton
showBackdrop
onClose={onClose}
bodyElements={
<Input height='large' placeholder='New name' value={name} onChange={(event) => setName(event.target.value)} />
<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>
}
footerElements={
<>
@@ -1,12 +1,16 @@
.dialog {
position: fixed;
top: 10%;
top: 10vh;
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;
@@ -43,7 +47,8 @@
flex-direction: column;
gap: 0.5rem;
max-height: min(80vh, 600px);
flex: 1;
min-height: 0;
overflow-y: auto;
}
@@ -53,4 +58,5 @@
align-items: center;
justify-content: end;
gap: 1rem;
flex-wrap: wrap;
}
@@ -21,4 +21,15 @@
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 { IoOpenOutline } from 'react-icons/io5';
import { HiArrowLongRight } from 'react-icons/hi2';
import { openLink } from '../../../utils/linkUtils';
import { cx } from '../../../utils/styleUtils';
@@ -10,9 +10,10 @@ interface ExternalLinkProps {
href: string;
children: ReactNode;
inline?: boolean;
className?: string;
}
export default function ExternalLink({ href, inline, children }: ExternalLinkProps) {
export default function ExternalLink({ href, inline, className, children }: ExternalLinkProps) {
const handleClick = (event: MouseEvent) => {
event.preventDefault();
openLink(href);
@@ -23,10 +24,11 @@ export default function ExternalLink({ href, inline, children }: ExternalLinkPro
href='#!'
target='_blank'
rel='noreferrer'
className={cx([style.link, inline && style.inline])}
className={cx([style.link, inline && style.inline, className])}
onClick={handleClick}
>
{children} <IoOpenOutline style={{ fontSize: '1em' }} />
{children}
<HiArrowLongRight className={style.icon} />
</a>
);
}
@@ -4,10 +4,13 @@
left: 50%;
transform: translateX(-50%);
padding-inline: 1rem;
min-width: min(880px, 90vw);
box-sizing: border-box;
width: min(880px, 90vw);
min-height: min(200px, 10vh);
max-width: min(1200px, 90vw);
max-height: calc(90vh - 1rem);
display: flex;
flex-direction: column;
background-color: $gray-1250;
color: $ui-white;
@@ -17,14 +20,21 @@
outline: none;
}
.compact {
min-width: 0;
width: min(680px, 90vw);
}
.small {
min-width: 0;
width: min(480px, 90vw);
}
.wide {
top: 4vh;
min-width: min(1280px, 96vw);
max-width: min(1600px, 96vw);
height: 88vh;
max-height: 88vh;
display: flex;
flex-direction: column;
width: min(1800px, 98vw);
height: min(88vh, calc(96vh - 1rem));
min-height: 0;
}
.backdrop {
@@ -41,15 +51,19 @@
.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 {
@@ -57,21 +71,23 @@
flex-direction: column;
gap: 0.5rem;
max-height: 60vh;
flex: 1;
min-height: 0;
padding: 0.5rem 1rem;
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?: 'default' | 'wide';
size?: 'small' | 'compact' | '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, size === 'wide' && style.wide])}>
<BaseDialog.Popup aria-label={title} className={cx([style.modal, style[size]])}>
<div className={style.title}>
{title ? <BaseDialog.Title className={style.titleText}>{title}</BaseDialog.Title> : <div />}
{showCloseButton && (
@@ -2,7 +2,7 @@ import { useDisclosure, useHotkeys } from '@mantine/hooks';
import { memo } from 'react';
import { useSearchParams } from 'react-router';
import { hasCustomParams, useSavedViewParams } from '../../stores/savedViewParams';
import { hasCustomParams } from '../../stores/savedViewParams';
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
import FloatingNavigation from './floating-navigation/FloatingNavigation';
import NavigationMenu from './NavigationMenu';
@@ -20,8 +20,7 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
const [isMenuOpen, menuHandler] = useDisclosure();
const { open: showEditFormDrawer } = useViewParamsEditorStore();
const [searchParams] = useSearchParams();
const savedParams = useSavedViewParams((store) => store.params);
const hasSavedChanges = hasCustomParams(searchParams) || Object.keys(savedParams).length > 0;
const hasSavedChanges = hasCustomParams(searchParams);
useHotkeys([
[
@@ -1,20 +1,43 @@
@use '@/theme/viewerDefs' as *;
.emptyContainer {
width: 100%;
text-align: center;
color: $white-10;
color: var(--secondary-color-override, $viewer-secondary-color);
.empty {
display: block;
width: min(100%, 24rem);
margin-inline: auto;
opacity: 0.8;
width: min(100%, 14rem);
margin: 0 auto -1.5rem;
opacity: 0.6;
}
.text {
display: block;
margin-inline: auto;
font-weight: 600;
font-size: 2em;
max-width: min(100%, 600px);
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;
}
}
@@ -1,4 +1,5 @@
import { CSSProperties } from 'react';
import { IoWarningOutline } from 'react-icons/io5';
import EmptyImage from '../../../assets/images/empty.svg?react';
import { cx } from '../../utils/styleUtils';
@@ -9,12 +10,18 @@ interface EmptyProps {
text?: string;
injectedStyles?: CSSProperties;
className?: string;
variant?: 'error';
}
export default function Empty({ text, className, injectedStyles }: EmptyProps) {
export default function Empty({ text, className, injectedStyles, variant }: EmptyProps) {
return (
<div className={cx([style.emptyContainer, className])} style={injectedStyles}>
<div
className={cx([style.emptyContainer, variant === 'error' && style.error, className])}
style={injectedStyles}
role={variant === 'error' ? 'alert' : undefined}
>
<EmptyImage className={style.empty} />
{variant === 'error' && <IoWarningOutline className={style.errorIcon} aria-hidden />}
{text && <span className={style.text}>{text}</span>}
</div>
);
@@ -0,0 +1,11 @@
.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;
}
@@ -0,0 +1,19 @@
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: 100vh;
height: 100dvh;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color);
@@ -16,5 +16,6 @@
display: flex;
flex-direction: column;
align-items: center;
padding-top: 5rem;
justify-content: center;
padding-block: 5rem;
}
@@ -7,12 +7,13 @@ import style from './EmptyPage.module.scss';
interface EmptyPageProps {
text?: string;
injectedStyles?: CSSProperties;
variant?: 'error';
}
export default function EmptyPage({ text, injectedStyles }: EmptyPageProps) {
export default function EmptyPage({ text, injectedStyles, variant }: EmptyPageProps) {
return (
<div className={style.page}>
<Empty text={text} injectedStyles={injectedStyles} />
<Empty text={text} injectedStyles={injectedStyles} variant={variant} />
</div>
);
}
@@ -15,9 +15,4 @@
gap: 1rem;
margin-top: 1em;
}
.text {
font-weight: 600;
font-size: 2em;
}
}
@@ -18,8 +18,7 @@ export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
<tbody className={style.emptyContainer}>
<tr>
<td colSpan={99} className={style.emptyCell}>
<Empty injectedStyles={{ marginTop: '5vh' }} />
<span className={style.text}>{text}</span>
<Empty text={text} injectedStyles={{ marginTop: '5vh' }} />
{handleAddNew && (
<div className={style.inline}>
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
@@ -11,6 +11,11 @@
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' | 'warning';
variant?: 'default' | 'active' | 'warning';
}
export default function Tag({ className, variant = 'default', children }: PropsWithChildren<TagProps>) {
@@ -0,0 +1,21 @@
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,9 +1,4 @@
.corner {
position: fixed;
top: 6rem;
right: 4rem;
z-index: $zindex-floating;
}
$content-max-width: 1280px;
.contentWrapper {
display: flex;
@@ -14,9 +9,29 @@
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 {
margin: 1rem;
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;
overflow-y: auto;
flex-grow: 1;
padding-bottom: 300px;
// room for the last section to scroll to the top of the viewport
padding-bottom: 40vh;
}
@@ -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,3 +1,13 @@
.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;
@@ -6,22 +16,20 @@ 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 {
&:focus-visible {
background-color: $gray-1000;
outline: 0;
outline: 2px solid $blue-500;
outline-offset: -2px;
}
&:hover {
@@ -32,14 +40,21 @@ 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: $blue-400;
background-color: $gray-1100;
color: $ui-white;
border-left-color: $blue-400;
background-color: rgba($blue-500, 0.16);
font-weight: 600;
}
&.highlight {
@@ -62,6 +77,9 @@ ul {
font-size: $inner-section-text-size;
&.active {
color: $blue-400;
color: $ui-white;
border-left-color: $blue-400;
background-color: rgba($blue-500, 0.16);
font-weight: 600;
}
}
@@ -1,10 +1,18 @@
import { Fragment } from 'react';
import { Fragment, useState } from 'react';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { isKeyEnter } from '../../../common/utils/keyEvent';
import { cx } from '../../../common/utils/styleUtils';
import { SettingsOption, SettingsOptionId, useAppSettingsMenu } from '../useAppSettingsMenu';
import * as Panel from '../panel-utils/PanelUtils';
import {
filterSettingsOptions,
matchesSettingsOptionQuery,
SettingsOption,
SettingsOptionId,
useAppSettingsMenu,
} from '../useAppSettingsMenu';
import useAppSettingsNavigation from '../useAppSettingsNavigation';
import SettingsSearch from './SettingsSearch';
import style from './PanelList.module.scss';
@@ -16,23 +24,57 @@ 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 (
<ul className={style.tabs}>
{options.map((panel) => {
const isSelected = selectedPanel === panel.id;
if (panel.highlight) {
return (
<Tooltip key={panel.id} text={panel.highlight} render={<span />}>
<PanelListItem panel={panel} location={location} isSelected={isSelected} />
</Tooltip>
);
}
return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />;
})}
</ul>
<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) => {
const isSelected = selectedPanel === panel.id;
if (panel.highlight) {
return (
<Tooltip key={panel.id} text={panel.highlight} render={<span />}>
<PanelListItem panel={panel} location={location} isSelected={isSelected} />
</Tooltip>
);
}
return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />;
})}
</ul>
)}
</div>
);
}
@@ -44,7 +86,15 @@ interface PanelListItemProps {
function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
const { setLocation } = useAppSettingsNavigation();
const classes = cx([style.primary, isSelected && style.active, panel.highlight && style.highlight]);
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,
]);
return (
<Fragment key={panel.id}>
@@ -75,6 +125,7 @@ function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
}
}}
className={secondaryClasses}
tabIndex={0}
role='button'
>
{secondary.label}
@@ -0,0 +1,24 @@
.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;
}
@@ -0,0 +1,60 @@
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,24 +1,26 @@
$inner-padding: 1rem;
$card-padding: 2rem;
.header {
font-size: 2rem;
font-size: 1.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid $white-10;
font-weight: 600;
}
.subheader {
font-size: 1.5rem;
font-size: 1.125rem;
padding-bottom: 0.5rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.title {
font-size: 1.375rem;
padding: 0 2rem;
font-size: 1rem;
padding: 0 var(--panel-card-padding, #{$card-padding});
font-weight: 600;
display: flex;
align-items: center;
@@ -35,10 +37,12 @@ $inner-padding: 1rem;
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 {
@@ -51,8 +55,10 @@ $inner-padding: 1rem;
}
.card {
--panel-card-padding: #{$card-padding};
position: relative;
padding: 2rem;
padding: var(--panel-card-padding);
background-color: $white-3;
border: 1px solid $gray-1100;
border-radius: 3px;
@@ -76,10 +82,10 @@ $inner-padding: 1rem;
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 2rem;
max-height: 550px;
overflow-y: scroll;
padding: 0 var(--panel-card-padding, #{$card-padding});
}
.table {
@@ -97,10 +103,13 @@ $inner-padding: 1rem;
}
th {
font-weight: 400;
color: $gray-400;
font-weight: 600;
color: $gray-300;
font-size: calc(1rem - 3px);
letter-spacing: 0.02em;
vertical-align: bottom;
white-space: nowrap;
text-transform: capitalize;
text-transform: uppercase;
}
th,
@@ -112,6 +121,17 @@ $inner-padding: 1rem;
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'] {
@@ -120,7 +140,7 @@ $inner-padding: 1rem;
}
.listGroup {
padding: 0 2rem;
padding: 0 var(--panel-card-padding, #{$card-padding});
> li:not(:last-child) {
border-bottom: 1px solid $white-10;
@@ -133,6 +153,17 @@ $inner-padding: 1rem;
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 {
@@ -163,7 +194,7 @@ $inner-padding: 1rem;
.divider {
border: none;
border-top: 1px solid $white-10;
margin: 1rem -2rem;
margin: 1rem calc(var(--panel-card-padding, #{$card-padding}) * -1);
}
.overlay {
@@ -188,19 +219,35 @@ $inner-padding: 1rem;
animation: animloader 1s ease-in infinite;
}
.empty {
background-color: $black-10;
.emptyState {
padding: 3rem 1.5rem;
text-align: center;
color: $muted-gray;
}
td {
padding-block: 5rem;
}
.emptyMessage {
width: min(30rem, 100%);
margin-inline: auto;
}
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 {
@@ -239,14 +286,3 @@ $inner-padding: 1rem;
opacity: 0;
}
}
@keyframes animloader {
0% {
transform: scale(0);
opacity: 0.6;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@@ -1,7 +1,5 @@
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';
@@ -62,16 +60,29 @@ export function Table({ className, children }: { className?: string; children: R
);
}
export function TableEmpty({ label, handleClick }: { label?: string; handleClick?: () => void }) {
export interface EmptyStateProps {
title: string;
description?: ReactNode;
action?: ReactNode;
}
export function EmptyState({ title, description, action }: EmptyStateProps) {
return (
<tr className={style.empty}>
<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>
<td colSpan={99}>
<div>{label ?? 'No data yet'}</div>
{handleClick && (
<Button onClick={handleClick} disabled={!handleClick} variant='primary'>
New <IoAdd />
</Button>
)}
<EmptyState {...props} />
</td>
</tr>
);
@@ -81,8 +92,8 @@ export function ListGroup({ className, children }: { className?: string; childre
return <ul className={cx([style.listGroup, className])}>{children}</ul>;
}
export function ListItem({ children }: { children: ReactNode }) {
return <li className={style.listItem}>{children}</li>;
export function ListItem({ children, interactive = false }: { children: ReactNode; interactive?: boolean }) {
return <li className={cx([style.listItem, interactive && style.interactive])}>{children}</li>;
}
export function Field({
@@ -1,4 +1,4 @@
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import Info from '../../../../common/components/info/Info';
import {
buyMeACoffeeUrl,
discordUrl,
@@ -11,6 +11,8 @@ 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 (
@@ -19,28 +21,49 @@ export default function AboutPanel() {
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Ontime</Panel.SubHeader>
<Panel.Paragraph>
Free, open-source software for managing rundowns and event timers
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
</Panel.Paragraph>
<Panel.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.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.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>Current version</Panel.SubHeader>
<AppVersion />
<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,6 +1,6 @@
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import useAppVersion from '../../../../common/hooks-query/useAppVersion';
import { appVersion, isOntimeCloud } from '../../../../externals';
import { appVersion, isOntimeCloud, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
export default function AppVersion() {
@@ -8,28 +8,33 @@ export default function AppVersion() {
if (isError) {
return (
<Panel.Paragraph>
{`You are currently using Ontime version ${appVersion}`}
<Panel.Error>Could not fetch version information</Panel.Error>
</Panel.Paragraph>
<Panel.ListItem>
<Panel.Field title={`Ontime ${appVersion}`} description='' error='Could not fetch version information' />
</Panel.ListItem>
);
}
if (data.hasUpdates) {
return (
<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.Field
title={`Ontime ${appVersion}`}
description={
isOntimeCloud
? `Version ${data.version} is available. Restart your stage to update.`
: `Version ${data.version} is available.`
}
/>
{!isOntimeCloud && (
<ExternalLink href={websiteUrl}>Visit Ontime's page to download the latest version.</ExternalLink>
)}
</Panel.Paragraph>
</Panel.ListItem>
);
}
return <Panel.Paragraph>{`You are currently using the latest version of Ontime: ${appVersion}`}</Panel.Paragraph>;
return (
<Panel.ListItem>
<Panel.Field title={`Ontime ${appVersion}`} description='You are using the latest version.' />
</Panel.ListItem>
);
}
@@ -0,0 +1,74 @@
.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;
}
}
@@ -0,0 +1,52 @@
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>
);
}
@@ -0,0 +1,15 @@
.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);
}
@@ -0,0 +1,21 @@
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,6 +1,13 @@
.outerColumn {
display: flex;
flex-direction: column;
gap: 2rem;
margin-bottom: 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;
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,6 +34,7 @@ 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;
@@ -185,291 +186,320 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
const canSubmit = !isSubmitting && isDirty && isValid;
return (
<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>
Title
<Input
{...register('title', { required: { value: true, message: 'Required field' } })}
fluid
placeholder='Load preset'
/>
</label>
<Panel.Error>{errors.title?.message}</Panel.Error>
</div>
</div>
<div className={style.innerSection}>
<h3>Filters (optional)</h3>
<div className={style.ruleSection}>
<label>
Trigger outputs if
<RadioGroup
orientation='horizontal'
value={watch('filterRule')}
onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })}
items={[
{ value: 'all', label: 'All filters pass' },
{ value: 'any', label: 'Any filter passes' },
]}
/>
</label>
{fieldFilters.map((field, index) => {
const key = `filters.${index}.field.${field.id}`;
return (
<div key={key} className={style.filterSection}>
<label>
Runtime data source
<Select<string | null>
// need to normalize '' to null for the Select to show the placeholder
value={watch(`filters.${index}.field`) || null}
onValueChange={(value) => {
if (value === null) return;
setValue(`filters.${index}.field`, value, { shouldDirty: true });
}}
options={fieldList.map(({ value, label }) => ({
value,
label,
disabled: value === null,
}))}
aria-label='Event field'
/>
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
</label>
<label>
Matching condition
<Select
value={watch(`filters.${index}.operator`)}
onValueChange={(value: string | null) => {
if (value === null) return;
setValue(
`filters.${index}.operator`,
value as 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains' | 'not_contains',
{ shouldDirty: true },
);
}}
options={[
{ value: 'equals', label: 'equals' },
{ value: 'not_equals', label: 'not equals' },
{ value: 'contains', label: 'contains' },
]}
aria-label='Operator'
/>
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
</label>
<label>
Value to match
<Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' />
</label>
<div>
<span>&nbsp;</span>
<div>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeFilter(index)}>
<IoTrash />
</IconButton>
</div>
</div>
</div>
);
})}
<div>
<Button onClick={handleAddNewFilter}>
Add filter <IoAdd />
</Button>
<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}>
<h3>Automation options</h3>
<div className={style.titleSection}>
<label>
Title
<Input
{...register('title', { required: { value: true, message: 'Required field' } })}
fluid
placeholder='Load preset'
/>
</label>
<Panel.Error>{errors.title?.message}</Panel.Error>
</div>
</div>
</div>
</div>
<div className={style.innerColumn}>
<h3>Outputs</h3>
<Info>
Automation outputs can be used to send data from Ontime to external software <br />
or to change properties of Ontime itself. <br /> <br />
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '}
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
</Info>
{fieldOutputs.map((output, index) => {
if (isOSCOutput(output)) {
const rowErrors = errors.outputs?.[index] as
| {
targetIP?: { message?: string };
targetPort?: { message?: string };
address?: { message?: string };
args?: { message?: string };
}
| undefined;
return (
<div key={output.id} className={style.outputCard}>
<Tag>OSC</Tag>
<div className={style.oscSection}>
<label>
Target IP
<Input
{...register(`outputs.${index}.targetIP`, {
required: { value: true, message: 'Required field' },
})}
fluid
placeholder='127.0.0.1'
/>
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error>
</label>
<label>
Target Port
<Input
{...register(`outputs.${index}.targetPort`, {
required: { value: true, message: 'Required field' },
setValueAs: (value) => (value === '' ? 0 : Number(value)),
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
})}
fluid
type='number'
maxLength={5}
placeholder='8000'
/>
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
</label>
<label>
Address
<TemplateInput
{...register(`outputs.${index}.address`)}
value={output.address}
fluid
placeholder='/cue/start'
/>
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
</label>
<label>
Arguments
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} fluid placeholder='1' />
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label>
<div>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestOSCOutput(index)}>
Test
</Button>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
<IoTrash />
</IconButton>
</Panel.InlineElements>
<div className={style.innerColumn}>
<h3>Filters (optional)</h3>
<div className={style.ruleSection}>
<label>
Trigger outputs if
<RadioGroup
orientation='horizontal'
value={watch('filterRule')}
onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })}
items={[
{ value: 'all', label: 'All filters pass' },
{ value: 'any', label: 'Any filter passes' },
]}
/>
</label>
{fieldFilters.map((field, index) => {
const key = `filters.${index}.field.${field.id}`;
return (
<div key={key} className={style.filterSection}>
<label>
Runtime data source
<Select<string | null>
// need to normalize '' to null for the Select to show the placeholder
value={watch(`filters.${index}.field`) || null}
onValueChange={(value) => {
if (value === null) return;
setValue(`filters.${index}.field`, value, { shouldDirty: true });
}}
options={fieldList.map(({ value, label }) => ({
value,
label,
disabled: value === null,
}))}
aria-label='Event field'
/>
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
</label>
<label>
Matching condition
<Select
value={watch(`filters.${index}.operator`)}
onValueChange={(value: string | null) => {
if (value === null) return;
setValue(
`filters.${index}.operator`,
value as
| 'equals'
| 'not_equals'
| 'greater_than'
| 'less_than'
| 'contains'
| 'not_contains',
{ shouldDirty: true },
);
}}
options={[
{ value: 'equals', label: 'equals' },
{ value: 'not_equals', label: 'not equals' },
{ value: 'contains', label: 'contains' },
]}
aria-label='Operator'
/>
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
</label>
<label>
Value to match
<Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' />
</label>
<div>
<span>&nbsp;</span>
<div>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeFilter(index)}
>
<IoTrash />
</IconButton>
</div>
</div>
</div>
</div>
);
})}
<div>
<Button onClick={handleAddNewFilter}>
Add filter <IoAdd />
</Button>
</div>
);
}
if (isHTTPOutput(output)) {
const rowErrors = errors.outputs?.[index] as
| {
url?: { message?: string };
}
| undefined;
return (
<div key={output.id} className={style.outputCard}>
<Tag>HTTP</Tag>
<div className={style.httpSection}>
<label>
Target URL
<TemplateInput
{...register(`outputs.${index}.url`, {
required: { value: true, message: 'Required field' },
pattern: {
value: startsWithHttp,
message: 'HTTP messages should target http:// or https://',
},
})}
value={output.url}
fluid
placeholder='http://127.0.0.1/start/1'
/>
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
</label>
<div>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestHTTPOutput(index)}>
Test
</Button>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</div>
</div>
<div className={style.innerColumn}>
<h3>Outputs</h3>
<Info>
Automation outputs can be used to send data from Ontime to external software <br />
or to change properties of Ontime itself. <br /> <br />
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '}
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
</Info>
{fieldOutputs.map((output, index) => {
if (isOSCOutput(output)) {
const rowErrors = errors.outputs?.[index] as
| {
targetIP?: { message?: string };
targetPort?: { message?: string };
address?: { message?: string };
args?: { message?: string };
}
| undefined;
return (
<div key={output.id} className={style.outputCard}>
<Tag>OSC</Tag>
<div className={style.oscSection}>
<label>
Target IP
<Input
{...register(`outputs.${index}.targetIP`, {
required: { value: true, message: 'Required field' },
})}
fluid
placeholder='127.0.0.1'
/>
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error>
</label>
<label>
Target Port
<Input
{...register(`outputs.${index}.targetPort`, {
required: { value: true, message: 'Required field' },
setValueAs: (value) => (value === '' ? 0 : Number(value)),
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
})}
fluid
type='number'
maxLength={5}
placeholder='8000'
/>
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
</label>
<label>
Address
<TemplateInput
{...register(`outputs.${index}.address`)}
value={output.address}
fluid
placeholder='/cue/start'
/>
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
</label>
<label>
Arguments
<TemplateInput
{...register(`outputs.${index}.args`)}
value={output.args}
fluid
placeholder='1'
/>
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label>
<div>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestOSCOutput(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeOutput(index)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</div>
</div>
</div>
</div>
</div>
);
}
);
}
if (isHTTPOutput(output)) {
const rowErrors = errors.outputs?.[index] as
| {
url?: { message?: string };
}
| undefined;
return (
<div key={output.id} className={style.outputCard}>
<Tag>HTTP</Tag>
<div className={style.httpSection}>
<label>
Target URL
<TemplateInput
{...register(`outputs.${index}.url`, {
required: { value: true, message: 'Required field' },
pattern: {
value: startsWithHttp,
message: 'HTTP messages should target http:// or https://',
},
})}
value={output.url}
fluid
placeholder='http://127.0.0.1/start/1'
/>
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
</label>
<div>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestHTTPOutput(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeOutput(index)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</div>
</div>
</div>
);
}
if (isOntimeAction(output)) {
const rowErrors = errors.outputs?.[index] as
| {
action?: { message?: string };
time?: { message?: string };
text?: { message?: string };
visible?: { message?: string };
secondarySource?: { message?: string };
}
| undefined;
return (
<div key={output.id} className={style.outputCard}>
<Tag>Ontime action</Tag>
<OntimeActionForm
value={output.action}
index={index}
register={register}
rowErrors={rowErrors}
setValue={setValue}
watch={watch}
>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestOntimeAction(index)}>
Test
</Button>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</OntimeActionForm>
</div>
);
}
if (isOntimeAction(output)) {
const rowErrors = errors.outputs?.[index] as
| {
action?: { message?: string };
time?: { message?: string };
text?: { message?: string };
visible?: { message?: string };
secondarySource?: { message?: string };
}
| undefined;
return (
<div key={output.id} className={style.outputCard}>
<Tag>Ontime action</Tag>
<OntimeActionForm
value={output.action}
index={index}
register={register}
rowErrors={rowErrors}
setValue={setValue}
watch={watch}
>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestOntimeAction(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeOutput(index)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</OntimeActionForm>
</div>
);
}
// there should be no other output types
return null;
})}
<Panel.InlineElements relation='inner'>
<Button onClick={handleAddNewOSCOutput}>
OSC <IoAdd />
return null;
})}
<Panel.InlineElements relation='inner'>
<Button onClick={handleAddNewOSCOutput}>
OSC <IoAdd />
</Button>
<Button onClick={handleAddNewHTTPOutput}>
HTTP <IoAdd />
</Button>
<Button onClick={handleAddnewOntimeAction}>
Ontime action <IoAdd />
</Button>
</Panel.InlineElements>
</div>
</form>
}
footerElements={
<>
{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}>
Save
</Button>
<Button onClick={handleAddNewHTTPOutput}>
HTTP <IoAdd />
</Button>
<Button onClick={handleAddnewOntimeAction}>
Ontime action <IoAdd />
</Button>
</Panel.InlineElements>
</div>
<Panel.InlineElements align='end'>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
</Panel.Indent>
</>
}
/>
);
}
@@ -19,24 +19,27 @@ 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}
enabledOscIn={data.enabledOscIn}
oscPortIn={data.oscPortIn}
automationState={automationState}
oscInputState={oscInputState}
/>
</div>
<div ref={automationsRef}>
<AutomationsList automations={data.automations} enabledAutomations={automationState} />
</div>
<div ref={triggersRef}>
<TriggersList triggers={data.triggers} automations={data.automations} enabledAutomations={automationState} />
</div>
</Panel.Section>
<div ref={settingsRef}>
<AutomationSettingsForm
enabledAutomations={data.enabledAutomations}
enabledOscIn={data.enabledOscIn}
oscPortIn={data.oscPortIn}
automationState={automationState}
oscInputState={oscInputState}
isLoading={isLoading}
/>
</div>
<div ref={automationsRef}>
<AutomationsList automations={data.automations} enabledAutomations={automationState} isLoading={isLoading} />
</div>
<div ref={triggersRef}>
<TriggersList
triggers={data.triggers}
automations={data.automations}
enabledAutomations={automationState}
isLoading={isLoading}
/>
</div>
</>
);
}
@@ -21,6 +21,7 @@ interface AutomationSettingsProps {
oscPortIn: number;
automationState?: boolean;
oscInputState?: boolean;
isLoading?: boolean;
}
export default function AutomationSettingsForm({
@@ -29,6 +30,7 @@ export default function AutomationSettingsForm({
oscPortIn,
automationState,
oscInputState,
isLoading,
}: AutomationSettingsProps) {
const {
handleSubmit,
@@ -65,126 +67,129 @@ export default function AutomationSettingsForm({
const oscInputEnabled = watch('enabledOscIn');
return (
<Panel.Card>
<Panel.SubHeader>
Automation settings
<Panel.InlineElements>
<Button variant='ghosted' onClick={onReset} disabled={!canSubmit}>
Revert to saved
</Button>
<Button
variant='primary'
type='submit'
form='automation-settings-form'
disabled={!canSubmit}
loading={isSubmitting}
>
Save
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.Section>
<Panel.Card>
<Panel.Loader isLoading={Boolean(isLoading)} />
<Panel.SubHeader>
Automation settings
<Panel.InlineElements>
<Button variant='ghosted' onClick={onReset} disabled={!canSubmit}>
Revert to saved
</Button>
<Button
variant='primary'
type='submit'
form='automation-settings-form'
disabled={!canSubmit}
loading={isSubmitting}
>
Save
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.Divider />
<Panel.Divider />
<Panel.Section>
<Info>
<span>Control Ontime and share its data with external systems in your workflow.</span>
<span>- Automations allow Ontime to send its data on lifecycle triggers.</span>
<span>- OSC Input tells Ontime to listen to messages on the specific port.</span>
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
</Info>
</Panel.Section>
<Panel.Section>
<Info>
<span>Control Ontime and share its data with external systems in your workflow.</span>
<span>- Automations allow Ontime to send its data on lifecycle triggers.</span>
<span>- OSC Input tells Ontime to listen to messages on the specific port.</span>
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
</Info>
</Panel.Section>
<Panel.Section
as='form'
id='automation-settings-form'
onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onReset)}
>
<Panel.Loader isLoading={false} />
<Panel.Section
as='form'
id='automation-settings-form'
onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onReset)}
>
<Panel.Title>Automation</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title={
<>
<span>Enable automations</span>
{automationState === false && <Tag variant='warning'>OFF</Tag>}
</>
}
description={
automationState === false
? 'Automations are OFF. Triggers stay configured, but Ontime will not send messages.'
: 'Allow Ontime to send messages on lifecycle triggers'
}
descriptionTone={automationState === false ? 'warning' : 'default'}
error={errors.enabledAutomations?.message}
/>
<Switch
size='large'
checked={automationsEnabled}
onCheckedChange={(value: boolean) =>
setValue('enabledAutomations', value, { shouldDirty: true, shouldValidate: true })
}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.Title>OSC Input</Panel.Title>
<Panel.Title>Automation</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title={
<>
<span>Enable automations</span>
{automationState === false && <Tag variant='warning'>OFF</Tag>}
</>
}
description={
automationState === false
? 'Automations are OFF. Triggers stay configured, but Ontime will not send messages.'
: 'Allow Ontime to send messages on lifecycle triggers'
}
descriptionTone={automationState === false ? 'warning' : 'default'}
error={errors.enabledAutomations?.message}
/>
<Switch
size='large'
checked={automationsEnabled}
onCheckedChange={(value: boolean) =>
setValue('enabledAutomations', value, { shouldDirty: true, shouldValidate: true })
}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.Title>OSC Input</Panel.Title>
<Panel.ListGroup>
{isOntimeCloud && <Info>For security reasons OSC integrations are not available in the cloud service.</Info>}
<Panel.ListItem>
<Panel.Field
title={
<>
<span>OSC input</span>
{oscInputState === false && <Tag variant='warning'>OFF</Tag>}
</>
}
description={
oscInputState === false
? 'OSC input is OFF. Ontime will not listen for incoming OSC control messages.'
: 'Allow control of Ontime through OSC'
}
descriptionTone={oscInputState === false ? 'warning' : 'default'}
error={errors.enabledOscIn?.message}
/>
<Switch
size='large'
checked={oscInputEnabled}
onCheckedChange={(value: boolean) =>
setValue('enabledOscIn', value, { shouldDirty: true, shouldValidate: true })
}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Listen on port'
description='Port for incoming OSC. Default: 8888'
error={errors.oscPortIn?.message}
/>
<Input
id='oscPortIn'
placeholder='8888'
maxLength={5}
style={{ textAlign: 'right', width: '5rem' }}
type='number'
fluid
{...register('oscPortIn', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
pattern: {
value: isOnlyNumbers,
message: 'Value should be numeric',
},
})}
/>
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
</Panel.Card>
<Panel.ListGroup>
{isOntimeCloud && (
<Info>For security reasons OSC integrations are not available in the cloud service.</Info>
)}
<Panel.ListItem>
<Panel.Field
title={
<>
<span>OSC input</span>
{oscInputState === false && <Tag variant='warning'>OFF</Tag>}
</>
}
description={
oscInputState === false
? 'OSC input is OFF. Ontime will not listen for incoming OSC control messages.'
: 'Allow control of Ontime through OSC'
}
descriptionTone={oscInputState === false ? 'warning' : 'default'}
error={errors.enabledOscIn?.message}
/>
<Switch
size='large'
checked={oscInputEnabled}
onCheckedChange={(value: boolean) =>
setValue('enabledOscIn', value, { shouldDirty: true, shouldValidate: true })
}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Listen on port'
description='Port for incoming OSC. Default: 8888'
error={errors.oscPortIn?.message}
/>
<Input
id='oscPortIn'
placeholder='8888'
maxLength={5}
style={{ textAlign: 'right', width: '5rem' }}
type='number'
fluid
{...register('oscPortIn', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
pattern: {
value: isOnlyNumbers,
message: 'Value should be numeric',
},
})}
/>
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -22,10 +22,10 @@ const automationPlaceholder: AutomationDTO = {
interface AutomationsListProps {
automations: NormalisedAutomation;
enabledAutomations?: boolean;
isLoading: boolean;
}
export default function AutomationsList(props: AutomationsListProps) {
const { automations, enabledAutomations } = props;
export default function AutomationsList({ automations, enabledAutomations, isLoading }: AutomationsListProps) {
const { refetch } = useAutomationSettings();
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -44,89 +44,94 @@ export default function AutomationsList(props: AutomationsListProps) {
const arrayAutomations = Object.keys(automations);
return (
<Panel.Card>
<Panel.SubHeader>
Manage automations
<Button
type='submit'
disabled={Boolean(automationFormData)}
onClick={() => setAutomationFormData(automationPlaceholder)}
>
New <IoAdd />
</Button>
</Panel.SubHeader>
<Panel.Section>
<Panel.Card>
{automationFormData !== null && (
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
)}
<Panel.SubHeader>
Manage automations
<Button onClick={() => setAutomationFormData(automationPlaceholder)}>
New <IoAdd />
</Button>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Divider />
{enabledAutomations === false && (
<Info>
Automations are disabled. You can still manage automation definitions here, but they will not run until
enabled.
</Info>
)}
{automationFormData !== null && (
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
)}
<Panel.Table>
<thead>
<tr>
<th style={{ width: '45%' }}>Title</th>
<th style={{ width: '15%' }}>Trigger rule</th>
<th style={{ width: '15%' }}>Filters</th>
<th style={{ width: '15%' }}>Outputs</th>
<th />
</tr>
</thead>
<tbody>
{arrayAutomations.length === 0 && (
<Panel.TableEmpty
handleClick={!automationFormData ? () => setAutomationFormData(automationPlaceholder) : undefined}
/>
<Panel.Section>
{enabledAutomations === false && (
<Info>
Automations are disabled. You can still manage automation definitions here, but they will not run until
enabled.
</Info>
)}
{arrayAutomations.map((automationId) => {
if (!Object.hasOwn(automations, automationId)) {
return null;
}
return (
<Fragment key={automationId}>
<Panel.Table>
<thead>
<tr>
<th style={{ width: '45%' }}>Title</th>
<th style={{ width: '15%' }}>Trigger rule</th>
<th style={{ width: '15%' }}>Filters</th>
<th style={{ width: '15%' }}>Outputs</th>
<th />
</tr>
</thead>
<tbody>
{!isLoading && 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>
}
/>
)}
{arrayAutomations.map((automationId) => {
if (!Object.hasOwn(automations, automationId)) {
return null;
}
return (
<Fragment key={automationId}>
<tr>
<td>{automations[automationId].title}</td>
<td>
<Tag>{automations[automationId].filterRule}</Tag>
</td>
<td>{automations[automationId].filters.length}</td>
<td>{automations[automationId].outputs.length}</td>
<Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton
variant='ghosted-white'
aria-label='Edit entry'
onClick={() => setAutomationFormData(automations[automationId])}
>
<IoPencil />
</IconButton>
<IconButton
variant='ghosted-destructive'
aria-label='Delete entry'
onClick={() => handleDelete(automationId)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</tr>
</Fragment>
);
})}
{deleteError && (
<tr>
<td>{automations[automationId].title}</td>
<td>
<Tag>{automations[automationId].filterRule}</Tag>
<td colSpan={5}>
<Panel.Error>{deleteError}</Panel.Error>
</td>
<td>{automations[automationId].filters.length}</td>
<td>{automations[automationId].outputs.length}</td>
<Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton
variant='ghosted-white'
aria-label='Edit entry'
onClick={() => setAutomationFormData(automations[automationId])}
>
<IoPencil />
</IconButton>
<IconButton
variant='ghosted-destructive'
aria-label='Delete entry'
onClick={() => handleDelete(automationId)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</tr>
</Fragment>
);
})}
{deleteError && (
<tr>
<td colSpan={5}>
<Panel.Error>{deleteError}</Panel.Error>
</td>
</tr>
)}
</tbody>
</Panel.Table>
</Panel.Card>
)}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -1,4 +1,4 @@
import { NormalisedAutomation, TimerLifeCycle, TriggerDTO } from 'ontime-types';
import { NormalisedAutomation, TimerLifeCycle, Trigger, TriggerDTO } from 'ontime-types';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
@@ -6,30 +6,21 @@ 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;
initialId?: string;
initialTitle?: string;
initialAutomationId?: string;
initialTrigger?: TimerLifeCycle;
trigger: Trigger | null;
onCancel: () => void;
postSubmit: () => void;
}
export default function TriggerForm({
automations,
initialId,
initialTitle,
initialAutomationId,
initialTrigger,
onCancel,
postSubmit,
}: TriggerFormProps) {
export default function TriggerForm({ automations, trigger, onCancel, postSubmit }: TriggerFormProps) {
const {
handleSubmit,
register,
@@ -40,9 +31,9 @@ export default function TriggerForm({
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<TriggerDTO>({
defaultValues: {
title: initialTitle,
trigger: initialTrigger ?? (cycles[0].value as TimerLifeCycle | undefined),
automationId: initialAutomationId ?? automations?.[Object.keys(automations)[0]]?.id,
title: trigger?.title,
trigger: trigger?.trigger ?? (cycles[0].value as TimerLifeCycle | undefined),
automationId: trigger?.automationId ?? automations?.[Object.keys(automations)[0]]?.id,
},
resetOptions: {
keepDirtyValues: true,
@@ -55,10 +46,9 @@ export default function TriggerForm({
}, [setFocus]);
const onSubmit = async (values: TriggerDTO) => {
// if we were passed an ID we are editing a Trigger
if (initialId) {
if (trigger) {
try {
await editTrigger(initialId, { id: initialId, ...values });
await editTrigger(trigger.id, { id: trigger.id, ...values });
postSubmit();
} catch (error) {
setError('root', { message: `Failed to save changes to trigger ${maybeAxiosError(error)}` });
@@ -85,56 +75,63 @@ export default function TriggerForm({
const canSubmit = isDirty && isValid;
return (
<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={initialTitle}
/>
<Panel.Error>{errors.title?.message}</Panel.Error>
</label>
<label>
Lifecycle trigger
<Select
value={watch('trigger')}
onValueChange={(value) => {
if (value === null) return;
setValue('trigger', value as TimerLifeCycle, { shouldDirty: true });
}}
options={cycles.map((cycle) => ({ value: cycle.value, label: cycle.label }))}
aria-label='Lifecycle trigger'
/>
<Panel.Error>{errors.trigger?.message}</Panel.Error>
</label>
<label>
Automation title
<Select
value={watch('automationId')}
onValueChange={(value: string | null) => {
if (value === null) return;
setValue('automationId', value, { shouldDirty: true });
}}
options={automationSelect}
aria-label='Automation title'
/>
<Panel.Error>{errors.automationId?.message}</Panel.Error>
</label>
<Panel.InlineElements align='end'>
<Button disabled={isSubmitting} onClick={onCancel}>
Cancel
</Button>
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
</Panel.Indent>
<Modal
isOpen
onClose={onCancel}
showBackdrop
showCloseButton
size='compact'
title={trigger ? 'Edit trigger' : 'Create trigger'}
bodyElements={
<form id={formId} onSubmit={handleSubmit(onSubmit)}>
<label>
Title
<Input
{...register('title', { required: { value: true, message: 'Required field' } })}
fluid
defaultValue={trigger?.title}
/>
<Panel.Error>{errors.title?.message}</Panel.Error>
</label>
<label>
Lifecycle trigger
<Select
value={watch('trigger')}
onValueChange={(value) => {
if (value === null) return;
setValue('trigger', value as TimerLifeCycle, { shouldDirty: true });
}}
options={cycles.map((cycle) => ({ value: cycle.value, label: cycle.label }))}
aria-label='Lifecycle trigger'
/>
<Panel.Error>{errors.trigger?.message}</Panel.Error>
</label>
<label>
Automation title
<Select
value={watch('automationId')}
onValueChange={(value: string | null) => {
if (value === null) return;
setValue('automationId', value, { shouldDirty: true });
}}
options={automationSelect}
aria-label='Automation title'
/>
<Panel.Error>{errors.automationId?.message}</Panel.Error>
</label>
</form>
}
footerElements={
<>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button disabled={isSubmitting} onClick={onCancel}>
Cancel
</Button>
<Button type='submit' form={formId} variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</>
}
/>
);
}
@@ -9,23 +9,33 @@ 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 AutomationForm from './TriggerForm';
import TriggerForm 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(props: TriggersListProps) {
const { triggers, automations, enabledAutomations } = props;
const [showForm, setShowForm] = useState(false);
export default function TriggersList({ triggers, automations, enabledAutomations, isLoading }: TriggersListProps) {
const [formState, setFormState] = useState<FormState>({ isOpen: false, trigger: undefined });
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));
@@ -35,7 +45,7 @@ export default function TriggersList(props: TriggersListProps) {
};
const postSubmit = () => {
setShowForm(false);
closeForm();
refetch();
};
@@ -45,70 +55,86 @@ export default function TriggersList(props: TriggersListProps) {
const canAdd = Object.keys(automations).length > 0;
return (
<Panel.Card>
<Panel.SubHeader>
Manage triggers
<Button type='submit' form='trigger-form' disabled={!canAdd} loading={false} onClick={() => setShowForm(true)}>
New <IoAdd />
</Button>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
{enabledAutomations === false && (
<Info>
Automations are disabled. You can still manage triggers here, but they will not run until enabled.
</Info>
<Panel.Section>
<Panel.Card>
{formState.isOpen && (
<TriggerForm
automations={automations}
trigger={formState.trigger ?? null}
onCancel={closeForm}
postSubmit={postSubmit}
/>
)}
{duplicates && (
<Panel.Error>
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>
<th style={{ width: '35%' }}>Title</th>
<th style={{ width: '25%' }}>Lifecycle trigger</th>
<th style={{ width: '25%' }}>Automation</th>
<th />
</tr>
</thead>
<tbody>
{!showForm && triggers.length === 0 && (
<Panel.TableEmpty
label='Create a trigger to run an automation'
handleClick={canAdd ? () => setShowForm(true) : undefined}
/>
)}
{triggers.map((trigger, index) => {
return (
<Fragment key={trigger.id}>
<TriggersListItem
automations={automations}
id={trigger.id}
title={trigger.title}
trigger={trigger.trigger}
automationId={trigger.automationId}
duplicate={duplicates?.includes(index)}
handleDelete={() => handleDelete(trigger.id)}
postSubmit={postSubmit}
/>
{deleteError && (
<tr>
<td colSpan={5}>
<Panel.Error>{deleteError}</Panel.Error>
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card>
<Panel.SubHeader>
Manage triggers
<Button disabled={!canAdd} onClick={openNewForm}>
New <IoAdd />
</Button>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
{enabledAutomations === false && (
<Info>
Automations are disabled. You can still manage triggers here, but they will not run until enabled.
</Info>
)}
{duplicates && (
<Panel.Error>
You have created multiple links between the same trigger and automation which can cause performance
issues.
</Panel.Error>
)}
<Panel.Table>
<thead>
<tr>
<th style={{ width: '35%' }}>Title</th>
<th style={{ width: '25%' }}>Lifecycle trigger</th>
<th style={{ width: '25%' }}>Automation</th>
<th />
</tr>
</thead>
<tbody>
{!isLoading && 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>
)
}
/>
)}
{triggers.map((trigger, index) => {
return (
<Fragment key={trigger.id}>
<TriggersListItem
automations={automations}
trigger={trigger}
duplicate={duplicates?.includes(index)}
handleEdit={() => openEditForm(trigger)}
handleDelete={() => handleDelete(trigger.id)}
/>
</Fragment>
);
})}
{deleteError && (
<tr>
<td colSpan={5}>
<Panel.Error>{deleteError}</Panel.Error>
</td>
</tr>
)}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -1,48 +1,21 @@
import { NormalisedAutomation, TimerLifeCycle } from 'ontime-types';
import { useState } from 'react';
import { NormalisedAutomation, Trigger } from 'ontime-types';
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;
id: string;
title: string;
trigger: TimerLifeCycle;
automationId: string;
trigger: Trigger;
duplicate?: boolean;
handleEdit: () => void;
handleDelete: () => void;
postSubmit: () => void;
}
export default function TriggersListItem(props: TriggersListItemProps) {
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>
);
}
const { automations, trigger, duplicate, handleEdit, handleDelete } = props;
return (
<tr data-warn={duplicate}>
@@ -52,16 +25,16 @@ export default function TriggersListItem(props: TriggersListItemProps) {
color='#FFBC56' // $orange-500
/>
)}
{title}
{trigger.title}
</Panel.InlineElements>
<td>
<Tag>{cycles.find((cycle) => cycle.value === trigger)?.label}</Tag>
<Tag>{cycles.find((cycle) => cycle.value === trigger.trigger)?.label}</Tag>
</td>
<td>
<Tag>{automations?.[automationId]?.title}</Tag>
<Tag>{automations?.[trigger.automationId]?.title}</Tag>
</td>
<Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={() => setIsEditing(true)}>
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={handleEdit}>
<IoPencil />
</IconButton>
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={handleDelete}>
@@ -66,7 +66,10 @@ export default function ReportSettings() {
</thead>
<tbody>
{combinedReport.length === 0 && (
<Panel.TableEmpty label='Reports are generated when running through the show.' />
<Panel.TableEmpty
title='No report data yet'
description='Reports are generated as you run through the show, comparing scheduled times against what actually happened.'
/>
)}
{combinedReport.map((entry) => {
@@ -43,6 +43,7 @@ export default function URLPresets() {
return (
<Panel.Section>
<Panel.Card>
{formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />}
<Panel.SubHeader>
URL presets
<Button onClick={openNewForm}>
@@ -63,7 +64,6 @@ 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,7 +76,17 @@ export default function URLPresets() {
</tr>
</thead>
<tbody>
{data.length === 0 && <Panel.TableEmpty handleClick={openNewForm} />}
{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.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,6 +28,8 @@ const targetOptions: SelectOption<OntimeViewPresettable>[] = [
{ value: OntimeView.ProjectInfo, label: 'Project Info' },
];
const formId = 'url-preset-form';
const defaultValues: URLPreset = {
alias: '',
target: OntimeView.Timer,
@@ -134,95 +136,101 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
};
return (
<Panel.Indent
as='form'
onSubmit={handleSubmit(setupSubmit)}
onKeyDown={(event) => preventEscape(event, onClose)}
className={style.column}
>
<input hidden name='enabled' value='true' />
<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}>
<input hidden name='enabled' value='true' />
<div>1. Enter URL and let Ontime generate the preset options</div>
<Panel.InlineElements>
<div>
<Panel.Description>Alias</Panel.Description>
<Input
{...register('alias', {
required: 'Alias is required',
pattern: {
value: isUrlSafe,
message: 'Field can only contain URL safe characters (a-z, 0-9, _ and -)',
},
})}
/>
</div>
<div className={style.expand}>
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
<div>1. Enter URL and let Ontime generate the preset options</div>
<Panel.InlineElements>
<Input placeholder='Paste URL' fluid ref={urlRef} disabled={isEditingCuesheet} />
<Button onClick={generateOptions} disabled={isEditingCuesheet}>
Generate
</Button>
<div>
<Panel.Description>Alias</Panel.Description>
<Input
{...register('alias', {
required: 'Alias is required',
pattern: {
value: isUrlSafe,
message: 'Field can only contain URL safe characters (a-z, 0-9, _ and -)',
},
})}
/>
</div>
<div className={style.expand}>
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
<Panel.InlineElements>
<Input placeholder='Paste URL' fluid ref={urlRef} disabled={isEditingCuesheet} />
<Button onClick={generateOptions} disabled={isEditingCuesheet}>
Generate
</Button>
</Panel.InlineElements>
</div>
</Panel.InlineElements>
</div>
</Panel.InlineElements>
{errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>}
{!isEditingCuesheet && (
<>
<div>
{enDash} or {enDash}
</div>
<div>2. Choose a view and its parameters</div>
<div>
<Panel.Description>Target</Panel.Description>
<Select
options={targetOptions}
{...register('target', { required: 'Target is required' })}
value={watch('target')}
onValueChange={(value: OntimeViewPresettable | null) => {
if (value === null) return;
setValue('target', value, { shouldDirty: true });
}}
/>
</div>
<div>
<Panel.Description>Parameters</Panel.Description>
<Textarea
fluid
rows={3}
{...register('search', {
validate: validateParams,
})}
/>
<Panel.Error>{errors.search?.message}</Panel.Error>
</div>
</>
)}
{errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>}
{!isEditingCuesheet && (
<>
<div>
{enDash} or {enDash}
</div>
<div>2. Choose a view and its parameters</div>
<div>
<Panel.Description>Target</Panel.Description>
<Select
options={targetOptions}
{...register('target', { required: 'Target is required' })}
value={watch('target')}
onValueChange={(value: OntimeViewPresettable | null) => {
if (value === null) return;
setValue('target', value, { shouldDirty: true });
}}
/>
</div>
<div>
<Panel.Description>Parameters</Panel.Description>
<Textarea
fluid
rows={3}
{...register('search', {
validate: validateParams,
})}
/>
<Panel.Error>{errors.search?.message}</Panel.Error>
</div>
</>
)}
{isCuesheet && (
<div>
<Panel.Description>Permissions</Panel.Description>
<CuesheetLinkOptions
initialRead={initialPermissions.current.read}
initialWrite={initialPermissions.current.write}
onChange={setCuesheetPermissions}
/>
</div>
)}
<div>
<Panel.Error>{errors.root?.message}</Panel.Error>
<Panel.InlineElements align='end'>
{isCuesheet && (
<div>
<Panel.Description>Permissions</Panel.Description>
<CuesheetLinkOptions
initialRead={initialPermissions.current.read}
initialWrite={initialPermissions.current.write}
onChange={setCuesheetPermissions}
/>
</div>
)}
</form>
}
footerElements={
<>
<Panel.Error>{errors.root?.message}</Panel.Error>
<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,27 +13,30 @@ 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 [isAdding, setIsAdding] = useState(false);
const [formState, setFormState] = useState<FormState>({ isOpen: false, field: undefined });
const handleInitiateCreate = () => {
setIsAdding(true);
};
const openNewForm = () => setFormState({ isOpen: true });
const openEditForm = (field: CustomFieldEntity) => setFormState({ isOpen: true, field });
const closeForm = () => setFormState({ isOpen: false, field: undefined });
const handleCancel = () => {
setIsAdding(false);
};
const handleCreate = async (customField: CustomField) => {
await postCustomField(customField);
refetch();
setIsAdding(false);
};
const handleEditField = async (key: CustomFieldKey, customField: CustomField) => {
await editCustomField(key, customField);
const handleSubmit = async (customField: CustomField) => {
const editing = formState.field;
if (editing) {
await editCustomField(editing.key, customField);
} else {
await postCustomField(customField);
}
refetch();
closeForm();
};
const handleDelete = async (key: CustomFieldKey) => {
@@ -45,12 +48,24 @@ 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={handleInitiateCreate}>
<Button onClick={openNewForm}>
New <IoAdd />
</Button>
</Panel.SubHeader>
@@ -66,7 +81,6 @@ export default function CustomFieldSettings() {
</Info>
</Panel.Section>
<Panel.Section>
{isAdding && <CustomFieldForm onSubmit={handleCreate} onCancel={handleCancel} />}
<Panel.Table>
<thead>
<tr>
@@ -78,7 +92,18 @@ export default function CustomFieldSettings() {
</tr>
</thead>
<tbody>
{Object.entries(data).map(([key, { colour, label, type }]) => {
{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 }]) => {
return (
<CustomFieldEntry
key={key}
@@ -86,7 +111,7 @@ export default function CustomFieldSettings() {
colour={colour}
label={label}
type={type}
onEdit={handleEditField}
onEdit={() => openEditForm({ key, colour, label, type })}
onDelete={handleDelete}
/>
);
@@ -5,11 +5,14 @@ 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;
@@ -53,62 +56,72 @@ export default function CustomViewForm({ onComplete, onClose }: CustomViewFormPr
};
return (
<Panel.Indent as='form' onSubmit={handleUpload} className={style.uploadForm}>
<input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
onChange={handleSelectFile}
accept='.html,text/html'
/>
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
size='compact'
title='Upload custom view'
bodyElements={
<form id={formId} onSubmit={handleUpload} className={style.uploadForm}>
<input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
onChange={handleSelectFile}
accept='.html,text/html'
/>
<div className={style.step}>
<div className={style.stepTitle}>1. Choose a name</div>
<Panel.Description>Name</Panel.Description>
<Input
value={slug}
onChange={(event) => {
setSlug(event.target.value);
setSlugDirty(true);
}}
placeholder='my-view'
aria-label='Custom view name'
autoCapitalize='off'
autoComplete='off'
fluid
/>
<Panel.Description>
Use lowercase letters, numbers, and dashes. Example: <Panel.Highlight>my-view</Panel.Highlight>
</Panel.Description>
<Panel.Description>
Preview URL: <Panel.Highlight>{previewUrl}</Panel.Highlight>
</Panel.Description>
{slugDirty && slugError && <Panel.Error>{slugError}</Panel.Error>}
</div>
<div className={style.step}>
<div className={style.stepTitle}>1. Choose a name</div>
<Panel.Description>Name</Panel.Description>
<Input
value={slug}
onChange={(event) => {
setSlug(event.target.value);
setSlugDirty(true);
}}
placeholder='my-view'
aria-label='Custom view name'
autoCapitalize='off'
autoComplete='off'
fluid
/>
<Panel.Description>
Use lowercase letters, numbers, and dashes. Example: <Panel.Highlight>my-view</Panel.Highlight>
</Panel.Description>
<Panel.Description>
Preview URL: <Panel.Highlight>{previewUrl}</Panel.Highlight>
</Panel.Description>
{slugDirty && slugError && <Panel.Error>{slugError}</Panel.Error>}
</div>
<div className={style.step}>
<div className={style.stepTitle}>2. Select index.html</div>
<Panel.Description>Upload file</Panel.Description>
<Panel.InlineElements wrap='wrap' className={style.filePicker}>
<Button onClick={() => fileInputRef.current?.click()}>
{selectedFile ? 'Replace index.html' : 'Choose index.html'}
<div className={style.step}>
<div className={style.stepTitle}>2. Select index.html</div>
<Panel.Description>Upload file</Panel.Description>
<Panel.InlineElements wrap='wrap' className={style.filePicker}>
<Button onClick={() => fileInputRef.current?.click()}>
{selectedFile ? 'Replace index.html' : 'Choose index.html'}
</Button>
<span className={style.fileName}>
{selectedFile ? `${selectedFile.name} (${Math.ceil(selectedFile.size / 1024)} KB)` : 'No file selected'}
</span>
</Panel.InlineElements>
<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>}
<Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' form={formId} loading={isUploading} disabled={!canUpload}>
Upload view <IoCloudUploadOutline />
</Button>
<span className={style.fileName}>
{selectedFile ? `${selectedFile.name} (${Math.ceil(selectedFile.size / 1024)} KB)` : 'No file selected'}
</span>
</Panel.InlineElements>
<Panel.Description>Accepted: index.html only, maximum {maxUploadLabel}.</Panel.Description>
{fileDirty && fileError && <Panel.Error>{fileError}</Panel.Error>}
</div>
{error && <Panel.Error>{error}</Panel.Error>}
<Panel.InlineElements align='end'>
<Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' loading={isUploading} disabled={!canUpload}>
Upload view <IoCloudUploadOutline />
</Button>
</Panel.InlineElements>
</Panel.Indent>
</>
}
/>
);
}
@@ -37,13 +37,14 @@ 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)} disabled={isUploadOpen}>
<Button onClick={() => setIsUploadOpen(true)}>
New <IoAdd />
</Button>
</Panel.InlineElements>
@@ -63,8 +64,6 @@ 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,5 +1,6 @@
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';
@@ -53,7 +54,17 @@ export default function CustomViewsList({ views, onOpenUpload, onMutate, onError
</tr>
</thead>
<tbody>
{views.length === 0 && <Panel.TableEmpty handleClick={onOpenUpload} label='No custom views yet' />}
{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.map((view, index) => (
<CustomViewsListItem
key={view.slug}
@@ -12,6 +12,13 @@
gap: 1rem;
}
.current {
background-color: $blue-1100 !important; // fighting zebra styles
.formColumn {
display: flex;
flex-direction: column;
gap: 1rem;
}
.current {
// marker consumed by the shared Panel.Table active-row treatment
background-color: inherit;
}
@@ -3,9 +3,12 @@ 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;
};
@@ -42,22 +45,32 @@ export function ManageRundownForm({ onClose }: ManageRundownForm) {
}, [setFocus]);
return (
<Panel.Indent as='form' onSubmit={handleSubmit(createRundown)}>
<Panel.Section>
<label>
<Panel.Description>Rundown title</Panel.Description>
<Input {...register('title')} fluid placeholder='Your rundown name' />
</label>
</Panel.Section>
<Panel.InlineElements relation='inner' align='end'>
<Button variant='ghosted' disabled={isSubmitting} onClick={onClose}>
Cancel
</Button>
<Button type='submit' variant='primary' disabled={isSubmitting}>
Create rundown
</Button>
</Panel.InlineElements>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
</Panel.Indent>
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
size='small'
title='Create rundown'
bodyElements={
<form id={formId} onSubmit={handleSubmit(createRundown)}>
<label>
<Panel.Description>Rundown title</Panel.Description>
<Input {...register('title')} fluid placeholder='Your rundown name' maxLength={64} />
</label>
</form>
}
footerElements={
<>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button variant='ghosted' disabled={isSubmitting} onClick={onClose}>
Cancel
</Button>
<Button type='submit' form={formId} variant='primary' disabled={isSubmitting}>
Create rundown
</Button>
</>
}
/>
);
}
@@ -153,7 +153,7 @@ export default function ManageRundowns() {
<tr key={id} className={cx([isLoaded && style.current])}>
<td>{numEntries}</td>
<td>
{title} {isLoaded && <Tag>Loaded</Tag>}
{title} {isLoaded && <Tag variant='active'>Loaded</Tag>}
</td>
<td>
<DropdownMenu
@@ -1,5 +1,4 @@
import { CustomField, CustomFieldKey } from 'ontime-types';
import { useState } from 'react';
import { IoPencil, IoTrash } from 'react-icons/io5';
import IconButton from '../../../../../common/components/buttons/IconButton';
@@ -7,7 +6,6 @@ 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';
@@ -16,35 +14,12 @@ interface CustomFieldEntryProps {
label: string;
fieldKey: string;
type: CustomField['type'];
onEdit: (key: CustomFieldKey, patch: CustomField) => Promise<void>;
onEdit: () => 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>
@@ -61,7 +36,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
</CopyTag>
</td>
<Panel.InlineElements relation='inner' as='td'>
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={() => setIsEditing(true)}>
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={onEdit}>
<IoPencil />
</IconButton>
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={() => onDelete(fieldKey)}>
@@ -8,13 +8,15 @@ 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;
@@ -85,65 +87,77 @@ export default function CustomFieldForm({
const isEditMode = initialKey !== undefined;
return (
<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 />
Prefer using small, and compressed images.
</Info>
<div>
<Panel.Description>Type</Panel.Description>
<RadioGroup
orientation='horizontal'
disabled={isEditMode}
onValueChange={(value) => setValue('type', value, { shouldDirty: true })}
value={watch('type')}
items={[
{ value: 'text', label: 'Text' },
{ value: 'image', label: 'Image' },
]}
/>
</div>
<div className={style.twoCols}>
<label>
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
<Input
{...register('label', {
required: { value: true, message: 'Required field' },
onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'),
validate: (value) => {
if (value.trim().length === 0) return 'Required field';
if (!checkRegex.isAlphanumericWithSpace(value))
return 'Only alphanumeric characters and space are allowed';
if (!isEditMode) {
if (isEditMode && Object.keys(data).includes(value)) return 'Custom fields must be unique';
}
return true;
},
})}
fluid
/>
</label>
<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}>
<Info>
Please note that images can quickly deteriorate your app&apos;s performance.
<br />
Prefer using small, and compressed images.
</Info>
<div>
<Panel.Description>Type</Panel.Description>
<RadioGroup
orientation='horizontal'
disabled={isEditMode}
onValueChange={(value) => setValue('type', value, { shouldDirty: true })}
value={watch('type')}
items={[
{ value: 'text', label: 'Text' },
{ value: 'image', label: 'Image' },
]}
/>
</div>
<div className={style.twoCols}>
<label>
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
<Input
{...register('label', {
required: { value: true, message: 'Required field' },
onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'),
validate: (value) => {
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';
}
return true;
},
})}
fluid
/>
</label>
<label>
<Panel.Description>Key (use in Integrations and API)</Panel.Description>
<Input {...register('key')} variant='ghosted' readOnly fluid />
</label>
</div>
<label>
<Panel.Description>Colour</Panel.Description>
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
</label>
{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' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
</Panel.Indent>
<label>
<Panel.Description>Key (use in Integrations and API)</Panel.Description>
<Input {...register('key')} variant='ghosted' readOnly fluid />
</label>
</div>
<label>
<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>}
<Button variant='ghosted' onClick={onCancel}>
Cancel
</Button>
<Button type='submit' form={formId} variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</>
}
/>
);
}
@@ -59,6 +59,7 @@ export default function RundownRenameForm({ onSubmit, onCancel, initialTitle }:
return true;
},
})}
maxLength={64}
fluid
/>
{errors.title && <Panel.Error>{errors.title.message}</Panel.Error>}
@@ -25,6 +25,13 @@
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));
@@ -8,6 +8,7 @@ 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,
@@ -56,9 +57,11 @@ 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 fileInputRef = useRef<HTMLInputElement>(null);
@@ -101,6 +104,7 @@ export default function SourcesPanel() {
setHasFile('none');
setActiveSource(null);
setError('');
setCompletedRundownTitle('');
};
const openGSheetFlow = () => {
@@ -123,11 +127,12 @@ export default function SourcesPanel() {
}
};
const handleFinished = () => {
const handleFinished = (rundownTitle: string) => {
setImportFlow('finished');
setHasFile('none');
setActiveSource(null);
setError('');
setCompletedRundownTitle(rundownTitle);
};
const handleApplyImport = async (
@@ -139,7 +144,7 @@ export default function SourcesPanel() {
if (mode === 'new') {
const title = newRundownTitle.trim() || preview.rundown.title;
await applyImport({ mode: 'new', rundown: { ...preview.rundown, title }, customFields: preview.customFields });
handleFinished();
handleFinished(title);
return;
}
@@ -156,7 +161,7 @@ export default function SourcesPanel() {
customFields: preview.customFields,
providedFields,
});
handleFinished();
handleFinished(currentRundown.title);
};
const loadWorksheetMetadata = useCallback(
@@ -289,11 +294,20 @@ export default function SourcesPanel() {
{showCompleted && (
<div className={style.finishSection}>
<span className={style.finishBadge}>Import complete</span>
<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}>
Reset flow
</Button>
<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>
<Button variant='subtle-white' onClick={resetFlow}>
Import another
</Button>
</div>
</div>
)}
{isGSheetFlow && (
@@ -92,6 +92,10 @@
font-weight: 600;
}
.toolbarWarning {
color: $orange-400;
}
.mappingPaneTitle {
align-self: center;
}
@@ -132,7 +132,16 @@ export default function SheetImportEditor({
}
/>
</label>
{toolbarStatus && <Panel.Description>{toolbarStatus}</Panel.Description>}
{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>
)}
</Panel.InlineElements>
<div className={style.editorBody}>
@@ -150,13 +159,19 @@ export default function SheetImportEditor({
<section className={style.previewPane}>
<div className={style.previewPaneHeader}>
<span className={style.previewPaneTitle}>Import preview</span>
<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>
@@ -1,27 +1,8 @@
.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: collapse;
border-collapse: separate;
border-spacing: 0;
color: $ui-white;
font-size: calc(1rem - 2px);
text-align: left;
table-layout: auto;
@@ -34,31 +15,101 @@
}
th {
font-weight: 400;
color: $gray-400;
text-transform: capitalize;
vertical-align: top;
color: $gray-300;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
vertical-align: bottom;
white-space: normal;
}
th,
td {
padding: 0.5rem;
box-sizing: border-box;
min-width: 8rem;
vertical-align: top;
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;
}
tr:nth-child(even) {
background-color: $white-1;
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);
}
td[data-empty='true'] {
color: $gray-600;
}
}
.rowNumber,
.rowType {
position: sticky;
z-index: 1;
background-color: inherit;
}
.rowNumber {
width: 4.5rem;
min-width: 4.5rem;
left: 0;
width: 3.25rem;
min-width: 3.25rem !important;
color: $gray-400;
text-align: right;
}
.rowType {
width: 7rem;
min-width: 7rem;
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;
}
@@ -1,7 +1,12 @@
import type { CustomField, CustomFieldKey, SpreadsheetPreviewResponse } from 'ontime-types';
import { isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { isOntimeDelay, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import type { CSSProperties } from 'react';
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';
@@ -9,14 +14,85 @@ 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(() => {
@@ -24,33 +100,51 @@ 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 (
<div className={style.emptyState}>
<div className={style.emptyTitle}>Preview not generated</div>
<div className={style.emptyBody}>{emptyContent}</div>
</div>
<Panel.EmptyState
title={emptyTitle}
description={emptyContent}
action={
needsPreviewRefresh && (
<Button variant='primary' onClick={onRefresh} disabled={!canRefresh} loading={isRefreshing}>
Refresh preview
</Button>
)
}
/>
);
}
let eventIndex = 0;
return (
<table className={style.table}>
<thead>
<tr>
<th className={style.rowNumber}>#</th>
<th className={style.rowType}>Type</th>
{columnLabels.map((label, index) => (
{displayColumns.map((label, index) => (
<th key={`${label}-${index}`}>{label}</th>
))}
</tr>
@@ -59,16 +153,29 @@ export default function PreviewTable({
{preview.rundown.flatOrder.map((entryId) => {
const entry = preview.rundown.entries[entryId];
const isEvent = isOntimeEvent(entry);
if (isEvent) eventIndex++;
const hasType = isEvent || isOntimeGroup(entry) || isOntimeMilestone(entry);
const entryMetadata = previewMetadata?.[entryId];
const { rowClassName, entryColour, entryType } = getEntryDisplay(entry, entryMetadata?.groupColour);
return (
<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 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>
);
})}
@@ -5,7 +5,8 @@ import type {
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
} from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { millisToString, removeTrailingZero } from 'ontime-utils';
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
@@ -32,7 +33,7 @@ type ImportAction =
| { type: 'previewSuccess'; preview: SpreadsheetPreviewResponse }
| { type: 'applySuccess' }
| { type: 'exportSuccess' }
| { type: 'clearPreview'; error?: string }
| { type: 'clearPreview'; error?: string; needsRefresh?: boolean }
| { type: 'failure'; error: string }
| { type: 'reset' };
@@ -40,12 +41,14 @@ 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 {
@@ -57,15 +60,15 @@ function importReducer(state: ImportState, action: ImportAction): ImportState {
case 'startExport':
return { ...state, loading: 'export', error: '' };
case 'previewSuccess':
return { loading: '', error: '', preview: action.preview };
return { loading: '', error: '', preview: action.preview, needsPreviewRefresh: false };
case 'applySuccess':
case 'exportSuccess':
return { ...state, loading: '' };
case 'clearPreview':
return { ...state, error: action.error ?? '', preview: null };
return { ...state, error: action.error ?? '', preview: null, needsPreviewRefresh: action.needsRefresh ?? false };
case 'failure': {
if (state.loading === 'preview') {
return { loading: '', error: action.error, preview: null };
return { loading: '', error: action.error, preview: null, needsPreviewRefresh: false };
}
return { ...state, loading: '', error: action.error };
}
@@ -221,7 +224,7 @@ export function useSheetImportForm({
const sub = watch(() => {
if (!previewRef.current) return;
previewRef.current = null;
dispatch({ type: 'clearPreview' });
dispatch({ type: 'clearPreview', needsRefresh: true });
});
return () => sub.unsubscribe();
}, [watch]);
@@ -297,15 +300,37 @@ export function useSheetImportForm({
}, [append]);
const toolbarStatus = (() => {
const warningText = warningCount > 0 ? ` | warnings: ${warningCount}` : '';
if (!state.preview) {
return `entries: | start: | end: | duration: ${warningText}`;
return {
entries: '',
groups: '',
milestones: '',
start: '',
end: '',
duration: '',
warnings: warningCount,
};
}
const { flatOrder } = state.preview.rundown;
const { entries, flatOrder } = state.preview.rundown;
const { start, end, duration } = state.preview.summary;
return `entries: ${flatOrder.length} | start: ${millisToString(start)} | end: ${millisToString(end)} | duration: ${formatDuration(duration)}${warningText}`;
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 {
@@ -16,6 +16,22 @@
user-select: text;
}
.self {
background-color: $blue-1100;
.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;
}
@@ -64,50 +64,58 @@ export default function ClientList() {
<tr>
<td style={{ width: '20%' }}>Client Name</td>
<td>Path</td>
<td />
<td className={style.actionsHeader} />
</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.self])}>
<tr key={key} className={cx([isCurrent && style.current])}>
<Panel.InlineElements relation='inner' as='td'>
{isCurrent && <Tag>SELF</Tag>}
{isCurrent && <Tag variant='active'>SELF</Tag>}
{name}
</Panel.InlineElements>
<td className={style.copiable}>{path}</td>
<Panel.InlineElements relation='inner' as='td'>
<Button
size='small'
className={`${identify ? style.blink : ''}`}
disabled={isCurrent}
variant={identify ? 'primary' : 'subtle'}
data-testid={isCurrent ? '' : 'not-self-identify'}
onClick={() => {
setIdentify({ target: key, identify: !identify });
}}
>
Identify
</Button>
<Button
size='small'
data-testid={isCurrent ? '' : 'not-self-rename'}
onClick={() => openRename(key)}
>
Rename
</Button>
<td className={style.actionsCell}>
<Panel.InlineElements relation='inner' align='end' className={style.actionsGroup}>
<Button
size='small'
className={`${identify ? style.blink : ''}`}
disabled={isCurrent}
variant={identify ? 'primary' : 'subtle'}
data-testid={isCurrent ? '' : 'not-self-identify'}
onClick={() => {
setIdentify({ target: key, identify: !identify });
}}
>
Identify
</Button>
<Button
size='small'
data-testid={isCurrent ? '' : 'not-self-rename'}
onClick={() => openRename(key)}
>
Rename
</Button>
<Button
size='small'
disabled={isCurrent}
data-testid={isCurrent ? '' : 'not-self-redirect'}
onClick={() => openRedirect(key)}
>
Redirect
</Button>
</Panel.InlineElements>
<Button
size='small'
disabled={isCurrent}
data-testid={isCurrent ? '' : 'not-self-redirect'}
onClick={() => openRedirect(key)}
>
Redirect
</Button>
</Panel.InlineElements>
</td>
</tr>
);
})}
@@ -125,6 +133,12 @@ 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,29 +1,21 @@
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 ProjectCreateForm from './ProjectCreateForm';
import useAppSettingsNavigation from '../../useAppSettingsNavigation';
import ProjectList from './ProjectList';
export default function ManageProjects() {
const [searchParams, setSearchParams] = useSearchParams();
const { setLocation } = useAppSettingsNavigation();
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();
};
@@ -49,11 +41,6 @@ export default function ManageProjects() {
setLoading(null);
};
const handleCloseForm = () => {
searchParams.delete('new');
setSearchParams(searchParams);
};
return (
<Panel.Section>
<input
@@ -68,21 +55,16 @@ export default function ManageProjects() {
<Panel.SubHeader>
Manage projects
<Panel.InlineElements>
<Button
onClick={handleSelectFile}
disabled={Boolean(loading) || isCreatingProject}
loading={loading === 'import'}
>
<Button onClick={handleSelectFile} disabled={Boolean(loading)} loading={loading === 'import'}>
Import
</Button>
<Button onClick={handleToggleCreate} disabled={Boolean(loading) || isCreatingProject}>
<Button onClick={() => setLocation('project__create')} disabled={Boolean(loading)}>
New <IoAdd />
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
{error && <Panel.Error>{error}</Panel.Error>}
<Panel.Divider />
{isCreatingProject && <ProjectCreateForm onClose={handleCloseForm} />}
<ProjectList />
</Panel.Card>
</Panel.Section>
@@ -1,84 +0,0 @@
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.empty}>
<div className={style.loaderBox}>
<Panel.Loader isLoading />
</div>
);
@@ -74,6 +74,12 @@ 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,6 +20,7 @@ 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';
@@ -143,7 +144,7 @@ export default function ProjectListItem({
) : (
<>
<td className={style.containCell}>{filename}</td>
<td>{current ? 'Currently loaded' : new Date(updatedAt).toLocaleString()}</td>
<td>{current ? <Tag variant='active'>Loaded</Tag> : new Date(updatedAt).toLocaleString()}</td>
<td>
<ActionMenu
current={current}
@@ -158,13 +159,7 @@ export default function ProjectListItem({
</>
)}
</tr>
{showMergeForm && (
<tr>
<td colSpan={99}>
<ProjectMergeForm onClose={handleCancel} fileName={filename} />
</td>
</tr>
)}
{showMergeForm && <ProjectMergeForm onClose={handleCancel} fileName={filename} />}
<Dialog
isOpen={isDeleteOpen}
onClose={() => setDeleteOpen(false)}
@@ -7,13 +7,15 @@ 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;
@@ -77,68 +79,122 @@ export default function ProjectMergeForm({ onClose, fileName }: ProjectMergeFrom
};
return (
<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 data from <i>{`"${fileName}"`}</i> to merge into the current project.
</Panel.Description>
<Info type='warning'>
This process is irreversible and can result in data loss. <br />
You may want to create a duplicate backup beforehand.
</Info>
<label>
<Switch
size='large'
checked={watch('project')}
onCheckedChange={(value: boolean) => setValue('project', value, { shouldDirty: true })}
/>
Project data
</label>
<label>
<Switch
size='large'
checked={watch('rundowns')}
onCheckedChange={(value: boolean) => setValue('rundowns', value, { shouldDirty: true })}
/>
Rundown + Custom Fields
</label>
<label>
<Switch
size='large'
checked={watch('viewSettings')}
onCheckedChange={(value: boolean) => setValue('viewSettings', value, { shouldDirty: true })}
/>
View Settings
</label>
<label>
<Switch
size='large'
checked={watch('urlPresets')}
onCheckedChange={(value: boolean) => setValue('urlPresets', value, { shouldDirty: true })}
/>
URL Presets
</label>
<label>
<Switch
size='large'
checked={watch('automation')}
onCheckedChange={(value: boolean) => setValue('automation', value, { shouldDirty: true })}
/>
Automation Settings
</label>
</Panel.Section>
</Panel.Section>
<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.Description>
Select the sections from <span className={style.sourceFile}>{`"${fileName}"`}</span> that you want 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>
<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>
<Switch
size='large'
checked={watch('project')}
onCheckedChange={(value: boolean) => setValue('project', value, { shouldDirty: true })}
/>
</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>
<Switch
size='large'
checked={watch('rundowns')}
onCheckedChange={(value: boolean) => setValue('rundowns', value, { shouldDirty: true })}
/>
</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>
<Switch
size='large'
checked={watch('viewSettings')}
onCheckedChange={(value: boolean) => setValue('viewSettings', value, { shouldDirty: true })}
/>
</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>
<Switch
size='large'
checked={watch('urlPresets')}
onCheckedChange={(value: boolean) => setValue('urlPresets', value, { shouldDirty: true })}
/>
</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>
<Switch
size='large'
checked={watch('automation')}
onCheckedChange={(value: boolean) => setValue('automation', value, { shouldDirty: true })}
/>
</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>
}
/>
);
}
@@ -1,5 +1,6 @@
.current {
background-color: $blue-1100;
// marker consumed by the shared Panel.Table active-row treatment
background-color: inherit;
}
.isEditing {
@@ -55,7 +56,90 @@
margin-bottom: 1rem;
}
.empty {
.loaderBox {
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 } from 'react';
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoDownloadOutline, IoTrash } from 'react-icons/io5';
@@ -17,9 +17,16 @@ 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,
@@ -45,13 +52,22 @@ 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]);
const handleUploadProjectLogo = async (event: ChangeEvent<HTMLInputElement>) => {
useEffect(() => {
return () => {
if (pendingLogo) {
URL.revokeObjectURL(pendingLogo.previewUrl);
}
};
}, [pendingLogo]);
const handleUploadProjectLogo = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
clearErrors('logo');
@@ -61,14 +77,16 @@ export default function ProjectData() {
try {
validateLogo(file);
const response = await uploadProjectLogo(file);
setValue('logo', response.data.logoFilename, {
shouldDirty: true,
setPendingLogo({
file,
previewUrl: URL.createObjectURL(file),
});
} catch (error) {
const message = maybeAxiosError(error);
setError('logo', { message });
} finally {
// Allow selecting the same local file again after an upload attempt.
event.target.value = '';
}
};
@@ -83,6 +101,7 @@ export default function ProjectData() {
const handleDeleteLogo = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setPendingLogo(null);
setValue('logo', null, {
shouldDirty: true,
});
@@ -92,10 +111,29 @@ 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();
await updateProjectData(formData);
const logo = (await uploadPendingLogo()) ?? formData.logo;
await updateProjectData({ ...formData, logo });
setPendingLogo(null);
} catch (error) {
const message = maybeAxiosError(error);
setError('root', { message });
@@ -104,10 +142,12 @@ 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)}>
@@ -118,7 +158,12 @@ export default function ProjectData() {
<Button onClick={onReset} disabled={isSubmitting || !isDirty}>
Revert to saved
</Button>
<Button variant='primary' type='submit' disabled={!isDirty || !isValid} loading={isSubmitting}>
<Button
variant='primary'
type='submit'
disabled={(!isDirty && !pendingLogo) || !isValid}
loading={isSubmitting}
>
Save
</Button>
</Panel.InlineElements>
@@ -150,17 +195,24 @@ export default function ProjectData() {
onChange={handleUploadProjectLogo}
/>
<Panel.Card className={style.uploadLogoCard}>
{watch('logo') ? (
{logo || pendingLogo ? (
<>
<img src={`${projectLogoPath}/${watch('logo')}`} />
<Button
variant='subtle-destructive'
disabled={isSubmitting || !watch('logo')}
onClick={handleDeleteLogo}
>
<IoTrash />
Delete
</Button>
<img src={pendingLogo ? pendingLogo.previewUrl : `${projectLogoPath}/${logo}`} />
<div className={style.logoActions}>
<Button disabled={isSubmitting} onClick={handleClickUpload} type='button'>
<IoDownloadOutline />
Replace logo
</Button>
<Button
variant='subtle-destructive'
disabled={isSubmitting || (!logo && !pendingLogo)}
onClick={handleDeleteLogo}
type='button'
>
<IoTrash />
Delete
</Button>
</div>
</>
) : (
<Button disabled={isSubmitting} onClick={handleClickUpload} type='button'>
@@ -15,6 +15,11 @@
}
}
.logoActions {
display: flex;
gap: 0.5rem;
}
.customDataItem {
width: 100%;
display: flex;
@@ -90,16 +90,13 @@ 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' size='large' onClick={resetToEnglish} disabled={isSubmitting}>
<Button variant='ghosted' onClick={resetToEnglish} disabled={isSubmitting}>
Reset to English
</Button>
<Panel.InlineElements>
<Button size='large' onClick={onClose}>
Cancel
</Button>
<Button onClick={onClose}>Cancel</Button>
<Button
variant='primary'
size='large'
type='submit'
form='custom-translations-form'
disabled={isSubmitting || !isDirty || !isValid}
@@ -109,19 +109,16 @@ 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' size='large' onClick={handleRestore} disabled={saveLoading || resetLoading}>
<Button variant='ghosted' onClick={handleRestore} disabled={saveLoading || resetLoading}>
Reset to example
</Button>
<Panel.InlineElements>
<Button variant='ghosted' size='large' onClick={clear}>
<Button variant='ghosted' onClick={clear}>
Clear
</Button>
<Button size='large' onClick={onClose}>
Cancel
</Button>
<Button onClick={onClose}>Cancel</Button>
<Button
variant='primary'
size='large'
onClick={handleSave}
disabled={saveLoading || resetLoading || !isDirty}
loading={saveLoading}
@@ -3,6 +3,7 @@ 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';
@@ -23,44 +24,50 @@ export default function ShutdownPanel() {
<>
<Panel.Header>Shutdown Ontime</Panel.Header>
<Panel.Section>
{isOntimeCloud ? (
<Panel.Highlight>
For security reasons, shutting down the server must be done from the Ontime Cloud dashboard.
</Panel.Highlight>
) : (
<Panel.Paragraph>
This will shutdown the Ontime server. <br />
The runtime state will be lost, but your project is kept for next time.
</Panel.Paragraph>
)}
<Button variant='destructive' onClick={handler.open} disabled={!(isElectron || isLocalhost)}>
Shutdown ontime
</Button>
{!canShutdown && (
<Panel.Description>Note: Ontime can only be shutdown from the machine it is running in.</Panel.Description>
)}
<Dialog
isOpen={isOpen}
title='Shutdown Ontime'
showCloseButton
onClose={handler.close}
bodyElements={
<Panel.Paragraph>
This will shutdown the Ontime server. <br /> Are you sure?
</Panel.Paragraph>
}
footerElements={
<>
<Button ref={cancelRef} onClick={handler.close} variant='ghosted-white'>
Cancel
<Panel.Card>
<Panel.SubHeader>Shutdown</Panel.SubHeader>
<Panel.Divider />
{isOntimeCloud ? (
<Info type='warning'>
For security reasons, shutting down the server must be done from the Ontime Cloud dashboard.
</Info>
) : (
<Panel.Field
title='Stop the Ontime server'
description='The runtime state will be lost, but your project is kept for next time.'
/>
)}
{!isOntimeCloud && (
<Panel.Section>
<Button variant='destructive' onClick={handler.open} disabled={!canShutdown}>
Shutdown ontime
</Button>
<Button variant='destructive' onClick={sendShutdown} disabled={!canShutdown}>
Shutdown
</Button>
</>
}
/>
{!canShutdown && <Panel.Description>Only available from the machine running Ontime.</Panel.Description>}
</Panel.Section>
)}
</Panel.Card>
</Panel.Section>
<Dialog
isOpen={isOpen}
title='Shutdown Ontime'
showCloseButton
onClose={handler.close}
bodyElements={
<Panel.Paragraph>
This will shutdown the Ontime server. <br /> Are you sure?
</Panel.Paragraph>
}
footerElements={
<>
<Button ref={cancelRef} onClick={handler.close} variant='ghosted-white'>
Cancel
</Button>
<Button variant='destructive' onClick={sendShutdown} disabled={!canShutdown}>
Shutdown
</Button>
</>
}
/>
</>
);
}
@@ -3,64 +3,107 @@ import { useMemo } from 'react';
import useAppVersion from '../../common/hooks-query/useAppVersion';
import { isDocker } from '../../externals';
export type SettingsOption = {
type SettingsOptionBase = {
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' },
{ 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: '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: 'project',
label: 'Project',
secondary: [
{ id: 'project__create', label: 'Create...' },
{ id: 'project__list', label: 'Manage projects' },
{ 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: 'manage',
label: 'Project settings',
secondary: [
{ 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: '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: 'automation',
label: 'Automation',
secondary: [
{ id: 'automation__settings', label: 'Automation settings' },
{ id: 'automation__automations', label: 'Manage automations' },
{ id: 'automation__triggers', label: 'Manage triggers' },
{
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: 'sharing',
label: 'Sharing and reporting',
secondary: [
{ id: 'sharing__presets', label: 'URL Presets' },
{ id: 'sharing__presets', label: 'URL Presets', keywords: ['alias', 'link', 'url', 'shortcut'] },
{
id: 'sharing__link',
label: 'Share link',
keywords: ['qr code', 'guest', 'cuesheet link', 'permissions', 'read only'],
},
{ id: 'sharing__report', label: 'Runtime report' },
{ id: 'sharing__report', label: 'Runtime report', keywords: ['actual times', 'csv', 'export', 'history'] },
],
},
{
@@ -70,20 +113,24 @@ 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;
@@ -92,6 +139,51 @@ 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();
+45 -2
View File
@@ -1,20 +1,54 @@
$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;
min-height: 20vh;
height: 40vh;
min-height: 40vh;
max-height: 40vh;
background-color: $gray-1350;
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;
}
.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;
@@ -44,10 +78,19 @@ $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;
}
+20 -4
View File
@@ -4,12 +4,14 @@ import { IoClose } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button';
import { clearLogs, useLogData } from '../../common/stores/logger';
import { cx } from '../../common/utils/styleUtils';
import * as Panel from '../app-settings/panel-utils/PanelUtils';
import style from './Log.module.scss';
export default function Log() {
const { logs: logData } = useLogData();
const isExtracted = window.location.pathname.includes('/log');
const [showClient, setShowClient] = useState(true);
const [showServer, setShowServer] = useState(true);
@@ -50,11 +52,14 @@ export default function Log() {
}, []);
return (
<>
<div className={cx([style.container, isExtracted && style.extracted])}>
<Panel.InlineElements className={style.buttonBar}>
<span className={style.filterLabel}>Filter by</span>
<Button
variant={showUser ? 'primary' : 'subtle'}
size='small'
aria-pressed={showUser}
aria-label={`${showUser ? 'Hide' : 'Show'} ${LogOrigin.User} events`}
onClick={() => setShowUser((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.User)}
onContextMenu={(e) => e.preventDefault()}
@@ -64,6 +69,8 @@ export default function Log() {
<Button
variant={showClient ? 'primary' : 'subtle'}
size='small'
aria-pressed={showClient}
aria-label={`${showClient ? 'Hide' : 'Show'} ${LogOrigin.Client} events`}
onClick={() => setShowClient((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Client)}
onContextMenu={(e) => e.preventDefault()}
@@ -73,6 +80,8 @@ export default function Log() {
<Button
variant={showServer ? 'primary' : 'subtle'}
size='small'
aria-pressed={showServer}
aria-label={`${showServer ? 'Hide' : 'Show'} ${LogOrigin.Server} events`}
onClick={() => setShowServer((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Server)}
onContextMenu={(e) => e.preventDefault()}
@@ -82,6 +91,8 @@ export default function Log() {
<Button
variant={showPlayback ? 'primary' : 'subtle'}
size='small'
aria-pressed={showPlayback}
aria-label={`${showPlayback ? 'Hide' : 'Show'} ${LogOrigin.Playback} events`}
onClick={() => setShowPlayback((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Playback)}
onContextMenu={(e) => e.preventDefault()}
@@ -91,6 +102,8 @@ export default function Log() {
<Button
variant={showRx ? 'primary' : 'subtle'}
size='small'
aria-pressed={showRx}
aria-label={`${showRx ? 'Hide' : 'Show'} ${LogOrigin.Rx} events`}
onClick={() => setShowRx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Rx)}
onContextMenu={(e) => e.preventDefault()}
@@ -100,6 +113,8 @@ export default function Log() {
<Button
variant={showTx ? 'primary' : 'subtle'}
size='small'
aria-pressed={showTx}
aria-label={`${showTx ? 'Hide' : 'Show'} ${LogOrigin.Tx} events`}
onClick={() => setShowTx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Tx)}
onContextMenu={(e) => e.preventDefault()}
@@ -110,15 +125,16 @@ export default function Log() {
<IoClose /> Clear
</Button>
</Panel.InlineElements>
<ul className={style.log}>
<ul className={style.log} aria-label='Event log entries'>
{filteredData.length === 0 && <li className={style.empty}>No events match the selected filters.</li>}
{filteredData.map((logEntry) => (
<li key={logEntry.id} className={`${style.logEntry} ${style[logEntry.level]} `}>
<li key={logEntry.id} className={`${style.logEntry} ${style[logEntry.level]}`}>
<span className={style.time}>{logEntry.time}</span>
<span className={style.origin}>{logEntry.origin}</span>
<span className={style.msg}>{logEntry.text}</span>
</li>
))}
</ul>
</>
</div>
);
}
+110 -102
View File
@@ -1,6 +1,7 @@
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import useFollowComponent from '../../common/hooks/useFollowComponent';
@@ -10,6 +11,7 @@ import { cx } from '../../common/utils/styleUtils';
import { throttle } from '../../common/utils/throttle';
import { getDefaultFormat } from '../../common/utils/time';
import { isTouchDevice } from '../../externals';
import { useTranslation } from '../../translation/TranslationProvider';
import Loader from '../../views/common/loader/Loader';
import CustomFieldEditModal from './custom-field-edit-modal/CustomFieldEditModal';
import FollowButton from './follow-button/FollowButton';
@@ -35,7 +37,7 @@ export default function OperatorLoader() {
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}
return <Operator {...data} />;
@@ -43,6 +45,7 @@ export default function OperatorLoader() {
function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) {
const selectedEventId = useSelectedEventId();
const { getLocalizedString } = useTranslation();
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
const [showEditPrompt, setShowEditPrompt] = useState(false);
@@ -113,6 +116,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
const operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]);
const canEdit = shouldEdit && subscribe.length;
const hasEvents = rundown.order.length > 0;
return (
<div className={style.operatorContainer} data-testid='operator-view'>
@@ -127,117 +131,121 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
</div>
)}
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{rundown.order.map((entryId) => {
const entry = rundown.entries[entryId];
if (isOntimeEvent(entry)) {
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || entry.skip) {
return null;
}
{!hasEvents ? (
<EmptyFill text={getLocalizedString('common.no_data')} />
) : (
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{rundown.order.map((entryId) => {
const entry = rundown.entries[entryId];
if (isOntimeEvent(entry)) {
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || entry.skip) {
return null;
}
const { mainField, secondaryField, subscribedData } = getEventData(
entry,
mainSource,
secondarySource,
subscribe,
customFields,
);
const { mainField, secondaryField, subscribedData } = getEventData(
entry,
mainSource,
secondarySource,
subscribe,
customFields,
);
return (
<OperatorEvent
key={entry.id}
id={entry.id}
colour={entry.colour}
cue={entry.cue}
main={mainField}
secondary={secondaryField}
timeStart={entry.timeStart}
duration={entry.duration}
delay={entry.delay}
dayOffset={entry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
/>
);
}
if (isOntimeGroup(entry)) {
const { isPast } = rundownMetadata[entry.id];
const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId]?.groupId === entry.id : false;
if (hidePast && isPast && !isCurrentParent) {
return null;
}
return (
<Fragment key={entry.id}>
<OperatorGroup
return (
<OperatorEvent
key={entry.id}
title={entry.title}
id={entry.id}
colour={entry.colour}
count={entry.entries.length}
cue={entry.cue}
main={mainField}
secondary={secondaryField}
timeStart={entry.timeStart}
duration={entry.duration}
delay={entry.delay}
dayOffset={entry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
/>
{entry.entries.map((nestedEntryId) => {
const nestedEntry = rundown.entries[nestedEntryId];
if (!isOntimeEvent(nestedEntry)) {
return null;
}
);
}
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
if (isOntimeGroup(entry)) {
const { isPast } = rundownMetadata[entry.id];
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || nestedEntry.skip) {
return null;
}
const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId]?.groupId === entry.id : false;
const { mainField, secondaryField, subscribedData } = getEventData(
nestedEntry,
mainSource,
secondarySource,
subscribe,
customFields,
);
if (hidePast && isPast && !isCurrentParent) {
return null;
}
return (
<OperatorEvent
key={nestedEntry.id}
id={nestedEntry.id}
colour={nestedEntry.colour}
cue={nestedEntry.cue}
main={mainField}
secondary={secondaryField}
timeStart={nestedEntry.timeStart}
duration={nestedEntry.duration}
delay={nestedEntry.delay}
dayOffset={nestedEntry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
groupColour={entry.colour}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
/>
);
})}
</Fragment>
);
}
return null;
})}
</div>
return (
<Fragment key={entry.id}>
<OperatorGroup
key={entry.id}
title={entry.title}
colour={entry.colour}
count={entry.entries.length}
duration={entry.duration}
/>
{entry.entries.map((nestedEntryId) => {
const nestedEntry = rundown.entries[nestedEntryId];
if (!isOntimeEvent(nestedEntry)) {
return null;
}
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || nestedEntry.skip) {
return null;
}
const { mainField, secondaryField, subscribedData } = getEventData(
nestedEntry,
mainSource,
secondarySource,
subscribe,
customFields,
);
return (
<OperatorEvent
key={nestedEntry.id}
id={nestedEntry.id}
colour={nestedEntry.colour}
cue={nestedEntry.cue}
main={mainField}
secondary={secondaryField}
timeStart={nestedEntry.timeStart}
duration={nestedEntry.duration}
delay={nestedEntry.delay}
dayOffset={nestedEntry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
groupColour={entry.colour}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
/>
);
})}
</Fragment>
);
}
return null;
})}
</div>
)}
<FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} />
</div>
);
@@ -2,7 +2,6 @@
.event {
opacity: 1;
border-top: 1px solid $white-1;
padding-right: 0.5rem;
color: $white-90;
background-color: $viewer-card-bg-color;
@@ -31,7 +30,6 @@
}
&.running {
border-top: 1px solid $gray-1300;
background-color: var(--operator-running-bg-override, $active-green);
}
@@ -42,8 +40,7 @@
}
&.past {
border-top: 1px solid transparent;
opacity: 0.2;
opacity: 0.4;
}
}
@@ -79,6 +76,7 @@
.mainField {
grid-area: main;
min-width: 0;
font-size: 1.5rem;
color: $ui-white;
@@ -144,6 +142,7 @@
.fields {
grid-area: fields;
min-width: 0;
margin-top: 0;
border-top: 0;
@@ -171,10 +170,9 @@
&.fieldsWithContent {
margin-top: 0.25rem;
padding-block: 0.5rem;
border-top: 1px solid $white-13;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr));
gap: 0.25rem 0.75rem;
line-height: 1.25em;
font-size: var(--operator-customfield-font-size-override, 1.25rem);
@@ -82,7 +82,7 @@ function OperatorEvent({
const fieldGridStyle =
columnCount > 0
? ({
gridTemplateColumns: `repeat(${columnCount}, minmax(12rem, 1fr))`,
gridTemplateColumns: `repeat(${columnCount}, minmax(min(12rem, 100%), 1fr))`,
} satisfies CSSProperties)
: undefined;
@@ -44,8 +44,8 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
},
{
id: 'subscribe',
title: 'Highlight Fields',
description: 'Choose custom fields to highlight',
title: 'Show custom fields',
description: 'Choose custom fields to show on each event',
type: 'multi-option',
values: customFieldSelect,
},
@@ -1,14 +1,43 @@
.title {
font-size: 1.75rem;
font-weight: 600;
color: $ui-white;
line-height: 1;
.titleOverview {
flex: 0 1 18rem;
min-width: 0;
width: 18rem;
max-width: 100%;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.project,
.rundown {
@include ellipsis-overflow;
}
.description {
font-size: 1rem;
.project {
color: $secondary-text-gray;
font-size: 0.75rem;
font-weight: 400;
@include ellipsis-overflow;
letter-spacing: 0.04em;
line-height: 1.1;
text-transform: uppercase;
}
.rundown {
color: $ui-white;
font-size: 1.5rem;
font-weight: 600;
line-height: 1.1;
}
// A lone title should still read as the primary piece of context.
.project:only-child,
.rundown:only-child {
color: $ui-white;
font-size: 1.5rem;
}
.project:only-child {
font-weight: 600;
letter-spacing: normal;
text-transform: none;
}
@@ -8,14 +8,17 @@ export default function TitleOverview() {
const { data: projectData } = useProjectData();
const { data: rundownData } = useRundownAuxData();
if (!projectData.title && !rundownData.title) {
const projectTitle = projectData.title.trim();
const rundownTitle = rundownData.title.trim();
if (!projectTitle && !rundownTitle) {
return null;
}
return (
<div>
<div className={style.title}>{projectData.title}</div>
<div className={style.description}>{rundownData.title}</div>
<div className={style.titleOverview}>
{projectTitle && <div className={style.project}>{projectTitle}</div>}
{rundownTitle && <div className={style.rundown}>{rundownTitle}</div>}
</div>
);
}
@@ -1,19 +1,24 @@
import { memo } from 'react';
import Empty from '../../common/components/state/Empty';
import EmptyFill from '../../common/components/state/EmptyFill';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { useTranslation } from '../../translation/TranslationProvider';
import Rundown from './Rundown';
export default memo(RundownList);
function RundownList() {
const { data, status, rundownMetadata } = useRundownWithMetadata();
const featureData = useRundownEditor();
const { getLocalizedString } = useTranslation();
const isLoading = status !== 'success' || !data || !rundownMetadata;
// avoid showing the editable empty state before we know whether the rundown is actually empty
if (status === 'pending') {
return <EmptyFill text='Loading…' />;
}
if (isLoading) {
return <Empty text='Connecting to server' />;
if (status === 'error') {
return <EmptyFill text={getLocalizedString('common.no_data')} />;
}
return (
@@ -1,6 +1,5 @@
import { memo, useEffect, useMemo } from 'react';
import EmptyPage from '../../../common/components/state/EmptyPage';
import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown';
@@ -13,7 +12,7 @@ import { makeRundownColumns } from './makeRundownColumns';
export default memo(RundownTable);
function RundownTable() {
const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: customFields } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const { editorMode } = useEditorFollowMode();
const source = useLoadedRundownSource();
@@ -32,16 +31,10 @@ function RundownTable() {
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
const isLoading = !customFields || customFieldStatus === 'pending';
return (
<EntryActionsProvider actions={actions}>
<CuesheetDnd columns={columns} tableRoot='editor'>
{isLoading ? (
<EmptyPage text='Loading...' />
) : (
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
)}
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
</CuesheetDnd>
</EntryActionsProvider>
);
@@ -39,9 +39,21 @@
font-size: $header-font-size;
font-weight: 600;
display: flex;
align-items: center;
gap: 1rem;
}
.project-header__brand {
display: flex;
align-items: center;
gap: 1rem;
min-width: 0;
}
.project-header__brand--without-logo {
align-self: baseline;
}
.logo {
max-width: min(200px, 20vw);
}
@@ -195,10 +207,15 @@
.project-header {
flex-direction: column;
align-items: flex-start;
position: relative;
gap: 0.5rem;
}
.project-header__brand--without-logo {
align-self: flex-start;
}
.logo img {
height: min(50px, 10vh);
}
@@ -35,7 +35,7 @@ export default function BackstageLoader() {
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}
return <Backstage {...data} />;
@@ -107,8 +107,10 @@ function Backstage({ events, customFields, projectData, isMirrored, settings }:
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
<ViewParamsEditor target={OntimeView.Backstage} viewOptions={backstageOptions} />
<div className='project-header'>
{projectData?.logo && <ViewLogo name={projectData.logo} className='logo' />}
<div className='title'>{projectData.title}</div>
<div className={cx(['project-header__brand', !projectData?.logo && 'project-header__brand--without-logo'])}>
{projectData?.logo && <ViewLogo name={projectData.logo} className='logo' />}
<div className='title'>{projectData.title}</div>
</div>
<BackstageClock timeformat={timeformat} />
</div>
@@ -7,7 +7,7 @@ $dot-spacing: 1.5rem;
display: grid;
place-items: center;
background-color: var(--background-color-override, $viewer-background-color);
height: 100vh;
height: 100dvh;
}
.ellipsis {
@@ -21,26 +21,26 @@ $dot-spacing: 1.5rem;
height: $dot-size;
border-radius: 50%;
background-color: var(--accent-color-override, $ontime-color);
animation-timing-function: cubic-bezier(0, 1, 1, 0);
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
&:nth-child(1) {
left: $dot-size;
animation: lds-ellipsis1 0.6s infinite;
animation: lds-ellipsis1 1s infinite;
}
&:nth-child(2) {
left: $dot-size;
animation: lds-ellipsis2 0.6s infinite;
animation: lds-ellipsis2 1s infinite;
}
&:nth-child(3) {
left: calc($dot-size + $dot-spacing);
animation: lds-ellipsis2 0.6s infinite;
animation: lds-ellipsis2 1s infinite;
}
&:nth-child(4) {
left: calc($dot-size + 2 * $dot-spacing);
animation: lds-ellipsis3 0.6s infinite;
animation: lds-ellipsis3 1s infinite;
}
}
}
+13 -15
View File
@@ -23,9 +23,21 @@ $item-height: 3.5rem;
font-size: $header-font-size;
font-weight: 600;
display: flex;
align-items: center;
gap: 1rem;
}
.project-header__brand {
display: flex;
align-items: center;
gap: 1rem;
min-width: 0;
}
.project-header__brand--without-logo {
align-self: baseline;
}
.logo {
max-width: min(200px, 20vw);
}
@@ -80,21 +92,7 @@ $item-height: 3.5rem;
display: flex;
flex-direction: column;
align-items: center;
button {
margin-top: 1.75rem;
}
}
.empty-state__content {
max-width: none;
span {
max-width: none;
white-space: nowrap;
font-size: clamp(1.5rem, 4vw, 2.25rem);
line-height: 1.1;
}
gap: 1.5rem;
}
.list-container {
+10 -7
View File
@@ -20,6 +20,7 @@ import ViewParamsEditor from '../../common/components/view-params-editor/ViewPar
import { useAutoTickingClock } from '../../common/hooks/useAutoTickingClock';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { cx } from '../../common/utils/styleUtils';
import { formatTime, getDefaultFormat } from '../../common/utils/time';
import { useTranslation } from '../../translation/TranslationProvider';
import Loader from '../common/loader/Loader';
@@ -43,7 +44,7 @@ export default function CountdownLoader() {
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}
return <Countdown {...data} />;
@@ -80,14 +81,16 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
<ViewParamsEditor target={OntimeView.Countdown} viewOptions={countdownOptions} />
<div className='project-header'>
{projectData?.logo && <ViewLogo name={projectData.logo} className='logo' />}
<div className='title'>{projectData.title}</div>
<div className={cx(['project-header__brand', !projectData?.logo && 'project-header__brand--without-logo'])}>
{projectData?.logo && <ViewLogo name={projectData.logo} className='logo' />}
<div className='title'>{projectData.title}</div>
</div>
<CountdownClock />
</div>
{!hasEvents && (
<div className='empty-state'>
<Empty text={getLocalizedString('common.no_data')} className='empty-state__content' />
<Empty text={getLocalizedString('common.no_data')} />
</div>
)}
@@ -121,7 +124,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (subscriptions.length === 0) {
return (
<div className='empty-state'>
<Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' />
<Empty text={getLocalizedString('countdown.select_event')} />
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add
</Button>
@@ -137,7 +140,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (subscribedEvents.length === 0) {
return (
<div className='empty-state'>
<Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' />
<Empty text={getLocalizedString('countdown.select_event')} />
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add
</Button>
@@ -154,7 +157,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (eventsToShow.length === 0) {
return (
<div className='empty-state'>
<Empty text={getLocalizedString('countdown.all_have_finished')} className='empty-state__content' />
<Empty text={getLocalizedString('countdown.all_have_finished')} />
</div>
);
}
@@ -2,7 +2,6 @@ import { MaybeString, ProjectRundown } from 'ontime-types';
import { memo, use, useMemo } from 'react';
import Select from '../../common/components/select/Select';
import EmptyPage from '../../common/components/state/EmptyPage';
import { PresetContext } from '../../common/context/PresetContext';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
@@ -34,40 +33,34 @@ function CuesheetTableWrapper({
const preset = use(PresetContext);
const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: customFields } = useCustomFields();
const columns = useMemo(
() => makeCuesheetColumns(customFields, cuesheetMode, preset),
[customFields, cuesheetMode, preset],
);
const isLoading = !customFields || customFieldStatus === 'pending';
return (
<CuesheetDnd columns={columns}>
{isLoading ? (
<EmptyPage text='Loading...' />
) : (
<CuesheetTable
columns={columns}
source={source}
cuesheetMode={cuesheetMode}
tableRoot='cuesheet'
setCuesheetMode={setCuesheetMode}
isCurrentRundown={isCurrentRundown}
insertElement={
<>
<RundownSelect
cuesheetMode={cuesheetMode}
selectedRundownId={selectedRundownId}
loadedRundownId={loadedRundownId}
setSelectedRundownId={setSelectedRundownId}
projectRundowns={projectRundowns}
/>
</>
}
/>
)}
<CuesheetTable
columns={columns}
source={source}
cuesheetMode={cuesheetMode}
tableRoot='cuesheet'
setCuesheetMode={setCuesheetMode}
isCurrentRundown={isCurrentRundown}
insertElement={
<>
<RundownSelect
cuesheetMode={cuesheetMode}
selectedRundownId={selectedRundownId}
loadedRundownId={loadedRundownId}
setSelectedRundownId={setSelectedRundownId}
projectRundowns={projectRundowns}
/>
</>
}
/>
</CuesheetDnd>
);
}
@@ -5,6 +5,10 @@ $table-header-font-size: calc(1rem - 2px);
@include rows.cuesheet-row-columns($table-header-font-size);
.tableLoading {
grid-area: table;
}
.cuesheet {
font-size: $table-font-size;
font-weight: 400;
@@ -11,7 +11,7 @@ import {
TableVirtuosoHandle,
} from 'react-virtuoso';
import EmptyPage from '../../../common/components/state/EmptyPage';
import EmptyFill from '../../../common/components/state/EmptyFill';
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
@@ -19,6 +19,7 @@ import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
import { useEventSelection } from '../../../features/rundown/useEventSelection';
import { AppMode } from '../../../ontimeConfig';
import { useTranslation } from '../../../translation/TranslationProvider';
import { usePersistedCuesheetOptions } from '../cuesheet.options';
import { useCuesheetPermissions } from '../useTablePermissions';
import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader';
@@ -64,6 +65,7 @@ export default function CuesheetTable({
}: CuesheetTableProps) {
const { flatRundown, status, selectedEventId } = source;
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
const { getLocalizedString } = useTranslation();
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions;
@@ -228,10 +230,13 @@ export default function CuesheetTable({
});
}, [cuesheetMode, hideIndexColumn, table]);
const isLoading = !flatRundown || status === 'pending';
// avoid showing the editable empty state before we know whether the rundown is actually empty
if (status === 'pending') {
return <EmptyFill text='Loading…' className={style.tableLoading} />;
}
if (isLoading) {
return <EmptyPage text='Loading...' />;
if (status === 'error') {
return <EmptyFill text={getLocalizedString('common.no_data')} className={style.tableLoading} />;
}
return (
@@ -1,5 +1,7 @@
@use '@/theme/viewerDefs' as *;
$content-width: min(100%, 1100px);
.project {
margin: 0;
box-sizing: border-box; /* reset */
@@ -16,56 +18,104 @@
display: flex;
flex-direction: column;
/* =================== HEADER ===================*/
.project-header {
width: $content-width;
margin-inline: auto;
display: flex;
align-items: center;
gap: clamp(12px, 2vw, 24px);
padding-bottom: $view-element-gap;
border-bottom: 1px solid $white-10;
}
.logo {
max-width: min(200px, 30vw);
}
.title {
font-size: $header-font-size;
font-weight: 600;
line-height: 1.1em;
}
.description {
font-size: $base-font-size;
color: var(--secondary-color-override, $viewer-secondary-color);
}
/* =================== CONTENT ===================*/
.info {
flex: 1;
max-height: 100%;
width: $content-width;
margin-inline: auto;
overflow-y: auto;
width: min(calc(100vw - 4rem), 960px);
display: flex;
flex-direction: column;
align-items: start;
gap: $view-element-gap;
padding-block: $view-element-gap;
padding-bottom: 10vh;
}
.info__card {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
border-radius: $element-border-radius;
padding: $view-block-padding $view-inline-padding;
display: flex;
flex-direction: column;
gap: 0.35em;
}
.info__media {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: $view-element-gap;
}
.info__media .info__value {
flex: 1;
min-width: 0;
}
.info__label {
font-size: $timer-label-size;
font-weight: 600;
letter-spacing: 0.05em;
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
.info__value {
white-space: break-spaces;
}
.info__custom {
display: flex;
gap: 1rem;
line-height: 1.35;
overflow-wrap: anywhere;
}
.info__image-container {
display: flex;
justify-content: center;
align-items: center;
width: 192px;
height: 192px;
flex: 0 0 min(192px, 25%);
}
.info__image {
display: block;
max-width: 100%;
max-height: 100%;
height: auto;
object-fit: contain;
}
.link.info__value {
display: flex;
gap: $view-element-gap;
display: inline-flex;
gap: 0.35em;
align-items: center;
color: $action-text-color;
@@ -78,12 +128,13 @@
/* =================== MOBILE ===================*/
@media screen and (max-width: 768px) {
.project {
.project-header {
flex-direction: column;
align-items: start;
gap: 0.5rem;
}
.logo img {
height: min(50px, 10vh);
}
.info__image-container {
width: 96px;
height: 96px;
}
}
}
@@ -1,4 +1,5 @@
import { OntimeView } from 'ontime-types';
import { type ReactNode, useState } from 'react';
import { IoOpenOutline } from 'react-icons/io5';
import EmptyPage from '../../common/components/state/EmptyPage';
@@ -21,7 +22,7 @@ export default function ProjectInfoLoader() {
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}
return <ProjectInfo {...data} />;
@@ -41,56 +42,49 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
return (
<>
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
<EmptyPage text={getLocalizedString('common.no_data')} />;
<EmptyPage text={getLocalizedString('common.no_data')} />
</>
);
}
const hasHeader = Boolean(projectData.logo || projectData.title || projectData.description);
return (
<div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'>
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
{projectData.logo && <ViewLogo name={projectData.logo} className='logo' />}
{hasHeader && (
<div className='project-header'>
{projectData.logo && <ViewLogo name={projectData.logo} className='logo' />}
<div className='project-header__text'>
{projectData.title && <div className='title'>{projectData.title}</div>}
{projectData.description && <div className='description'>{projectData.description}</div>}
</div>
</div>
)}
<div className='info'>
{projectData.title && (
<div>
<div className='info__label'>{getLocalizedString('project.title')}</div>
<div className='info__value'>{projectData.title}</div>
</div>
)}
{projectData.description && (
<div>
<div className='info__label'>{getLocalizedString('project.description')}</div>
<div className='info__value'>{projectData.description}</div>
</div>
)}
{projectData.info && (
<div>
<div className='info__label'>{getLocalizedString('project.info')}</div>
<div className='info__value'>{projectData.info}</div>
</div>
)}
{projectData.info && <InfoCard label={getLocalizedString('project.info')}>{projectData.info}</InfoCard>}
{projectData.url && (
<div>
<div className='info__card'>
<div className='info__label'>{getLocalizedString('project.url')}</div>
<a href={projectData.url} target='_blank' rel='noreferrer' className='info__value link'>
{projectData.url} <IoOpenOutline style={{ fontSize: '1em' }} />
{projectData.url}
<IoOpenOutline style={{ fontSize: '1em' }} />
</a>
</div>
)}
{projectData.custom.map((info, idx) => {
const hasUrl = Boolean(info.url);
return (
// oxlint-disable-next-line react/no-array-index-key - we only have the index to go of here
<div key={`${info.title}-${idx}`} className='info__custom'>
{hasUrl && (
<div className='info__image-container'>
<img className='info__image' src={info.url} loading='lazy' />
<div key={`${info.title}-${idx}`} className='info__card'>
{info.title && <div className='info__label'>{info.title}</div>}
{info.url ? (
<div className='info__media'>
<InfoImage src={info.url} />
{info.value && <div className='info__value'>{info.value}</div>}
</div>
) : (
info.value && <div className='info__value'>{info.value}</div>
)}
<div>
<div className='info__label'>{info.title}</div>
<div className='info__value'>{info.value}</div>
</div>
</div>
);
})}
@@ -98,3 +92,31 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
</div>
);
}
interface InfoCardProps {
label: string;
children: ReactNode;
}
function InfoCard({ label, children }: InfoCardProps) {
return (
<div className='info__card'>
<div className='info__label'>{label}</div>
<div className='info__value'>{children}</div>
</div>
);
}
function InfoImage({ src }: { src: string }) {
const [hasError, setHasError] = useState(false);
if (hasError) {
return null;
}
return (
<div className='info__image-container'>
<img className='info__image' src={src} loading='lazy' alt='' onError={() => setHasError(true)} />
</div>
);
}
+12
View File
@@ -35,9 +35,21 @@
font-size: $header-font-size;
font-weight: 600;
display: flex;
align-items: center;
gap: 1rem;
}
.project-header__brand {
display: flex;
align-items: center;
gap: 1rem;
min-width: 0;
}
.project-header__brand--without-logo {
align-self: baseline;
}
.logo {
max-width: min(200px, 20vw);
}
+5 -3
View File
@@ -25,7 +25,7 @@ export default function StudioLoader() {
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}
return <Studio {...data} />;
@@ -43,8 +43,10 @@ function Studio({ customFields, projectData, isMirrored, settings, viewSettings
<ViewParamsEditor target={OntimeView.StudioClock} viewOptions={studioOptions} />
<div className='project-header'>
{projectData?.logo && <ViewLogo name={projectData.logo} className='logo' />}
<div className='title'>{projectData.title}</div>
<div className={cx(['project-header__brand', !projectData?.logo && 'project-header__brand--without-logo'])}>
{projectData?.logo && <ViewLogo name={projectData.logo} className='logo' />}
<div className='title'>{projectData.title}</div>
</div>
</div>
<div className={cx(['studio-contents', hideCards && 'studio-contents--onecol'])}>
@@ -20,9 +20,21 @@
font-size: $header-font-size;
font-weight: 600;
display: flex;
align-items: center;
gap: 1rem;
}
.project-header__brand {
display: flex;
align-items: center;
gap: 1rem;
min-width: 0;
}
.project-header__brand--without-logo {
align-self: baseline;
}
.logo {
max-width: min(200px, 20vw);
}

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