Compare commits

...

25 Commits

Author SHA1 Message Date
Claude e29a019376 feat(mcp): validate tool arguments with zod
The MCP endpoint was the only route accepting a body without validation. Its
JSON Schema tool declarations were advertisement only: the low-level SDK Server
validates the JSON-RPC envelope but never the arguments of a tool call, and
every handler cast them with `args as SomeType`.

Tools are now declared with `defineTool(name, config, handler)`, the argument
shape `registerTool` takes, and the JSON Schema advertised in tools/list is
generated from the same zod schema that parses incoming arguments, so the two
cannot drift. Handlers receive inferred arguments and pass them to the existing
services, whose argument types are derived from ontime-types — which means a
schema that drifts from the domain model now fails typecheck.

mcp.registry.ts is the only module aware of the SDK internals. Migrating to the
SDK v2 registerTool API means replacing its two functions with a registration
loop, leaving the tool declarations untouched.

Malformed calls are now protocol errors, matching what the SDK does on the
registerTool path and what v2 will do. Failures raised by the services are
still returned as tool errors so agents can read them and recover.

Fixes three bugs the missing validation allowed:

- Project filenames reached `join(projectsDir, name)` unsanitised, so
  `../canary.json` resolved outside the projects directory. They are now
  confined with the same sanitize-filename + ensureJsonExtension pair the HTTP
  routes use in db.validation.ts.
- ontime_update_project_info forwarded its whole argument object into
  setProjectData, which spreads it into the stored project; an undeclared
  `logo` key also caused the current logo file to be deleted. Only declared
  fields reach it now.
- Batch creation declared nested entries as a bare object. They are modelled
  two levels deep, which encodes that groups cannot be nested and keeps the
  generated schema free of $ref/$defs.

Unknown keys are rejected rather than stripped, so an agent gets an actionable
error instead of a write that silently does less than it reported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MZioYpY8NR8UM5tAQ7BkfN
2026-08-12 05:28:22 +00:00
Claude ae16491599 chore(server): add zod as a direct dependency
Zod 4.4.3 is already resolved in the lockfile as a transitive dependency of
@modelcontextprotocol/sdk, but pnpm's strict node_modules will not resolve it
from apps/server without an explicit entry. Pinned to the resolved version so
the server and the SDK share a single instance.

