refactor(settings): polish settings UI

This commit is contained in:
Carlos Valente
2026-08-01 17:09:48 +02:00
committed by Carlos Valente
parent f0c284b708
commit 24be49ef66
49 changed files with 1946 additions and 1268 deletions
@@ -1,24 +1,82 @@
.section {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem;
background-color: $gray-1300;
border: 1px solid $gray-1100;
border-radius: $component-border-radius-md;
}
.sectionHeader {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.separator {
display: flex;
align-items: center;
gap: 0.75rem;
color: $gray-500;
font-size: $inner-section-text-size;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
&::before,
&::after {
content: '';
flex: 1;
border-top: 1px solid $gray-1100;
}
}
.textEntry { .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,21 +68,26 @@ 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}>
<div className={style.sectionHeader}>
<span className={style.label}>Enter custom path</span> <span className={style.label}>Enter custom path</span>
<span className={style.description}>Type the path the client should open.</span>
</div>
<div className={style.inlineEntry}>
<label className={style.textEntry}> <label className={style.textEntry}>
{origin} <span className={style.origin}>{origin}</span>
<Input placeholder='eg. /timer' fluid value={path} onChange={(event) => setPath(event.target.value)} /> <Input placeholder='eg. /timer' fluid value={path} onChange={(event) => setPath(event.target.value)} />
</label> </label>
<Button <Button
variant='primary' variant='primary'
aria-label='Redirect' aria-label='Redirect to custom path'
disabled={path === currentPath || path === ''} disabled={path === currentPath || path === ''}
className={style.redirect} className={style.redirect}
onClick={() => handleRedirect(path)} onClick={() => handleRedirect(path)}
@@ -89,11 +96,18 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
<IoArrowForward /> <IoArrowForward />
</Button> </Button>
</div> </div>
<div> </section>
<span className={style.label}>Select View or URL Preset</span> <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}> <div className={style.inlineEntry}>
<label className={style.textEntry}> <label className={style.textEntry}>
{origin} <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,6 +1,6 @@
.dialog { .dialog {
position: fixed; position: fixed;
top: 1rem; top: 10vh;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
@@ -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,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,7 +194,7 @@ $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 {
@@ -188,19 +219,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 {
padding-block: 5rem;
} }
button { .emptyMessage {
margin-top: 1rem; width: min(30rem, 100%);
margin-inline: auto; margin-inline: auto;
} }
.emptyTitle {
margin-bottom: 0.25rem;
color: rgba($gray-200, 0.72);
font-size: calc(1rem + 2px);
font-weight: 400;
}
.emptyBody {
color: rgba($gray-200, 0.55);
font-size: calc(1rem - 3px);
line-height: 1.5;
}
.emptyAction {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-top: 1rem;
} }
.inlineElements { .inlineElements {
@@ -239,14 +286,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.Card>
</Panel.Section>
<CloudPanel />
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Current version</Panel.SubHeader> <Panel.SubHeader>Current version</Panel.SubHeader>
<Panel.Divider />
<Panel.ListGroup>
<AppVersion /> <AppVersion />
<Panel.SubHeader>Links</Panel.SubHeader> </Panel.ListGroup>
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink> </Panel.Card>
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink> </Panel.Section>
<ExternalLink href={discordUrl}>Discord server</ExternalLink> <Panel.Section>
<ExternalLink href={youtubeUrl}>YouTube channel</ExternalLink> <Panel.Card>
<ExternalLink href={subredditUrl}>Subreddit</ExternalLink> <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.Section> </Panel.Section>
</> </>
); );
@@ -1,6 +1,6 @@
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';
export default function AppVersion() { export default function AppVersion() {
@@ -8,28 +8,33 @@ export default function AppVersion() {
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={`Ontime ${appVersion}`}
<br /> description={
{`A new version ${data.version} is available.`} <br /> isOntimeCloud
{isOntimeCloud ? ( ? `Version ${data.version} is available. Restart your stage to update.`
'You can restart your stage to get the latest available version.' : `Version ${data.version} is available.`
) : ( }
<ExternalLink href={data.url}>Please visit the release page to download</ExternalLink> />
{!isOntimeCloud && (
<ExternalLink href={websiteUrl}>Visit Ontime's page to download the latest version.</ExternalLink>
)} )}
</Panel.Paragraph> </Panel.ListItem>
); );
} }
return <Panel.Paragraph>{`You are currently using the latest version of Ontime: ${appVersion}`}</Panel.Paragraph>; return (
<Panel.ListItem>
<Panel.Field title={`Ontime ${appVersion}`} description='You are using the latest version.' />
</Panel.ListItem>
);
} }
@@ -0,0 +1,74 @@
.card {
padding: 1.5rem;
border-color: rgba($action-blue, 0.42);
background-color: rgba($action-blue, 0.06);
}
.eyebrow {
color: $action-text-color;
font-size: calc(1rem - 3px);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.title {
margin-top: 0.35rem;
font-size: 1.5rem;
font-weight: 600;
line-height: 1.15;
}
.description {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 33rem;
margin-top: 0.9rem;
color: $gray-300;
line-height: 1.5;
}
.portability {
padding-top: 0.75rem;
border-top: 1px solid $white-10;
color: $gray-400;
font-size: calc(1rem - 2px);
}
.benefits {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem 1rem;
margin-top: 1.5rem;
padding: 0;
list-style: none;
li {
display: flex;
align-items: flex-start;
gap: 0.5rem;
line-height: 1.4;
}
svg {
flex: 0 0 auto;
margin-top: 0.1rem;
color: $action-text-color;
font-size: 1.1rem;
}
}
.actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
margin-top: 1.5rem;
}
@media (max-width: $min-tablet) {
.benefits {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,52 @@
import { IoCheckmarkCircle } from 'react-icons/io5';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import { isOntimeCloud, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './CloudPanel.module.scss';
export default function CloudPanel() {
if (isOntimeCloud) {
return null;
}
return (
<Panel.Section>
<Panel.Card className={style.card}>
<span className={style.eyebrow}>Ontime Cloud</span>
<h3 className={style.title}>Run Ontime online with zero setup</h3>
<p className={style.description}>
<span>
Ontime Cloud lets you run and share Ontime online with your team. Subscriptions support continued
development while Ontime remains fully open-source and self-hostable.
</span>
<span className={style.portability}>
Project files work locally and in Cloud, so your shows can move freely.
</span>
</p>
<ul className={style.benefits}>
<li>
<IoCheckmarkCircle />
Start in minutes, with no setup or maintenance
</li>
<li>
<IoCheckmarkCircle />
Coordinate multi-track rundowns with Overview views
</li>
<li>
<IoCheckmarkCircle />
Manage every instance from one dashboard
</li>
<li>
<IoCheckmarkCircle />
Share secure read-only views with collaborators
</li>
</ul>
<div className={style.actions}>
<ExternalLink href={websiteUrl}>Learn more about Ontime Cloud</ExternalLink>
</div>
</Panel.Card>
</Panel.Section>
);
}
@@ -0,0 +1,15 @@
.link {
width: calc(100% + (var(--panel-card-padding, 2rem) * 2));
margin: -0.75rem calc(var(--panel-card-padding, 2rem) * -1);
padding: 0.75rem var(--panel-card-padding, 2rem);
> :first-child {
flex: 1;
min-width: 0;
}
}
.action {
color: $action-text-color;
font-size: calc(1rem - 2px);
}
@@ -0,0 +1,21 @@
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './ExternalLinkRow.module.scss';
interface ExternalLinkRowProps {
href: string;
title: string;
description: string;
}
export default function ExternalLinkRow({ href, title, description }: ExternalLinkRowProps) {
return (
<Panel.ListItem interactive>
<ExternalLink href={href} className={style.link}>
<Panel.Field title={title} description={description} />
<span className={style.action}>Open</span>
</ExternalLink>
</Panel.ListItem>
);
}
@@ -1,6 +1,13 @@
.outerColumn { .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,15 +186,15 @@ 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>
@@ -208,7 +209,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
</div> </div>
</div> </div>
<div className={style.innerSection}> <div className={style.innerColumn}>
<h3>Filters (optional)</h3> <h3>Filters (optional)</h3>
<div className={style.ruleSection}> <div className={style.ruleSection}>
<label> <label>
@@ -253,7 +254,13 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
if (value === null) return; if (value === null) return;
setValue( setValue(
`filters.${index}.operator`, `filters.${index}.operator`,
value as 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains' | 'not_contains', value as
| 'equals'
| 'not_equals'
| 'greater_than'
| 'less_than'
| 'contains'
| 'not_contains',
{ shouldDirty: true }, { shouldDirty: true },
); );
}} }}
@@ -273,7 +280,11 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<div> <div>
<span>&nbsp;</span> <span>&nbsp;</span>
<div> <div>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeFilter(index)}> <IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeFilter(index)}
>
<IoTrash /> <IoTrash />
</IconButton> </IconButton>
</div> </div>
@@ -352,7 +363,12 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
</label> </label>
<label> <label>
Arguments Arguments
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} fluid placeholder='1' /> <TemplateInput
{...register(`outputs.${index}.args`)}
value={output.args}
fluid
placeholder='1'
/>
<Panel.Error>{rowErrors?.args?.message}</Panel.Error> <Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label> </label>
<div> <div>
@@ -361,7 +377,11 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<Button variant='ghosted-white' onClick={() => handleTestOSCOutput(index)}> <Button variant='ghosted-white' onClick={() => handleTestOSCOutput(index)}>
Test Test
</Button> </Button>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}> <IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeOutput(index)}
>
<IoTrash /> <IoTrash />
</IconButton> </IconButton>
</Panel.InlineElements> </Panel.InlineElements>
@@ -402,7 +422,11 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<Button variant='ghosted-white' onClick={() => handleTestHTTPOutput(index)}> <Button variant='ghosted-white' onClick={() => handleTestHTTPOutput(index)}>
Test Test
</Button> </Button>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}> <IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeOutput(index)}
>
<IoTrash /> <IoTrash />
</IconButton> </IconButton>
</Panel.InlineElements> </Panel.InlineElements>
@@ -438,7 +462,11 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<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
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeOutput(index)}
>
<IoTrash /> <IoTrash />
</IconButton> </IconButton>
</Panel.InlineElements> </Panel.InlineElements>
@@ -447,7 +475,6 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
); );
} }
// there should be no other output types
return null; return null;
})} })}
<Panel.InlineElements relation='inner'> <Panel.InlineElements relation='inner'>
@@ -462,14 +489,17 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
</div> </div>
</form>
<Panel.InlineElements align='end'> }
footerElements={
<>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>} {errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button onClick={onClose}>Cancel</Button> <Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' disabled={!canSubmit} loading={isSubmitting}> <Button variant='primary' type='submit' form={formId} disabled={!canSubmit} loading={isSubmitting}>
Save Save
</Button> </Button>
</Panel.InlineElements> </>
</Panel.Indent> }
/>
); );
} }
@@ -19,8 +19,6 @@ export default function AutomationPanel({ location }: PanelBaseProps) {
return ( return (
<> <>
<Panel.Header>Automation</Panel.Header> <Panel.Header>Automation</Panel.Header>
<Panel.Section>
<Panel.Loader isLoading={isLoading} />
<div ref={settingsRef}> <div ref={settingsRef}>
<AutomationSettingsForm <AutomationSettingsForm
enabledAutomations={data.enabledAutomations} enabledAutomations={data.enabledAutomations}
@@ -28,15 +26,20 @@ export default function AutomationPanel({ location }: PanelBaseProps) {
oscPortIn={data.oscPortIn} oscPortIn={data.oscPortIn}
automationState={automationState} automationState={automationState}
oscInputState={oscInputState} oscInputState={oscInputState}
isLoading={isLoading}
/> />
</div> </div>
<div ref={automationsRef}> <div ref={automationsRef}>
<AutomationsList automations={data.automations} enabledAutomations={automationState} /> <AutomationsList automations={data.automations} enabledAutomations={automationState} isLoading={isLoading} />
</div> </div>
<div ref={triggersRef}> <div ref={triggersRef}>
<TriggersList triggers={data.triggers} automations={data.automations} enabledAutomations={automationState} /> <TriggersList
triggers={data.triggers}
automations={data.automations}
enabledAutomations={automationState}
isLoading={isLoading}
/>
</div> </div>
</Panel.Section>
</> </>
); );
} }
@@ -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,7 +67,9 @@ export default function AutomationSettingsForm({
const oscInputEnabled = watch('enabledOscIn'); const oscInputEnabled = watch('enabledOscIn');
return ( return (
<Panel.Section>
<Panel.Card> <Panel.Card>
<Panel.Loader isLoading={Boolean(isLoading)} />
<Panel.SubHeader> <Panel.SubHeader>
Automation settings Automation settings
<Panel.InlineElements> <Panel.InlineElements>
@@ -102,8 +106,6 @@ export default function AutomationSettingsForm({
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.Title>Automation</Panel.Title>
<Panel.ListGroup> <Panel.ListGroup>
<Panel.ListItem> <Panel.ListItem>
@@ -134,7 +136,9 @@ export default function AutomationSettingsForm({
<Panel.Title>OSC Input</Panel.Title> <Panel.Title>OSC Input</Panel.Title>
<Panel.ListGroup> <Panel.ListGroup>
{isOntimeCloud && <Info>For security reasons OSC integrations are not available in the cloud service.</Info>} {isOntimeCloud && (
<Info>For security reasons OSC integrations are not available in the cloud service.</Info>
)}
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title={ title={
@@ -186,5 +190,6 @@ export default function AutomationSettingsForm({
</Panel.ListGroup> </Panel.ListGroup>
</Panel.Section> </Panel.Section>
</Panel.Card> </Panel.Card>
</Panel.Section>
); );
} }
@@ -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,20 +44,21 @@ export default function AutomationsList(props: AutomationsListProps) {
const arrayAutomations = Object.keys(automations); const arrayAutomations = Object.keys(automations);
return ( return (
<Panel.Section>
<Panel.Card> <Panel.Card>
{automationFormData !== null && (
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
)}
<Panel.SubHeader> <Panel.SubHeader>
Manage automations Manage automations
<Button <Button onClick={() => setAutomationFormData(automationPlaceholder)}>
type='submit'
disabled={Boolean(automationFormData)}
onClick={() => setAutomationFormData(automationPlaceholder)}
>
New <IoAdd /> New <IoAdd />
</Button> </Button>
</Panel.SubHeader> </Panel.SubHeader>
<Panel.Divider /> <Panel.Divider />
<Panel.Section>
{enabledAutomations === false && ( {enabledAutomations === false && (
<Info> <Info>
Automations are disabled. You can still manage automation definitions here, but they will not run until Automations are disabled. You can still manage automation definitions here, but they will not run until
@@ -65,10 +66,6 @@ export default function AutomationsList(props: AutomationsListProps) {
</Info> </Info>
)} )}
{automationFormData !== null && (
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
)}
<Panel.Table> <Panel.Table>
<thead> <thead>
<tr> <tr>
@@ -80,9 +77,15 @@ export default function AutomationsList(props: AutomationsListProps) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{arrayAutomations.length === 0 && ( {!isLoading && arrayAutomations.length === 0 && (
<Panel.TableEmpty <Panel.TableEmpty
handleClick={!automationFormData ? () => setAutomationFormData(automationPlaceholder) : undefined} 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) => { {arrayAutomations.map((automationId) => {
@@ -127,6 +130,8 @@ export default function AutomationsList(props: AutomationsListProps) {
)} )}
</tbody> </tbody>
</Panel.Table> </Panel.Table>
</Panel.Section>
</Panel.Card> </Panel.Card>
</Panel.Section>
); );
} }
@@ -1,4 +1,4 @@
import { NormalisedAutomation, TimerLifeCycle, TriggerDTO } from 'ontime-types'; import { NormalisedAutomation, TimerLifeCycle, Trigger, TriggerDTO } from 'ontime-types';
import { useEffect } from 'react'; import { 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,19 +75,21 @@ 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'}
bodyElements={
<form id={formId} onSubmit={handleSubmit(onSubmit)}>
<label> <label>
Title Title
<Input <Input
{...register('title', { required: { value: true, message: 'Required field' } })} {...register('title', { required: { value: true, message: 'Required field' } })}
fluid fluid
defaultValue={initialTitle} defaultValue={trigger?.title}
/> />
<Panel.Error>{errors.title?.message}</Panel.Error> <Panel.Error>{errors.title?.message}</Panel.Error>
</label> </label>
@@ -127,14 +119,19 @@ export default function TriggerForm({
/> />
<Panel.Error>{errors.automationId?.message}</Panel.Error> <Panel.Error>{errors.automationId?.message}</Panel.Error>
</label> </label>
<Panel.InlineElements align='end'> </form>
}
footerElements={
<>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button disabled={isSubmitting} onClick={onCancel}> <Button disabled={isSubmitting} onClick={onCancel}>
Cancel Cancel
</Button> </Button>
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}> <Button type='submit' form={formId} variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Save Save
</Button> </Button>
</Panel.InlineElements> </>
</Panel.Indent> }
/>
); );
} }
@@ -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,10 +55,19 @@ export default function TriggersList(props: TriggersListProps) {
const canAdd = Object.keys(automations).length > 0; const canAdd = Object.keys(automations).length > 0;
return ( return (
<Panel.Section>
<Panel.Card> <Panel.Card>
{formState.isOpen && (
<TriggerForm
automations={automations}
trigger={formState.trigger ?? null}
onCancel={closeForm}
postSubmit={postSubmit}
/>
)}
<Panel.SubHeader> <Panel.SubHeader>
Manage triggers Manage triggers
<Button type='submit' form='trigger-form' disabled={!canAdd} loading={false} onClick={() => setShowForm(true)}> <Button disabled={!canAdd} onClick={openNewForm}>
New <IoAdd /> New <IoAdd />
</Button> </Button>
</Panel.SubHeader> </Panel.SubHeader>
@@ -61,12 +80,10 @@ export default function TriggersList(props: TriggersListProps) {
)} )}
{duplicates && ( {duplicates && (
<Panel.Error> <Panel.Error>
You have created multiple links between the same trigger and automation which can performance issues. You have created multiple links between the same trigger and automation which can cause performance
issues.
</Panel.Error> </Panel.Error>
)} )}
{showForm && (
<AutomationForm automations={automations} onCancel={() => setShowForm(false)} postSubmit={postSubmit} />
)}
<Panel.Table> <Panel.Table>
<thead> <thead>
<tr> <tr>
@@ -77,10 +94,21 @@ export default function TriggersList(props: TriggersListProps) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{!showForm && triggers.length === 0 && ( {!isLoading && triggers.length === 0 && (
<Panel.TableEmpty <Panel.TableEmpty
label='Create a trigger to run an automation' title='No triggers yet'
handleClick={canAdd ? () => setShowForm(true) : undefined} description={
canAdd
? 'Triggers run an automation at a given point of the timer lifecycle, like when an event starts or finishes.'
: 'Create an automation first, then add a trigger to decide when it should run.'
}
action={
canAdd && (
<Button variant='primary' onClick={openNewForm}>
Create trigger <IoAdd />
</Button>
)
}
/> />
)} )}
{triggers.map((trigger, index) => { {triggers.map((trigger, index) => {
@@ -88,14 +116,14 @@ export default function TriggersList(props: TriggersListProps) {
<Fragment key={trigger.id}> <Fragment key={trigger.id}>
<TriggersListItem <TriggersListItem
automations={automations} automations={automations}
id={trigger.id} trigger={trigger}
title={trigger.title}
trigger={trigger.trigger}
automationId={trigger.automationId}
duplicate={duplicates?.includes(index)} duplicate={duplicates?.includes(index)}
handleEdit={() => openEditForm(trigger)}
handleDelete={() => handleDelete(trigger.id)} handleDelete={() => handleDelete(trigger.id)}
postSubmit={postSubmit}
/> />
</Fragment>
);
})}
{deleteError && ( {deleteError && (
<tr> <tr>
<td colSpan={5}> <td colSpan={5}>
@@ -103,12 +131,10 @@ export default function TriggersList(props: TriggersListProps) {
</td> </td>
</tr> </tr>
)} )}
</Fragment>
);
})}
</tbody> </tbody>
</Panel.Table> </Panel.Table>
</Panel.Section> </Panel.Section>
</Panel.Card> </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,12 +136,15 @@ 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'
title={urlPreset ? 'Edit URL preset' : 'Create URL preset'}
bodyElements={
<form id={formId} onSubmit={handleSubmit(setupSubmit)} className={style.column}>
<input hidden name='enabled' value='true' /> <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>
@@ -209,20 +214,23 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
/> />
</div> </div>
)} )}
<div> </form>
}
footerElements={
<>
<Panel.Error>{errors.root?.message}</Panel.Error> <Panel.Error>{errors.root?.message}</Panel.Error>
<Panel.InlineElements align='end'>
<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(); refetch();
setIsAdding(false); closeForm();
};
const handleEditField = async (key: CustomFieldKey, customField: CustomField) => {
await editCustomField(key, customField);
refetch();
}; };
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,7 +56,15 @@ export default function CustomViewForm({ onComplete, onClose }: CustomViewFormPr
}; };
return ( return (
<Panel.Indent as='form' onSubmit={handleUpload} className={style.uploadForm}> <Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
size='compact'
title='Upload custom view'
bodyElements={
<form id={formId} onSubmit={handleUpload} className={style.uploadForm}>
<input <input
ref={fileInputRef} ref={fileInputRef}
style={{ display: 'none' }} style={{ display: 'none' }}
@@ -100,15 +111,17 @@ export default function CustomViewForm({ onComplete, onClose }: CustomViewFormPr
<Panel.Description>Accepted: index.html only, maximum {maxUploadLabel}.</Panel.Description> <Panel.Description>Accepted: index.html only, maximum {maxUploadLabel}.</Panel.Description>
{fileDirty && fileError && <Panel.Error>{fileError}</Panel.Error>} {fileDirty && fileError && <Panel.Error>{fileError}</Panel.Error>}
</div> </div>
</form>
}
footerElements={
<>
{error && <Panel.Error>{error}</Panel.Error>} {error && <Panel.Error>{error}</Panel.Error>}
<Panel.InlineElements align='end'>
<Button onClick={onClose}>Cancel</Button> <Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' loading={isUploading} disabled={!canUpload}> <Button variant='primary' type='submit' form={formId} loading={isUploading} disabled={!canUpload}>
Upload view <IoCloudUploadOutline /> Upload view <IoCloudUploadOutline />
</Button> </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
onClose={onClose}
showBackdrop
showCloseButton
size='small'
title='Create rundown'
bodyElements={
<form id={formId} onSubmit={handleSubmit(createRundown)}>
<label> <label>
<Panel.Description>Rundown title</Panel.Description> <Panel.Description>Rundown title</Panel.Description>
<Input {...register('title')} maxLength={64} fluid placeholder='Your rundown name' /> <Input {...register('title')} fluid placeholder='Your rundown name' maxLength={64} />
</label> </label>
</Panel.Section> </form>
<Panel.InlineElements relation='inner' align='end'> }
footerElements={
<>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button variant='ghosted' disabled={isSubmitting} onClick={onClose}> <Button variant='ghosted' disabled={isSubmitting} onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button type='submit' variant='primary' disabled={isSubmitting}> <Button type='submit' form={formId} variant='primary' disabled={isSubmitting}>
Create rundown Create rundown
</Button> </Button>
</Panel.InlineElements> </>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>} }
</Panel.Indent> />
); );
} }
@@ -153,7 +153,7 @@ export default function ManageRundowns() {
<tr key={id} className={cx([isLoaded && style.current])}> <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,7 +87,15 @@ 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
isOpen
onClose={onCancel}
showBackdrop
showCloseButton
size='compact'
title={isEditMode ? 'Edit custom field' : 'Create custom field'}
bodyElements={
<form id={formId} onSubmit={handleSubmit(setupSubmit)} className={style.formColumn}>
<Info> <Info>
Please note that images can quickly deteriorate your app&apos;s performance. Please note that images can quickly deteriorate your app&apos;s performance.
<br /> <br />
@@ -116,8 +126,8 @@ export default function CustomFieldForm({
if (value.trim().length === 0) return 'Required field'; if (value.trim().length === 0) return 'Required field';
if (!checkRegex.isAlphanumericWithSpace(value)) if (!checkRegex.isAlphanumericWithSpace(value))
return 'Only alphanumeric characters and space are allowed'; return 'Only alphanumeric characters and space are allowed';
if (!isEditMode) { if (!isEditMode && Object.hasOwn(data, customFieldLabelToKey(value) ?? '')) {
if (isEditMode && Object.keys(data).includes(value)) return 'Custom fields must be unique'; return 'Custom fields must be unique';
} }
return true; return true;
}, },
@@ -135,15 +145,19 @@ export default function CustomFieldForm({
<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>
</form>
}
footerElements={
<>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>} {errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.InlineElements relation='inner' align='end'>
<Button variant='ghosted' onClick={onCancel}> <Button variant='ghosted' onClick={onCancel}>
Cancel Cancel
</Button> </Button>
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}> <Button type='submit' form={formId} variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Save Save
</Button> </Button>
</Panel.InlineElements> </>
</Panel.Indent> }
/>
); );
} }
@@ -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,21 +64,28 @@ 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}>
<Panel.InlineElements relation='inner' align='end' className={style.actionsGroup}>
<Button <Button
size='small' size='small'
className={`${identify ? style.blink : ''}`} className={`${identify ? style.blink : ''}`}
@@ -108,6 +115,7 @@ export default function ClientList() {
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;
@@ -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>
{error && <Panel.Error>{error}</Panel.Error>}
<Panel.Section className={cx([style.innerColumn, style.inlineLabels])}>
<Panel.Description> <Panel.Description>
Select data from <i>{`"${fileName}"`}</i> to merge into the current project. Select the sections from <span className={style.sourceFile}>{`"${fileName}"`}</span> that you want to
merge into the current project.
</Panel.Description> </Panel.Description>
<Info type='warning'> <div className={style.mergeSummary}>
This process is irreversible and can result in data loss. <br /> Only the enabled sections are copied. Existing data stays intact.
You may want to create a duplicate backup beforehand. </div>
</div>
<Info type='warning' className={style.mergeWarningInfo}>
<Info.Title>Back up before merging</Info.Title>
<Info.Body>
This action can overwrite project data. Create a duplicate project first if you want a recovery point.
</Info.Body>
</Info> </Info>
<label>
<Panel.ListGroup className={style.optionList}>
<Panel.ListItem>
<label className={style.optionRow}>
<div className={style.optionCopy}>
<span className={style.optionTitle}>Project data</span>
<span className={style.optionBody}>Core project metadata and settings.</span>
</div>
<Switch <Switch
size='large' size='large'
checked={watch('project')} checked={watch('project')}
onCheckedChange={(value: boolean) => setValue('project', value, { shouldDirty: true })} onCheckedChange={(value: boolean) => setValue('project', value, { shouldDirty: true })}
/> />
Project data
</label> </label>
<label> </Panel.ListItem>
<Panel.ListItem>
<label className={style.optionRow}>
<div className={style.optionCopy}>
<span className={style.optionTitle}>Rundown + Custom Fields</span>
<span className={style.optionBody}>Copies all rundowns and any associated custom fields.</span>
</div>
<Switch <Switch
size='large' size='large'
checked={watch('rundowns')} checked={watch('rundowns')}
onCheckedChange={(value: boolean) => setValue('rundowns', value, { shouldDirty: true })} onCheckedChange={(value: boolean) => setValue('rundowns', value, { shouldDirty: true })}
/> />
Rundown + Custom Fields
</label> </label>
<label> </Panel.ListItem>
<Panel.ListItem>
<label className={style.optionRow}>
<div className={style.optionCopy}>
<span className={style.optionTitle}>View Settings</span>
<span className={style.optionBody}>View configuration, and display preferences.</span>
</div>
<Switch <Switch
size='large' size='large'
checked={watch('viewSettings')} checked={watch('viewSettings')}
onCheckedChange={(value: boolean) => setValue('viewSettings', value, { shouldDirty: true })} onCheckedChange={(value: boolean) => setValue('viewSettings', value, { shouldDirty: true })}
/> />
View Settings
</label> </label>
<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 <Switch
size='large' size='large'
checked={watch('urlPresets')} checked={watch('urlPresets')}
onCheckedChange={(value: boolean) => setValue('urlPresets', value, { shouldDirty: true })} onCheckedChange={(value: boolean) => setValue('urlPresets', value, { shouldDirty: true })}
/> />
URL Presets
</label> </label>
<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 <Switch
size='large' size='large'
checked={watch('automation')} checked={watch('automation')}
onCheckedChange={(value: boolean) => setValue('automation', value, { shouldDirty: true })} onCheckedChange={(value: boolean) => setValue('automation', value, { shouldDirty: true })}
/> />
Automation Settings
</label> </label>
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section> </Panel.Section>
</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 {
@@ -59,3 +60,86 @@
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;
}
@@ -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,22 +24,29 @@ export default function ShutdownPanel() {
<> <>
<Panel.Header>Shutdown Ontime</Panel.Header> <Panel.Header>Shutdown Ontime</Panel.Header>
<Panel.Section> <Panel.Section>
<Panel.Card>
<Panel.SubHeader>Shutdown</Panel.SubHeader>
<Panel.Divider />
{isOntimeCloud ? ( {isOntimeCloud ? (
<Panel.Highlight> <Info type='warning'>
For security reasons, shutting down the server must be done from the Ontime Cloud dashboard. For security reasons, shutting down the server must be done from the Ontime Cloud dashboard.
</Panel.Highlight> </Info>
) : ( ) : (
<Panel.Paragraph> <Panel.Field
This will shutdown the Ontime server. <br /> title='Stop the Ontime server'
The runtime state will be lost, but your project is kept for next time. description='The runtime state will be lost, but your project is kept for next time.'
</Panel.Paragraph> />
)} )}
<Button variant='destructive' onClick={handler.open} disabled={!(isElectron || isLocalhost)}> {!isOntimeCloud && (
<Panel.Section>
<Button variant='destructive' onClick={handler.open} disabled={!canShutdown}>
Shutdown ontime Shutdown ontime
</Button> </Button>
{!canShutdown && ( {!canShutdown && <Panel.Description>Only available from the machine running Ontime.</Panel.Description>}
<Panel.Description>Note: Ontime can only be shutdown from the machine it is running in.</Panel.Description> </Panel.Section>
)} )}
</Panel.Card>
</Panel.Section>
<Dialog <Dialog
isOpen={isOpen} isOpen={isOpen}
title='Shutdown Ontime' title='Shutdown Ontime'
@@ -60,7 +68,6 @@ export default function ShutdownPanel() {
</> </>
} }
/> />
</Panel.Section>
</> </>
); );
} }
+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;
} }
+19 -3
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,7 +125,8 @@ 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>
@@ -119,6 +135,6 @@ export default function Log() {
</li> </li>
))} ))}
</ul> </ul>
</> </div>
); );
} }
+18 -5
View File
@@ -7,10 +7,11 @@ test('redirect', async ({ context }) => {
await controllerPage.goto('/editor?settings=network__clients'); await controllerPage.goto('/editor?settings=network__clients');
await remotePage.goto('/timer'); await remotePage.goto('/timer');
await controllerPage.getByTestId('not-self-redirect').click(); const remoteClient = controllerPage.getByRole('row').filter({ hasText: /\/timer/ });
await remoteClient.getByTestId('not-self-redirect').click();
await controllerPage.getByRole('textbox', { name: 'http://localhost:' }).click(); await controllerPage.getByRole('textbox', { name: 'http://localhost:' }).click();
await controllerPage.getByRole('textbox', { name: 'http://localhost:' }).fill('studio'); await controllerPage.getByRole('textbox', { name: 'http://localhost:' }).fill('studio');
await controllerPage.getByLabel('Redirect', { exact: true }).click(); await controllerPage.getByRole('button', { name: 'Redirect to custom path' }).click();
await expect(remotePage.getByTestId('studio-view')).toBeVisible(); await expect(remotePage.getByTestId('studio-view')).toBeVisible();
}); });
@@ -22,7 +23,11 @@ test('identify', async ({ context }) => {
await controllerPage.goto('/editor?settings=network__clients'); await controllerPage.goto('/editor?settings=network__clients');
await remotePage.goto('/timer'); await remotePage.goto('/timer');
await controllerPage.getByTestId('not-self-identify').click(); await controllerPage
.getByRole('row')
.filter({ hasText: /\/timer/ })
.getByTestId('not-self-identify')
.click();
await expect(remotePage.getByTestId('identify-overlay')).toBeVisible(); await expect(remotePage.getByTestId('identify-overlay')).toBeVisible();
}); });
@@ -34,11 +39,19 @@ test('rename', async ({ context }) => {
await controllerPage.goto('/editor?settings=network__clients'); await controllerPage.goto('/editor?settings=network__clients');
await remotePage.goto('/timer'); await remotePage.goto('/timer');
await controllerPage.getByTestId('not-self-rename').click(); await controllerPage
.getByRole('row')
.filter({ hasText: /\/timer/ })
.getByTestId('not-self-rename')
.click();
await controllerPage.getByPlaceholder('new name').click(); await controllerPage.getByPlaceholder('new name').click();
await controllerPage.getByPlaceholder('new name').fill('test'); await controllerPage.getByPlaceholder('new name').fill('test');
await controllerPage.getByRole('button', { name: 'Submit' }).click(); await controllerPage.getByRole('button', { name: 'Submit' }).click();
await controllerPage.getByTestId('not-self-identify').click(); await controllerPage
.getByRole('row')
.filter({ hasText: /\/timer/ })
.getByTestId('not-self-identify')
.click();
await expect(remotePage.getByTestId('identify-overlay')).toContainText('test'); await expect(remotePage.getByTestId('identify-overlay')).toContainText('test');
}); });