Version >= 4.2.0 is also what the SDK v2 upgrade will require, since v2 takes
Standard Schema objects for tool schemas and drops zod v3 support.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MZioYpY8NR8UM5tAQ7BkfN
2026-08-12 05:28:08 +00:00
Carlos Valente c6eccec30e refactor(settings): show new app indicator 2026-08-09 16:48:20 +02:00
Carlos Valente 5220c2c374 fix(settings): prevent loader overflow 2026-08-09 16:48:20 +02:00
Carlos Valente 4eeeb294f7 chore: update electron navigation 2026-08-09 16:48:20 +02:00
Alex Christoffer Rasmussen a006331fea Group duration context menu utils (#1748) 2026-08-09 16:45:47 +02:00
Carlos Valente ac0ef06459 bump version to 4.12.0 2026-08-09 10:44:13 +02:00
Carlos Valente 4d04fe35c3 refactor(e2e): improve test stability 2026-08-09 10:41:12 +02:00
Carlos Valente fb559ed9da refactor(progress bar): reset when event changes 2026-08-05 15:30:22 +02:00
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
141 changed files with 4577 additions and 2605 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "4.11.0", "version": "4.12.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "4.11.0", "version": "4.12.0",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
+7
View File
@@ -176,6 +176,13 @@ export async function postCloneEntry(
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options); return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
} }
/**
* HTTP request events duration to fit inside the group target
*/
export async function requestFitGroupTarget(rundownId: RundownId, eventId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/${eventId}/fit-group-duration`);
}
/** /**
* HTTP request for grouping a list of entries into a group * HTTP request for grouping a list of entries into a group
*/ */
@@ -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 { .textEntry {
grid-area: input;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
flex: 1;
min-width: 0;
color: $label-gray; color: $label-gray;
} }
.inlineEntry { .inlineEntry {
display: grid; display: grid;
grid-template-areas: grid-template-columns: minmax(0, 1fr) auto;
'label label' align-items: center;
'input btn'; gap: 0.75rem;
grid-template-columns: 1fr auto;
column-gap: 0.5rem;
} }
.redirect { .redirect {
grid-area: btn; margin-left: auto;
} }
.label { .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 { useState } from 'react';
import { IoArrowForward } from 'react-icons/io5'; import { IoArrowForward } from 'react-icons/io5';
import { baseURI } from '../../../externals';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import useUrlPresets from '../../hooks-query/useUrlPresets'; import useUrlPresets from '../../hooks-query/useUrlPresets';
import { setClientRemote } from '../../hooks/useSocket'; import { setClientRemote } from '../../hooks/useSocket';
@@ -8,7 +9,7 @@ import Button from '../buttons/Button';
import Dialog from '../dialog/Dialog'; import Dialog from '../dialog/Dialog';
import Info from '../info/Info'; import Info from '../info/Info';
import Input from '../input/input/Input'; 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 Select from '../select/Select';
import style from './RedirectClientModal.module.scss'; 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 enabledPresets = data.filter((preset) => preset.enabled);
const settingsUrl = `${window.location.origin}${baseURI}/?settings=sharing__presets`;
const viewOptions = [ const viewOptions = [
...navigatorConstants.map((view) => ({ ...navigatorConstants.map((view) => ({
@@ -66,34 +68,46 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
bodyElements={ bodyElements={
<> <>
<Info> <Info>
Remotely redirect the client to a different URL. <br /> <Info.Body>
Either by entering a custom path or selecting a URL Preset. Remotely redirect the client to a different URL. Choose a custom path or select a URL Preset.
<br /> </Info.Body>
<br /> <Info.Footer>
<AppLink search='settings=sharing__presets'>Manage URL Presets</AppLink> <ExternalLink href={settingsUrl}>Manage URL Presets</ExternalLink>
</Info.Footer>
</Info> </Info>
<div className={style.inlineEntry}> <section className={style.section}>
<span className={style.label}>Enter custom path</span> <div className={style.sectionHeader}>
<label className={style.textEntry}> <span className={style.label}>Enter custom path</span>
{origin} <span className={style.description}>Type the path the client should open.</span>
<Input placeholder='eg. /timer' fluid value={path} onChange={(event) => setPath(event.target.value)} /> </div>
</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>
<div className={style.inlineEntry}> <div className={style.inlineEntry}>
<label className={style.textEntry}> <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 <Select
fluid fluid
options={viewOptions} options={viewOptions}
@@ -115,7 +129,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
Redirect <IoArrowForward /> Redirect <IoArrowForward />
</Button> </Button>
</div> </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 Dialog from '../dialog/Dialog';
import Input from '../input/input/Input'; import Input from '../input/input/Input';
import style from './RenameClientModal.module.scss';
interface RenameClientModalProps { interface RenameClientModalProps {
id: string; id: string;
name?: string; name?: string;
@@ -31,9 +33,22 @@ export function RenameClientModal({ id, name: currentName = '', isOpen, onClose
isOpen={isOpen} isOpen={isOpen}
title={`Rename Client: ${currentName}`} title={`Rename Client: ${currentName}`}
showCloseButton showCloseButton
showBackdrop
onClose={onClose} onClose={onClose}
bodyElements={ 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={ footerElements={
<> <>
@@ -1,12 +1,16 @@
.dialog { .dialog {
position: fixed; position: fixed;
top: 10%; top: 10vh;
left: 50%; left: 50%;
transform: translateX(-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; padding-inline: 1rem;
min-width: min(600px, 90vw);
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
@@ -43,7 +47,8 @@
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
max-height: min(80vh, 600px); flex: 1;
min-height: 0;
overflow-y: auto; overflow-y: auto;
} }
@@ -53,4 +58,5 @@
align-items: center; align-items: center;
justify-content: end; justify-content: end;
gap: 1rem; gap: 1rem;
flex-wrap: wrap;
} }
@@ -21,4 +21,15 @@
outline: none; outline: none;
box-shadow: 0 1px 0 0 currentColor; 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 { MouseEvent, ReactNode } from 'react';
import { IoOpenOutline } from 'react-icons/io5'; import { HiArrowLongRight } from 'react-icons/hi2';
import { openLink } from '../../../utils/linkUtils'; import { openLink } from '../../../utils/linkUtils';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
@@ -10,9 +10,10 @@ interface ExternalLinkProps {
href: string; href: string;
children: ReactNode; children: ReactNode;
inline?: boolean; 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) => { const handleClick = (event: MouseEvent) => {
event.preventDefault(); event.preventDefault();
openLink(href); openLink(href);
@@ -23,10 +24,11 @@ export default function ExternalLink({ href, inline, children }: ExternalLinkPro
href='#!' href='#!'
target='_blank' target='_blank'
rel='noreferrer' rel='noreferrer'
className={cx([style.link, inline && style.inline])} className={cx([style.link, inline && style.inline, className])}
onClick={handleClick} onClick={handleClick}
> >
{children} <IoOpenOutline style={{ fontSize: '1em' }} /> {children}
<HiArrowLongRight className={style.icon} />
</a> </a>
); );
} }
@@ -4,10 +4,13 @@
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
padding-inline: 1rem; box-sizing: border-box;
min-width: min(880px, 90vw); width: min(880px, 90vw);
min-height: min(200px, 10vh); min-height: min(200px, 10vh);
max-width: min(1200px, 90vw); max-height: calc(90vh - 1rem);
display: flex;
flex-direction: column;
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
@@ -17,14 +20,21 @@
outline: none; outline: none;
} }
.compact {
min-width: 0;
width: min(680px, 90vw);
}
.small {
min-width: 0;
width: min(480px, 90vw);
}
.wide { .wide {
top: 4vh; top: 4vh;
min-width: min(1280px, 96vw); width: min(1800px, 98vw);
max-width: min(1800px, 98vw); height: min(88vh, calc(96vh - 1rem));
height: 88vh; min-height: 0;
max-height: 88vh;
display: flex;
flex-direction: column;
} }
.backdrop { .backdrop {
@@ -41,15 +51,19 @@
.title { .title {
padding-block: 1rem; padding-block: 1rem;
padding-inline: 1rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem;
} }
.titleText { .titleText {
min-width: 0;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
overflow-wrap: anywhere;
} }
.body { .body {
@@ -57,21 +71,23 @@
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
max-height: 60vh; flex: 1;
min-height: 0;
padding: 0.5rem 1rem;
overflow-y: auto; overflow-y: auto;
scroll-padding-block: 0.5rem;
} }
.wide .body { .wide .body {
flex: 1;
min-height: 0;
max-height: none;
overflow: hidden; overflow: hidden;
} }
.footer { .footer {
padding-block: 1rem; padding-block: 1rem;
padding-inline: 1rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: end; justify-content: end;
gap: 1rem; gap: 1rem;
flex-wrap: wrap;
} }
@@ -12,7 +12,7 @@ interface ModalProps {
title?: string; title?: string;
showCloseButton?: boolean; showCloseButton?: boolean;
showBackdrop?: boolean; showBackdrop?: boolean;
size?: 'default' | 'wide'; size?: 'small' | 'compact' | 'default' | 'wide';
bodyElements: ReactNode; bodyElements: ReactNode;
footerElements?: ReactNode; footerElements?: ReactNode;
onClose: () => void; onClose: () => void;
@@ -38,7 +38,7 @@ export default function Modal({
> >
<BaseDialog.Portal> <BaseDialog.Portal>
{showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />} {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}> <div className={style.title}>
{title ? <BaseDialog.Title className={style.titleText}>{title}</BaseDialog.Title> : <div />} {title ? <BaseDialog.Title className={style.titleText}>{title}</BaseDialog.Title> : <div />}
{showCloseButton && ( {showCloseButton && (
@@ -9,6 +9,7 @@ import './MultiPartProgressBar.scss';
interface MultiPartProgressBar { interface MultiPartProgressBar {
now: MaybeNumber; now: MaybeNumber;
complete: MaybeNumber; complete: MaybeNumber;
eventId?: string | null;
normalColor: string; normalColor: string;
warning?: MaybeNumber; warning?: MaybeNumber;
warningColor: string; warningColor: string;
@@ -24,6 +25,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
const { const {
now, now,
complete, complete,
eventId,
normalColor, normalColor,
warning, warning,
warningColor, warningColor,
@@ -35,7 +37,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
className = '', className = '',
} = props; } = props;
const percentRemaining = 100 - useAnimatedProgress(now, complete); const percentRemaining = 100 - useAnimatedProgress(now, complete, eventId);
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0; const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0; const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
const isOvertime = now !== null && now < 0; const isOvertime = now !== null && now < 0;
@@ -2,7 +2,7 @@ import { useDisclosure, useHotkeys } from '@mantine/hooks';
import { memo } from 'react'; import { memo } from 'react';
import { useSearchParams } from 'react-router'; 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 { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
import FloatingNavigation from './floating-navigation/FloatingNavigation'; import FloatingNavigation from './floating-navigation/FloatingNavigation';
import NavigationMenu from './NavigationMenu'; import NavigationMenu from './NavigationMenu';
@@ -20,8 +20,7 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
const [isMenuOpen, menuHandler] = useDisclosure(); const [isMenuOpen, menuHandler] = useDisclosure();
const { open: showEditFormDrawer } = useViewParamsEditorStore(); const { open: showEditFormDrawer } = useViewParamsEditorStore();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const savedParams = useSavedViewParams((store) => store.params); const hasSavedChanges = hasCustomParams(searchParams);
const hasSavedChanges = hasCustomParams(searchParams) || Object.keys(savedParams).length > 0;
useHotkeys([ useHotkeys([
[ [
@@ -7,12 +7,12 @@ import './ProgressBar.scss';
interface ProgressBarProps { interface ProgressBarProps {
current: MaybeNumber; current: MaybeNumber;
duration: MaybeNumber; duration: MaybeNumber;
eventId?: string | null;
className?: string; className?: string;
} }
export default function ProgressBar(props: ProgressBarProps) { export default function ProgressBar({ current, duration, eventId, className }: ProgressBarProps) {
const { current, duration, className } = props; const progress = useAnimatedProgress(current, duration, eventId);
const progress = useAnimatedProgress(current, duration);
return ( return (
<div className={`progress-bar__bg ${className}`}> <div className={`progress-bar__bg ${className}`}>
@@ -1,20 +1,43 @@
@use '@/theme/viewerDefs' as *;
.emptyContainer { .emptyContainer {
width: 100%; width: 100%;
text-align: center; text-align: center;
color: $white-10; color: var(--secondary-color-override, $viewer-secondary-color);
.empty { .empty {
display: block; display: block;
width: min(100%, 24rem); width: min(100%, 14rem);
margin-inline: auto; margin: 0 auto -1.5rem;
opacity: 0.8; opacity: 0.6;
} }
.text { .text {
display: block; display: block;
margin-inline: auto; margin-inline: auto;
font-weight: 600; font-weight: 400;
font-size: 2em; font-size: clamp(1rem, 1.55vw, 1.5rem);
max-width: min(100%, 600px); 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 { CSSProperties } from 'react';
import { IoWarningOutline } from 'react-icons/io5';
import EmptyImage from '../../../assets/images/empty.svg?react'; import EmptyImage from '../../../assets/images/empty.svg?react';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -9,12 +10,18 @@ interface EmptyProps {
text?: string; text?: string;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
className?: string; className?: string;
variant?: 'error';
} }
export default function Empty({ text, className, injectedStyles }: EmptyProps) { export default function Empty({ text, className, injectedStyles, variant }: EmptyProps) {
return ( 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} /> <EmptyImage className={style.empty} />
{variant === 'error' && <IoWarningOutline className={style.errorIcon} aria-hidden />}
{text && <span className={style.text}>{text}</span>} {text && <span className={style.text}>{text}</span>}
</div> </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 */ box-sizing: border-box; /* reset */
overflow: hidden; overflow: hidden;
width: 100%; /* restrict the page width to viewport */ width: 100%; /* restrict the page width to viewport */
height: 100vh; height: 100dvh;
font-family: var(--font-family-override, $viewer-font-family); font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color); background: var(--background-color-override, $viewer-background-color);
@@ -16,5 +16,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding-top: 5rem; justify-content: center;
padding-block: 5rem;
} }
@@ -7,12 +7,13 @@ import style from './EmptyPage.module.scss';
interface EmptyPageProps { interface EmptyPageProps {
text?: string; text?: string;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
variant?: 'error';
} }
export default function EmptyPage({ text, injectedStyles }: EmptyPageProps) { export default function EmptyPage({ text, injectedStyles, variant }: EmptyPageProps) {
return ( return (
<div className={style.page}> <div className={style.page}>
<Empty text={text} injectedStyles={injectedStyles} /> <Empty text={text} injectedStyles={injectedStyles} variant={variant} />
</div> </div>
); );
} }
@@ -15,9 +15,4 @@
gap: 1rem; gap: 1rem;
margin-top: 1em; margin-top: 1em;
} }
.text {
font-weight: 600;
font-size: 2em;
}
} }
@@ -18,8 +18,7 @@ export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
<tbody className={style.emptyContainer}> <tbody className={style.emptyContainer}>
<tr> <tr>
<td colSpan={99} className={style.emptyCell}> <td colSpan={99} className={style.emptyCell}>
<Empty injectedStyles={{ marginTop: '5vh' }} /> <Empty text={text} injectedStyles={{ marginTop: '5vh' }} />
<span className={style.text}>{text}</span>
{handleAddNew && ( {handleAddNew && (
<div className={style.inline}> <div className={style.inline}>
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'> <Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
@@ -11,6 +11,11 @@
color: $ui-white; color: $ui-white;
} }
.active {
border: 1px solid $green-700;
color: $green-400;
}
.warning { .warning {
background-color: $orange-1300; background-color: $orange-1300;
color: $orange-300; color: $orange-300;
@@ -6,7 +6,7 @@ import style from './Tag.module.scss';
interface TagProps { interface TagProps {
className?: string; className?: string;
variant?: 'default' | 'warning'; variant?: 'default' | 'active' | 'warning';
} }
export default function Tag({ className, variant = 'default', children }: PropsWithChildren<TagProps>) { export default function Tag({ className, variant = 'default', children }: PropsWithChildren<TagProps>) {
@@ -1,26 +1,34 @@
import { MaybeNumber, Playback } from 'ontime-types'; import { EntryId, MaybeNumber, Playback } from 'ontime-types';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { getProgress } from '../utils/getProgress'; import { getProgress } from '../utils/getProgress';
import { usePlayback } from './useSocket'; import { useIsOnline, usePlayback } from './useSocket';
/** /**
* Returns the live completion percentage (0100) of a countdown, interpolated locally. * Returns the live completion percentage (0100) of a countdown, interpolated locally.
*/ */
export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber): number { export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber, eventId?: EntryId | null): number {
const playback = usePlayback(); const playback = usePlayback();
const isOnline = useIsOnline();
const isRunning = playback === Playback.Play || playback === Playback.Roll; const isRunning = playback === Playback.Play || playback === Playback.Roll;
const baseline = useRef({ current, at: performance.now() }); const baseline = useRef({ current, duration, eventId, playback, at: performance.now() });
const [, setTick] = useState(0); const [, setTick] = useState(0);
const now = performance.now();
// there is only something to animate while a running timer is counting down towards 0 const hasAuthoritativeUpdate =
const shouldAnimate = isRunning && current !== null && current > 0 && duration !== null; baseline.current.current !== current || // handle timer updates
baseline.current.duration !== duration || // handle duration changes
baseline.current.eventId !== eventId || // handle event changing
baseline.current.playback !== playback; // handle playback changes
// re-anchor to the authoritative value whenever the server pushes a new timer update if (hasAuthoritativeUpdate) {
useEffect(() => { // Reset during render so an event change is reflected in this very paint.
baseline.current = { current, at: performance.now() }; baseline.current = { current, duration, eventId, playback, at: now };
}, [current, duration, playback]); }
// There is only something to animate while a connected timer is counting down towards 0.
const shouldAnimate = isOnline && isRunning && current !== null && current > 0 && duration !== null;
// while counting down, re-render every animation frame so the derived progress stays smooth // while counting down, re-render every animation frame so the derived progress stays smooth
useEffect(() => { useEffect(() => {
@@ -34,8 +42,8 @@ export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber)
return () => cancelAnimationFrame(frame); return () => cancelAnimationFrame(frame);
}, [shouldAnimate]); }, [shouldAnimate]);
// derive from the anchor plus elapsed time at render; frozen to the anchor when not running // Derive from the anchor plus elapsed time at render; freeze while disconnected or not running.
const anchored = baseline.current.current; const anchored = baseline.current.current;
const value = isRunning && anchored !== null ? anchored - (performance.now() - baseline.current.at) : anchored; const value = isOnline && isRunning && anchored !== null ? anchored - (now - baseline.current.at) : anchored;
return getProgress(value, duration); return getProgress(value, duration);
} }
+24 -1
View File
@@ -49,6 +49,7 @@ import {
requestEventSwap, requestEventSwap,
requestGroupEntries, requestGroupEntries,
requestUngroup, requestUngroup,
requestFitGroupTarget,
} from '../api/rundown'; } from '../api/rundown';
import { logAxiosError } from '../api/utils'; import { logAxiosError } from '../api/utils';
import { useEditorSettings } from '../stores/editorSettings'; import { useEditorSettings } from '../stores/editorSettings';
@@ -466,7 +467,27 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
return previousEnd; return previousEnd;
} }
}, },
[getCurrentRundownData, updateEntryMutation, queryClient], [getCurrentRundownData, updateEntryMutation, queryClient, resolveCurrentRundownQueryKey],
);
/**
* Updates time of existing event so it satisfies the group target duration
* @param eventId {EntryId} - id of the event
*/
const matchGroupDuration = useCallback(
async (eventId: EntryId) => {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
try {
await requestFitGroupTarget(rundownId, eventId);
} catch (error) {
logAxiosError('Error updating event', error);
}
},
[getCurrentRundownData],
); );
/** /**
@@ -1009,6 +1030,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
swapEvents, swapEvents,
updateEntry, updateEntry,
updateTimer, updateTimer,
matchGroupDuration,
}), }),
[ [
addEntry, addEntry,
@@ -1026,6 +1048,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
swapEvents, swapEvents,
updateEntry, updateEntry,
updateTimer, updateTimer,
matchGroupDuration,
], ],
); );
} }
@@ -163,6 +163,7 @@ export const useNextFlag = createSelector((state: RuntimeStore) => ({
export const useProgressData = createSelector((state: RuntimeStore) => ({ export const useProgressData = createSelector((state: RuntimeStore) => ({
current: state.timer.current, current: state.timer.current,
duration: state.timer.duration, duration: state.timer.duration,
eventId: state.eventNow?.id ?? null,
timeWarning: state.eventNow?.timeWarning ?? null, timeWarning: state.eventNow?.timeWarning ?? null,
timeDanger: state.eventNow?.timeDanger ?? null, timeDanger: state.eventNow?.timeDanger ?? null,
})); }));
@@ -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 { $content-max-width: 1280px;
position: fixed;
top: 6rem;
right: 4rem;
z-index: $zindex-floating;
}
.contentWrapper { .contentWrapper {
display: flex; display: flex;
@@ -14,9 +9,29 @@
position: relative; 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 { .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; overflow-y: auto;
flex-grow: 1; 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>) { export default function PanelContent({ onClose, children }: PropsWithChildren<PanelContentProps>) {
return ( return (
<div className={style.contentWrapper}> <div className={style.contentWrapper}>
<div className={style.content}>{children}</div>
<div className={style.corner}> <div className={style.corner}>
<Button size='large' onClick={onClose}> <Button size='large' onClick={onClose}>
Close settings <IoClose /> Close settings <IoClose />
</Button> </Button>
</div> </div>
<div className={style.content}>{children}</div>
</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, .tabs,
ul { ul {
list-style: none; list-style: none;
@@ -6,22 +16,20 @@ ul {
} }
.tabs { .tabs {
width: min(30vw, 300px);
flex: 0 0 min(30vw, 300px);
min-width: min(30vw, 300px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: auto;
} }
.primary, .primary,
.secondary { .secondary {
padding: 0.25rem 1rem; padding: 0.25rem 1rem;
margin-right: 1rem; margin-right: 1rem;
border-radius: 2px;
&:focus { &:focus-visible {
background-color: $gray-1000; background-color: $gray-1000;
outline: 0; outline: 2px solid $blue-500;
outline-offset: -2px;
} }
&:hover { &:hover {
@@ -32,14 +40,21 @@ ul {
.primary { .primary {
font-size: 1rem; font-size: 1rem;
border-radius: 2px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
border-left: 3px solid transparent;
&.groupActive {
color: $blue-400;
font-weight: 600;
}
&.active { &.active {
color: $blue-400; color: $ui-white;
background-color: $gray-1100; border-left-color: $blue-400;
background-color: rgba($blue-500, 0.16);
font-weight: 600;
} }
&.highlight { &.highlight {
@@ -62,6 +77,9 @@ ul {
font-size: $inner-section-text-size; font-size: $inner-section-text-size;
&.active { &.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 Tooltip from '../../../common/components/tooltip/Tooltip';
import { isKeyEnter } from '../../../common/utils/keyEvent'; import { isKeyEnter } from '../../../common/utils/keyEvent';
import { cx } from '../../../common/utils/styleUtils'; 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 useAppSettingsNavigation from '../useAppSettingsNavigation';
import SettingsSearch from './SettingsSearch';
import style from './PanelList.module.scss'; import style from './PanelList.module.scss';
@@ -16,23 +24,57 @@ interface PanelListProps extends PanelBaseProps {
selectedPanel: string; 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) { export default function PanelList({ selectedPanel, location }: PanelListProps) {
const { options } = useAppSettingsMenu(); 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 ( return (
<ul className={style.tabs}> <div className={style.container}>
{options.map((panel) => { <SettingsSearch query={query} onQueryChange={setQuery} onSubmit={handleSearchSubmit} />
const isSelected = selectedPanel === panel.id;
if (panel.highlight) { {results.length === 0 ? (
return ( <Panel.EmptyState title='No settings match' description={`Nothing found for "${query.trim()}"`} />
<Tooltip key={panel.id} text={panel.highlight} render={<span />}> ) : (
<PanelListItem panel={panel} location={location} isSelected={isSelected} /> <ul className={style.tabs}>
</Tooltip> {results.map((panel) => {
); const isSelected = selectedPanel === panel.id;
} if (panel.highlight) {
return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />; return (
})} <Tooltip key={panel.id} text={panel.highlight} render={<span />}>
</ul> <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) { function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
const { setLocation } = useAppSettingsNavigation(); 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 ( return (
<Fragment key={panel.id}> <Fragment key={panel.id}>
@@ -75,6 +125,7 @@ function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
} }
}} }}
className={secondaryClasses} className={secondaryClasses}
tabIndex={0}
role='button' role='button'
> >
{secondary.label} {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; $inner-padding: 1rem;
$card-padding: 2rem;
.header { .header {
font-size: 2rem; font-size: 1.5rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
border-bottom: 1px solid $white-10; border-bottom: 1px solid $white-10;
font-weight: 600; font-weight: 600;
} }
.subheader { .subheader {
font-size: 1.5rem; font-size: 1.125rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
font-weight: 600; font-weight: 600;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem;
} }
.title { .title {
font-size: 1.375rem; font-size: 1rem;
padding: 0 2rem; padding: 0 var(--panel-card-padding, #{$card-padding});
font-weight: 600; font-weight: 600;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -35,10 +37,12 @@ $inner-padding: 1rem;
color: $ui-white; 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 { .section {
position: relative; position: relative;
margin-top: 2rem; margin-top: 2rem;
max-width: 1024px;
} }
.indent { .indent {
@@ -51,8 +55,10 @@ $inner-padding: 1rem;
} }
.card { .card {
--panel-card-padding: #{$card-padding};
position: relative; position: relative;
padding: 2rem; padding: var(--panel-card-padding);
background-color: $white-3; background-color: $white-3;
border: 1px solid $gray-1100; border: 1px solid $gray-1100;
border-radius: 3px; border-radius: 3px;
@@ -76,10 +82,10 @@ $inner-padding: 1rem;
color: $error-red; 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 { .pad {
padding: 0 2rem; padding: 0 var(--panel-card-padding, #{$card-padding});
max-height: 550px;
overflow-y: scroll;
} }
.table { .table {
@@ -97,10 +103,13 @@ $inner-padding: 1rem;
} }
th { th {
font-weight: 400; font-weight: 600;
color: $gray-400; color: $gray-300;
font-size: calc(1rem - 3px);
letter-spacing: 0.02em;
vertical-align: bottom;
white-space: nowrap; white-space: nowrap;
text-transform: capitalize; text-transform: uppercase;
} }
th, th,
@@ -112,6 +121,17 @@ $inner-padding: 1rem;
background-color: $white-1; 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 // allow highlighting table elements
tr[data-warn='true'], tr[data-warn='true'],
td[data-warn='true'] { td[data-warn='true'] {
@@ -120,7 +140,7 @@ $inner-padding: 1rem;
} }
.listGroup { .listGroup {
padding: 0 2rem; padding: 0 var(--panel-card-padding, #{$card-padding});
> li:not(:last-child) { > li:not(:last-child) {
border-bottom: 1px solid $white-10; border-bottom: 1px solid $white-10;
@@ -133,6 +153,17 @@ $inner-padding: 1rem;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
padding: 0.5rem 0; 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 { .fieldTitle {
@@ -163,14 +194,13 @@ $inner-padding: 1rem;
.divider { .divider {
border: none; border: none;
border-top: 1px solid $white-10; border-top: 1px solid $white-10;
margin: 1rem -2rem; margin: 1rem calc(var(--panel-card-padding, #{$card-padding}) * -1);
} }
.overlay { .overlay {
position: absolute; position: absolute;
z-index: $zindex-backdrop; z-index: $zindex-backdrop;
width: 100%; inset: 0;
height: 100%;
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
display: grid; display: grid;
place-content: center; place-content: center;
@@ -188,19 +218,35 @@ $inner-padding: 1rem;
animation: animloader 1s ease-in infinite; animation: animloader 1s ease-in infinite;
} }
.empty { .emptyState {
background-color: $black-10; padding: 3rem 1.5rem;
text-align: center; text-align: center;
color: $muted-gray; }
td { .emptyMessage {
padding-block: 5rem; width: min(30rem, 100%);
} margin-inline: auto;
}
button { .emptyTitle {
margin-top: 1rem; margin-bottom: 0.25rem;
margin-inline: auto; 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 { .inlineElements {
@@ -239,14 +285,3 @@ $inner-padding: 1rem;
opacity: 0; 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 { 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 { cx } from '../../../common/utils/styleUtils';
import style from './PanelUtils.module.scss'; 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 ( 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}> <td colSpan={99}>
<div>{label ?? 'No data yet'}</div> <EmptyState {...props} />
{handleClick && (
<Button onClick={handleClick} disabled={!handleClick} variant='primary'>
New <IoAdd />
</Button>
)}
</td> </td>
</tr> </tr>
); );
@@ -81,8 +92,8 @@ export function ListGroup({ className, children }: { className?: string; childre
return <ul className={cx([style.listGroup, className])}>{children}</ul>; return <ul className={cx([style.listGroup, className])}>{children}</ul>;
} }
export function ListItem({ children }: { children: ReactNode }) { export function ListItem({ children, interactive = false }: { children: ReactNode; interactive?: boolean }) {
return <li className={style.listItem}>{children}</li>; return <li className={cx([style.listItem, interactive && style.interactive])}>{children}</li>;
} }
export function Field({ export function Field({
@@ -1,4 +1,4 @@
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink'; import Info from '../../../../common/components/info/Info';
import { import {
buyMeACoffeeUrl, buyMeACoffeeUrl,
discordUrl, discordUrl,
@@ -11,6 +11,8 @@ import {
} from '../../../../externals'; } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import AppVersion from './AppVersion'; import AppVersion from './AppVersion';
import CloudPanel from './CloudPanel';
import ExternalLinkRow from './ExternalLinkRow';
export default function AboutPanel() { export default function AboutPanel() {
return ( return (
@@ -19,28 +21,49 @@ export default function AboutPanel() {
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
<Panel.SubHeader>Ontime</Panel.SubHeader> <Panel.SubHeader>Ontime</Panel.SubHeader>
<Panel.Paragraph> <Panel.Divider />
Free, open-source software for managing rundowns and event timers <Info>Free, open-source software for managing rundowns and event timers.</Info>
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink> <Panel.ListGroup>
</Panel.Paragraph> <ExternalLinkRow href={websiteUrl} title='www.getontime.no' description='App site and downloads' />
<Panel.Paragraph> <ExternalLinkRow
Considering sponsoring our work href={githubSponsorUrl}
<ExternalLink href={githubSponsorUrl}>GitHub Sponsors</ExternalLink> title='GitHub Sponsors'
<ExternalLink href={buyMeACoffeeUrl}>Buy Me a Coffee</ExternalLink> description='Support development through GitHub'
</Panel.Paragraph> />
<Panel.Paragraph> <ExternalLinkRow
If you are looking for an online version of Ontime, consider trying out our cloud service href={buyMeACoffeeUrl}
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink> title='Buy Me a Coffee'
</Panel.Paragraph> 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.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> </Panel.Section>
</> </>
); );
@@ -0,0 +1,7 @@
.updateIndicator {
width: 0.5em;
height: 0.5em;
flex: 0 0 auto;
border-radius: 99px;
background-color: $red-400;
}
@@ -1,35 +1,47 @@
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink'; import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import useAppVersion from '../../../../common/hooks-query/useAppVersion'; 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'; import * as Panel from '../../panel-utils/PanelUtils';
import style from './AppVersion.module.scss';
export default function AppVersion() { export default function AppVersion() {
const { data, isError } = useAppVersion(); const { data, isError } = useAppVersion();
if (isError) { if (isError) {
return ( return (
<Panel.Paragraph> <Panel.ListItem>
{`You are currently using Ontime version ${appVersion}`} <Panel.Field title={`Ontime ${appVersion}`} description='' error='Could not fetch version information' />
<Panel.Error>Could not fetch version information</Panel.Error> </Panel.ListItem>
</Panel.Paragraph>
); );
} }
if (data.hasUpdates) { if (data.hasUpdates) {
return ( return (
<Panel.Paragraph> <Panel.ListItem>
{`You are currently using Ontime version ${appVersion}.`} <Panel.Field
<br /> title={
<br /> <>
{`A new version ${data.version} is available.`} <br /> <span className={style.updateIndicator} aria-hidden='true' />
{isOntimeCloud ? ( {`Ontime ${appVersion}`}
'You can restart your stage to get the latest available version.' </>
) : ( }
<ExternalLink href={data.url}>Please visit the release page to download</ExternalLink> description={
isOntimeCloud
? `Version ${data.version} is available. Restart your stage to update.`
: `Version ${data.version} is available.`
}
/>
{!isOntimeCloud && (
<ExternalLink href={websiteUrl}>Download the latest version from Ontime's page</ExternalLink>
)} )}
</Panel.Paragraph> </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 { .outerColumn {
display: flex;
flex-direction: column;
gap: 2rem; 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 { h3 {
font-size: 1rem; font-size: 1rem;
@@ -19,12 +19,12 @@ import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink'; 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 RadioGroup from '../../../../common/components/radio-group/RadioGroup';
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import useCustomFields from '../../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../../common/hooks-query/useCustomFields';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { startsWithHttp } from '../../../../common/utils/regex'; import { startsWithHttp } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { isAutomation, makeFieldList } from './automationUtils'; import { isAutomation, makeFieldList } from './automationUtils';
@@ -34,6 +34,7 @@ import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss'; import style from './AutomationForm.module.scss';
const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation'; const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation';
const formId = 'automation-form';
interface AutomationFormProps { interface AutomationFormProps {
automation: Automation | AutomationDTO; automation: Automation | AutomationDTO;
@@ -185,291 +186,320 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
const canSubmit = !isSubmitting && isDirty && isValid; const canSubmit = !isSubmitting && isDirty && isValid;
return ( return (
<Panel.Indent <Modal
as='form' isOpen
name='automation-form' onClose={onClose}
onSubmit={handleSubmit(onSubmit)} showBackdrop
className={style.outerColumn} showCloseButton
onKeyDown={(event) => preventEscape(event, onClose)} title={isEdit ? 'Edit automation' : 'Create automation'}
> bodyElements={
<Panel.SubHeader>{isEdit ? 'Edit automation' : 'Create automation'}</Panel.SubHeader> <form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.outerColumn}>
<div className={style.innerSection}> <div className={style.innerColumn}>
<h3>Automation options</h3> <h3>Automation options</h3>
<div className={style.titleSection}> <div className={style.titleSection}>
<label> <label>
Title Title
<Input <Input
{...register('title', { required: { value: true, message: 'Required field' } })} {...register('title', { required: { value: true, message: 'Required field' } })}
fluid fluid
placeholder='Load preset' placeholder='Load preset'
/> />
</label> </label>
<Panel.Error>{errors.title?.message}</Panel.Error> <Panel.Error>{errors.title?.message}</Panel.Error>
</div> </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>
</div> </div>
</div>
</div>
<div className={style.innerColumn}> <div className={style.innerColumn}>
<h3>Outputs</h3> <h3>Filters (optional)</h3>
<Info> <div className={style.ruleSection}>
Automation outputs can be used to send data from Ontime to external software <br /> <label>
or to change properties of Ontime itself. <br /> <br /> Trigger outputs if
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '} <RadioGroup
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink> orientation='horizontal'
</Info> value={watch('filterRule')}
onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })}
{fieldOutputs.map((output, index) => { items={[
if (isOSCOutput(output)) { { value: 'all', label: 'All filters pass' },
const rowErrors = errors.outputs?.[index] as { value: 'any', label: 'Any filter passes' },
| { ]}
targetIP?: { message?: string }; />
targetPort?: { message?: string }; </label>
address?: { message?: string }; {fieldFilters.map((field, index) => {
args?: { message?: string }; const key = `filters.${index}.field.${field.id}`;
} return (
| undefined; <div key={key} className={style.filterSection}>
<label>
return ( Runtime data source
<div key={output.id} className={style.outputCard}> <Select<string | null>
<Tag>OSC</Tag> // need to normalize '' to null for the Select to show the placeholder
<div className={style.oscSection}> value={watch(`filters.${index}.field`) || null}
<label> onValueChange={(value) => {
Target IP if (value === null) return;
<Input setValue(`filters.${index}.field`, value, { shouldDirty: true });
{...register(`outputs.${index}.targetIP`, { }}
required: { value: true, message: 'Required field' }, options={fieldList.map(({ value, label }) => ({
})} value,
fluid label,
placeholder='127.0.0.1' disabled: value === null,
/> }))}
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error> aria-label='Event field'
</label> />
<label> <Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
Target Port </label>
<Input <label>
{...register(`outputs.${index}.targetPort`, { Matching condition
required: { value: true, message: 'Required field' }, <Select
setValueAs: (value) => (value === '' ? 0 : Number(value)), value={watch(`filters.${index}.operator`)}
max: { value: 65535, message: 'Port must be within range 1024 - 65535' }, onValueChange={(value: string | null) => {
min: { value: 1024, message: 'Port must be within range 1024 - 65535' }, if (value === null) return;
})} setValue(
fluid `filters.${index}.operator`,
type='number' value as
maxLength={5} | 'equals'
placeholder='8000' | 'not_equals'
/> | 'greater_than'
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error> | 'less_than'
</label> | 'contains'
<label> | 'not_contains',
Address { shouldDirty: true },
<TemplateInput );
{...register(`outputs.${index}.address`)} }}
value={output.address} options={[
fluid { value: 'equals', label: 'equals' },
placeholder='/cue/start' { value: 'not_equals', label: 'not equals' },
/> { value: 'contains', label: 'contains' },
<Panel.Error>{rowErrors?.address?.message}</Panel.Error> ]}
</label> aria-label='Operator'
<label> />
Arguments <Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} fluid placeholder='1' /> </label>
<Panel.Error>{rowErrors?.args?.message}</Panel.Error> <label>
</label> Value to match
<div> <Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' />
<span>&nbsp;</span> </label>
<Panel.InlineElements relation='inner'> <div>
<Button variant='ghosted-white' onClick={() => handleTestOSCOutput(index)}> <span>&nbsp;</span>
Test <div>
</Button> <IconButton
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}> aria-label='Delete'
<IoTrash /> variant='ghosted-destructive'
</IconButton> onClick={() => removeFilter(index)}
</Panel.InlineElements> >
<IoTrash />
</IconButton>
</div>
</div>
</div> </div>
</div> );
})}
<div>
<Button onClick={handleAddNewFilter}>
Add filter <IoAdd />
</Button>
</div> </div>
); </div>
} </div>
if (isHTTPOutput(output)) {
const rowErrors = errors.outputs?.[index] as <div className={style.innerColumn}>
| { <h3>Outputs</h3>
url?: { message?: string }; <Info>
} Automation outputs can be used to send data from Ontime to external software <br />
| undefined; or to change properties of Ontime itself. <br /> <br />
return ( Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '}
<div key={output.id} className={style.outputCard}> <ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
<Tag>HTTP</Tag> </Info>
<div className={style.httpSection}>
<label> {fieldOutputs.map((output, index) => {
Target URL if (isOSCOutput(output)) {
<TemplateInput const rowErrors = errors.outputs?.[index] as
{...register(`outputs.${index}.url`, { | {
required: { value: true, message: 'Required field' }, targetIP?: { message?: string };
pattern: { targetPort?: { message?: string };
value: startsWithHttp, address?: { message?: string };
message: 'HTTP messages should target http:// or https://', args?: { message?: string };
}, }
})} | undefined;
value={output.url}
fluid return (
placeholder='http://127.0.0.1/start/1' <div key={output.id} className={style.outputCard}>
/> <Tag>OSC</Tag>
<Panel.Error>{rowErrors?.url?.message}</Panel.Error> <div className={style.oscSection}>
</label> <label>
<div> Target IP
<span>&nbsp;</span> <Input
<Panel.InlineElements relation='inner'> {...register(`outputs.${index}.targetIP`, {
<Button variant='ghosted-white' onClick={() => handleTestHTTPOutput(index)}> required: { value: true, message: 'Required field' },
Test })}
</Button> fluid
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}> placeholder='127.0.0.1'
<IoTrash /> />
</IconButton> <Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error>
</Panel.InlineElements> </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> );
</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)) { if (isOntimeAction(output)) {
const rowErrors = errors.outputs?.[index] as const rowErrors = errors.outputs?.[index] as
| { | {
action?: { message?: string }; action?: { message?: string };
time?: { message?: string }; time?: { message?: string };
text?: { message?: string }; text?: { message?: string };
visible?: { message?: string }; visible?: { message?: string };
secondarySource?: { message?: string }; secondarySource?: { message?: string };
} }
| undefined; | undefined;
return ( return (
<div key={output.id} className={style.outputCard}> <div key={output.id} className={style.outputCard}>
<Tag>Ontime action</Tag> <Tag>Ontime action</Tag>
<OntimeActionForm <OntimeActionForm
value={output.action} value={output.action}
index={index} index={index}
register={register} register={register}
rowErrors={rowErrors} rowErrors={rowErrors}
setValue={setValue} setValue={setValue}
watch={watch} watch={watch}
> >
<span>&nbsp;</span> <span>&nbsp;</span>
<Panel.InlineElements relation='inner'> <Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestOntimeAction(index)}> <Button variant='ghosted-white' onClick={() => handleTestOntimeAction(index)}>
Test Test
</Button> </Button>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}> <IconButton
<IoTrash /> aria-label='Delete'
</IconButton> variant='ghosted-destructive'
</Panel.InlineElements> onClick={() => removeOutput(index)}
</OntimeActionForm> >
</div> <IoTrash />
); </IconButton>
} </Panel.InlineElements>
</OntimeActionForm>
</div>
);
}
// there should be no other output types return null;
return null; })}
})} <Panel.InlineElements relation='inner'>
<Panel.InlineElements relation='inner'> <Button onClick={handleAddNewOSCOutput}>
<Button onClick={handleAddNewOSCOutput}> OSC <IoAdd />
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>
<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 ( return (
<> <>
<Panel.Header>Automation</Panel.Header> <Panel.Header>Automation</Panel.Header>
<Panel.Section> <div ref={settingsRef}>
<Panel.Loader isLoading={isLoading} /> <AutomationSettingsForm
<div ref={settingsRef}> enabledAutomations={data.enabledAutomations}
<AutomationSettingsForm enabledOscIn={data.enabledOscIn}
enabledAutomations={data.enabledAutomations} oscPortIn={data.oscPortIn}
enabledOscIn={data.enabledOscIn} automationState={automationState}
oscPortIn={data.oscPortIn} oscInputState={oscInputState}
automationState={automationState} isLoading={isLoading}
oscInputState={oscInputState} />
/> </div>
</div> <div ref={automationsRef}>
<div ref={automationsRef}> <AutomationsList automations={data.automations} enabledAutomations={automationState} isLoading={isLoading} />
<AutomationsList automations={data.automations} enabledAutomations={automationState} /> </div>
</div> <div ref={triggersRef}>
<div ref={triggersRef}> <TriggersList
<TriggersList triggers={data.triggers} automations={data.automations} enabledAutomations={automationState} /> triggers={data.triggers}
</div> automations={data.automations}
</Panel.Section> enabledAutomations={automationState}
isLoading={isLoading}
/>
</div>
</> </>
); );
} }
@@ -21,6 +21,7 @@ interface AutomationSettingsProps {
oscPortIn: number; oscPortIn: number;
automationState?: boolean; automationState?: boolean;
oscInputState?: boolean; oscInputState?: boolean;
isLoading?: boolean;
} }
export default function AutomationSettingsForm({ export default function AutomationSettingsForm({
@@ -29,6 +30,7 @@ export default function AutomationSettingsForm({
oscPortIn, oscPortIn,
automationState, automationState,
oscInputState, oscInputState,
isLoading,
}: AutomationSettingsProps) { }: AutomationSettingsProps) {
const { const {
handleSubmit, handleSubmit,
@@ -65,126 +67,129 @@ export default function AutomationSettingsForm({
const oscInputEnabled = watch('enabledOscIn'); const oscInputEnabled = watch('enabledOscIn');
return ( return (
<Panel.Card> <Panel.Section>
<Panel.SubHeader> <Panel.Card>
Automation settings <Panel.Loader isLoading={Boolean(isLoading)} />
<Panel.InlineElements> <Panel.SubHeader>
<Button variant='ghosted' onClick={onReset} disabled={!canSubmit}> Automation settings
Revert to saved <Panel.InlineElements>
</Button> <Button variant='ghosted' onClick={onReset} disabled={!canSubmit}>
<Button Revert to saved
variant='primary' </Button>
type='submit' <Button
form='automation-settings-form' variant='primary'
disabled={!canSubmit} type='submit'
loading={isSubmitting} form='automation-settings-form'
> disabled={!canSubmit}
Save loading={isSubmitting}
</Button> >
</Panel.InlineElements> Save
</Panel.SubHeader> </Button>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>} </Panel.InlineElements>
</Panel.SubHeader>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.Divider /> <Panel.Divider />
<Panel.Section> <Panel.Section>
<Info> <Info>
<span>Control Ontime and share its data with external systems in your workflow.</span> <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>- 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> <span>- OSC Input tells Ontime to listen to messages on the specific port.</span>
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink> <ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
</Info> </Info>
</Panel.Section> </Panel.Section>
<Panel.Section <Panel.Section
as='form' as='form'
id='automation-settings-form' id='automation-settings-form'
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onReset)} onKeyDown={(event) => preventEscape(event, onReset)}
> >
<Panel.Loader isLoading={false} /> <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.ListGroup> {isOntimeCloud && (
<Panel.ListItem> <Info>For security reasons OSC integrations are not available in the cloud service.</Info>
<Panel.Field )}
title={ <Panel.ListItem>
<> <Panel.Field
<span>Enable automations</span> title={
{automationState === false && <Tag variant='warning'>OFF</Tag>} <>
</> <span>OSC input</span>
} {oscInputState === false && <Tag variant='warning'>OFF</Tag>}
description={ </>
automationState === false }
? 'Automations are OFF. Triggers stay configured, but Ontime will not send messages.' description={
: 'Allow Ontime to send messages on lifecycle triggers' oscInputState === false
} ? 'OSC input is OFF. Ontime will not listen for incoming OSC control messages.'
descriptionTone={automationState === false ? 'warning' : 'default'} : 'Allow control of Ontime through OSC'
error={errors.enabledAutomations?.message} }
/> descriptionTone={oscInputState === false ? 'warning' : 'default'}
<Switch error={errors.enabledOscIn?.message}
size='large' />
checked={automationsEnabled} <Switch
onCheckedChange={(value: boolean) => size='large'
setValue('enabledAutomations', value, { shouldDirty: true, shouldValidate: true }) checked={oscInputEnabled}
} onCheckedChange={(value: boolean) =>
/> setValue('enabledOscIn', value, { shouldDirty: true, shouldValidate: true })
</Panel.ListItem> }
</Panel.ListGroup> />
<Panel.Title>OSC Input</Panel.Title> </Panel.ListItem>
<Panel.ListItem>
<Panel.ListGroup> <Panel.Field
{isOntimeCloud && <Info>For security reasons OSC integrations are not available in the cloud service.</Info>} title='Listen on port'
<Panel.ListItem> description='Port for incoming OSC. Default: 8888'
<Panel.Field error={errors.oscPortIn?.message}
title={ />
<> <Input
<span>OSC input</span> id='oscPortIn'
{oscInputState === false && <Tag variant='warning'>OFF</Tag>} placeholder='8888'
</> maxLength={5}
} style={{ textAlign: 'right', width: '5rem' }}
description={ type='number'
oscInputState === false fluid
? 'OSC input is OFF. Ontime will not listen for incoming OSC control messages.' {...register('oscPortIn', {
: 'Allow control of Ontime through OSC' required: { value: true, message: 'Required field' },
} max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
descriptionTone={oscInputState === false ? 'warning' : 'default'} min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
error={errors.enabledOscIn?.message} pattern: {
/> value: isOnlyNumbers,
<Switch message: 'Value should be numeric',
size='large' },
checked={oscInputEnabled} })}
onCheckedChange={(value: boolean) => />
setValue('enabledOscIn', value, { shouldDirty: true, shouldValidate: true }) </Panel.ListItem>
} </Panel.ListGroup>
/> </Panel.Section>
</Panel.ListItem> </Panel.Card>
<Panel.ListItem> </Panel.Section>
<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>
); );
} }
@@ -22,10 +22,10 @@ const automationPlaceholder: AutomationDTO = {
interface AutomationsListProps { interface AutomationsListProps {
automations: NormalisedAutomation; automations: NormalisedAutomation;
enabledAutomations?: boolean; enabledAutomations?: boolean;
isLoading: boolean;
} }
export default function AutomationsList(props: AutomationsListProps) { export default function AutomationsList({ automations, enabledAutomations, isLoading }: AutomationsListProps) {
const { automations, enabledAutomations } = props;
const { refetch } = useAutomationSettings(); const { refetch } = useAutomationSettings();
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null); const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -44,89 +44,94 @@ export default function AutomationsList(props: AutomationsListProps) {
const arrayAutomations = Object.keys(automations); const arrayAutomations = Object.keys(automations);
return ( return (
<Panel.Card> <Panel.Section>
<Panel.SubHeader> <Panel.Card>
Manage automations {automationFormData !== null && (
<Button <AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
type='submit' )}
disabled={Boolean(automationFormData)} <Panel.SubHeader>
onClick={() => setAutomationFormData(automationPlaceholder)} Manage automations
> <Button onClick={() => setAutomationFormData(automationPlaceholder)}>
New <IoAdd /> New <IoAdd />
</Button> </Button>
</Panel.SubHeader> </Panel.SubHeader>
<Panel.Divider /> <Panel.Divider />
{enabledAutomations === false && ( <Panel.Section>
<Info> {enabledAutomations === false && (
Automations are disabled. You can still manage automation definitions here, but they will not run until <Info>
enabled. Automations are disabled. You can still manage automation definitions here, but they will not run until
</Info> 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}
/>
)} )}
{arrayAutomations.map((automationId) => {
if (!Object.hasOwn(automations, automationId)) { <Panel.Table>
return null; <thead>
} <tr>
return ( <th style={{ width: '45%' }}>Title</th>
<Fragment key={automationId}> <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> <tr>
<td>{automations[automationId].title}</td> <td colSpan={5}>
<td> <Panel.Error>{deleteError}</Panel.Error>
<Tag>{automations[automationId].filterRule}</Tag>
</td> </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> </tr>
</Fragment> )}
); </tbody>
})} </Panel.Table>
{deleteError && ( </Panel.Section>
<tr> </Panel.Card>
<td colSpan={5}> </Panel.Section>
<Panel.Error>{deleteError}</Panel.Error>
</td>
</tr>
)}
</tbody>
</Panel.Table>
</Panel.Card>
); );
} }
@@ -1,4 +1,4 @@
import { NormalisedAutomation, TimerLifeCycle, TriggerDTO } from 'ontime-types'; import { NormalisedAutomation, TimerLifeCycle, Trigger, TriggerDTO } from 'ontime-types';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@@ -6,30 +6,21 @@ import { addTrigger, editTrigger } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import Modal from '../../../../common/components/modal/Modal';
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import { preventEscape } from '../../../../common/utils/keyEvent';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './automationUtils'; import { cycles } from './automationUtils';
const formId = 'trigger-form';
interface TriggerFormProps { interface TriggerFormProps {
automations: NormalisedAutomation; automations: NormalisedAutomation;
initialId?: string; trigger: Trigger | null;
initialTitle?: string;
initialAutomationId?: string;
initialTrigger?: TimerLifeCycle;
onCancel: () => void; onCancel: () => void;
postSubmit: () => void; postSubmit: () => void;
} }
export default function TriggerForm({ export default function TriggerForm({ automations, trigger, onCancel, postSubmit }: TriggerFormProps) {
automations,
initialId,
initialTitle,
initialAutomationId,
initialTrigger,
onCancel,
postSubmit,
}: TriggerFormProps) {
const { const {
handleSubmit, handleSubmit,
register, register,
@@ -40,9 +31,9 @@ export default function TriggerForm({
formState: { errors, isSubmitting, isValid, isDirty }, formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<TriggerDTO>({ } = useForm<TriggerDTO>({
defaultValues: { defaultValues: {
title: initialTitle, title: trigger?.title,
trigger: initialTrigger ?? (cycles[0].value as TimerLifeCycle | undefined), trigger: trigger?.trigger ?? (cycles[0].value as TimerLifeCycle | undefined),
automationId: initialAutomationId ?? automations?.[Object.keys(automations)[0]]?.id, automationId: trigger?.automationId ?? automations?.[Object.keys(automations)[0]]?.id,
}, },
resetOptions: { resetOptions: {
keepDirtyValues: true, keepDirtyValues: true,
@@ -55,10 +46,9 @@ export default function TriggerForm({
}, [setFocus]); }, [setFocus]);
const onSubmit = async (values: TriggerDTO) => { const onSubmit = async (values: TriggerDTO) => {
// if we were passed an ID we are editing a Trigger if (trigger) {
if (initialId) {
try { try {
await editTrigger(initialId, { id: initialId, ...values }); await editTrigger(trigger.id, { id: trigger.id, ...values });
postSubmit(); postSubmit();
} catch (error) { } catch (error) {
setError('root', { message: `Failed to save changes to trigger ${maybeAxiosError(error)}` }); setError('root', { message: `Failed to save changes to trigger ${maybeAxiosError(error)}` });
@@ -85,56 +75,63 @@ export default function TriggerForm({
const canSubmit = isDirty && isValid; const canSubmit = isDirty && isValid;
return ( return (
<Panel.Indent <Modal
as='form' isOpen
name='trigger-form' onClose={onCancel}
onSubmit={handleSubmit(onSubmit)} showBackdrop
onKeyDown={(event) => preventEscape(event, onCancel)} showCloseButton
> size='compact'
<Panel.SubHeader>{initialId ? 'Edit trigger' : 'Create trigger'}</Panel.SubHeader> title={trigger ? 'Edit trigger' : 'Create trigger'}
<label> bodyElements={
Title <form id={formId} onSubmit={handleSubmit(onSubmit)}>
<Input <label>
{...register('title', { required: { value: true, message: 'Required field' } })} Title
fluid <Input
defaultValue={initialTitle} {...register('title', { required: { value: true, message: 'Required field' } })}
/> fluid
<Panel.Error>{errors.title?.message}</Panel.Error> defaultValue={trigger?.title}
</label> />
<label> <Panel.Error>{errors.title?.message}</Panel.Error>
Lifecycle trigger </label>
<Select <label>
value={watch('trigger')} Lifecycle trigger
onValueChange={(value) => { <Select
if (value === null) return; value={watch('trigger')}
setValue('trigger', value as TimerLifeCycle, { shouldDirty: true }); onValueChange={(value) => {
}} if (value === null) return;
options={cycles.map((cycle) => ({ value: cycle.value, label: cycle.label }))} setValue('trigger', value as TimerLifeCycle, { shouldDirty: true });
aria-label='Lifecycle trigger' }}
/> options={cycles.map((cycle) => ({ value: cycle.value, label: cycle.label }))}
<Panel.Error>{errors.trigger?.message}</Panel.Error> aria-label='Lifecycle trigger'
</label> />
<label> <Panel.Error>{errors.trigger?.message}</Panel.Error>
Automation title </label>
<Select <label>
value={watch('automationId')} Automation title
onValueChange={(value: string | null) => { <Select
if (value === null) return; value={watch('automationId')}
setValue('automationId', value, { shouldDirty: true }); onValueChange={(value: string | null) => {
}} if (value === null) return;
options={automationSelect} setValue('automationId', value, { shouldDirty: true });
aria-label='Automation title' }}
/> options={automationSelect}
<Panel.Error>{errors.automationId?.message}</Panel.Error> aria-label='Automation title'
</label> />
<Panel.InlineElements align='end'> <Panel.Error>{errors.automationId?.message}</Panel.Error>
<Button disabled={isSubmitting} onClick={onCancel}> </label>
Cancel </form>
</Button> }
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}> footerElements={
Save <>
</Button> {errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
</Panel.InlineElements> <Button disabled={isSubmitting} onClick={onCancel}>
</Panel.Indent> 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 useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { checkDuplicates } from './automationUtils'; import { checkDuplicates } from './automationUtils';
import AutomationForm from './TriggerForm'; import TriggerForm from './TriggerForm';
import TriggersListItem from './TriggersListItem'; import TriggersListItem from './TriggersListItem';
type FormState = {
isOpen: boolean;
trigger?: Trigger;
};
interface TriggersListProps { interface TriggersListProps {
triggers: Trigger[]; triggers: Trigger[];
automations: NormalisedAutomation; automations: NormalisedAutomation;
enabledAutomations?: boolean; enabledAutomations?: boolean;
isLoading: boolean;
} }
export default function TriggersList(props: TriggersListProps) { export default function TriggersList({ triggers, automations, enabledAutomations, isLoading }: TriggersListProps) {
const { triggers, automations, enabledAutomations } = props; const [formState, setFormState] = useState<FormState>({ isOpen: false, trigger: undefined });
const [showForm, setShowForm] = useState(false);
const { refetch } = useAutomationSettings(); const { refetch } = useAutomationSettings();
const [deleteError, setDeleteError] = useState<string | null>(null); 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) => { const handleDelete = async (id: string) => {
try { try {
setDeleteError(null);
await deleteTrigger(id); await deleteTrigger(id);
} catch (error) { } catch (error) {
setDeleteError(maybeAxiosError(error)); setDeleteError(maybeAxiosError(error));
@@ -35,7 +45,7 @@ export default function TriggersList(props: TriggersListProps) {
}; };
const postSubmit = () => { const postSubmit = () => {
setShowForm(false); closeForm();
refetch(); refetch();
}; };
@@ -45,70 +55,86 @@ export default function TriggersList(props: TriggersListProps) {
const canAdd = Object.keys(automations).length > 0; const canAdd = Object.keys(automations).length > 0;
return ( return (
<Panel.Card> <Panel.Section>
<Panel.SubHeader> <Panel.Card>
Manage triggers {formState.isOpen && (
<Button type='submit' form='trigger-form' disabled={!canAdd} loading={false} onClick={() => setShowForm(true)}> <TriggerForm
New <IoAdd /> automations={automations}
</Button> trigger={formState.trigger ?? null}
</Panel.SubHeader> onCancel={closeForm}
<Panel.Divider /> postSubmit={postSubmit}
<Panel.Section> />
{enabledAutomations === false && (
<Info>
Automations are disabled. You can still manage triggers here, but they will not run until enabled.
</Info>
)} )}
{duplicates && ( <Panel.SubHeader>
<Panel.Error> Manage triggers
You have created multiple links between the same trigger and automation which can performance issues. <Button disabled={!canAdd} onClick={openNewForm}>
</Panel.Error> New <IoAdd />
)} </Button>
{showForm && ( </Panel.SubHeader>
<AutomationForm automations={automations} onCancel={() => setShowForm(false)} postSubmit={postSubmit} /> <Panel.Divider />
)} <Panel.Section>
<Panel.Table> {enabledAutomations === false && (
<thead> <Info>
<tr> Automations are disabled. You can still manage triggers here, but they will not run until enabled.
<th style={{ width: '35%' }}>Title</th> </Info>
<th style={{ width: '25%' }}>Lifecycle trigger</th> )}
<th style={{ width: '25%' }}>Automation</th> {duplicates && (
<th /> <Panel.Error>
</tr> You have created multiple links between the same trigger and automation which can cause performance
</thead> issues.
<tbody> </Panel.Error>
{!showForm && triggers.length === 0 && ( )}
<Panel.TableEmpty <Panel.Table>
label='Create a trigger to run an automation' <thead>
handleClick={canAdd ? () => setShowForm(true) : undefined} <tr>
/> <th style={{ width: '35%' }}>Title</th>
)} <th style={{ width: '25%' }}>Lifecycle trigger</th>
{triggers.map((trigger, index) => { <th style={{ width: '25%' }}>Automation</th>
return ( <th />
<Fragment key={trigger.id}> </tr>
<TriggersListItem </thead>
automations={automations} <tbody>
id={trigger.id} {!isLoading && triggers.length === 0 && (
title={trigger.title} <Panel.TableEmpty
trigger={trigger.trigger} title='No triggers yet'
automationId={trigger.automationId} description={
duplicate={duplicates?.includes(index)} canAdd
handleDelete={() => handleDelete(trigger.id)} ? 'Triggers run an automation at a given point of the timer lifecycle, like when an event starts or finishes.'
postSubmit={postSubmit} : 'Create an automation first, then add a trigger to decide when it should run.'
/> }
{deleteError && ( action={
<tr> canAdd && (
<td colSpan={5}> <Button variant='primary' onClick={openNewForm}>
<Panel.Error>{deleteError}</Panel.Error> Create trigger <IoAdd />
</td> </Button>
</tr> )
)} }
</Fragment> />
); )}
})} {triggers.map((trigger, index) => {
</tbody> return (
</Panel.Table> <Fragment key={trigger.id}>
</Panel.Section> <TriggersListItem
</Panel.Card> 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 { NormalisedAutomation, Trigger } from 'ontime-types';
import { useState } from 'react';
import { IoPencil, IoTrash, IoWarningOutline } from 'react-icons/io5'; import { IoPencil, IoTrash, IoWarningOutline } from 'react-icons/io5';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './automationUtils'; import { cycles } from './automationUtils';
import AutomationForm from './TriggerForm';
interface TriggersListItemProps { interface TriggersListItemProps {
automations: NormalisedAutomation; automations: NormalisedAutomation;
id: string; trigger: Trigger;
title: string;
trigger: TimerLifeCycle;
automationId: string;
duplicate?: boolean; duplicate?: boolean;
handleEdit: () => void;
handleDelete: () => void; handleDelete: () => void;
postSubmit: () => void;
} }
export default function TriggersListItem(props: TriggersListItemProps) { export default function TriggersListItem(props: TriggersListItemProps) {
const { automations, id, title, trigger, automationId, duplicate, handleDelete, postSubmit } = props; const { automations, trigger, duplicate, handleEdit, handleDelete } = props;
const [isEditing, setIsEditing] = useState(false);
if (isEditing) {
return (
<tr>
<td colSpan={99}>
<AutomationForm
automations={automations}
initialId={id}
initialTitle={title}
initialTrigger={trigger}
initialAutomationId={automationId}
onCancel={() => setIsEditing(false)}
postSubmit={() => {
setIsEditing(false);
postSubmit();
}}
/>
</td>
</tr>
);
}
return ( return (
<tr data-warn={duplicate}> <tr data-warn={duplicate}>
@@ -52,16 +25,16 @@ export default function TriggersListItem(props: TriggersListItemProps) {
color='#FFBC56' // $orange-500 color='#FFBC56' // $orange-500
/> />
)} )}
{title} {trigger.title}
</Panel.InlineElements> </Panel.InlineElements>
<td> <td>
<Tag>{cycles.find((cycle) => cycle.value === trigger)?.label}</Tag> <Tag>{cycles.find((cycle) => cycle.value === trigger.trigger)?.label}</Tag>
</td> </td>
<td> <td>
<Tag>{automations?.[automationId]?.title}</Tag> <Tag>{automations?.[trigger.automationId]?.title}</Tag>
</td> </td>
<Panel.InlineElements align='end' relation='inner' as='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 /> <IoPencil />
</IconButton> </IconButton>
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={handleDelete}> <IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={handleDelete}>
@@ -66,7 +66,10 @@ export default function ReportSettings() {
</thead> </thead>
<tbody> <tbody>
{combinedReport.length === 0 && ( {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) => { {combinedReport.map((entry) => {
@@ -43,6 +43,7 @@ export default function URLPresets() {
return ( return (
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
{formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />}
<Panel.SubHeader> <Panel.SubHeader>
URL presets URL presets
<Button onClick={openNewForm}> <Button onClick={openNewForm}>
@@ -63,7 +64,6 @@ export default function URLPresets() {
</Panel.Section> </Panel.Section>
<Panel.Section> <Panel.Section>
<Panel.Loader isLoading={status === 'pending'} /> <Panel.Loader isLoading={status === 'pending'} />
{formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />}
{actionError && <Panel.Error>{actionError}</Panel.Error>} {actionError && <Panel.Error>{actionError}</Panel.Error>}
<Panel.Table> <Panel.Table>
<thead> <thead>
@@ -76,7 +76,17 @@ export default function URLPresets() {
</tr> </tr>
</thead> </thead>
<tbody> <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) => { {data.map((preset, index) => {
const isCuesheet = preset.target === OntimeView.Cuesheet; const isCuesheet = preset.target === OntimeView.Cuesheet;
return ( return (
@@ -6,9 +6,9 @@ import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils';
import Button from '../../../../../common/components/buttons/Button'; import Button from '../../../../../common/components/buttons/Button';
import Input from '../../../../../common/components/input/input/Input'; import Input from '../../../../../common/components/input/input/Input';
import Textarea from '../../../../../common/components/input/textarea/Textarea'; import Textarea from '../../../../../common/components/input/textarea/Textarea';
import Modal from '../../../../../common/components/modal/Modal';
import Select, { SelectOption } from '../../../../../common/components/select/Select'; import Select, { SelectOption } from '../../../../../common/components/select/Select';
import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets'; import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets';
import { preventEscape } from '../../../../../common/utils/keyEvent';
import { isUrlSafe } from '../../../../../common/utils/regex'; import { isUrlSafe } from '../../../../../common/utils/regex';
import { enDash } from '../../../../../common/utils/styleUtils'; import { enDash } from '../../../../../common/utils/styleUtils';
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets'; import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
@@ -28,6 +28,8 @@ const targetOptions: SelectOption<OntimeViewPresettable>[] = [
{ value: OntimeView.ProjectInfo, label: 'Project Info' }, { value: OntimeView.ProjectInfo, label: 'Project Info' },
]; ];
const formId = 'url-preset-form';
const defaultValues: URLPreset = { const defaultValues: URLPreset = {
alias: '', alias: '',
target: OntimeView.Timer, target: OntimeView.Timer,
@@ -134,95 +136,101 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
}; };
return ( return (
<Panel.Indent <Modal
as='form' isOpen
onSubmit={handleSubmit(setupSubmit)} onClose={onClose}
onKeyDown={(event) => preventEscape(event, onClose)} showBackdrop
className={style.column} showCloseButton
> size='compact'
<input hidden name='enabled' value='true' /> 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> <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>
<Panel.InlineElements> <Panel.InlineElements>
<Input placeholder='Paste URL' fluid ref={urlRef} disabled={isEditingCuesheet} /> <div>
<Button onClick={generateOptions} disabled={isEditingCuesheet}> <Panel.Description>Alias</Panel.Description>
Generate <Input
</Button> {...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> </Panel.InlineElements>
</div> {errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>}
</Panel.InlineElements> {!isEditingCuesheet && (
{errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>} <>
{!isEditingCuesheet && ( <div>
<> {enDash} or {enDash}
<div> </div>
{enDash} or {enDash} <div>2. Choose a view and its parameters</div>
</div> <div>
<div>2. Choose a view and its parameters</div> <Panel.Description>Target</Panel.Description>
<div> <Select
<Panel.Description>Target</Panel.Description> options={targetOptions}
<Select {...register('target', { required: 'Target is required' })}
options={targetOptions} value={watch('target')}
{...register('target', { required: 'Target is required' })} onValueChange={(value: OntimeViewPresettable | null) => {
value={watch('target')} if (value === null) return;
onValueChange={(value: OntimeViewPresettable | null) => { setValue('target', value, { shouldDirty: true });
if (value === null) return; }}
setValue('target', value, { shouldDirty: true }); />
}} </div>
/> <div>
</div> <Panel.Description>Parameters</Panel.Description>
<div> <Textarea
<Panel.Description>Parameters</Panel.Description> fluid
<Textarea rows={3}
fluid {...register('search', {
rows={3} validate: validateParams,
{...register('search', { })}
validate: validateParams, />
})} <Panel.Error>{errors.search?.message}</Panel.Error>
/> </div>
<Panel.Error>{errors.search?.message}</Panel.Error> </>
</div> )}
</>
)}
{isCuesheet && ( {isCuesheet && (
<div> <div>
<Panel.Description>Permissions</Panel.Description> <Panel.Description>Permissions</Panel.Description>
<CuesheetLinkOptions <CuesheetLinkOptions
initialRead={initialPermissions.current.read} initialRead={initialPermissions.current.read}
initialWrite={initialPermissions.current.write} initialWrite={initialPermissions.current.write}
onChange={setCuesheetPermissions} onChange={setCuesheetPermissions}
/> />
</div> </div>
)} )}
<div> </form>
<Panel.Error>{errors.root?.message}</Panel.Error> }
<Panel.InlineElements align='end'> footerElements={
<>
<Panel.Error>{errors.root?.message}</Panel.Error>
<Button onClick={onClose}>Cancel</Button> <Button onClick={onClose}>Cancel</Button>
<Button <Button
variant='primary' variant='primary'
type='submit' type='submit'
form={formId}
disabled={!isValid || (!isDirty && !permissionsDirty) || noReadAccess} disabled={!isValid || (!isDirty && !permissionsDirty) || noReadAccess}
loading={isSubmitting || isMutating} loading={isSubmitting || isMutating}
> >
Save Save
</Button> </Button>
</Panel.InlineElements> </>
</div> }
</Panel.Indent> />
); );
} }
@@ -13,27 +13,30 @@ import * as Panel from '../../panel-utils/PanelUtils';
import CustomFieldEntry from './composite/CustomFieldEntry'; import CustomFieldEntry from './composite/CustomFieldEntry';
import CustomFieldForm from './composite/CustomFieldForm'; import CustomFieldForm from './composite/CustomFieldForm';
type CustomFieldEntity = CustomField & { key: CustomFieldKey };
type FormState = {
isOpen: boolean;
field?: CustomFieldEntity;
};
export default function CustomFieldSettings() { export default function CustomFieldSettings() {
const { data, refetch } = useCustomFields(); const { data, refetch } = useCustomFields();
const [isAdding, setIsAdding] = useState(false); const [formState, setFormState] = useState<FormState>({ isOpen: false, field: undefined });
const handleInitiateCreate = () => { const openNewForm = () => setFormState({ isOpen: true });
setIsAdding(true); const openEditForm = (field: CustomFieldEntity) => setFormState({ isOpen: true, field });
}; const closeForm = () => setFormState({ isOpen: false, field: undefined });
const handleCancel = () => { const handleSubmit = async (customField: CustomField) => {
setIsAdding(false); const editing = formState.field;
}; if (editing) {
await editCustomField(editing.key, customField);
const handleCreate = async (customField: CustomField) => { } else {
await postCustomField(customField); await postCustomField(customField);
refetch(); }
setIsAdding(false);
};
const handleEditField = async (key: CustomFieldKey, customField: CustomField) => {
await editCustomField(key, customField);
refetch(); refetch();
closeForm();
}; };
const handleDelete = async (key: CustomFieldKey) => { const handleDelete = async (key: CustomFieldKey) => {
@@ -45,12 +48,24 @@ export default function CustomFieldSettings() {
} }
}; };
const entries = Object.entries(data);
return ( return (
<Panel.Section> <Panel.Section>
<Panel.Card> <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> <Panel.SubHeader>
Custom fields Custom fields
<Button onClick={handleInitiateCreate}> <Button onClick={openNewForm}>
New <IoAdd /> New <IoAdd />
</Button> </Button>
</Panel.SubHeader> </Panel.SubHeader>
@@ -66,7 +81,6 @@ export default function CustomFieldSettings() {
</Info> </Info>
</Panel.Section> </Panel.Section>
<Panel.Section> <Panel.Section>
{isAdding && <CustomFieldForm onSubmit={handleCreate} onCancel={handleCancel} />}
<Panel.Table> <Panel.Table>
<thead> <thead>
<tr> <tr>
@@ -78,7 +92,18 @@ export default function CustomFieldSettings() {
</tr> </tr>
</thead> </thead>
<tbody> <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 ( return (
<CustomFieldEntry <CustomFieldEntry
key={key} key={key}
@@ -86,7 +111,7 @@ export default function CustomFieldSettings() {
colour={colour} colour={colour}
label={label} label={label}
type={type} type={type}
onEdit={handleEditField} onEdit={() => openEditForm({ key, colour, label, type })}
onDelete={handleDelete} onDelete={handleDelete}
/> />
); );
@@ -5,11 +5,14 @@ import { uploadCustomView } from '../../../../common/api/customViews';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import Modal from '../../../../common/components/modal/Modal';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { getFileError, getSlugError, getViewUrl, maxUploadLabel } from './customViews.utils'; import { getFileError, getSlugError, getViewUrl, maxUploadLabel } from './customViews.utils';
import style from './CustomViews.module.scss'; import style from './CustomViews.module.scss';
const formId = 'custom-view-form';
interface CustomViewFormProps { interface CustomViewFormProps {
onComplete: () => void; onComplete: () => void;
onClose: () => void; onClose: () => void;
@@ -53,62 +56,72 @@ export default function CustomViewForm({ onComplete, onClose }: CustomViewFormPr
}; };
return ( return (
<Panel.Indent as='form' onSubmit={handleUpload} className={style.uploadForm}> <Modal
<input isOpen
ref={fileInputRef} onClose={onClose}
style={{ display: 'none' }} showBackdrop
type='file' showCloseButton
onChange={handleSelectFile} size='compact'
accept='.html,text/html' 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.step}>
<div className={style.stepTitle}>1. Choose a name</div> <div className={style.stepTitle}>1. Choose a name</div>
<Panel.Description>Name</Panel.Description> <Panel.Description>Name</Panel.Description>
<Input <Input
value={slug} value={slug}
onChange={(event) => { onChange={(event) => {
setSlug(event.target.value); setSlug(event.target.value);
setSlugDirty(true); setSlugDirty(true);
}} }}
placeholder='my-view' placeholder='my-view'
aria-label='Custom view name' aria-label='Custom view name'
autoCapitalize='off' autoCapitalize='off'
autoComplete='off' autoComplete='off'
fluid fluid
/> />
<Panel.Description> <Panel.Description>
Use lowercase letters, numbers, and dashes. Example: <Panel.Highlight>my-view</Panel.Highlight> Use lowercase letters, numbers, and dashes. Example: <Panel.Highlight>my-view</Panel.Highlight>
</Panel.Description> </Panel.Description>
<Panel.Description> <Panel.Description>
Preview URL: <Panel.Highlight>{previewUrl}</Panel.Highlight> Preview URL: <Panel.Highlight>{previewUrl}</Panel.Highlight>
</Panel.Description> </Panel.Description>
{slugDirty && slugError && <Panel.Error>{slugError}</Panel.Error>} {slugDirty && slugError && <Panel.Error>{slugError}</Panel.Error>}
</div> </div>
<div className={style.step}> <div className={style.step}>
<div className={style.stepTitle}>2. Select index.html</div> <div className={style.stepTitle}>2. Select index.html</div>
<Panel.Description>Upload file</Panel.Description> <Panel.Description>Upload file</Panel.Description>
<Panel.InlineElements wrap='wrap' className={style.filePicker}> <Panel.InlineElements wrap='wrap' className={style.filePicker}>
<Button onClick={() => fileInputRef.current?.click()}> <Button onClick={() => fileInputRef.current?.click()}>
{selectedFile ? 'Replace index.html' : 'Choose index.html'} {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> </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 ( return (
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
{isUploadOpen && <CustomViewForm onComplete={handleUploadComplete} onClose={() => setIsUploadOpen(false)} />}
<Panel.SubHeader> <Panel.SubHeader>
Custom views Custom views
<Panel.InlineElements> <Panel.InlineElements>
<Button variant='ghosted' onClick={handleRestoreDemo} disabled={hasDemoView}> <Button variant='ghosted' onClick={handleRestoreDemo} disabled={hasDemoView}>
Restore demo Restore demo
</Button> </Button>
<Button onClick={() => setIsUploadOpen(true)} disabled={isUploadOpen}> <Button onClick={() => setIsUploadOpen(true)}>
New <IoAdd /> New <IoAdd />
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
@@ -63,8 +64,6 @@ export default function CustomViews() {
<Panel.Section> <Panel.Section>
<Panel.Loader isLoading={status === 'pending'} /> <Panel.Loader isLoading={status === 'pending'} />
{isUploadOpen && <CustomViewForm onComplete={handleUploadComplete} onClose={() => setIsUploadOpen(false)} />}
{actionError && <Panel.Error>{actionError}</Panel.Error>} {actionError && <Panel.Error>{actionError}</Panel.Error>}
<CustomViewsList <CustomViewsList
@@ -1,5 +1,6 @@
import { CustomViewSummary } from 'ontime-types'; import { CustomViewSummary } from 'ontime-types';
import { useState } from 'react'; import { useState } from 'react';
import { IoCloudUploadOutline } from 'react-icons/io5';
import { deleteCustomView } from '../../../../common/api/customViews'; import { deleteCustomView } from '../../../../common/api/customViews';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
@@ -53,7 +54,17 @@ export default function CustomViewsList({ views, onOpenUpload, onMutate, onError
</tr> </tr>
</thead> </thead>
<tbody> <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) => ( {views.map((view, index) => (
<CustomViewsListItem <CustomViewsListItem
key={view.slug} key={view.slug}
@@ -12,6 +12,13 @@
gap: 1rem; gap: 1rem;
} }
.current { .formColumn {
background-color: $blue-1100 !important; // fighting zebra styles 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 Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import Modal from '../../../../common/components/modal/Modal';
import { useMutateProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns'; import { useMutateProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
const formId = 'new-rundown-form';
type NewRundownFormState = { type NewRundownFormState = {
title: string; title: string;
}; };
@@ -42,22 +45,32 @@ export function ManageRundownForm({ onClose }: ManageRundownForm) {
}, [setFocus]); }, [setFocus]);
return ( return (
<Panel.Indent as='form' onSubmit={handleSubmit(createRundown)}> <Modal
<Panel.Section> isOpen
<label> onClose={onClose}
<Panel.Description>Rundown title</Panel.Description> showBackdrop
<Input {...register('title')} fluid placeholder='Your rundown name' /> showCloseButton
</label> size='small'
</Panel.Section> title='Create rundown'
<Panel.InlineElements relation='inner' align='end'> bodyElements={
<Button variant='ghosted' disabled={isSubmitting} onClick={onClose}> <form id={formId} onSubmit={handleSubmit(createRundown)}>
Cancel <label>
</Button> <Panel.Description>Rundown title</Panel.Description>
<Button type='submit' variant='primary' disabled={isSubmitting}> <Input {...register('title')} fluid placeholder='Your rundown name' maxLength={64} />
Create rundown </label>
</Button> </form>
</Panel.InlineElements> }
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>} footerElements={
</Panel.Indent> <>
{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])}> <tr key={id} className={cx([isLoaded && style.current])}>
<td>{numEntries}</td> <td>{numEntries}</td>
<td> <td>
{title} {isLoaded && <Tag>Loaded</Tag>} {title} {isLoaded && <Tag variant='active'>Loaded</Tag>}
</td> </td>
<td> <td>
<DropdownMenu <DropdownMenu
@@ -1,5 +1,4 @@
import { CustomField, CustomFieldKey } from 'ontime-types'; import { CustomField, CustomFieldKey } from 'ontime-types';
import { useState } from 'react';
import { IoPencil, IoTrash } from 'react-icons/io5'; import { IoPencil, IoTrash } from 'react-icons/io5';
import IconButton from '../../../../../common/components/buttons/IconButton'; 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 Swatch from '../../../../../common/components/input/colour-input/Swatch';
import Tag from '../../../../../common/components/tag/Tag'; import Tag from '../../../../../common/components/tag/Tag';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import CustomFieldForm from './CustomFieldForm';
import style from '../ManagePanel.module.scss'; import style from '../ManagePanel.module.scss';
@@ -16,35 +14,12 @@ interface CustomFieldEntryProps {
label: string; label: string;
fieldKey: string; fieldKey: string;
type: CustomField['type']; type: CustomField['type'];
onEdit: (key: CustomFieldKey, patch: CustomField) => Promise<void>; onEdit: () => void;
onDelete: (key: CustomFieldKey) => Promise<void>; onDelete: (key: CustomFieldKey) => Promise<void>;
} }
export default function CustomFieldEntry(props: CustomFieldEntryProps) { export default function CustomFieldEntry(props: CustomFieldEntryProps) {
const { colour, label, fieldKey, type, onEdit, onDelete } = props; 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 ( return (
<tr> <tr>
@@ -61,7 +36,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
</CopyTag> </CopyTag>
</td> </td>
<Panel.InlineElements relation='inner' as='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 /> <IoPencil />
</IconButton> </IconButton>
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={() => onDelete(fieldKey)}> <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 Info from '../../../../../common/components/info/Info';
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect'; import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
import Input from '../../../../../common/components/input/input/Input'; import Input from '../../../../../common/components/input/input/Input';
import Modal from '../../../../../common/components/modal/Modal';
import RadioGroup from '../../../../../common/components/radio-group/RadioGroup'; import RadioGroup from '../../../../../common/components/radio-group/RadioGroup';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { preventEscape } from '../../../../../common/utils/keyEvent';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import style from '../ManagePanel.module.scss'; import style from '../ManagePanel.module.scss';
const formId = 'custom-field-form';
interface CustomFieldsFormProps { interface CustomFieldsFormProps {
onSubmit: (field: CustomField) => Promise<void>; onSubmit: (field: CustomField) => Promise<void>;
onCancel: () => void; onCancel: () => void;
@@ -85,65 +87,77 @@ export default function CustomFieldForm({
const isEditMode = initialKey !== undefined; const isEditMode = initialKey !== undefined;
return ( return (
<Panel.Indent as='form' onSubmit={handleSubmit(setupSubmit)} onKeyDown={(event) => preventEscape(event, onCancel)}> <Modal
<Info> isOpen
Please note that images can quickly deteriorate your app&apos;s performance. onClose={onCancel}
<br /> showBackdrop
Prefer using small, and compressed images. showCloseButton
</Info> size='compact'
<div> title={isEditMode ? 'Edit custom field' : 'Create custom field'}
<Panel.Description>Type</Panel.Description> bodyElements={
<RadioGroup <form id={formId} onSubmit={handleSubmit(setupSubmit)} className={style.formColumn}>
orientation='horizontal' <Info>
disabled={isEditMode} Please note that images can quickly deteriorate your app&apos;s performance.
onValueChange={(value) => setValue('type', value, { shouldDirty: true })} <br />
value={watch('type')} Prefer using small, and compressed images.
items={[ </Info>
{ value: 'text', label: 'Text' }, <div>
{ value: 'image', label: 'Image' }, <Panel.Description>Type</Panel.Description>
]} <RadioGroup
/> orientation='horizontal'
</div> disabled={isEditMode}
<div className={style.twoCols}> onValueChange={(value) => setValue('type', value, { shouldDirty: true })}
<label> value={watch('type')}
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description> items={[
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>} { value: 'text', label: 'Text' },
<Input { value: 'image', label: 'Image' },
{...register('label', { ]}
required: { value: true, message: 'Required field' }, />
onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'), </div>
validate: (value) => { <div className={style.twoCols}>
if (value.trim().length === 0) return 'Required field'; <label>
if (!checkRegex.isAlphanumericWithSpace(value)) <Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
return 'Only alphanumeric characters and space are allowed'; {errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
if (!isEditMode) { <Input
if (isEditMode && Object.keys(data).includes(value)) return 'Custom fields must be unique'; {...register('label', {
} required: { value: true, message: 'Required field' },
return true; onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'),
}, validate: (value) => {
})} if (value.trim().length === 0) return 'Required field';
fluid if (!checkRegex.isAlphanumericWithSpace(value))
/> return 'Only alphanumeric characters and space are allowed';
</label> if (!isEditMode && Object.hasOwn(data, customFieldLabelToKey(value) ?? '')) {
return 'Custom fields must be unique';
}
return true;
},
})}
fluid
/>
</label>
<label> <label>
<Panel.Description>Key (use in Integrations and API)</Panel.Description> <Panel.Description>Key (use in Integrations and API)</Panel.Description>
<Input {...register('key')} variant='ghosted' readOnly fluid /> <Input {...register('key')} variant='ghosted' readOnly fluid />
</label> </label>
</div> </div>
<label> <label>
<Panel.Description>Colour</Panel.Description> <Panel.Description>Colour</Panel.Description>
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} /> <SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
</label> </label>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>} </form>
<Panel.InlineElements relation='inner' align='end'> }
<Button variant='ghosted' onClick={onCancel}> footerElements={
Cancel <>
</Button> {errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}> <Button variant='ghosted' onClick={onCancel}>
Save Cancel
</Button> </Button>
</Panel.InlineElements> <Button type='submit' form={formId} variant='primary' disabled={!canSubmit} loading={isSubmitting}>
</Panel.Indent> Save
</Button>
</>
}
/>
); );
} }
@@ -59,6 +59,7 @@ export default function RundownRenameForm({ onSubmit, onCancel, initialTitle }:
return true; return true;
}, },
})} })}
maxLength={64}
fluid fluid
/> />
{errors.title && <Panel.Error>{errors.title.message}</Panel.Error>} {errors.title && <Panel.Error>{errors.title.message}</Panel.Error>}
@@ -1,30 +1,3 @@
.emptyState {
padding: 3rem 1.5rem;
text-align: center;
}
.emptyMessage {
width: min(30rem, 100%);
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 {
margin: 1rem auto 0;
}
.table { .table {
width: 100%; width: 100%;
border-collapse: separate; border-collapse: separate;
@@ -6,6 +6,7 @@ import { useMemo } from 'react';
import Button from '../../../../../../../common/components/buttons/Button'; import Button from '../../../../../../../common/components/buttons/Button';
import Tag from '../../../../../../../common/components/tag/Tag'; import Tag from '../../../../../../../common/components/tag/Tag';
import { getRundownMetadata } from '../../../../../../../common/utils/rundownMetadata'; import { getRundownMetadata } from '../../../../../../../common/utils/rundownMetadata';
import * as Panel from '../../../../../panel-utils/PanelUtils';
import { getCellValue } from './previewTableUtils'; import { getCellValue } from './previewTableUtils';
import style from './PreviewTable.module.scss'; import style from './PreviewTable.module.scss';
@@ -123,23 +124,17 @@ export default function PreviewTable({
} }
return ( return (
<div className={style.emptyState}> <Panel.EmptyState
<div className={style.emptyMessage}> title={emptyTitle}
<div className={style.emptyTitle}>{emptyTitle}</div> description={emptyContent}
<div className={style.emptyBody}>{emptyContent}</div> action={
{needsPreviewRefresh && ( needsPreviewRefresh && (
<Button <Button variant='primary' onClick={onRefresh} disabled={!canRefresh} loading={isRefreshing}>
className={style.emptyAction}
variant='primary'
onClick={onRefresh}
disabled={!canRefresh}
loading={isRefreshing}
>
Refresh preview Refresh preview
</Button> </Button>
)} )
</div> }
</div> />
); );
} }
@@ -16,6 +16,22 @@
user-select: text; user-select: text;
} }
.self { .actionsCell {
background-color: $blue-1100; 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> <tr>
<td style={{ width: '20%' }}>Client Name</td> <td style={{ width: '20%' }}>Client Name</td>
<td>Path</td> <td>Path</td>
<td /> <td className={style.actionsHeader} />
</tr> </tr>
</thead> </thead>
<tbody> <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]) => { {ontimeClients.map(([key, client]) => {
const { identify, name, path } = client; const { identify, name, path } = client;
const isCurrent = id === key; const isCurrent = id === key;
return ( return (
<tr key={key} className={cx([isCurrent && style.self])}> <tr key={key} className={cx([isCurrent && style.current])}>
<Panel.InlineElements relation='inner' as='td'> <Panel.InlineElements relation='inner' as='td'>
{isCurrent && <Tag>SELF</Tag>} {isCurrent && <Tag variant='active'>SELF</Tag>}
{name} {name}
</Panel.InlineElements> </Panel.InlineElements>
<td className={style.copiable}>{path}</td> <td className={style.copiable}>{path}</td>
<Panel.InlineElements relation='inner' as='td'> <td className={style.actionsCell}>
<Button <Panel.InlineElements relation='inner' align='end' className={style.actionsGroup}>
size='small' <Button
className={`${identify ? style.blink : ''}`} size='small'
disabled={isCurrent} className={`${identify ? style.blink : ''}`}
variant={identify ? 'primary' : 'subtle'} disabled={isCurrent}
data-testid={isCurrent ? '' : 'not-self-identify'} variant={identify ? 'primary' : 'subtle'}
onClick={() => { data-testid={isCurrent ? '' : 'not-self-identify'}
setIdentify({ target: key, identify: !identify }); onClick={() => {
}} setIdentify({ target: key, identify: !identify });
> }}
Identify >
</Button> Identify
<Button </Button>
size='small' <Button
data-testid={isCurrent ? '' : 'not-self-rename'} size='small'
onClick={() => openRename(key)} data-testid={isCurrent ? '' : 'not-self-rename'}
> onClick={() => openRename(key)}
Rename >
</Button> Rename
</Button>
<Button <Button
size='small' size='small'
disabled={isCurrent} disabled={isCurrent}
data-testid={isCurrent ? '' : 'not-self-redirect'} data-testid={isCurrent ? '' : 'not-self-redirect'}
onClick={() => openRedirect(key)} onClick={() => openRedirect(key)}
> >
Redirect Redirect
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
</td>
</tr> </tr>
); );
})} })}
@@ -125,6 +133,12 @@ export default function ClientList() {
</tr> </tr>
</thead> </thead>
<tbody> <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]) => { {otherClients.map(([key, client]) => {
const { name, type } = client; const { name, type } = client;
@@ -1,29 +1,21 @@
import { ChangeEvent, useRef, useState } from 'react'; import { ChangeEvent, useRef, useState } from 'react';
import { IoAdd } from 'react-icons/io5'; import { IoAdd } from 'react-icons/io5';
import { useSearchParams } from 'react-router';
import { uploadProjectFile } from '../../../../common/api/db'; import { uploadProjectFile } from '../../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils'; import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import { validateProjectFile } from '../../../../common/utils/uploadUtils'; import { validateProjectFile } from '../../../../common/utils/uploadUtils';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import ProjectCreateForm from './ProjectCreateForm'; import useAppSettingsNavigation from '../../useAppSettingsNavigation';
import ProjectList from './ProjectList'; import ProjectList from './ProjectList';
export default function ManageProjects() { export default function ManageProjects() {
const [searchParams, setSearchParams] = useSearchParams(); const { setLocation } = useAppSettingsNavigation();
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState<'import' | null>(null); const [loading, setLoading] = useState<'import' | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const isCreatingProject = searchParams.get('new') === 'true';
const handleToggleCreate = () => {
searchParams.set('new', isCreatingProject ? 'false' : 'true');
setSearchParams(searchParams);
};
const handleSelectFile = () => { const handleSelectFile = () => {
fileInputRef.current?.click(); fileInputRef.current?.click();
}; };
@@ -49,11 +41,6 @@ export default function ManageProjects() {
setLoading(null); setLoading(null);
}; };
const handleCloseForm = () => {
searchParams.delete('new');
setSearchParams(searchParams);
};
return ( return (
<Panel.Section> <Panel.Section>
<input <input
@@ -68,21 +55,16 @@ export default function ManageProjects() {
<Panel.SubHeader> <Panel.SubHeader>
Manage projects Manage projects
<Panel.InlineElements> <Panel.InlineElements>
<Button <Button onClick={handleSelectFile} disabled={Boolean(loading)} loading={loading === 'import'}>
onClick={handleSelectFile}
disabled={Boolean(loading) || isCreatingProject}
loading={loading === 'import'}
>
Import Import
</Button> </Button>
<Button onClick={handleToggleCreate} disabled={Boolean(loading) || isCreatingProject}> <Button onClick={() => setLocation('project__create')} disabled={Boolean(loading)}>
New <IoAdd /> New <IoAdd />
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
</Panel.SubHeader> </Panel.SubHeader>
{error && <Panel.Error>{error}</Panel.Error>} {error && <Panel.Error>{error}</Panel.Error>}
<Panel.Divider /> <Panel.Divider />
{isCreatingProject && <ProjectCreateForm onClose={handleCloseForm} />}
<ProjectList /> <ProjectList />
</Panel.Card> </Panel.Card>
</Panel.Section> </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') { if (status === 'pending') {
return ( return (
<div className={style.empty}> <div className={style.loaderBox}>
<Panel.Loader isLoading /> <Panel.Loader isLoading />
</div> </div>
); );
@@ -74,6 +74,12 @@ export default function ProjectList() {
</tr> </tr>
</thead> </thead>
<tbody> <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) => ( {data.reorderedProjectFiles.map((project) => (
<ProjectListItem <ProjectListItem
key={project.filename} key={project.filename}
@@ -20,6 +20,7 @@ import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import Dialog from '../../../../common/components/dialog/Dialog'; import Dialog from '../../../../common/components/dialog/Dialog';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu'; import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import Tag from '../../../../common/components/tag/Tag';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import ProjectForm, { ProjectFormValues } from './ProjectForm'; import ProjectForm, { ProjectFormValues } from './ProjectForm';
@@ -143,7 +144,7 @@ export default function ProjectListItem({
) : ( ) : (
<> <>
<td className={style.containCell}>{filename}</td> <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> <td>
<ActionMenu <ActionMenu
current={current} current={current}
@@ -158,13 +159,7 @@ export default function ProjectListItem({
</> </>
)} )}
</tr> </tr>
{showMergeForm && ( {showMergeForm && <ProjectMergeForm onClose={handleCancel} fileName={filename} />}
<tr>
<td colSpan={99}>
<ProjectMergeForm onClose={handleCancel} fileName={filename} />
</td>
</tr>
)}
<Dialog <Dialog
isOpen={isDeleteOpen} isOpen={isDeleteOpen}
onClose={() => setDeleteOpen(false)} onClose={() => setDeleteOpen(false)}
@@ -7,13 +7,15 @@ import { getDb, patchData } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
import Modal from '../../../../common/components/modal/Modal';
import Switch from '../../../../common/components/switch/Switch'; import Switch from '../../../../common/components/switch/Switch';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { makeProjectPatch } from './project.utils'; import { makeProjectPatch } from './project.utils';
import style from './ProjectPanel.module.scss'; import style from './ProjectPanel.module.scss';
const formId = 'project-merge-form';
interface ProjectMergeFromProps { interface ProjectMergeFromProps {
onClose: () => void; onClose: () => void;
fileName: string; fileName: string;
@@ -77,68 +79,122 @@ export default function ProjectMergeForm({ onClose, fileName }: ProjectMergeFrom
}; };
return ( return (
<Panel.Section as='form' onSubmit={handleSubmit(handleSubmitCreate)}> <Modal
<Panel.Title> isOpen
Partial project merge onClose={onClose}
<Panel.InlineElements> showBackdrop
<Button onClick={onClose} variant='ghosted' disabled={isSubmitting}> showCloseButton
Cancel size='compact'
</Button> title='Partial project merge'
<Button type='submit' disabled={!isValid || !isDirty} loading={isSubmitting} variant='primary'> bodyElements={
Merge <form id={formId} onSubmit={handleSubmit(handleSubmitCreate)}>
</Button> <Panel.Section className={style.mergeBody}>
</Panel.InlineElements> <div className={style.mergeIntro}>
</Panel.Title> <Panel.Description>
{error && <Panel.Error>{error}</Panel.Error>} Select the sections from <span className={style.sourceFile}>{`"${fileName}"`}</span> that you want to
<Panel.Section className={cx([style.innerColumn, style.inlineLabels])}> merge into the current project.
<Panel.Description> </Panel.Description>
Select data from <i>{`"${fileName}"`}</i> to merge into the current project. <div className={style.mergeSummary}>
</Panel.Description> Only the enabled sections are copied. Existing data stays intact.
<Info type='warning'> </div>
This process is irreversible and can result in data loss. <br /> </div>
You may want to create a duplicate backup beforehand.
</Info> <Info type='warning' className={style.mergeWarningInfo}>
<label> <Info.Title>Back up before merging</Info.Title>
<Switch <Info.Body>
size='large' This action can overwrite project data. Create a duplicate project first if you want a recovery point.
checked={watch('project')} </Info.Body>
onCheckedChange={(value: boolean) => setValue('project', value, { shouldDirty: true })} </Info>
/>
Project data <Panel.ListGroup className={style.optionList}>
</label> <Panel.ListItem>
<label> <label className={style.optionRow}>
<Switch <div className={style.optionCopy}>
size='large' <span className={style.optionTitle}>Project data</span>
checked={watch('rundowns')} <span className={style.optionBody}>Core project metadata and settings.</span>
onCheckedChange={(value: boolean) => setValue('rundowns', value, { shouldDirty: true })} </div>
/> <Switch
Rundown + Custom Fields size='large'
</label> checked={watch('project')}
<label> onCheckedChange={(value: boolean) => setValue('project', value, { shouldDirty: true })}
<Switch />
size='large' </label>
checked={watch('viewSettings')} </Panel.ListItem>
onCheckedChange={(value: boolean) => setValue('viewSettings', value, { shouldDirty: true })} <Panel.ListItem>
/> <label className={style.optionRow}>
View Settings <div className={style.optionCopy}>
</label> <span className={style.optionTitle}>Rundown + Custom Fields</span>
<label> <span className={style.optionBody}>Copies all rundowns and any associated custom fields.</span>
<Switch </div>
size='large' <Switch
checked={watch('urlPresets')} size='large'
onCheckedChange={(value: boolean) => setValue('urlPresets', value, { shouldDirty: true })} checked={watch('rundowns')}
/> onCheckedChange={(value: boolean) => setValue('rundowns', value, { shouldDirty: true })}
URL Presets />
</label> </label>
<label> </Panel.ListItem>
<Switch <Panel.ListItem>
size='large' <label className={style.optionRow}>
checked={watch('automation')} <div className={style.optionCopy}>
onCheckedChange={(value: boolean) => setValue('automation', value, { shouldDirty: true })} <span className={style.optionTitle}>View Settings</span>
/> <span className={style.optionBody}>View configuration, and display preferences.</span>
Automation Settings </div>
</label> <Switch
</Panel.Section> size='large'
</Panel.Section> 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 { .current {
background-color: $blue-1100; // marker consumed by the shared Panel.Table active-row treatment
background-color: inherit;
} }
.isEditing { .isEditing {
@@ -55,7 +56,90 @@
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.empty { .loaderBox {
height: 300px; height: 300px;
position: relative; 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 { 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 { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoDownloadOutline, IoTrash } from 'react-icons/io5'; 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'; import style from './SettingsPanel.module.scss';
type PendingLogo = {
file: File;
previewUrl: string;
uploadedFilename?: string;
};
export default function ProjectData() { export default function ProjectData() {
const { data, status } = useProjectData(); const { data, status } = useProjectData();
const { updateProjectData } = useUpdateProjectData(); const { updateProjectData } = useUpdateProjectData();
const [pendingLogo, setPendingLogo] = useState<PendingLogo | null>(null);
const { const {
handleSubmit, handleSubmit,
@@ -45,13 +52,22 @@ export default function ProjectData() {
}); });
// reset form values if data changes // reset form values if data changes
// Release the browser-managed preview URL when the selection changes or this form unmounts.
useEffect(() => { useEffect(() => {
if (data) { if (data) {
reset(data); reset(data);
} }
}, [data, reset]); }, [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]; const file = event.target.files?.[0];
clearErrors('logo'); clearErrors('logo');
@@ -61,14 +77,16 @@ export default function ProjectData() {
try { try {
validateLogo(file); validateLogo(file);
const response = await uploadProjectLogo(file); setPendingLogo({
file,
setValue('logo', response.data.logoFilename, { previewUrl: URL.createObjectURL(file),
shouldDirty: true,
}); });
} catch (error) { } catch (error) {
const message = maybeAxiosError(error); const message = maybeAxiosError(error);
setError('logo', { message }); 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>) => { const handleDeleteLogo = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setPendingLogo(null);
setValue('logo', null, { setValue('logo', null, {
shouldDirty: true, shouldDirty: true,
}); });
@@ -92,10 +111,29 @@ export default function ProjectData() {
append({ title: '', value: '', url: '' }); 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) => { const onSubmit = async (formData: ProjectData) => {
try { try {
clearErrors(); clearErrors();
await updateProjectData(formData); const logo = (await uploadPendingLogo()) ?? formData.logo;
await updateProjectData({ ...formData, logo });
setPendingLogo(null);
} catch (error) { } catch (error) {
const message = maybeAxiosError(error); const message = maybeAxiosError(error);
setError('root', { message }); setError('root', { message });
@@ -104,10 +142,12 @@ export default function ProjectData() {
// reset data to the initial state // reset data to the initial state
const onReset = () => { const onReset = () => {
setPendingLogo(null);
reset(data); reset(data);
}; };
const isLoading = status === 'pending'; const isLoading = status === 'pending';
const logo = watch('logo');
return ( return (
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} onKeyDown={(event) => preventEscape(event, onReset)}> <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}> <Button onClick={onReset} disabled={isSubmitting || !isDirty}>
Revert to saved Revert to saved
</Button> </Button>
<Button variant='primary' type='submit' disabled={!isDirty || !isValid} loading={isSubmitting}> <Button
variant='primary'
type='submit'
disabled={(!isDirty && !pendingLogo) || !isValid}
loading={isSubmitting}
>
Save Save
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
@@ -150,17 +195,24 @@ export default function ProjectData() {
onChange={handleUploadProjectLogo} onChange={handleUploadProjectLogo}
/> />
<Panel.Card className={style.uploadLogoCard}> <Panel.Card className={style.uploadLogoCard}>
{watch('logo') ? ( {logo || pendingLogo ? (
<> <>
<img src={`${projectLogoPath}/${watch('logo')}`} /> <img src={pendingLogo ? pendingLogo.previewUrl : `${projectLogoPath}/${logo}`} />
<Button <div className={style.logoActions}>
variant='subtle-destructive' <Button disabled={isSubmitting} onClick={handleClickUpload} type='button'>
disabled={isSubmitting || !watch('logo')} <IoDownloadOutline />
onClick={handleDeleteLogo} Replace logo
> </Button>
<IoTrash /> <Button
Delete variant='subtle-destructive'
</Button> disabled={isSubmitting || (!logo && !pendingLogo)}
onClick={handleDeleteLogo}
type='button'
>
<IoTrash />
Delete
</Button>
</div>
</> </>
) : ( ) : (
<Button disabled={isSubmitting} onClick={handleClickUpload} type='button'> <Button disabled={isSubmitting} onClick={handleClickUpload} type='button'>
@@ -85,10 +85,10 @@ export default function ServerPortSettings() {
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
</Panel.SubHeader> </Panel.SubHeader>
<Panel.Loader isLoading={status === 'pending'} />
{rootError && <Panel.Error>{rootError}</Panel.Error>} {rootError && <Panel.Error>{rootError}</Panel.Error>}
<Panel.Divider /> <Panel.Divider />
<Panel.Section> <Panel.Section>
<Panel.Loader isLoading={status === 'pending'} />
{data.pendingRestart && ( {data.pendingRestart && (
<Info type='warning'>A port change is pending and will happen on the next restart.</Info> <Info type='warning'>A port change is pending and will happen on the next restart.</Info>
)} )}
@@ -15,6 +15,11 @@
} }
} }
.logoActions {
display: flex;
gap: 0.5rem;
}
.customDataItem { .customDataItem {
width: 100%; width: 100%;
display: flex; display: flex;
@@ -90,16 +90,13 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
<div className={style.footer}> <div className={style.footer}>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>} {errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.InlineElements align='apart'> <Panel.InlineElements align='apart'>
<Button variant='ghosted' size='large' onClick={resetToEnglish} disabled={isSubmitting}> <Button variant='ghosted' onClick={resetToEnglish} disabled={isSubmitting}>
Reset to English Reset to English
</Button> </Button>
<Panel.InlineElements> <Panel.InlineElements>
<Button size='large' onClick={onClose}> <Button onClick={onClose}>Cancel</Button>
Cancel
</Button>
<Button <Button
variant='primary' variant='primary'
size='large'
type='submit' type='submit'
form='custom-translations-form' form='custom-translations-form'
disabled={isSubmitting || !isDirty || !isValid} 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> <Info>Invalid CSS will be refused by the browser</Info>
{error && <Panel.Error className={style.right}>{`Error: ${error}`}</Panel.Error>} {error && <Panel.Error className={style.right}>{`Error: ${error}`}</Panel.Error>}
<Panel.InlineElements align='apart' className={style.editorActions}> <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 Reset to example
</Button> </Button>
<Panel.InlineElements> <Panel.InlineElements>
<Button variant='ghosted' size='large' onClick={clear}> <Button variant='ghosted' onClick={clear}>
Clear Clear
</Button> </Button>
<Button size='large' onClick={onClose}> <Button onClick={onClose}>Cancel</Button>
Cancel
</Button>
<Button <Button
variant='primary' variant='primary'
size='large'
onClick={handleSave} onClick={handleSave}
disabled={saveLoading || resetLoading || !isDirty} disabled={saveLoading || resetLoading || !isDirty}
loading={saveLoading} loading={saveLoading}
@@ -3,6 +3,7 @@ import { useRef } from 'react';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import Dialog from '../../../../common/components/dialog/Dialog'; import Dialog from '../../../../common/components/dialog/Dialog';
import Info from '../../../../common/components/info/Info';
import { useElectronEvent } from '../../../../common/hooks/useElectronEvent'; import { useElectronEvent } from '../../../../common/hooks/useElectronEvent';
import { isLocalhost, isOntimeCloud } from '../../../../externals'; import { isLocalhost, isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -23,44 +24,50 @@ export default function ShutdownPanel() {
<> <>
<Panel.Header>Shutdown Ontime</Panel.Header> <Panel.Header>Shutdown Ontime</Panel.Header>
<Panel.Section> <Panel.Section>
{isOntimeCloud ? ( <Panel.Card>
<Panel.Highlight> <Panel.SubHeader>Shutdown</Panel.SubHeader>
For security reasons, shutting down the server must be done from the Ontime Cloud dashboard. <Panel.Divider />
</Panel.Highlight> {isOntimeCloud ? (
) : ( <Info type='warning'>
<Panel.Paragraph> For security reasons, shutting down the server must be done from the Ontime Cloud dashboard.
This will shutdown the Ontime server. <br /> </Info>
The runtime state will be lost, but your project is kept for next time. ) : (
</Panel.Paragraph> <Panel.Field
)} title='Stop the Ontime server'
<Button variant='destructive' onClick={handler.open} disabled={!(isElectron || isLocalhost)}> description='The runtime state will be lost, but your project is kept for next time.'
Shutdown ontime />
</Button> )}
{!canShutdown && ( {!isOntimeCloud && (
<Panel.Description>Note: Ontime can only be shutdown from the machine it is running in.</Panel.Description> <Panel.Section>
)} <Button variant='destructive' onClick={handler.open} disabled={!canShutdown}>
<Dialog Shutdown ontime
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>
<Button variant='destructive' onClick={sendShutdown} disabled={!canShutdown}> {!canShutdown && <Panel.Description>Only available from the machine running Ontime.</Panel.Description>}
Shutdown </Panel.Section>
</Button> )}
</> </Panel.Card>
}
/>
</Panel.Section> </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 useAppVersion from '../../common/hooks-query/useAppVersion';
import { isDocker } from '../../externals'; import { isDocker } from '../../externals';
export type SettingsOption = { type SettingsOptionBase = {
id: string; id: string;
label: string; label: string;
secondary?: Readonly<SettingsOption[]>;
highlight?: string; highlight?: string;
}; };
export type SettingsOption =
| (SettingsOptionBase & { secondary: Readonly<SettingsOption[]>; keywords?: never })
| (SettingsOptionBase & { secondary?: never; keywords: Readonly<string[]> });
const staticOptions = [ const staticOptions = [
{ {
id: 'settings', id: 'settings',
label: 'Settings', label: 'Settings',
secondary: [ secondary: [
{ id: 'settings__data', label: 'Project data' }, { id: 'settings__data', label: 'Project data', keywords: ['title', 'description', 'logo', 'url', 'info'] },
{ id: 'settings__general', label: 'General settings' }, {
{ id: 'settings__view', label: 'View settings' }, id: 'settings__general',
{ id: 'settings__custom-views', label: 'Custom views' }, label: 'General settings',
{ id: 'settings__mcp', label: 'MCP Server' }, keywords: ['pin', 'password', 'lock', 'language', 'time format', 'timezone'],
...(isDocker ? [] : [{ id: 'settings__port', label: 'Server port' }]), },
{
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', id: 'project',
label: 'Project', label: 'Project',
secondary: [ secondary: [
{ id: 'project__create', label: 'Create...' }, { id: 'project__create', label: 'Create...', keywords: ['new project', 'quick start', 'wizard'] },
{ id: 'project__list', label: 'Manage projects' }, {
id: 'project__list',
label: 'Manage projects',
keywords: ['load', 'open', 'rename', 'duplicate', 'delete', 'download', 'import', 'backup', 'merge'],
},
], ],
}, },
{ {
id: 'manage', id: 'manage',
label: 'Project settings', label: 'Project settings',
secondary: [ secondary: [
{ id: 'manage__defaults', label: 'Rundown defaults' }, {
{ id: 'manage__custom', label: 'Custom fields' }, id: 'manage__defaults',
{ id: 'manage__rundowns', label: 'Manage rundowns' }, label: 'Rundown defaults',
{ id: 'manage__sheets', label: 'Import spreadsheet' }, keywords: ['duration', 'warning', 'danger', 'defaults'],
{ id: 'manage__sheets', label: 'Sync with Google Sheet' }, },
{
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', id: 'automation',
label: 'Automation', label: 'Automation',
secondary: [ secondary: [
{ id: 'automation__settings', label: 'Automation settings' }, {
{ id: 'automation__automations', label: 'Manage automations' }, id: 'automation__settings',
{ id: 'automation__triggers', label: 'Manage triggers' }, 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', id: 'sharing',
label: 'Sharing and reporting', label: 'Sharing and reporting',
secondary: [ secondary: [
{ id: 'sharing__presets', label: 'URL Presets' }, { id: 'sharing__presets', label: 'URL Presets', keywords: ['alias', 'link', 'url', 'shortcut'] },
{ {
id: 'sharing__link', id: 'sharing__link',
label: 'Share 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', id: 'network__log',
label: 'Event log', label: 'Event log',
keywords: ['debug', 'errors', 'console', 'export log'],
}, },
{ {
id: 'network__clients', id: 'network__clients',
label: 'Manage clients', label: 'Manage clients',
keywords: ['redirect', 'identify', 'rename client', 'connected'],
}, },
], ],
}, },
{ {
id: 'about', id: 'about',
label: 'About', label: 'About',
keywords: ['version', 'update', 'licence', 'license', 'credits'],
}, },
{ {
id: 'shutdown', id: 'shutdown',
label: 'Shutdown', label: 'Shutdown',
keywords: ['quit', 'exit', 'close ontime'],
}, },
] as const; ] as const;
@@ -92,6 +139,51 @@ export type SettingsOptionId =
| (typeof staticOptions)[number]['id'] | (typeof staticOptions)[number]['id']
| Extract<(typeof staticOptions)[number], { secondary: object }>['secondary'][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() { export function useAppSettingsMenu() {
const { data } = useAppVersion(); const { data } = useAppVersion();
+45 -2
View File
@@ -1,20 +1,54 @@
$info-gray: $secondary-text-gray; $info-gray: $secondary-text-gray;
$info-hover: $section-white; $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 { .log {
font-size: $inner-section-text-size; font-size: $inner-section-text-size;
overflow-y: auto; overflow-y: auto;
user-select: text; user-select: text;
min-height: 20vh; height: 40vh;
min-height: 40vh;
max-height: 40vh; max-height: 40vh;
background-color: $gray-1350; margin: 0;
padding: 0.5rem; 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 { .apart {
margin-left: auto; margin-left: auto;
} }
.filterLabel {
color: $gray-400;
font-size: $aux-text-size;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.logEntry { .logEntry {
display: flex; display: flex;
@@ -44,10 +78,19 @@ $info-hover: $section-white;
.msg { .msg {
flex: 1; flex: 1;
min-width: 0;
overflow-wrap: anywhere;
} }
} }
.empty {
padding: 2.5rem 1rem;
color: $gray-600;
text-align: center;
}
.buttonBar { .buttonBar {
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem;
padding-bottom: 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 Button from '../../common/components/buttons/Button';
import { clearLogs, useLogData } from '../../common/stores/logger'; import { clearLogs, useLogData } from '../../common/stores/logger';
import { cx } from '../../common/utils/styleUtils';
import * as Panel from '../app-settings/panel-utils/PanelUtils'; import * as Panel from '../app-settings/panel-utils/PanelUtils';
import style from './Log.module.scss'; import style from './Log.module.scss';
export default function Log() { export default function Log() {
const { logs: logData } = useLogData(); const { logs: logData } = useLogData();
const isExtracted = window.location.pathname.includes('/log');
const [showClient, setShowClient] = useState(true); const [showClient, setShowClient] = useState(true);
const [showServer, setShowServer] = useState(true); const [showServer, setShowServer] = useState(true);
@@ -50,11 +52,14 @@ export default function Log() {
}, []); }, []);
return ( return (
<> <div className={cx([style.container, isExtracted && style.extracted])}>
<Panel.InlineElements className={style.buttonBar}> <Panel.InlineElements className={style.buttonBar}>
<span className={style.filterLabel}>Filter by</span>
<Button <Button
variant={showUser ? 'primary' : 'subtle'} variant={showUser ? 'primary' : 'subtle'}
size='small' size='small'
aria-pressed={showUser}
aria-label={`${showUser ? 'Hide' : 'Show'} ${LogOrigin.User} events`}
onClick={() => setShowUser((s) => !s)} onClick={() => setShowUser((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.User)} onAuxClick={() => disableOthers(LogOrigin.User)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
@@ -64,6 +69,8 @@ export default function Log() {
<Button <Button
variant={showClient ? 'primary' : 'subtle'} variant={showClient ? 'primary' : 'subtle'}
size='small' size='small'
aria-pressed={showClient}
aria-label={`${showClient ? 'Hide' : 'Show'} ${LogOrigin.Client} events`}
onClick={() => setShowClient((s) => !s)} onClick={() => setShowClient((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Client)} onAuxClick={() => disableOthers(LogOrigin.Client)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
@@ -73,6 +80,8 @@ export default function Log() {
<Button <Button
variant={showServer ? 'primary' : 'subtle'} variant={showServer ? 'primary' : 'subtle'}
size='small' size='small'
aria-pressed={showServer}
aria-label={`${showServer ? 'Hide' : 'Show'} ${LogOrigin.Server} events`}
onClick={() => setShowServer((s) => !s)} onClick={() => setShowServer((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Server)} onAuxClick={() => disableOthers(LogOrigin.Server)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
@@ -82,6 +91,8 @@ export default function Log() {
<Button <Button
variant={showPlayback ? 'primary' : 'subtle'} variant={showPlayback ? 'primary' : 'subtle'}
size='small' size='small'
aria-pressed={showPlayback}
aria-label={`${showPlayback ? 'Hide' : 'Show'} ${LogOrigin.Playback} events`}
onClick={() => setShowPlayback((s) => !s)} onClick={() => setShowPlayback((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Playback)} onAuxClick={() => disableOthers(LogOrigin.Playback)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
@@ -91,6 +102,8 @@ export default function Log() {
<Button <Button
variant={showRx ? 'primary' : 'subtle'} variant={showRx ? 'primary' : 'subtle'}
size='small' size='small'
aria-pressed={showRx}
aria-label={`${showRx ? 'Hide' : 'Show'} ${LogOrigin.Rx} events`}
onClick={() => setShowRx((s) => !s)} onClick={() => setShowRx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Rx)} onAuxClick={() => disableOthers(LogOrigin.Rx)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
@@ -100,6 +113,8 @@ export default function Log() {
<Button <Button
variant={showTx ? 'primary' : 'subtle'} variant={showTx ? 'primary' : 'subtle'}
size='small' size='small'
aria-pressed={showTx}
aria-label={`${showTx ? 'Hide' : 'Show'} ${LogOrigin.Tx} events`}
onClick={() => setShowTx((s) => !s)} onClick={() => setShowTx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Tx)} onAuxClick={() => disableOthers(LogOrigin.Tx)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
@@ -110,15 +125,16 @@ export default function Log() {
<IoClose /> Clear <IoClose /> Clear
</Button> </Button>
</Panel.InlineElements> </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) => ( {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.time}>{logEntry.time}</span>
<span className={style.origin}>{logEntry.origin}</span> <span className={style.origin}>{logEntry.origin}</span>
<span className={style.msg}>{logEntry.text}</span> <span className={style.msg}>{logEntry.text}</span>
</li> </li>
))} ))}
</ul> </ul>
</> </div>
); );
} }
+110 -102
View File
@@ -1,6 +1,7 @@
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types'; import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage'; import EmptyPage from '../../common/components/state/EmptyPage';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import useFollowComponent from '../../common/hooks/useFollowComponent'; import useFollowComponent from '../../common/hooks/useFollowComponent';
@@ -10,6 +11,7 @@ import { cx } from '../../common/utils/styleUtils';
import { throttle } from '../../common/utils/throttle'; import { throttle } from '../../common/utils/throttle';
import { getDefaultFormat } from '../../common/utils/time'; import { getDefaultFormat } from '../../common/utils/time';
import { isTouchDevice } from '../../externals'; import { isTouchDevice } from '../../externals';
import { useTranslation } from '../../translation/TranslationProvider';
import Loader from '../../views/common/loader/Loader'; import Loader from '../../views/common/loader/Loader';
import CustomFieldEditModal from './custom-field-edit-modal/CustomFieldEditModal'; import CustomFieldEditModal from './custom-field-edit-modal/CustomFieldEditModal';
import FollowButton from './follow-button/FollowButton'; import FollowButton from './follow-button/FollowButton';
@@ -35,7 +37,7 @@ export default function OperatorLoader() {
} }
if (status === 'error') { 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} />; return <Operator {...data} />;
@@ -43,6 +45,7 @@ export default function OperatorLoader() {
function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) { function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) {
const selectedEventId = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const { getLocalizedString } = useTranslation();
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions(); const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
const [showEditPrompt, setShowEditPrompt] = useState(false); 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 operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]);
const canEdit = shouldEdit && subscribe.length; const canEdit = shouldEdit && subscribe.length;
const hasEvents = rundown.order.length > 0;
return ( return (
<div className={style.operatorContainer} data-testid='operator-view'> <div className={style.operatorContainer} data-testid='operator-view'>
@@ -127,117 +131,121 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
</div> </div>
)} )}
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}> {!hasEvents ? (
{rundown.order.map((entryId) => { <EmptyFill text={getLocalizedString('common.no_data')} />
const entry = rundown.entries[entryId]; ) : (
if (isOntimeEvent(entry)) { <div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId]; {rundown.order.map((entryId) => {
// hide past events (if setting) and skipped events const entry = rundown.entries[entryId];
if ((hidePast && isPast) || entry.skip) { if (isOntimeEvent(entry)) {
return null; 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( const { mainField, secondaryField, subscribedData } = getEventData(
entry, entry,
mainSource, mainSource,
secondarySource, secondarySource,
subscribe, subscribe,
customFields, customFields,
); );
return ( return (
<OperatorEvent <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
key={entry.id} key={entry.id}
title={entry.title} id={entry.id}
colour={entry.colour} colour={entry.colour}
count={entry.entries.length} cue={entry.cue}
main={mainField}
secondary={secondaryField}
timeStart={entry.timeStart}
duration={entry.duration} 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 const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId]?.groupId === entry.id : false;
if ((hidePast && isPast) || nestedEntry.skip) {
return null;
}
const { mainField, secondaryField, subscribedData } = getEventData( if (hidePast && isPast && !isCurrentParent) {
nestedEntry, return null;
mainSource, }
secondarySource,
subscribe,
customFields,
);
return ( return (
<OperatorEvent <Fragment key={entry.id}>
key={nestedEntry.id} <OperatorGroup
id={nestedEntry.id} key={entry.id}
colour={nestedEntry.colour} title={entry.title}
cue={nestedEntry.cue} colour={entry.colour}
main={mainField} count={entry.entries.length}
secondary={secondaryField} duration={entry.duration}
timeStart={nestedEntry.timeStart} />
duration={nestedEntry.duration} {entry.entries.map((nestedEntryId) => {
delay={nestedEntry.delay} const nestedEntry = rundown.entries[nestedEntryId];
dayOffset={nestedEntry.dayOffset} if (!isOntimeEvent(nestedEntry)) {
isLinkedToLoaded={isLinkedToLoaded} return null;
isSelected={isLoaded} }
isPast={isPast}
groupColour={entry.colour} const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart} // hide past events (if setting) and skipped events
subscribed={subscribedData} if ((hidePast && isPast) || nestedEntry.skip) {
totalGap={totalGap} return null;
onLongPress={canEdit ? handleEdit : () => undefined} }
/>
); const { mainField, secondaryField, subscribedData } = getEventData(
})} nestedEntry,
</Fragment> mainSource,
); secondarySource,
} subscribe,
return null; customFields,
})} );
</div>
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} /> <FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} />
</div> </div>
); );
@@ -2,7 +2,6 @@
.event { .event {
opacity: 1; opacity: 1;
border-top: 1px solid $white-1;
padding-right: 0.5rem; padding-right: 0.5rem;
color: $white-90; color: $white-90;
background-color: $viewer-card-bg-color; background-color: $viewer-card-bg-color;
@@ -31,7 +30,6 @@
} }
&.running { &.running {
border-top: 1px solid $gray-1300;
background-color: var(--operator-running-bg-override, $active-green); background-color: var(--operator-running-bg-override, $active-green);
} }
@@ -42,8 +40,7 @@
} }
&.past { &.past {
border-top: 1px solid transparent; opacity: 0.4;
opacity: 0.2;
} }
} }
@@ -79,6 +76,7 @@
.mainField { .mainField {
grid-area: main; grid-area: main;
min-width: 0;
font-size: 1.5rem; font-size: 1.5rem;
color: $ui-white; color: $ui-white;
@@ -144,6 +142,7 @@
.fields { .fields {
grid-area: fields; grid-area: fields;
min-width: 0;
margin-top: 0; margin-top: 0;
border-top: 0; border-top: 0;
@@ -171,10 +170,9 @@
&.fieldsWithContent { &.fieldsWithContent {
margin-top: 0.25rem; margin-top: 0.25rem;
padding-block: 0.5rem; padding-block: 0.5rem;
border-top: 1px solid $white-13;
display: grid; 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; gap: 0.25rem 0.75rem;
line-height: 1.25em; line-height: 1.25em;
font-size: var(--operator-customfield-font-size-override, 1.25rem); font-size: var(--operator-customfield-font-size-override, 1.25rem);
@@ -82,7 +82,7 @@ function OperatorEvent({
const fieldGridStyle = const fieldGridStyle =
columnCount > 0 columnCount > 0
? ({ ? ({
gridTemplateColumns: `repeat(${columnCount}, minmax(12rem, 1fr))`, gridTemplateColumns: `repeat(${columnCount}, minmax(min(12rem, 100%), 1fr))`,
} satisfies CSSProperties) } satisfies CSSProperties)
: undefined; : undefined;
@@ -44,8 +44,8 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
}, },
{ {
id: 'subscribe', id: 'subscribe',
title: 'Highlight Fields', title: 'Show custom fields',
description: 'Choose custom fields to highlight', description: 'Choose custom fields to show on each event',
type: 'multi-option', type: 'multi-option',
values: customFieldSelect, values: customFieldSelect,
}, },
@@ -10,12 +10,13 @@ interface StatusBarProgressProps {
} }
export default function StatusBarProgress({ viewSettings }: StatusBarProgressProps) { export default function StatusBarProgress({ viewSettings }: StatusBarProgressProps) {
const { current, duration, timeWarning, timeDanger } = useProgressData(); const { current, duration, eventId, timeWarning, timeDanger } = useProgressData();
return ( return (
<MultiPartProgressBar <MultiPartProgressBar
now={current} now={current}
complete={duration} complete={duration}
eventId={eventId}
normalColor={viewSettings.normalColor} normalColor={viewSettings.normalColor}
warning={timeWarning} warning={timeWarning}
warningColor={viewSettings.warningColor} warningColor={viewSettings.warningColor}
@@ -1,14 +1,43 @@
.title { .titleOverview {
font-size: 1.75rem; flex: 0 1 18rem;
font-weight: 600; min-width: 0;
color: $ui-white; width: 18rem;
line-height: 1; max-width: 100%;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.project,
.rundown {
@include ellipsis-overflow; @include ellipsis-overflow;
} }
.description { .project {
font-size: 1rem;
color: $secondary-text-gray; color: $secondary-text-gray;
font-size: 0.75rem;
font-weight: 400; 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: projectData } = useProjectData();
const { data: rundownData } = useRundownAuxData(); 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 null;
} }
return ( return (
<div> <div className={style.titleOverview}>
<div className={style.title}>{projectData.title}</div> {projectTitle && <div className={style.project}>{projectTitle}</div>}
<div className={style.description}>{rundownData.title}</div> {rundownTitle && <div className={style.rundown}>{rundownTitle}</div>}
</div> </div>
); );
} }
@@ -1,19 +1,24 @@
import { memo } from 'react'; 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 { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import { useRundownEditor } from '../../common/hooks/useSocket'; import { useRundownEditor } from '../../common/hooks/useSocket';
import { useTranslation } from '../../translation/TranslationProvider';
import Rundown from './Rundown'; import Rundown from './Rundown';
export default memo(RundownList); export default memo(RundownList);
function RundownList() { function RundownList() {
const { data, status, rundownMetadata } = useRundownWithMetadata(); const { data, status, rundownMetadata } = useRundownWithMetadata();
const featureData = useRundownEditor(); 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) { if (status === 'error') {
return <Empty text='Connecting to server' />; return <EmptyFill text={getLocalizedString('common.no_data')} />;
} }
return ( return (
@@ -81,7 +81,7 @@ export default function GroupEditor({ group }: GroupEditorProps) {
<div> <div>
<Editor.Label htmlFor='eventId'>Plan offset</Editor.Label> <Editor.Label htmlFor='eventId'>Plan offset</Editor.Label>
<TextLikeInput <TextLikeInput
offset={planOffsetLabel} offset={planOffsetLabel === 'under' ? 'over' : planOffsetLabel}
className={cx([style.textLikeInput, planOffset === null && style.inactive])} className={cx([style.textLikeInput, planOffset === null && style.inactive])}
disabled disabled
> >
@@ -1,6 +1,6 @@
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { Day, EndAction, EntryId, Playback, TimeStrategy, TimerType } from 'ontime-types'; import { Day, EndAction, EntryId, Maybe, OntimeGroup, Playback, TimeStrategy, TimerType } from 'ontime-types';
import { isPlaybackActive } from 'ontime-utils'; import { isPlaybackActive } from 'ontime-utils';
import { MouseEvent, useEffect, useRef } from 'react'; import { MouseEvent, useEffect, useRef } from 'react';
import { import {
@@ -13,9 +13,10 @@ import {
IoTrash, IoTrash,
IoUnlink, IoUnlink,
} from 'react-icons/io5'; } from 'react-icons/io5';
import { TbFlagFilled, TbListNumbers } from 'react-icons/tb'; import { TbClockPin, TbFlagFilled, TbListNumbers } from 'react-icons/tb';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useEntry } from '../../../common/hooks-query/useRundown';
import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryCopy } from '../../../common/stores/entryCopyStore'; import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils'; import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
@@ -102,7 +103,10 @@ export default function RundownEvent({
const clearSelectedEventId = useEventIdSwapping((state) => state.clearSelectedEventId); const clearSelectedEventId = useEventIdSwapping((state) => state.clearSelectedEventId);
const openRenumberDialog = useRenumberCuesDialogStore((state) => state.onOpen); const openRenumberDialog = useRenumberCuesDialogStore((state) => state.onOpen);
const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents } = useEntryActionsContext(); const parentGroup = useEntry(parent) as Maybe<OntimeGroup>;
const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents, matchGroupDuration } =
useEntryActionsContext();
const isSelected = useEventSelection((state) => state.selectedEvents.has(eventId)); const isSelected = useEventSelection((state) => state.selectedEvents.has(eventId));
const unselect = useEventSelection((state) => state.unselect); const unselect = useEventSelection((state) => state.unselect);
@@ -114,6 +118,15 @@ export default function RundownEvent({
const handleRef = useRef<null | HTMLSpanElement>(null); const handleRef = useRef<null | HTMLSpanElement>(null);
const [enableMatchDuration, groupTargetDurationDescription] = (() => {
if (!parentGroup || parentGroup.targetDuration === null || parentGroup.duration === parentGroup.targetDuration)
return [false, ''];
const { targetDuration, duration } = parentGroup;
return targetDuration > duration
? [true, 'Increase event duration to fit the group target']
: [true, 'Decrease event duration to fit the group target'];
})();
const [onContextMenu] = useContextMenu<HTMLDivElement>(() => const [onContextMenu] = useContextMenu<HTMLDivElement>(() =>
selectedEvents.size > 1 selectedEvents.size > 1
? [ ? [
@@ -172,6 +185,17 @@ export default function RundownEvent({
updateEntry({ id: eventId, flag: !flag }); updateEntry({ id: eventId, flag: !flag });
}, },
}, },
{
type: 'item',
label: 'Match Group Target Duration',
description: groupTargetDurationDescription,
icon: TbClockPin,
onClick: () => {
if (!parent) return;
matchGroupDuration(eventId);
},
disabled: !enableMatchDuration,
},
{ type: 'divider' }, { type: 'divider' },
{ {
type: 'item', type: 'item',
@@ -1,12 +1,13 @@
import { useAnimatedProgress } from '../../../../common/hooks/useAnimatedProgress'; import { useAnimatedProgress } from '../../../../common/hooks/useAnimatedProgress';
import { useTimer } from '../../../../common/hooks/useSocket'; import { useSelectedEventId, useTimer } from '../../../../common/hooks/useSocket';
import style from './RundownEventProgressBar.module.scss'; import style from './RundownEventProgressBar.module.scss';
export default function RundownEventProgressBar() { export default function RundownEventProgressBar() {
const timer = useTimer(); const timer = useTimer();
const eventId = useSelectedEventId();
const progress = useAnimatedProgress(timer.current, timer.duration); const progress = useAnimatedProgress(timer.current, timer.duration, eventId);
return <div className={style.progressBar} style={{ width: `${progress}%` }} />; return <div className={style.progressBar} style={{ width: `${progress}%` }} />;
} }
@@ -74,42 +74,36 @@
.metaLabel { .metaLabel {
color: $muted-gray; color: $muted-gray;
font-size: calc(1rem - 3px); font-size: calc(1rem - 3px);
display: flex;
align-items: center;
gap: 0.25rem;
} }
} }
.strike {
text-decoration: wavy underline;
margin-right: 0.25rem;
color: $ui-white;
}
.duration { .duration {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
color: $ui-white;
&.warning {
.strike {
// color: $playback-over;
text-decoration: wavy underline;
text-decoration-color: $playback-over;
}
.offsetLabel {
background-color: $playback-over;
}
}
} }
.lockIcon { .lockIcon {
opacity: 0.6; color: $muted-gray;
} }
.over { .target {
color: $playback-over; display: contents;
.strike {
text-decoration-color: $playback-over;
}
.offsetLabel {
background-color: $playback-over;
}
}
.under {
color: $playback-under;
.strike {
text-decoration-color: $playback-under;
}
.offsetLabel {
background-color: $playback-under;
}
} }
.drag { .drag {
@@ -2,24 +2,25 @@ import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { EntryId, OntimeGroup } from 'ontime-types'; import { EntryId, OntimeGroup } from 'ontime-types';
import { MILLIS_PER_MINUTE } from 'ontime-utils'; import { MILLIS_PER_MINUTE } from 'ontime-utils';
import { MouseEvent, useRef } from 'react'; import { MouseEvent, useCallback, useRef } from 'react';
import { import {
IoChevronDown, IoChevronDown,
IoChevronUp, IoChevronUp,
IoDuplicateOutline, IoDuplicateOutline,
IoFolderOpenOutline, IoFolderOpenOutline,
IoLockClosed,
IoReorderTwo, IoReorderTwo,
IoTrash, IoTrash,
IoLockClosed,
} from 'react-icons/io5'; } from 'react-icons/io5';
import { TbClockPin } from 'react-icons/tb';
import IconButton from '../../../common/components/buttons/IconButton'; import IconButton from '../../../common/components/buttons/IconButton';
import Tag from '../../../common/components/tag/Tag'; import Tag from '../../../common/components/tag/Tag';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryCopy } from '../../../common/stores/entryCopyStore'; import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils'; import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
import { getOffsetState } from '../../../common/utils/offset';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../common/utils/time'; import { formatDuration, formatTime } from '../../../common/utils/time';
import TitleEditor from '../common/TitleEditor'; import TitleEditor from '../common/TitleEditor';
@@ -40,12 +41,31 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
'use memo'; 'use memo';
const handleRef = useRef<null | HTMLSpanElement>(null); const handleRef = useRef<null | HTMLSpanElement>(null);
const { clone, ungroup, deleteEntry } = useEntryActionsContext(); const { clone, ungroup, deleteEntry, updateEntry } = useEntryActionsContext();
const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection); const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection);
const selectedEvents = useEventSelection((state) => state.selectedEvents); const selectedEvents = useEventSelection((state) => state.selectedEvents);
const entryCopyId = useEntryCopy((state) => state.entryCopyId); const entryCopyId = useEntryCopy((state) => state.entryCopyId);
const isDurationMatching = data.targetDuration !== null && data.targetDuration === data.duration;
const [planOffset, offset] = (() => {
if (data.targetDuration === null) {
return [null, 0];
}
const offset = data.duration - data.targetDuration;
if (offset === 0) {
return [null, 0];
}
const absOffset = Math.abs(offset);
return [`${offset < 0 ? '-' : '+'}${formatDuration(absOffset, absOffset > 2 * MILLIS_PER_MINUTE)}`, offset];
})();
const matchDuration = useCallback(() => {
updateEntry({ id: data.id, targetDuration: data.duration });
}, [data.duration, data.id, updateEntry]);
const [onContextMenu] = useContextMenu<HTMLDivElement>(() => [ const [onContextMenu] = useContextMenu<HTMLDivElement>(() => [
{ {
type: 'item', type: 'item',
@@ -62,6 +82,18 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
disabled: data.entries.length === 0, disabled: data.entries.length === 0,
}, },
{ type: 'divider' }, { type: 'divider' },
{
type: 'item',
label: 'Match Content Duration',
icon: TbClockPin,
onClick: matchDuration,
disabled: isDurationMatching,
description:
offset > 0
? "Increase group target duration to match it's contents"
: "Decrease group target duration to match it's contents",
},
{ type: 'divider' },
{ {
type: 'item', type: 'item',
label: 'Delete Group', label: 'Delete Group',
@@ -105,22 +137,6 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
const binderColours = data.colour && getAccessibleColour(data.colour); const binderColours = data.colour && getAccessibleColour(data.colour);
const isValidDrop = isDragging && over?.id && canDrop(over.data.current?.type, over.data.current?.parent); const isValidDrop = isDragging && over?.id && canDrop(over.data.current?.type, over.data.current?.parent);
const [planOffset, planOffsetLabel] = (() => {
if (data.targetDuration === null) {
return [null, null];
}
const offset = data.duration - data.targetDuration;
if (offset === 0) {
return [null, 'under'];
}
const absOffset = Math.abs(offset);
return [
`${offset < 0 ? '-' : '+'}${formatDuration(absOffset, absOffset > 2 * MILLIS_PER_MINUTE)}`,
getOffsetState(offset),
];
})();
const dragStyle = { const dragStyle = {
zIndex: isDragging ? 2 : 'inherit', zIndex: isDragging ? 2 : 'inherit',
transform: CSS.Translate.toString(transform), transform: CSS.Translate.toString(transform),
@@ -175,20 +191,18 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
<div className={style.metaLabel}>End</div> <div className={style.metaLabel}>End</div>
<div>{formatTime(data.timeEnd)}</div> <div>{formatTime(data.timeEnd)}</div>
</div> </div>
<div className={style.metaEntry}> <Tooltip text={'Group has target duration'} disabled={data.targetDuration === null}>
<div className={style.metaLabel}>Duration</div> <div className={style.metaEntry}>
<div className={style.duration}> <div className={style.metaLabel}>
{planOffset === null ? ( Duration
formatDuration(data.duration) {data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
) : ( </div>
<span className={cx([planOffsetLabel && style[planOffsetLabel]])}> <div className={cx([style.duration, planOffset && style.warning])}>
<span className={style.strike}>{formatDuration(data.duration)}</span> <span className={style.strike}>{formatDuration(data.duration)}</span>
<Tag className={style.offsetLabel}>{planOffset}</Tag> {planOffset && <Tag className={style.offsetLabel}>{planOffset}</Tag>}
</span> </div>
)}
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
</div> </div>
</div> </Tooltip>
</div> </div>
</div> </div>
</div> </div>
@@ -1,6 +1,5 @@
import { memo, useEffect, useMemo } from 'react'; import { memo, useEffect, useMemo } from 'react';
import EmptyPage from '../../../common/components/state/EmptyPage';
import { EntryActionsProvider } from '../../../common/context/EntryActionsContext'; import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown'; import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown';
@@ -13,7 +12,7 @@ import { makeRundownColumns } from './makeRundownColumns';
export default memo(RundownTable); export default memo(RundownTable);
function RundownTable() { function RundownTable() {
const { data: customFields, status: customFieldStatus } = useCustomFields(); const { data: customFields } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions); const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const { editorMode } = useEditorFollowMode(); const { editorMode } = useEditorFollowMode();
const source = useLoadedRundownSource(); const source = useLoadedRundownSource();
@@ -32,16 +31,10 @@ function RundownTable() {
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]); const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
const isLoading = !customFields || customFieldStatus === 'pending';
return ( return (
<EntryActionsProvider actions={actions}> <EntryActionsProvider actions={actions}>
<CuesheetDnd columns={columns} tableRoot='editor'> <CuesheetDnd columns={columns} tableRoot='editor'>
{isLoading ? ( <CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
<EmptyPage text='Loading...' />
) : (
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
)}
</CuesheetDnd> </CuesheetDnd>
</EntryActionsProvider> </EntryActionsProvider>
); );
@@ -39,9 +39,21 @@
font-size: $header-font-size; font-size: $header-font-size;
font-weight: 600; font-weight: 600;
display: flex; display: flex;
align-items: center;
gap: 1rem; gap: 1rem;
} }
.project-header__brand {
display: flex;
align-items: center;
gap: 1rem;
min-width: 0;
}
.project-header__brand--without-logo {
align-self: baseline;
}
.logo { .logo {
max-width: min(200px, 20vw); max-width: min(200px, 20vw);
} }
@@ -195,10 +207,15 @@
.project-header { .project-header {
flex-direction: column; flex-direction: column;
align-items: flex-start;
position: relative; position: relative;
gap: 0.5rem; gap: 0.5rem;
} }
.project-header__brand--without-logo {
align-self: flex-start;
}
.logo img { .logo img {
height: min(50px, 10vh); height: min(50px, 10vh);
} }
+13 -4
View File
@@ -35,7 +35,7 @@ export default function BackstageLoader() {
} }
if (status === 'error') { 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} />; return <Backstage {...data} />;
@@ -107,12 +107,21 @@ function Backstage({ events, customFields, projectData, isMirrored, settings }:
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'> <div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
<ViewParamsEditor target={OntimeView.Backstage} viewOptions={backstageOptions} /> <ViewParamsEditor target={OntimeView.Backstage} viewOptions={backstageOptions} />
<div className='project-header'> <div className='project-header'>
{projectData?.logo && <ViewLogo name={projectData.logo} className='logo' />} <div className={cx(['project-header__brand', !projectData?.logo && 'project-header__brand--without-logo'])}>
<div className='title'>{projectData.title}</div> {projectData?.logo && <ViewLogo name={projectData.logo} className='logo' />}
<div className='title'>{projectData.title}</div>
</div>
<BackstageClock timeformat={timeformat} /> <BackstageClock timeformat={timeformat} />
</div> </div>
{showProgress && <ProgressBar className='progress-container' current={time.current} duration={time.duration} />} {showProgress && (
<ProgressBar
className='progress-container'
current={time.current}
duration={time.duration}
eventId={selectedEventId}
/>
)}
{!hasEvents && <Empty text={getLocalizedString('common.no_data')} className='empty-container' />} {!hasEvents && <Empty text={getLocalizedString('common.no_data')} className='empty-container' />}

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