mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-02 22:18:08 +00:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d5b48aca6 | |||
| 0f57689750 | |||
| d34280c03d | |||
| bb1e9072fd | |||
| 4503d1e411 | |||
| b4caec0644 | |||
| 960f0322ad | |||
| a12694b186 | |||
| a1d34c8f6c | |||
| 7f8dbbfa87 | |||
| 39e012452b | |||
| c333117347 | |||
| eb0e5e6420 | |||
| 8ba65fa2bd | |||
| efe5ac16f2 | |||
| f4f266dbd4 | |||
| bc9345199b | |||
| 2cc434b0e9 | |||
| 1f71d4578c | |||
| 6c4d80158c | |||
| ab4dd300ca | |||
| 8b84963e17 | |||
| 05e61e7bf8 | |||
| a8fe4c8e68 | |||
| d175c788c5 | |||
| f1f628a48e | |||
| d58335d1a1 | |||
| 78c2bd79b5 | |||
| 28452d1e38 | |||
| 46790441e0 | |||
| 3f35b5e297 | |||
| 49cae647d6 | |||
| 90700c85e8 | |||
| 8fac23c078 | |||
| b5cf6c4bd1 | |||
| 0ea00ff6be | |||
| 30e290c320 | |||
| ac52b3f0c1 | |||
| 8e3db6c559 | |||
| 1d2013ad41 | |||
| 9c910c079d | |||
| 420cef8b0d | |||
| b4c7b84f92 | |||
| 0450eb9169 | |||
| 7fe5223af9 | |||
| ce8d534953 | |||
| 4a6fbc2a92 | |||
| 50ebcf4e14 | |||
| f0f7d1cede | |||
| d2bdb1ea03 | |||
| 9be6febcda | |||
| aa929fd0dc | |||
| 7baa6a4ab9 | |||
| 082fe8b8d7 |
@@ -24,6 +24,7 @@ COPY --from=builder /app/apps/client/build ./client/
|
||||
COPY --from=builder /app/apps/server/dist/ ./server/
|
||||
COPY --from=builder /app/apps/server/src/external/ ./external/
|
||||
COPY --from=builder /app/apps/server/src/user/ ./user/
|
||||
COPY --from=builder /app/apps/server/src/html/ ./html/
|
||||
|
||||
# Export default ports
|
||||
EXPOSE 4001/tcp 8888/udp 9999/udp
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||

|
||||

|
||||

|
||||

|
||||
[](https://github.com/sponsors/cpvalente)
|
||||
[](https://www.buymeacoffee.com/cpvalente)
|
||||
|
||||
@@ -12,7 +13,11 @@
|
||||
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">MacOS Arm</a>
|
||||
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">MacOS Intel</a>
|
||||
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
|
||||
|
||||
... or
|
||||
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
|
||||
- Install from <a href="https://www.npmjs.com/package/ontime">NPM</a>
|
||||
- Install from <a href="https://formulae.brew.sh/cask/ontime">Homebrew</a>
|
||||
|
||||
## Need help?
|
||||
We do our best to have most topics covered by the documentation. However, if your question is not covered, you are welcome to [fill in a bug report in an issue](https://github.com/cpvalente/ontime/issues), [ask a question in GitHub discussions](https://github.com/cpvalente/ontime/discussions) or hop in the [discord server](https://discord.com/invite/eje3CSUEXm) for a chat.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "3.10.3",
|
||||
"version": "3.11.0-beta.1",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
],
|
||||
"plugins": [
|
||||
"react",
|
||||
"testing-library",
|
||||
"simple-import-sort",
|
||||
"@tanstack/query"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "3.10.3",
|
||||
"version": "3.11.0-beta.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -8,6 +8,7 @@
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/is-prop-valid": "^1.3.1",
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@emotion/styled": "^11.10.6",
|
||||
"@fontsource/open-sans": "^5.0.28",
|
||||
@@ -62,13 +63,9 @@
|
||||
"devDependencies": {
|
||||
"@sentry/vite-plugin": "^2.16.1",
|
||||
"@tanstack/eslint-plugin-query": "^5.8.4",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.1.1",
|
||||
"@testing-library/user-event": "^14.1.1",
|
||||
"@types/color": "^3.0.3",
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@types/testing-library__jest-dom": "^5.14.5",
|
||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||
"@typescript-eslint/parser": "catalog:",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
@@ -79,8 +76,7 @@
|
||||
"eslint-plugin-react": "^7.32.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-simple-import-sort": "^8.0.0",
|
||||
"eslint-plugin-testing-library": "^5.9.1",
|
||||
"jsdom": "^21.1.0",
|
||||
"happy-dom": "^16.7.2",
|
||||
"ontime-types": "workspace:*",
|
||||
"ontime-utils": "workspace:*",
|
||||
"prettier": "catalog:",
|
||||
|
||||
@@ -22,7 +22,7 @@ const Editor = React.lazy(() => import('./features/editors/ProtectedEditor'));
|
||||
const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
|
||||
const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
|
||||
|
||||
const TimerView = React.lazy(() => import('./features/viewers/timer/Timer'));
|
||||
const TimerView = React.lazy(() => import('./views/timer/Timer'));
|
||||
const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
|
||||
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
|
||||
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import axios from 'axios';
|
||||
import type {
|
||||
Automation,
|
||||
AutomationDTO,
|
||||
AutomationOutput,
|
||||
AutomationSettings,
|
||||
Trigger,
|
||||
TriggerDTO,
|
||||
} from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const automationsPath = `${apiEntryUrl}/automations`;
|
||||
|
||||
/**
|
||||
* HTTP request to get the automations settings
|
||||
*/
|
||||
export async function getAutomationSettings(): Promise<AutomationSettings> {
|
||||
const res = await axios.get(automationsPath);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to edit the automations settings
|
||||
*/
|
||||
export async function editAutomationSettings(
|
||||
automationSettings: Partial<AutomationSettings>,
|
||||
): Promise<AutomationSettings> {
|
||||
const res = await axios.post(automationsPath, automationSettings);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to create a new automation trigger
|
||||
*/
|
||||
export async function addTrigger(trigger: TriggerDTO): Promise<Trigger> {
|
||||
const res = await axios.post(`${automationsPath}/trigger`, trigger);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to update an automation trigger
|
||||
*/
|
||||
export async function editTrigger(id: string, trigger: Trigger): Promise<Trigger> {
|
||||
const res = await axios.put(`${automationsPath}/trigger/${id}`, trigger);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to delete an automation trigger
|
||||
*/
|
||||
export function deleteTrigger(id: string): Promise<void> {
|
||||
return axios.delete(`${automationsPath}/trigger/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to create a new automation
|
||||
*/
|
||||
export async function addAutomation(automation: AutomationDTO): Promise<Automation> {
|
||||
const res = await axios.post(`${automationsPath}/automation`, automation);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to update a automation
|
||||
*/
|
||||
export async function editAutomation(id: string, automation: Automation): Promise<Automation> {
|
||||
const res = await axios.put(`${automationsPath}/automation/${id}`, automation);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to delete a automation
|
||||
*/
|
||||
export function deleteAutomation(id: string): Promise<void> {
|
||||
return axios.delete(`${automationsPath}/automation/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to test automation output
|
||||
* The return is irrelevant as we care for the resolution of the promise
|
||||
*/
|
||||
export function testOutput(output: AutomationOutput): Promise<void> {
|
||||
return axios.post(`${automationsPath}/test`, output);
|
||||
}
|
||||
@@ -4,9 +4,8 @@ import { serverURL } from '../../externals';
|
||||
export const APP_INFO = ['appinfo'];
|
||||
export const APP_SETTINGS = ['appSettings'];
|
||||
export const APP_VERSION = ['appVersion'];
|
||||
export const AUTOMATION = ['automation'];
|
||||
export const CUSTOM_FIELDS = ['customFields'];
|
||||
export const HTTP_SETTINGS = ['httpSettings'];
|
||||
export const OSC_SETTINGS = ['oscSettings'];
|
||||
export const PROJECT_DATA = ['project'];
|
||||
export const PROJECT_LIST = ['projectList'];
|
||||
export const RUNDOWN = ['rundown'];
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { HttpSettings } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const httpPath = `${apiEntryUrl}/http`;
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve http settings
|
||||
*/
|
||||
export async function getHTTP(): Promise<HttpSettings> {
|
||||
const res = await axios.get(httpPath);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to mutate http settings
|
||||
*/
|
||||
export async function postHTTP(data: HttpSettings): Promise<AxiosResponse<HttpSettings>> {
|
||||
return axios.post(httpPath, data);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { OSCSettings } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const oscPath = `${apiEntryUrl}/osc`;
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve osc settings
|
||||
*/
|
||||
export async function getOSC(): Promise<OSCSettings> {
|
||||
const res = await axios.get(oscPath);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to mutate osc settings
|
||||
*/
|
||||
export async function postOSC(data: OSCSettings): Promise<AxiosResponse<OSCSettings>> {
|
||||
return axios.post(oscPath, data);
|
||||
}
|
||||
@@ -12,3 +12,16 @@ export async function getInfo(): Promise<GetInfo> {
|
||||
const res = await axios.get(`${sessionPath}/info`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to get a pre-authenticated URL
|
||||
*/
|
||||
export async function generateUrl(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
lock: boolean,
|
||||
authenticate: boolean,
|
||||
): Promise<string> {
|
||||
const res = await axios.post(`${sessionPath}/url`, { baseUrl, path, lock, authenticate });
|
||||
return res.data.url;
|
||||
}
|
||||
|
||||
@@ -19,3 +19,10 @@ export async function getSettings(): Promise<Settings> {
|
||||
export async function postSettings(data: Settings): Promise<AxiosResponse<Settings>> {
|
||||
return axios.post(settingsPath, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows setting the welcome modal dialog state from the clients
|
||||
*/
|
||||
export async function postShowWelcomeDialog(show: boolean) {
|
||||
axios.post(`${settingsPath}/welcomedialog`, { show });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
.infoLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $element-spacing;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
background-color: $gray-1100;
|
||||
border-radius: 2px;
|
||||
font-size: $inner-section-text-size;
|
||||
|
||||
svg {
|
||||
font-size: 1.5rem;
|
||||
color: $info-blue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { IoAlertCircle } from '@react-icons/all-files/io5/IoAlertCircle';
|
||||
|
||||
import style from './Info.module.scss';
|
||||
|
||||
export default function Info({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<div className={style.infoLabel}>
|
||||
<IoAlertCircle />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
$progress-bar-size: 12px;
|
||||
$progress-bar-size: 1.5rem;
|
||||
$progress-bar-br: 3px;
|
||||
|
||||
.multiprogress-bar {
|
||||
@@ -53,3 +53,6 @@ $progress-bar-br: 3px;
|
||||
.multiprogress-bar__bg-danger {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.multiprogress-bar__bg-danger--overtime {
|
||||
background: var(--timer-overtime-color-override, $timer-finished-color);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MaybeNumber } from 'ontime-types';
|
||||
|
||||
import { getProgress } from '../../utils/getProgress';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import './MultiPartProgressBar.scss';
|
||||
|
||||
@@ -13,6 +14,7 @@ interface MultiPartProgressBar {
|
||||
danger?: MaybeNumber;
|
||||
dangerColor: string;
|
||||
hidden?: boolean;
|
||||
hideOvertime?: boolean;
|
||||
ignoreCssOverride?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
@@ -27,6 +29,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
||||
danger,
|
||||
dangerColor,
|
||||
hidden,
|
||||
hideOvertime,
|
||||
ignoreCssOverride,
|
||||
className = '',
|
||||
} = props;
|
||||
@@ -34,12 +37,17 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
||||
const percentRemaining = 100 - getProgress(now, complete);
|
||||
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
|
||||
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
|
||||
const isOvertime = now !== null && now < 0;
|
||||
const showOvertime = isOvertime && !hideOvertime;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${
|
||||
ignoreCssOverride ? 'multiprogress-bar--ignore-css-override' : ''
|
||||
} ${className}`}
|
||||
className={cx([
|
||||
'multiprogress-bar',
|
||||
hidden && 'multiprogress-bar--hidden',
|
||||
ignoreCssOverride && 'multiprogress-bar--ignore-css-override',
|
||||
className,
|
||||
])}
|
||||
>
|
||||
{now !== null && (
|
||||
<>
|
||||
@@ -50,8 +58,8 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
||||
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
|
||||
/>
|
||||
<div
|
||||
className='multiprogress-bar__bg-danger'
|
||||
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
|
||||
className={cx(['multiprogress-bar__bg-danger', showOvertime && 'multiprogress-bar__bg-danger--overtime'])}
|
||||
style={{ width: `${dangerWidth}%`, backgroundColor: showOvertime ? undefined : dangerColor }}
|
||||
/>
|
||||
</div>
|
||||
<div className='multiprogress-bar__indicator'>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, PropsWithChildren, useRef } from 'react';
|
||||
import { memo, PropsWithChildren } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
@@ -19,7 +19,6 @@ import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
||||
|
||||
import { isLocalhost } from '../../../externals';
|
||||
import { navigatorConstants } from '../../../viewerConfig';
|
||||
import useClickOutside from '../../hooks/useClickOutside';
|
||||
import { useElectronEvent } from '../../hooks/useElectronEvent';
|
||||
import useInfo from '../../hooks-query/useInfo';
|
||||
import { useClientStore } from '../../stores/clientStore';
|
||||
@@ -48,12 +47,8 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
const { mirror, toggleMirror } = useViewOptionsStore();
|
||||
const location = useLocation();
|
||||
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useClickOutside(menuRef, () => onClose);
|
||||
|
||||
return createPortal(
|
||||
<div id='navigation-menu-portal' ref={menuRef}>
|
||||
<div id='navigation-menu-portal'>
|
||||
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
|
||||
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
|
||||
<DrawerOverlay />
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
$title-primary-size: clamp(1.5rem, 3vw, 3rem);
|
||||
$title-secondary-size: clamp(1rem, 2vw, 2.25rem);
|
||||
|
||||
.title-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.title-card__title,
|
||||
@@ -16,13 +18,13 @@
|
||||
|
||||
.title-card__title {
|
||||
font-weight: 600;
|
||||
font-size: clamp(1.5rem, 3vw, 3rem);
|
||||
font-size: $title-primary-size;
|
||||
color: var(--color-override, $viewer-color);
|
||||
line-height: 1.2em;
|
||||
}
|
||||
|
||||
.title-card__secondary {
|
||||
font-size: clamp(1rem, 2vw, 2.25rem);
|
||||
font-size: $title-secondary-size;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
line-height: 1.2em;
|
||||
}
|
||||
@@ -31,7 +33,7 @@
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 0.5rem;
|
||||
font-size: clamp(1rem, 1.5vw, 1.5rem);
|
||||
font-size: $timer-label-size;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
margin-left: auto;
|
||||
text-transform: uppercase;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
import './TitleCard.scss';
|
||||
|
||||
interface TitleCardProps {
|
||||
title: string;
|
||||
title?: string;
|
||||
label?: 'now' | 'next';
|
||||
secondary?: string;
|
||||
className?: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.drawerFooter {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
justify-content: end;
|
||||
gap: $section-spacing;
|
||||
|
||||
button {
|
||||
@@ -8,56 +8,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.infoLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $element-spacing;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
background-color: $gray-1100;
|
||||
border-radius: 2px;
|
||||
font-size: $inner-section-text-size;
|
||||
|
||||
svg {
|
||||
font-size: 1.5rem;
|
||||
color: $info-blue;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: $inner-section-text-size;
|
||||
color: $label-gray;
|
||||
.sectionList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
color: $ui-white;
|
||||
font-size: 1rem;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.fieldSet {
|
||||
display: flex;
|
||||
padding: $section-spacing 0;
|
||||
flex-direction: column;
|
||||
gap: $element-spacing;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: $inner-section-text-size;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: $inner-section-text-size;
|
||||
display: block;
|
||||
color: $modal-note-color;
|
||||
min-height: 100%;
|
||||
gap: 2rem;
|
||||
overflow-y: scroll;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -11,12 +11,12 @@ import {
|
||||
DrawerOverlay,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import { IoAlertCircle } from '@react-icons/all-files/io5/IoAlertCircle';
|
||||
|
||||
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
||||
import Info from '../info/Info';
|
||||
|
||||
import ParamInput from './ParamInput';
|
||||
import { isSection, ViewOption } from './types';
|
||||
import { ViewOption } from './types';
|
||||
import ViewParamsSection from './ViewParamsSection';
|
||||
|
||||
import style from './ViewParamsEditor.module.scss';
|
||||
|
||||
@@ -40,15 +40,15 @@ const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOp
|
||||
|
||||
// Convert paramFields to an object that contains default values
|
||||
const defaultValues: Record<string, string> = {};
|
||||
paramFields.forEach((option) => {
|
||||
if (!isSection(option)) {
|
||||
paramFields.forEach((section) => {
|
||||
section.options.forEach((option) => {
|
||||
defaultValues[option.id] = String(option.defaultValue);
|
||||
}
|
||||
|
||||
// extract persisted values
|
||||
if ('type' in option && option.type === 'persist') {
|
||||
newSearchParams.set(option.id, option.value);
|
||||
}
|
||||
// extract persisted values
|
||||
if ('type' in option && option.type === 'persist') {
|
||||
newSearchParams.set(option.id, option.value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// compare which values are different from the default values
|
||||
@@ -132,36 +132,16 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
||||
</DrawerHeader>
|
||||
|
||||
<DrawerBody>
|
||||
{viewSettings.overrideStyles && (
|
||||
<div className={style.infoLabel}>
|
||||
<IoAlertCircle />
|
||||
This view style is being modified by a custom CSS file. <br />
|
||||
</div>
|
||||
)}
|
||||
<form id='edit-params-form' onSubmit={onParamsFormSubmit}>
|
||||
{viewOptions.map((option) => {
|
||||
if (isSection(option)) {
|
||||
return (
|
||||
<div key={option.section} className={style.section}>
|
||||
{option.section}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (option.type === 'persist') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={option.title} className={style.fieldSet}>
|
||||
<label className={style.label}>
|
||||
<span className={style.title}>{option.title}</span>
|
||||
<span className={style.description}>{option.description}</span>
|
||||
<ParamInput key={option.title} paramField={option} />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{viewSettings.overrideStyles && <Info>This view style is being modified by a custom CSS file.</Info>}
|
||||
<form id='edit-params-form' onSubmit={onParamsFormSubmit} className={style.sectionList}>
|
||||
{viewOptions.map((section) => (
|
||||
<ViewParamsSection
|
||||
key={section.title}
|
||||
title={section.title}
|
||||
collapsible={section.collapsible}
|
||||
options={section.options}
|
||||
/>
|
||||
))}
|
||||
</form>
|
||||
</DrawerBody>
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
.section {
|
||||
color: $ui-white;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 2rem;
|
||||
|
||||
&.collapsible {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-bottom: 1px solid $white-10;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-top: $section-spacing;
|
||||
font-size: $inner-section-text-size;
|
||||
color: $label-gray;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: $inner-section-text-size;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: $inner-section-text-size;
|
||||
display: block;
|
||||
color: $modal-note-color;
|
||||
}
|
||||
|
||||
.closed {
|
||||
transition: rotate $transition-time-feedback;
|
||||
rotate: 0deg;
|
||||
}
|
||||
|
||||
.open {
|
||||
transition: rotate $transition-time-feedback;
|
||||
rotate: 180deg;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
|
||||
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import ParamInput from './ParamInput';
|
||||
import { type ParamField } from './types';
|
||||
|
||||
import style from './ViewParamsSection.module.scss';
|
||||
|
||||
interface ViewParamsSectionProps {
|
||||
title: string;
|
||||
collapsible?: boolean;
|
||||
options: ParamField[];
|
||||
}
|
||||
|
||||
export default function ViewParamsSection(props: ViewParamsSectionProps) {
|
||||
const { title, collapsible, options } = props;
|
||||
|
||||
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
|
||||
|
||||
const handleCollapse = () => {
|
||||
if (collapsible) {
|
||||
setCollapsed((prev) => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={style.section}>
|
||||
<div className={cx([style.sectionHeader, collapsible && style.collapsible])} onClick={handleCollapse}>
|
||||
{title}
|
||||
{collapsible && <IoChevronDown className={cx([collapsed ? style.closed : style.open])} />}
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<>
|
||||
{options.map((option) => {
|
||||
if (option.type === 'persist') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<label key={option.title} className={style.label}>
|
||||
<span className={style.title}>{option.title}</span>
|
||||
<span className={style.description}>{option.description}</span>
|
||||
<ParamInput paramField={option} />
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -38,3 +38,14 @@ export const showLeadingZeros: ParamField = {
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
};
|
||||
|
||||
export enum OptionTitle {
|
||||
ClockOptions = 'Clock Options',
|
||||
TimerOptions = 'Timer Options',
|
||||
DataSources = 'Data sources',
|
||||
ElementVisibility = 'Element visibility',
|
||||
BehaviourOptions = 'View behaviour',
|
||||
StyleOverride = 'View style override',
|
||||
Animation = 'View animation',
|
||||
Schedule = 'Schedule options',
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
type ParamSection = {
|
||||
section: string;
|
||||
};
|
||||
import { OptionTitle } from './constants';
|
||||
|
||||
type BaseField = {
|
||||
id: string;
|
||||
@@ -28,11 +26,9 @@ type PersistedField = { type: 'persist'; defaultValue?: string; value: string };
|
||||
|
||||
export type ParamField = BaseField &
|
||||
(OptionsField | MultiOptionsField | StringField | NumberField | BooleanField | ColourField | PersistedField);
|
||||
export type ViewOption = ParamSection | ParamField;
|
||||
|
||||
/**
|
||||
* Type assertion utility checks whether an entry is a section separator
|
||||
*/
|
||||
export function isSection(entry: ViewOption): entry is ParamSection {
|
||||
return 'section' in entry;
|
||||
}
|
||||
export type ViewOption = {
|
||||
title: OptionTitle;
|
||||
options: ParamField[];
|
||||
collapsible?: boolean;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { editAutomationSettings, getAutomationSettings } from '../api/automation';
|
||||
import { AUTOMATION } from '../api/constants';
|
||||
import { logAxiosError } from '../api/utils';
|
||||
import { automationPlaceholderSettings } from '../models/AutomationSettings';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
|
||||
export default function useAutomationSettings() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: AUTOMATION,
|
||||
queryFn: getAutomationSettings,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt: number) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch };
|
||||
}
|
||||
|
||||
export function useAutomationSettingsMutation() {
|
||||
const { isPending, mutateAsync } = useMutation({
|
||||
mutationFn: editAutomationSettings,
|
||||
onError: (error) => logAxiosError('Error saving Automation settings', error),
|
||||
onSuccess: (data) => {
|
||||
ontimeQueryClient.setQueryData(AUTOMATION, data);
|
||||
},
|
||||
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: AUTOMATION }),
|
||||
});
|
||||
return { isPending, mutateAsync };
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { HTTP_SETTINGS } from '../api/constants';
|
||||
import { getHTTP, postHTTP } from '../api/http';
|
||||
import { logAxiosError } from '../api/utils';
|
||||
import { httpPlaceholder } from '../models/Http';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
|
||||
export function useHttpSettings() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: HTTP_SETTINGS,
|
||||
queryFn: getHTTP,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt: number) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
return { data: data ?? httpPlaceholder, status, isFetching, isError, refetch };
|
||||
}
|
||||
|
||||
export function usePostHttpSettings() {
|
||||
const { isPending, mutateAsync } = useMutation({
|
||||
mutationFn: postHTTP,
|
||||
onError: (error) => logAxiosError('Error saving HTTP settings', error),
|
||||
onSuccess: (res) => {
|
||||
ontimeQueryClient.setQueryData(HTTP_SETTINGS, res.data);
|
||||
},
|
||||
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: HTTP_SETTINGS }),
|
||||
});
|
||||
return { isPending, mutateAsync };
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { OSC_SETTINGS } from '../api/constants';
|
||||
import { getOSC, postOSC } from '../api/osc';
|
||||
import { logAxiosError } from '../api/utils';
|
||||
import { oscPlaceholderSettings } from '../models/OscSettings';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
|
||||
export default function useOscSettings() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: OSC_SETTINGS,
|
||||
queryFn: getOSC,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt: number) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
return { data: data ?? oscPlaceholderSettings, status, isFetching, isError, refetch };
|
||||
}
|
||||
|
||||
export function useOscSettingsMutation() {
|
||||
const { isPending, mutateAsync } = useMutation({
|
||||
mutationFn: postOSC,
|
||||
onError: (error) => logAxiosError('Error saving OSC settings', error),
|
||||
onSuccess: (res) => {
|
||||
ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data);
|
||||
},
|
||||
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
|
||||
});
|
||||
return { isPending, mutateAsync };
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import useClickOutside from '../useClickOutside';
|
||||
|
||||
describe('useClickOutside', () => {
|
||||
let target: HTMLElement;
|
||||
let anotherElement: HTMLElement;
|
||||
|
||||
beforeAll(() => {
|
||||
target = global.document.createElement('div');
|
||||
global.document.body.appendChild(target);
|
||||
|
||||
anotherElement = global.document.createElement('div');
|
||||
global.document.body.appendChild(anotherElement);
|
||||
});
|
||||
|
||||
it('should trigger clicking outside', () => {
|
||||
const ref = { current: target };
|
||||
const callback = vi.fn();
|
||||
renderHook(() => useClickOutside(ref, callback));
|
||||
|
||||
act(() => {
|
||||
global.document.dispatchEvent(new Event('click'));
|
||||
});
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
anotherElement.click();
|
||||
});
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should not trigger clicking inside', () => {
|
||||
const ref = { current: target };
|
||||
const callback = vi.fn();
|
||||
renderHook(() => useClickOutside(ref, callback));
|
||||
|
||||
act(() => {
|
||||
target.click();
|
||||
});
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import { RefObject, useEffect } from 'react';
|
||||
|
||||
type ClickOutsideEventHandler = (event: MouseEvent) => void;
|
||||
|
||||
export default function useClickOutside<T extends HTMLElement = HTMLElement>(
|
||||
ref: RefObject<T>,
|
||||
callback: ClickOutsideEventHandler,
|
||||
) {
|
||||
useEffect(() => {
|
||||
function handleClick(event: MouseEvent) {
|
||||
const element = ref?.current;
|
||||
|
||||
// Do nothing if clicking ref's element or descendent element
|
||||
if (!element || element.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
callback(event);
|
||||
}
|
||||
|
||||
document.addEventListener('click', handleClick);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick);
|
||||
};
|
||||
}, [ref, callback]);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { AutomationSettings } from 'ontime-types';
|
||||
|
||||
export const automationPlaceholderSettings: AutomationSettings = {
|
||||
enabledAutomations: false,
|
||||
enabledOscIn: false,
|
||||
oscPortIn: 8888,
|
||||
triggers: [],
|
||||
automations: {},
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import { HttpSettings } from 'ontime-types';
|
||||
|
||||
export const httpPlaceholder: HttpSettings = {
|
||||
enabledOut: false,
|
||||
subscriptions: [],
|
||||
};
|
||||
@@ -1,11 +1,8 @@
|
||||
import { GetInfo } from 'ontime-types';
|
||||
|
||||
import { oscPlaceholderSettings } from './OscSettings';
|
||||
|
||||
export const ontimePlaceholderInfo: GetInfo = {
|
||||
networkInterfaces: [],
|
||||
version: '2.0.0',
|
||||
serverPort: 4001,
|
||||
osc: oscPlaceholderSettings,
|
||||
publicDir: '',
|
||||
};
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { OSCSettings } from 'ontime-types';
|
||||
|
||||
export const oscPlaceholderSettings: OSCSettings = {
|
||||
portIn: 8888,
|
||||
portOut: 9999,
|
||||
targetIP: '127.0.0.1',
|
||||
enabledIn: false,
|
||||
enabledOut: false,
|
||||
subscriptions: [],
|
||||
};
|
||||
@@ -14,6 +14,18 @@ export const runtimeStore = createWithEqualityFn<RuntimeStore>(
|
||||
export const useRuntimeStore = <T>(selector: (state: RuntimeStore) => T) =>
|
||||
useStoreWithEqualityFn(runtimeStore, selector, deepCompare);
|
||||
|
||||
let batchStore: Partial<RuntimeStore> = {};
|
||||
|
||||
export function addToBatchUpdates<K extends keyof RuntimeStore>(key: K, value: RuntimeStore[K]) {
|
||||
batchStore[key] = value;
|
||||
}
|
||||
|
||||
export function flushBatchUpdates() {
|
||||
const state = runtimeStore.getState();
|
||||
runtimeStore.setState({ ...state, ...batchStore });
|
||||
batchStore = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows patching a property of the runtime store
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { linkToOtherHost } from '../linkUtils';
|
||||
|
||||
describe('linkToOTherHost', () => {
|
||||
it('should handle electron links', () => {
|
||||
const serverUrl = 'http://localhost:4001';
|
||||
const baseUri = '';
|
||||
const destination = linkToOtherHost('192.168.10.166', 'path', serverUrl, baseUri);
|
||||
expect(destination).toBe('http://192.168.10.166:4001/path');
|
||||
});
|
||||
|
||||
it('should handle ontime cloud links', () => {
|
||||
const serverUrl = 'https://cloud.getontime.no/user-hash';
|
||||
const baseUri = 'user-hash';
|
||||
const destination = linkToOtherHost('cloud.getontime.no', 'path', serverUrl, baseUri);
|
||||
expect(destination).toBe('https://cloud.getontime.no/user-hash/path');
|
||||
});
|
||||
});
|
||||
@@ -26,8 +26,8 @@ describe('simple tests for regex', () => {
|
||||
});
|
||||
|
||||
test('startsWithHttp', () => {
|
||||
const right = ['http://test'];
|
||||
const wrong = ['https://test', 'testing', '123.0.1'];
|
||||
const right = ['https://test', 'http://test'];
|
||||
const wrong = ['testing', '123.0.1'];
|
||||
|
||||
right.forEach((t) => {
|
||||
expect(startsWithHttp.test(t)).toBe(true);
|
||||
|
||||
@@ -18,23 +18,32 @@ export function openLink(url: string) {
|
||||
|
||||
/**
|
||||
* Handles opening external links
|
||||
* @param event
|
||||
* @param location
|
||||
* serverUrl and baseURI are used for testing
|
||||
*/
|
||||
export function handleLinks(event: MouseEvent, location: string) {
|
||||
export function handleLinks(
|
||||
event: MouseEvent,
|
||||
location: string,
|
||||
externalServerUrl: string = serverURL,
|
||||
externalBaseURI: string = baseURI,
|
||||
) {
|
||||
// we handle the link manually
|
||||
event.preventDefault();
|
||||
|
||||
const destination = new URL(serverURL);
|
||||
destination.pathname = baseURI ? `${baseURI}/${location}` : location;
|
||||
const destination = new URL(externalServerUrl);
|
||||
destination.pathname = externalBaseURI ? `${externalBaseURI}/${location}` : location;
|
||||
openLink(destination.toString());
|
||||
}
|
||||
|
||||
export function linkToOtherHost(host: string, path?: string) {
|
||||
const destination = new URL(serverURL);
|
||||
export function linkToOtherHost(
|
||||
host: string,
|
||||
path?: string,
|
||||
externalServerUrl: string = serverURL,
|
||||
externalBaseURI: string = baseURI,
|
||||
) {
|
||||
const destination = new URL(externalServerUrl);
|
||||
destination.hostname = host;
|
||||
if (path) {
|
||||
destination.pathname = baseURI ? `${baseURI}/${path}` : path;
|
||||
destination.pathname = externalBaseURI ? `${externalBaseURI}/${path}` : path;
|
||||
}
|
||||
return destination.toString();
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
export const isOnlyNumbers = /^\d+$/;
|
||||
export const isIPAddress = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/;
|
||||
export const startsWithHttp = /^http:\/\//;
|
||||
export const startsWithHttp = /^https?:\/\//;
|
||||
export const startsWithSlash = /^\//;
|
||||
export const isAlphanumeric = /^[a-z0-9]+$/i;
|
||||
export const isASCII = /^[ -~]+$/; //https://catonmat.net/my-favorite-regex
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from '../stores/clientStore';
|
||||
import { addDialog } from '../stores/dialogStore';
|
||||
import { addLog } from '../stores/logger';
|
||||
import { patchRuntime, patchRuntimeProperty } from '../stores/runtime';
|
||||
import { addToBatchUpdates, flushBatchUpdates, patchRuntime, patchRuntimeProperty } from '../stores/runtime';
|
||||
|
||||
export let websocket: WebSocket | null = null;
|
||||
let reconnectTimeout: NodeJS.Timeout | null = null;
|
||||
@@ -140,57 +140,57 @@ export const connectSocket = () => {
|
||||
break;
|
||||
}
|
||||
case 'ontime-clock': {
|
||||
patchRuntimeProperty('clock', payload);
|
||||
addToBatchUpdates('clock', payload);
|
||||
updateDevTools({ clock: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-timer': {
|
||||
patchRuntimeProperty('timer', payload);
|
||||
addToBatchUpdates('timer', payload);
|
||||
updateDevTools({ timer: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-onAir': {
|
||||
patchRuntimeProperty('onAir', payload);
|
||||
addToBatchUpdates('onAir', payload);
|
||||
updateDevTools({ onAir: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-message': {
|
||||
patchRuntimeProperty('message', payload);
|
||||
addToBatchUpdates('message', payload);
|
||||
updateDevTools({ message: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-runtime': {
|
||||
patchRuntimeProperty('runtime', payload);
|
||||
addToBatchUpdates('runtime', payload);
|
||||
updateDevTools({ runtime: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-eventNow': {
|
||||
patchRuntimeProperty('eventNow', payload);
|
||||
addToBatchUpdates('eventNow', payload);
|
||||
updateDevTools({ eventNow: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-currentBlock': {
|
||||
patchRuntimeProperty('currentBlock', payload);
|
||||
addToBatchUpdates('currentBlock', payload);
|
||||
updateDevTools({ currentBlock: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-publicEventNow': {
|
||||
patchRuntimeProperty('publicEventNow', payload);
|
||||
addToBatchUpdates('publicEventNow', payload);
|
||||
updateDevTools({ publicEventNow: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-eventNext': {
|
||||
patchRuntimeProperty('eventNext', payload);
|
||||
addToBatchUpdates('eventNext', payload);
|
||||
updateDevTools({ eventNext: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-publicEventNext': {
|
||||
patchRuntimeProperty('publicEventNext', payload);
|
||||
addToBatchUpdates('publicEventNext', payload);
|
||||
updateDevTools({ publicEventNext: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-auxtimer1': {
|
||||
patchRuntimeProperty('auxtimer1', payload);
|
||||
addToBatchUpdates('auxtimer1', payload);
|
||||
updateDevTools({ auxtimer1: payload });
|
||||
break;
|
||||
}
|
||||
@@ -207,6 +207,10 @@ export const connectSocket = () => {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'ontime-flush': {
|
||||
flushBatchUpdates()
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore unhandled
|
||||
|
||||
@@ -28,6 +28,13 @@ export const validateUrlPresetPath = (preset: string): { message: string; isVali
|
||||
return { isValid: true, message: 'ok' };
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility removes trailing slash from a string
|
||||
*/
|
||||
function removeTrailingSlash(text: string): string {
|
||||
return text.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the URL to send a preset to
|
||||
* @param location
|
||||
@@ -38,7 +45,7 @@ export const getRouteFromPreset = (location: Location, data: URLPreset[], search
|
||||
const currentURL = location.pathname.substring(1);
|
||||
|
||||
// we need to check if the whole url here is an alias, so we can redirect
|
||||
const foundPreset = data.filter((d) => d.alias === currentURL && d.enabled)[0];
|
||||
const foundPreset = data.find((preset) => preset.alias === removeTrailingSlash(currentURL) && preset.enabled);
|
||||
if (foundPreset) {
|
||||
return generateUrlFromPreset(foundPreset);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
|
||||
export const documentationUrl = 'https://docs.getontime.no';
|
||||
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
|
||||
|
||||
export const githubSponsorUrl = 'https://github.com/sponsors/cpvalente';
|
||||
export const buyMeACoffeeUrl = 'https://buymeacoffee.com/cpvalente';
|
||||
|
||||
// resolve environment
|
||||
export const appVersion = version;
|
||||
export const isProduction = import.meta.env.MODE === 'production';
|
||||
@@ -72,4 +75,4 @@ function resolveBaseURI(): string {
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,9 @@ import { ErrorBoundary } from '@sentry/react';
|
||||
import { useKeyDown } from '../../common/hooks/useKeyDown';
|
||||
|
||||
import AboutPanel from './panel/about-panel/AboutPanel';
|
||||
import ClientControlPanel from './panel/client-control-panel/ClientControlPanel';
|
||||
import AutomationPanel from './panel/automations-panel/AutomationPanel';
|
||||
import FeatureSettingsPanel from './panel/feature-settings-panel/FeatureSettingsPanel';
|
||||
import GeneralPanel from './panel/general-panel/GeneralPanel';
|
||||
import IntegrationsPanel from './panel/integrations-panel/IntegrationsPanel';
|
||||
import NetworkLogPanel from './panel/network-panel/NetworkLogPanel';
|
||||
import ProjectPanel from './panel/project-panel/ProjectPanel';
|
||||
import ShutdownPanel from './panel/shutdown-panel/ShutdownPanel';
|
||||
@@ -30,10 +29,9 @@ export default function AppSettings() {
|
||||
{panel === 'general' && <GeneralPanel location={location} />}
|
||||
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
|
||||
{panel === 'sources' && <SourcesPanel />}
|
||||
{panel === 'integrations' && <IntegrationsPanel location={location} />}
|
||||
{panel === 'client_control' && <ClientControlPanel />}
|
||||
{panel === 'about' && <AboutPanel />}
|
||||
{panel === 'automation' && <AutomationPanel location={location} />}
|
||||
{panel === 'network' && <NetworkLogPanel location={location} />}
|
||||
{panel === 'about' && <AboutPanel />}
|
||||
{panel === 'shutdown' && <ShutdownPanel />}
|
||||
</PanelContent>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -18,4 +17,5 @@
|
||||
margin: 1rem;
|
||||
overflow-y: auto;
|
||||
flex-grow: 1;
|
||||
padding-bottom: 300px;
|
||||
}
|
||||
|
||||
@@ -26,13 +26,13 @@ $inner-padding: 1rem;
|
||||
color: $gray-300;
|
||||
}
|
||||
|
||||
.section, .indent {
|
||||
.section,
|
||||
.indent {
|
||||
font-size: calc(1rem - 1px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
color: $ui-white;
|
||||
|
||||
}
|
||||
|
||||
.section {
|
||||
@@ -111,6 +111,12 @@ $inner-padding: 1rem;
|
||||
tr:nth-child(even) {
|
||||
background-color: $white-1;
|
||||
}
|
||||
|
||||
// allow highlighting table elements
|
||||
tr[data-warn='true'],
|
||||
td[data-warn='true'] {
|
||||
background-color: $orange-1300;
|
||||
}
|
||||
}
|
||||
|
||||
.listGroup {
|
||||
@@ -177,7 +183,7 @@ $inner-padding: 1rem;
|
||||
color: $muted-gray;
|
||||
|
||||
td {
|
||||
padding: 2rem;
|
||||
padding-block: 1rem;
|
||||
}
|
||||
|
||||
button {
|
||||
@@ -185,23 +191,21 @@ $inner-padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.inlineSiblings {
|
||||
.inlineElements {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
background-color: $black-10;
|
||||
text-align: center;
|
||||
color: $muted-gray;
|
||||
|
||||
td {
|
||||
padding: 2rem;
|
||||
&.inner {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 1rem;
|
||||
&.component {
|
||||
gap: 1rem;
|
||||
}
|
||||
&.start {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
&.end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HTMLAttributes, ReactNode } from 'react';
|
||||
import { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
|
||||
@@ -55,20 +55,19 @@ export function Card({ children, className, ...props }: { children: ReactNode }
|
||||
}
|
||||
|
||||
export function Table({ className, children }: { className?: string; children: ReactNode }) {
|
||||
const classes = cx([style.table, className]);
|
||||
return (
|
||||
<div className={style.pad}>
|
||||
<table className={classes}>{children}</table>
|
||||
<table className={cx([style.table, className])}>{children}</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableEmpty({ handleClick }: { handleClick: () => void }) {
|
||||
export function TableEmpty({ label, handleClick }: { label?: string; handleClick?: () => void }) {
|
||||
return (
|
||||
<tr className={style.empty}>
|
||||
<td colSpan={99}>
|
||||
<div>No data yet</div>
|
||||
<Button onClick={handleClick} variant='ontime-subtle' rightIcon={<IoAdd />} size='sm'>
|
||||
<div>{label ?? 'No data yet'}</div>
|
||||
<Button onClick={handleClick} isDisabled={!handleClick} variant='ontime-filled' rightIcon={<IoAdd />} size='sm'>
|
||||
New
|
||||
</Button>
|
||||
</td>
|
||||
@@ -124,3 +123,22 @@ export function Loader({ isLoading }: { isLoading: boolean }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type AllowedInlineTags = 'div' | 'td';
|
||||
type InlineProps<C extends AllowedInlineTags> = {
|
||||
as?: C;
|
||||
relation?: 'inner' | 'component' | 'section';
|
||||
align?: 'start' | 'end';
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function InlineElements<C extends AllowedInlineTags = 'div'>({
|
||||
children,
|
||||
as,
|
||||
relation = 'component',
|
||||
align = 'start',
|
||||
className,
|
||||
}: PropsWithChildren<InlineProps<C>>) {
|
||||
const Element = as ?? 'div';
|
||||
return <Element className={cx([style.inlineElements, style[relation], style[align], className])}>{children}</Element>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import { discordUrl, documentationUrl, githubUrl, websiteUrl } from '../../../../externals';
|
||||
import {
|
||||
buyMeACoffeeUrl,
|
||||
discordUrl,
|
||||
documentationUrl,
|
||||
githubSponsorUrl,
|
||||
githubUrl,
|
||||
websiteUrl,
|
||||
} from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import AppVersion from './AppVersion';
|
||||
@@ -9,22 +16,29 @@ export default function AboutPanel() {
|
||||
<>
|
||||
<Panel.Header>About Ontime</Panel.Header>
|
||||
<Panel.Section>
|
||||
<Panel.SubHeader>Ontime</Panel.SubHeader>
|
||||
<Panel.Paragraph>
|
||||
Free, open-source software for managing rundowns and event timers
|
||||
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
|
||||
</Panel.Paragraph>
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>Ontime</Panel.SubHeader>
|
||||
<Panel.Paragraph>
|
||||
Free, open-source software for managing rundowns and event timers
|
||||
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
|
||||
</Panel.Paragraph>
|
||||
<Panel.Paragraph>
|
||||
Considering sponsoring our work
|
||||
<ExternalLink href={githubSponsorUrl}>GitHub Sponsors</ExternalLink>
|
||||
<ExternalLink href={buyMeACoffeeUrl}>Buy Me a Coffee</ExternalLink>
|
||||
</Panel.Paragraph>
|
||||
<Panel.Paragraph>
|
||||
And trying out our cloud service
|
||||
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
|
||||
</Panel.Paragraph>
|
||||
</Panel.Card>
|
||||
<Panel.SubHeader>Current version</Panel.SubHeader>
|
||||
<AppVersion />
|
||||
<Panel.SubHeader>Links</Panel.SubHeader>
|
||||
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
|
||||
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
|
||||
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
<Panel.SubHeader>Current version</Panel.SubHeader>
|
||||
<AppVersion />
|
||||
</Panel.Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
.outerColumn {
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.innerColumn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.matchRadio {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.ruleSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.titleSection,
|
||||
.filterSection,
|
||||
.oscSection,
|
||||
.httpSection {
|
||||
display: grid;
|
||||
grid-gap: 0.5rem;
|
||||
|
||||
button {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.titleSection,
|
||||
.ruleSection,
|
||||
.filterSection,
|
||||
.oscSection,
|
||||
.httpSection {
|
||||
label, div {
|
||||
// we use the div as non-interactive placeholder for button cells
|
||||
// it needs to match the size of the label element
|
||||
font-size: calc(1rem - 3px);
|
||||
}
|
||||
label {
|
||||
color: $label-gray;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.titleSection {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.filterSection {
|
||||
grid-template-columns: 2fr 1fr 2fr auto;
|
||||
}
|
||||
|
||||
.oscSection {
|
||||
grid-template-columns: 9rem 5rem 3fr 4fr auto;
|
||||
}
|
||||
|
||||
.httpSection {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.outputCard {
|
||||
border-left: 0.25rem solid $gray-1200;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertIcon,
|
||||
Button,
|
||||
IconButton,
|
||||
Input,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Select,
|
||||
} from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
import { Automation, AutomationDTO, HTTPOutput, isHTTPOutput, isOSCOutput, OSCOutput } from 'ontime-types';
|
||||
|
||||
import { addAutomation, editAutomation, testOutput } from '../../../../common/api/automation';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { startsWithHttp } from '../../../../common/utils/regex';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import { isAutomation, makeFieldList } from './automationUtils';
|
||||
|
||||
import style from './AutomationForm.module.scss';
|
||||
|
||||
const integrationsDocsUrl = 'https://docs.getontime.no/api/integrations/#using-variables-in-integrations';
|
||||
|
||||
interface AutomationFormProps {
|
||||
automation: Automation | AutomationDTO;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AutomationForm(props: AutomationFormProps) {
|
||||
const { automation, onClose } = props;
|
||||
const isEdit = isAutomation(automation);
|
||||
const { data } = useCustomFields();
|
||||
const { refetch } = useAutomationSettings();
|
||||
const fieldList = useMemo(() => makeFieldList(data), [data]);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
getValues,
|
||||
register,
|
||||
setError,
|
||||
setFocus,
|
||||
formState: { errors, isSubmitting, isDirty, isValid },
|
||||
} = useForm<AutomationDTO>({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
title: automation?.title ?? '',
|
||||
filterRule: automation?.filterRule ?? 'all',
|
||||
filters: automation?.filters ?? [],
|
||||
outputs: automation?.outputs ?? [],
|
||||
},
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
fields: fieldFilters,
|
||||
append: appendFilter,
|
||||
remove: removeFilter,
|
||||
} = useFieldArray({
|
||||
name: 'filters',
|
||||
control,
|
||||
});
|
||||
|
||||
const {
|
||||
fields: fieldOutputs,
|
||||
append: appendOutput,
|
||||
remove: removeOutput,
|
||||
} = useFieldArray({
|
||||
name: 'outputs',
|
||||
control,
|
||||
});
|
||||
|
||||
// give initial focus to the title field
|
||||
useEffect(() => {
|
||||
setFocus('title');
|
||||
}, [setFocus]);
|
||||
|
||||
const handleAddNewFilter = () => {
|
||||
appendFilter({ field: '', operator: 'equals', value: '' });
|
||||
};
|
||||
|
||||
const handleAddNewOSCOutput = () => {
|
||||
// @ts-expect-error -- we dont want to pass a port to the new object
|
||||
appendOutput({ type: 'osc', targetIP: '', targetPort: undefined, address: '', args: '' });
|
||||
};
|
||||
|
||||
const handleAddNewHTTPOutput = () => {
|
||||
appendOutput({ type: 'http', url: '' });
|
||||
};
|
||||
|
||||
const handleTestOSCOutput = async (index: number) => {
|
||||
try {
|
||||
const values = getValues(`outputs.${index}`) as OSCOutput;
|
||||
if (!values.targetIP || !values.targetPort || !values.address) {
|
||||
return;
|
||||
}
|
||||
await testOutput({
|
||||
type: 'osc',
|
||||
targetIP: values.targetIP,
|
||||
targetPort: values.targetPort,
|
||||
address: values.address,
|
||||
args: values.args,
|
||||
});
|
||||
} catch (_error) {
|
||||
/** we dont handle errors here, users should use the network tab */
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestHTTPOutput = async (index: number) => {
|
||||
try {
|
||||
const values = getValues(`outputs.${index}`) as HTTPOutput;
|
||||
if (!values.url) {
|
||||
return;
|
||||
}
|
||||
await testOutput({
|
||||
type: 'http',
|
||||
url: values.url,
|
||||
});
|
||||
} catch (_error) {
|
||||
/** we dont handle errors here, users should use the network tab */
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: AutomationDTO) => {
|
||||
if (isAutomation(automation)) {
|
||||
await handleEdit(automation.id, { id: automation.id, ...values });
|
||||
} else {
|
||||
await handleCreate(values);
|
||||
}
|
||||
refetch();
|
||||
|
||||
async function handleEdit(id: string, values: Automation) {
|
||||
try {
|
||||
await editAutomation(id, values);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setError('root', { message: maybeAxiosError(error) });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(values: AutomationDTO) {
|
||||
try {
|
||||
await addAutomation(values);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setError('root', { message: maybeAxiosError(error) });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const canSubmit = !isSubmitting && isDirty && isValid;
|
||||
|
||||
return (
|
||||
<Panel.Indent
|
||||
as='form'
|
||||
name='automation-form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className={style.outerColumn}
|
||||
onKeyDown={(event) => preventEscape(event, onClose)}
|
||||
>
|
||||
<Panel.SubHeader>{isEdit ? 'Edit automation' : 'Create automation'}</Panel.SubHeader>
|
||||
<div className={style.innerSection}>
|
||||
<h3>Automation options</h3>
|
||||
<div className={style.titleSection}>
|
||||
<label>
|
||||
Title
|
||||
<Input
|
||||
{...register('title', { required: { value: true, message: 'Required field' } })}
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='Load preset'
|
||||
autoComplete='off'
|
||||
/>
|
||||
</label>
|
||||
<Panel.Error>{errors.title?.message}</Panel.Error>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.innerSection}>
|
||||
<h3>Filters</h3>
|
||||
<div className={style.ruleSection}>
|
||||
<label>
|
||||
Trigger outputs if
|
||||
<Controller
|
||||
name='filterRule'
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<RadioGroup {...field} size='sm' className={style.matchRadio} variant='ontime'>
|
||||
<Radio value='all'>All filters pass</Radio>
|
||||
<Radio value='any'>Any filter passes</Radio>
|
||||
</RadioGroup>
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
{fieldFilters.map((field, index) => (
|
||||
<div key={field.id} className={style.filterSection}>
|
||||
<label>
|
||||
Runtime data source
|
||||
<Select
|
||||
{...register(`filters.${index}.field`, { required: { value: true, message: 'Required field' } })}
|
||||
size='sm'
|
||||
variant='ontime'
|
||||
>
|
||||
<option selected hidden disabled value=''>
|
||||
Event field
|
||||
</option>
|
||||
{fieldList.map(({ value, label }) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Matching condition
|
||||
<Select
|
||||
{...register(`filters.${index}.operator`, { required: { value: true, message: 'Required field' } })}
|
||||
size='sm'
|
||||
variant='ontime'
|
||||
>
|
||||
<option selected hidden disabled value=''>
|
||||
Operator
|
||||
</option>
|
||||
<option value='equals'>equals</option>
|
||||
<option value='not_equals'>not equals</option>
|
||||
<option value='contains'>contains</option>
|
||||
{/*
|
||||
We dont currently offer a data source where these operators would make sense
|
||||
<option value='greater_than'>greater than</option>
|
||||
<option value='less_than'>less than</option>
|
||||
*/}
|
||||
</Select>
|
||||
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Value to match
|
||||
<Input
|
||||
{...register(`filters.${index}.value`)}
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='<no value>'
|
||||
autoComplete='off'
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<span> </span>
|
||||
<div>
|
||||
<IconButton
|
||||
aria-label='Delete'
|
||||
icon={<IoTrash />}
|
||||
variant='ontime-ghosted'
|
||||
size='sm'
|
||||
color='#FA5656' // $red-500
|
||||
onClick={() => removeFilter(index)}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
size='sm'
|
||||
type='submit'
|
||||
rightIcon={<IoAdd />}
|
||||
onClick={handleAddNewFilter}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
>
|
||||
Add filter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Outputs</h3>
|
||||
<Alert status='info' variant='ontime-on-dark-info'>
|
||||
<AlertIcon />
|
||||
<AlertDescription>
|
||||
Automation outputs can be used to send data from Ontime to external software.
|
||||
<ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{fieldOutputs.map((output, index) => {
|
||||
if (isOSCOutput(output)) {
|
||||
const rowErrors = errors.outputs?.[index] as
|
||||
| {
|
||||
targetIP?: { message?: string };
|
||||
targetPort?: { message?: string };
|
||||
address?: { message?: string };
|
||||
args?: { message?: string };
|
||||
}
|
||||
| undefined;
|
||||
|
||||
return (
|
||||
<div key={output.id} className={style.outputCard}>
|
||||
<Tag>OSC</Tag>
|
||||
<div className={style.oscSection}>
|
||||
<label>
|
||||
Target IP
|
||||
<Input
|
||||
{...register(`outputs.${index}.targetIP`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
})}
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='127.0.0.1'
|
||||
autoComplete='off'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Target Port
|
||||
<Input
|
||||
{...register(`outputs.${index}.targetPort`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
setValueAs: (value) => (value === '' ? 0 : Number(value)),
|
||||
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
|
||||
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
|
||||
})}
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
type='number'
|
||||
maxLength={5}
|
||||
placeholder='8000'
|
||||
autoComplete='off'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Address
|
||||
<Input
|
||||
{...register(`outputs.${index}.address`)}
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='/cue/start'
|
||||
autoComplete='off'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Parameters
|
||||
<Input
|
||||
{...register(`outputs.${index}.args`)}
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='1'
|
||||
autoComplete='off'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
|
||||
</label>
|
||||
<div>
|
||||
<span> </span>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button size='sm' variant='ontime-ghosted-white' onClick={() => handleTestOSCOutput(index)}>
|
||||
Test
|
||||
</Button>
|
||||
<IconButton
|
||||
aria-label='Delete'
|
||||
icon={<IoTrash />}
|
||||
variant='ontime-ghosted'
|
||||
size='sm'
|
||||
onClick={() => removeOutput(index)}
|
||||
color='#FA5656' // $red-500
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
/>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isHTTPOutput(output)) {
|
||||
const rowErrors = errors.outputs?.[index] as
|
||||
| {
|
||||
url?: { message?: string };
|
||||
}
|
||||
| undefined;
|
||||
return (
|
||||
<div key={output.id} className={style.outputCard}>
|
||||
<Tag>HTTP</Tag>
|
||||
<div className={style.httpSection}>
|
||||
<label>
|
||||
Target URL
|
||||
<Input
|
||||
{...register(`outputs.${index}.url`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
pattern: {
|
||||
value: startsWithHttp,
|
||||
message: 'HTTP messages should target http:// or https://',
|
||||
},
|
||||
})}
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='http://127.0.0.1/start/1'
|
||||
autoComplete='off'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
|
||||
</label>
|
||||
<div>
|
||||
<span> </span>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button size='sm' variant='ontime-ghosted-white' onClick={() => handleTestHTTPOutput(index)}>
|
||||
Test
|
||||
</Button>
|
||||
<IconButton
|
||||
aria-label='Delete'
|
||||
icon={<IoTrash />}
|
||||
variant='ontime-ghosted'
|
||||
size='sm'
|
||||
onClick={() => removeOutput(index)}
|
||||
color='#FA5656' // $red-500
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
/>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// there should be no other output types
|
||||
return null;
|
||||
})}
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
rightIcon={<IoAdd />}
|
||||
size='sm'
|
||||
onClick={handleAddNewOSCOutput}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
>
|
||||
OSC
|
||||
</Button>
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
rightIcon={<IoAdd />}
|
||||
size='sm'
|
||||
onClick={handleAddNewHTTPOutput}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
>
|
||||
HTTP
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
|
||||
<Panel.InlineElements align='end'>
|
||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Button variant='ontime-subtle' size='sm' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='ontime-filled' size='sm' type='submit' isDisabled={!canSubmit} isLoading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.Indent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import AutomationSettingsForm from './AutomationSettingsForm';
|
||||
import AutomationsList from './AutomationsList';
|
||||
import TriggersList from './TriggersList';
|
||||
|
||||
export default function AutomationPanel({ location }: PanelBaseProps) {
|
||||
const { data, status } = useAutomationSettings();
|
||||
const settingsRef = useScrollIntoView<HTMLDivElement>('settings', location);
|
||||
const triggersRef = useScrollIntoView<HTMLDivElement>('triggers', location);
|
||||
const automationsRef = useScrollIntoView<HTMLDivElement>('automations', location);
|
||||
|
||||
const isLoading = status === 'pending';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Automation</Panel.Header>
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={isLoading} />
|
||||
<div ref={settingsRef}>
|
||||
<AutomationSettingsForm
|
||||
enabledAutomations={data.enabledAutomations}
|
||||
enabledOscIn={data.enabledOscIn}
|
||||
oscPortIn={data.oscPortIn}
|
||||
/>
|
||||
</div>
|
||||
<div ref={triggersRef}>
|
||||
<TriggersList triggers={data.triggers} automations={data.automations} />
|
||||
</div>
|
||||
<div ref={automationsRef}>
|
||||
<AutomationsList automations={data.automations} />
|
||||
</div>
|
||||
</Panel.Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { Alert, AlertDescription, AlertIcon, Button, Input, Switch } from '@chakra-ui/react';
|
||||
|
||||
import { editAutomationSettings } from '../../../../common/api/automation';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { isOnlyNumbers } from '../../../../common/utils/regex';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
const oscApiDocsUrl = 'https://docs.getontime.no/api/protocols/osc/';
|
||||
|
||||
interface AutomationSettingsProps {
|
||||
enabledAutomations: boolean;
|
||||
enabledOscIn: boolean;
|
||||
oscPortIn: number;
|
||||
}
|
||||
|
||||
export default function AutomationSettingsForm(props: AutomationSettingsProps) {
|
||||
const { enabledAutomations, enabledOscIn, oscPortIn } = props;
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
register,
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isDirty, isValid },
|
||||
} = useForm<AutomationSettingsProps>({
|
||||
mode: 'onChange',
|
||||
defaultValues: { enabledAutomations, enabledOscIn, oscPortIn },
|
||||
values: { enabledAutomations, enabledOscIn, oscPortIn },
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: AutomationSettingsProps) => {
|
||||
try {
|
||||
await editAutomationSettings(formData);
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
setError('root', { message });
|
||||
}
|
||||
};
|
||||
|
||||
const onReset = () => {
|
||||
reset({ enabledAutomations, enabledOscIn, oscPortIn });
|
||||
};
|
||||
|
||||
const canSubmit = !isSubmitting && isDirty && isValid;
|
||||
|
||||
return (
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Automation settings
|
||||
<Panel.InlineElements>
|
||||
<Button variant='ontime-ghosted' size='sm' onClick={onReset} isDisabled={!canSubmit}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
type='submit'
|
||||
form='automation-settings-form'
|
||||
isDisabled={!canSubmit}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
|
||||
<Panel.Divider />
|
||||
|
||||
<Panel.Section>
|
||||
<Alert status='info' variant='ontime-on-dark-info'>
|
||||
<AlertIcon />
|
||||
<AlertDescription>
|
||||
Control Ontime and share its data with external systems in your workflow. <br />
|
||||
- Automations allow Ontime to send its data on lifecycle triggers. <br />- OSC Input tells Ontime to listen
|
||||
to messages on the specific port. <ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</Panel.Section>
|
||||
|
||||
<Panel.Section
|
||||
as='form'
|
||||
id='automation-settings-form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
onKeyDown={(event) => preventEscape(event, onReset)}
|
||||
>
|
||||
<Panel.Loader isLoading={false} />
|
||||
|
||||
<Panel.Title>Automation</Panel.Title>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Enable automations'
|
||||
description='Allow Ontime to send messages on lifecycle triggers'
|
||||
error={errors.enabledAutomations?.message}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='enabledAutomations'
|
||||
render={({ field: { onChange, value, ref } }) => (
|
||||
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
|
||||
)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
|
||||
<Panel.Title>OSC Input</Panel.Title>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='OSC input'
|
||||
description='Allow control of Ontime through OSC'
|
||||
error={errors.enabledOscIn?.message}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='enabledOscIn'
|
||||
render={({ field: { onChange, value, ref } }) => (
|
||||
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
|
||||
)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Listen on port'
|
||||
description='Port for incoming OSC. Default: 8888'
|
||||
error={errors.oscPortIn?.message}
|
||||
/>
|
||||
<Input
|
||||
id='oscPortIn'
|
||||
placeholder='8888'
|
||||
width='5rem'
|
||||
maxLength={5}
|
||||
size='sm'
|
||||
textAlign='right'
|
||||
variant='ontime-filled'
|
||||
type='number'
|
||||
autoComplete='off'
|
||||
{...register('oscPortIn', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
|
||||
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
|
||||
pattern: {
|
||||
value: isOnlyNumbers,
|
||||
message: 'Value should be numeric',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Fragment, useState } from 'react';
|
||||
import { Button, IconButton } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
import { AutomationDTO, NormalisedAutomation } from 'ontime-types';
|
||||
|
||||
import { deleteAutomation } from '../../../../common/api/automation';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import AutomationForm from './AutomationForm';
|
||||
|
||||
const automationPlaceholder: AutomationDTO = {
|
||||
title: '',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [],
|
||||
};
|
||||
|
||||
interface AutomationsListProps {
|
||||
automations: NormalisedAutomation;
|
||||
}
|
||||
|
||||
export default function AutomationsList(props: AutomationsListProps) {
|
||||
const { automations } = props;
|
||||
const { refetch } = useAutomationSettings();
|
||||
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
setDeleteError(null);
|
||||
await deleteAutomation(id);
|
||||
} catch (error) {
|
||||
setDeleteError(maybeAxiosError(error));
|
||||
} finally {
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const arrayAutomations = Object.keys(automations);
|
||||
|
||||
return (
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Manage automations
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
rightIcon={<IoAdd />}
|
||||
size='sm'
|
||||
type='submit'
|
||||
isDisabled={Boolean(automationFormData)}
|
||||
onClick={() => setAutomationFormData(automationPlaceholder)}
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
|
||||
<Panel.Divider />
|
||||
|
||||
{automationFormData !== null && (
|
||||
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
|
||||
)}
|
||||
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '45%' }}>Title</th>
|
||||
<th style={{ width: '15%' }}>Trigger rule</th>
|
||||
<th style={{ width: '15%' }}>Filters</th>
|
||||
<th style={{ width: '15%' }}>Outputs</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{arrayAutomations.length === 0 && (
|
||||
<Panel.TableEmpty
|
||||
handleClick={!automationFormData ? () => setAutomationFormData(automationPlaceholder) : undefined}
|
||||
/>
|
||||
)}
|
||||
{arrayAutomations.map((automationId) => {
|
||||
if (!Object.hasOwn(automations, automationId)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Fragment key={automationId}>
|
||||
<tr>
|
||||
<td>{automations[automationId].title}</td>
|
||||
<td>
|
||||
<Tag>{automations[automationId].filterRule}</Tag>
|
||||
</td>
|
||||
<td>{automations[automationId].filters.length}</td>
|
||||
<td>{automations[automationId].outputs.length}</td>
|
||||
<Panel.InlineElements align='end' relation='inner' as='td'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='ontime-ghosted'
|
||||
color='#e2e2e2' // $gray-200
|
||||
icon={<IoPencil />}
|
||||
aria-label='Edit entry'
|
||||
onClick={() => setAutomationFormData(automations[automationId])}
|
||||
/>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='ontime-ghosted'
|
||||
color='#FA5656' // $red-500
|
||||
icon={<IoTrash />}
|
||||
aria-label='Delete entry'
|
||||
onClick={() => handleDelete(automationId)}
|
||||
/>
|
||||
</Panel.InlineElements>
|
||||
</tr>
|
||||
{deleteError && (
|
||||
<tr>
|
||||
<td colSpan={5}>
|
||||
<Panel.Error>{deleteError}</Panel.Error>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Button, Input, Select } from '@chakra-ui/react';
|
||||
import { NormalisedAutomation, TimerLifeCycle, TriggerDTO } from 'ontime-types';
|
||||
|
||||
import { addTrigger, editTrigger } from '../../../../common/api/automation';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import { cycles } from './automationUtils';
|
||||
|
||||
interface TriggerFormProps {
|
||||
automations: NormalisedAutomation;
|
||||
initialId?: string;
|
||||
initialTitle?: string;
|
||||
initialAutomationId?: string;
|
||||
initialTrigger?: TimerLifeCycle;
|
||||
onCancel: () => void;
|
||||
postSubmit: () => void;
|
||||
}
|
||||
|
||||
export default function TriggerForm(props: TriggerFormProps) {
|
||||
const { automations, initialId, initialTitle, initialAutomationId, initialTrigger, onCancel, postSubmit } = props;
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
setFocus,
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isValid, isDirty },
|
||||
} = useForm<TriggerDTO>({
|
||||
defaultValues: {
|
||||
title: initialTitle,
|
||||
trigger: initialTrigger,
|
||||
automationId: initialAutomationId,
|
||||
},
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
// give initial focus to the title field
|
||||
useEffect(() => {
|
||||
setFocus('title');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- focus on mount
|
||||
}, []);
|
||||
|
||||
const onSubmit = async (values: TriggerDTO) => {
|
||||
// if we were passed an ID we are editing a Trigger
|
||||
if (initialId) {
|
||||
try {
|
||||
await editTrigger(initialId, { id: initialId, ...values });
|
||||
postSubmit();
|
||||
} catch (error) {
|
||||
setError('root', { message: `Failed to save changes to trigger ${maybeAxiosError(error)}` });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// otherwise we are creating a new automation
|
||||
try {
|
||||
await addTrigger(values);
|
||||
postSubmit();
|
||||
} catch (error) {
|
||||
setError('root', { message: `Failed to save trigger ${maybeAxiosError(error)}` });
|
||||
}
|
||||
};
|
||||
|
||||
const automationSelect = Object.keys(automations).map((automation) => {
|
||||
return {
|
||||
value: automation,
|
||||
label: automations[automation].title,
|
||||
};
|
||||
});
|
||||
|
||||
const canSubmit = isDirty && isValid;
|
||||
|
||||
return (
|
||||
<Panel.Indent
|
||||
as='form'
|
||||
name='trigger-form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
onKeyDown={(event) => preventEscape(event, onCancel)}
|
||||
>
|
||||
<Panel.SubHeader>{initialId ? 'Edit trigger' : 'Create trigger'}</Panel.SubHeader>
|
||||
<label>
|
||||
Title
|
||||
<Input
|
||||
{...register('title', { required: { value: true, message: 'Required field' } })}
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
autoComplete='off'
|
||||
defaultValue={initialTitle}
|
||||
/>
|
||||
<Panel.Error>{errors.title?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Lifecycle trigger
|
||||
<Select
|
||||
size='sm'
|
||||
variant='ontime'
|
||||
defaultValue={initialTrigger}
|
||||
{...register('trigger', { required: { value: true, message: 'Required field' } })}
|
||||
>
|
||||
{cycles.map((cycle) => (
|
||||
<option key={cycle.id} value={cycle.value}>
|
||||
{cycle.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Panel.Error>{errors.trigger?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Automation title
|
||||
<Select
|
||||
size='sm'
|
||||
variant='ontime'
|
||||
defaultValue={initialAutomationId}
|
||||
{...register('automationId', { required: { value: true, message: 'Required field' } })}
|
||||
>
|
||||
{automationSelect.map((automation) => (
|
||||
<option key={automation.value} value={automation.value}>
|
||||
{automation.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Panel.Error>{errors.automationId?.message}</Panel.Error>
|
||||
</label>
|
||||
<Panel.InlineElements align='end'>
|
||||
<Button size='sm' variant='ontime-subtle' isDisabled={isSubmitting} onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' size='sm' variant='ontime-filled' isDisabled={!canSubmit} isLoading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.Indent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { NormalisedAutomation, Trigger } from 'ontime-types';
|
||||
|
||||
import { deleteTrigger } from '../../../../common/api/automation';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import { checkDuplicates } from './automationUtils';
|
||||
import AutomationForm from './TriggerForm';
|
||||
import TriggersListItem from './TriggersListItem';
|
||||
|
||||
interface TriggersListProps {
|
||||
triggers: Trigger[];
|
||||
automations: NormalisedAutomation;
|
||||
}
|
||||
|
||||
export default function TriggersList(props: TriggersListProps) {
|
||||
const { triggers, automations } = props;
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const { refetch } = useAutomationSettings();
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteTrigger(id);
|
||||
} catch (error) {
|
||||
setDeleteError(maybeAxiosError(error));
|
||||
} finally {
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const postSubmit = () => {
|
||||
setShowForm(false);
|
||||
refetch();
|
||||
};
|
||||
|
||||
const duplicates = useMemo(() => checkDuplicates(triggers), [triggers]);
|
||||
|
||||
// there is no point letting user creating a trigger if there are no automations
|
||||
const canAdd = Object.keys(automations).length > 0;
|
||||
|
||||
return (
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Manage triggers
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
rightIcon={<IoAdd />}
|
||||
size='sm'
|
||||
type='submit'
|
||||
form='trigger-form'
|
||||
isDisabled={!canAdd}
|
||||
isLoading={false}
|
||||
onClick={() => setShowForm(true)}
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
{duplicates && (
|
||||
<Panel.Error>
|
||||
You have created multiple links between the same trigger and automation which can performance issues.
|
||||
</Panel.Error>
|
||||
)}
|
||||
{showForm && (
|
||||
<AutomationForm automations={automations} onCancel={() => setShowForm(false)} postSubmit={postSubmit} />
|
||||
)}
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '35%' }}>Title</th>
|
||||
<th style={{ width: '25%' }}>Lifecycle trigger</th>
|
||||
<th style={{ width: '25%' }}>Automation</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!showForm && triggers.length === 0 && (
|
||||
<Panel.TableEmpty
|
||||
label='Create an automation before to attach triggers to'
|
||||
handleClick={canAdd ? () => setShowForm(true) : undefined}
|
||||
/>
|
||||
)}
|
||||
{triggers.map((trigger, index) => {
|
||||
return (
|
||||
<Fragment key={trigger.id}>
|
||||
<TriggersListItem
|
||||
automations={automations}
|
||||
id={trigger.id}
|
||||
title={trigger.title}
|
||||
trigger={trigger.trigger}
|
||||
automationId={trigger.automationId}
|
||||
duplicate={duplicates?.includes(index)}
|
||||
handleDelete={() => handleDelete(trigger.id)}
|
||||
postSubmit={postSubmit}
|
||||
/>
|
||||
{deleteError && (
|
||||
<tr>
|
||||
<td colSpan={5}>
|
||||
<Panel.Error>{deleteError}</Panel.Error>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState } from 'react';
|
||||
import { IconButton } from '@chakra-ui/react';
|
||||
import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline';
|
||||
import { NormalisedAutomation, TimerLifeCycle } from 'ontime-types';
|
||||
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import { cycles } from './automationUtils';
|
||||
import AutomationForm from './TriggerForm';
|
||||
|
||||
interface TriggersListItemProps {
|
||||
automations: NormalisedAutomation;
|
||||
id: string;
|
||||
title: string;
|
||||
trigger: TimerLifeCycle;
|
||||
automationId: string;
|
||||
duplicate?: boolean;
|
||||
handleDelete: () => void;
|
||||
postSubmit: () => void;
|
||||
}
|
||||
|
||||
export default function TriggersListItem(props: TriggersListItemProps) {
|
||||
const { automations, id, title, trigger, automationId, duplicate, handleDelete, postSubmit } = props;
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={99}>
|
||||
<AutomationForm
|
||||
automations={automations}
|
||||
initialId={id}
|
||||
initialTitle={title}
|
||||
initialTrigger={trigger}
|
||||
initialAutomationId={automationId}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
postSubmit={() => {
|
||||
setIsEditing(false);
|
||||
postSubmit();
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr data-warn={duplicate}>
|
||||
<Panel.InlineElements as='td' relation='inner'>
|
||||
{duplicate && (
|
||||
<IoWarningOutline
|
||||
color='#FFBC56' // $orange-500
|
||||
/>
|
||||
)}
|
||||
{title}
|
||||
</Panel.InlineElements>
|
||||
<td>
|
||||
<Tag>{cycles.find((cycle) => cycle.value === trigger)?.label}</Tag>
|
||||
</td>
|
||||
<td>
|
||||
<Tag>{automations?.[automationId]?.title}</Tag>
|
||||
</td>
|
||||
<Panel.InlineElements align='end' relation='inner' as='td'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='ontime-ghosted'
|
||||
color='#e2e2e2' // $gray-200
|
||||
icon={<IoPencil />}
|
||||
aria-label='Edit entry'
|
||||
onClick={() => setIsEditing(true)}
|
||||
/>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='ontime-ghosted'
|
||||
color='#FA5656' // $red-500
|
||||
icon={<IoTrash />}
|
||||
aria-label='Delete entry'
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</Panel.InlineElements>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { TimerLifeCycle, Trigger } from 'ontime-types';
|
||||
|
||||
import { checkDuplicates } from '../automationUtils';
|
||||
|
||||
describe('checkDuplicates', () => {
|
||||
it('should return undefined if there are no duplicates', () => {
|
||||
const triggers: Trigger[] = [
|
||||
{ id: '1', title: 'First', trigger: TimerLifeCycle.onClock, automationId: '1' },
|
||||
{ id: '2', title: 'Second', trigger: TimerLifeCycle.onDanger, automationId: '2' },
|
||||
{ id: '3', title: 'Third', trigger: TimerLifeCycle.onLoad, automationId: '3' },
|
||||
];
|
||||
expect(checkDuplicates(triggers)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return list of titles of duplicates', () => {
|
||||
const triggers: Trigger[] = [
|
||||
{ id: '1', title: 'First', trigger: TimerLifeCycle.onClock, automationId: '1' },
|
||||
{ id: '2', title: 'Second', trigger: TimerLifeCycle.onDanger, automationId: '2' },
|
||||
{ id: '3', title: 'Third', trigger: TimerLifeCycle.onClock, automationId: '1' },
|
||||
{ id: '3', title: 'Third', trigger: TimerLifeCycle.onPause, automationId: '1' },
|
||||
];
|
||||
expect(checkDuplicates(triggers)).toStrictEqual([2]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Automation, AutomationDTO, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types';
|
||||
|
||||
type CycleLabel = {
|
||||
id: number;
|
||||
label: string;
|
||||
value: keyof typeof TimerLifeCycle;
|
||||
};
|
||||
|
||||
export const cycles: CycleLabel[] = [
|
||||
{ id: 1, label: 'On Load', value: 'onLoad' },
|
||||
{ id: 2, label: 'On Start', value: 'onStart' },
|
||||
{ id: 3, label: 'On Pause', value: 'onPause' },
|
||||
{ id: 4, label: 'On Stop', value: 'onStop' },
|
||||
{ id: 5, label: 'Every second', value: 'onClock' },
|
||||
{ id: 6, label: 'On Timer Update', value: 'onUpdate' },
|
||||
{ id: 7, label: 'On Finish', value: 'onFinish' },
|
||||
{ id: 8, label: 'On Warning', value: 'onWarning' },
|
||||
{ id: 9, label: 'On Danger', value: 'onDanger' },
|
||||
];
|
||||
|
||||
/**
|
||||
* We use this guard to find out if the form is receiving an existing automation or creating a DTO
|
||||
* We do this by checking whether an ID has been generated
|
||||
*/
|
||||
export function isAutomation(automation: AutomationDTO | Automation): automation is Automation {
|
||||
return Object.hasOwn(automation, 'id');
|
||||
}
|
||||
|
||||
const staticSelectProperties = [
|
||||
{ value: 'eventNow.id', label: 'ID' },
|
||||
{ value: 'eventNow.title', label: 'Title' },
|
||||
{ value: 'eventNow.cue', label: 'Cue' },
|
||||
{ value: 'eventNow.countToEnd', label: 'Count to end' },
|
||||
{ value: 'eventNow.isPublic', label: 'Is public' },
|
||||
{ value: 'eventNow.note', label: 'Note' },
|
||||
{ value: 'eventNow.colour', label: 'Colour' },
|
||||
];
|
||||
|
||||
type SelectableField = {
|
||||
value: string; // string encodes path in runtime state object
|
||||
label: string;
|
||||
};
|
||||
|
||||
export function makeFieldList(customFields: CustomFields): SelectableField[] {
|
||||
return [
|
||||
...staticSelectProperties,
|
||||
...Object.entries(customFields).map(([key, { label }]) => ({
|
||||
value: `eventNow.custom.${key}`,
|
||||
label: `Custom: ${label}`,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* We warn the user if they have created multiple links between the same automation and a trigger
|
||||
*/
|
||||
export function checkDuplicates(triggers: Trigger[]) {
|
||||
const triggersMap: Record<string, string[]> = {};
|
||||
const duplicates = [];
|
||||
|
||||
for (let i = 0; i < triggers.length; i++) {
|
||||
const trigger = triggers[i];
|
||||
if (!Object.hasOwn(triggersMap, trigger.trigger)) {
|
||||
triggersMap[trigger.trigger] = [];
|
||||
}
|
||||
|
||||
if (triggersMap[trigger.trigger].includes(trigger.automationId)) {
|
||||
duplicates.push(i);
|
||||
} else {
|
||||
triggersMap[trigger.trigger].push(trigger.automationId);
|
||||
}
|
||||
}
|
||||
return duplicates.length > 0 ? duplicates : undefined;
|
||||
}
|
||||
+3
-15
@@ -2,23 +2,11 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
display: flex;
|
||||
gap: $element-spacing;
|
||||
.halfWidth {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.halfWidth {
|
||||
.halfWidthNoWrap {
|
||||
width: 50%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pathList {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.badgeList {
|
||||
width: 100%;
|
||||
* {
|
||||
margin-right: $element-spacing;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function ClientList() {
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td className={style.fullWidth}>Client Name (Connection ID)</td>
|
||||
<td className={style.halfWidth}>Client Name (Connection ID)</td>
|
||||
<td className={style.fullWidth}>Path</td>
|
||||
<td />
|
||||
</tr>
|
||||
@@ -63,7 +63,7 @@ export default function ClientList() {
|
||||
const isCurrent = id === key;
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td className={style.badgeList}>
|
||||
<Panel.InlineElements relation='inner' as='td'>
|
||||
<Badge variant='outline' size='xs'>
|
||||
{key}
|
||||
</Badge>
|
||||
@@ -73,9 +73,9 @@ export default function ClientList() {
|
||||
</Badge>
|
||||
)}
|
||||
{name}
|
||||
</td>
|
||||
<td className={style.pathList}>{path}</td>
|
||||
<td className={style.actionButtons}>
|
||||
</Panel.InlineElements>
|
||||
<td>{path}</td>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button
|
||||
size='xs'
|
||||
className={`${identify ? style.blink : ''}`}
|
||||
@@ -106,7 +106,7 @@ export default function ClientList() {
|
||||
>
|
||||
Redirect
|
||||
</Button>
|
||||
</td>
|
||||
</Panel.InlineElements>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -119,8 +119,8 @@ export default function ClientList() {
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td className={style.halfWidth}>Client Name (Connection ID)</td>
|
||||
<td className={style.halfWidth}>Client type</td>
|
||||
<td className={style.halfWidthNoWrap}>Client Name (Connection ID)</td>
|
||||
<td className={style.halfWidthNoWrap}>Client type</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -129,12 +129,12 @@ export default function ClientList() {
|
||||
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td className={style.badgeList}>
|
||||
<Panel.InlineElements relation='inner' as='td'>
|
||||
<Badge variant='outline' size='sx'>
|
||||
{key}
|
||||
</Badge>
|
||||
{name}
|
||||
</td>
|
||||
</Panel.InlineElements>
|
||||
<td>{type}</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
-20
@@ -6,16 +6,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5px;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.fieldForm {
|
||||
padding: 1rem;
|
||||
background-color: $gray-1350;
|
||||
@@ -24,12 +14,6 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.buttonRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.fit {
|
||||
width: fit-content;
|
||||
}
|
||||
@@ -38,10 +22,6 @@
|
||||
min-width: 12em;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.twoCols {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
+11
-5
@@ -11,6 +11,7 @@ import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import TooltipActionBtn from '../../../../common/components/buttons/TooltipActionBtn';
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { handleLinks } from '../../../../common/utils/linkUtils';
|
||||
import { validateUrlPresetPath } from '../../../../common/utils/urlPresets';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
@@ -88,18 +89,23 @@ export default function UrlPresetsForm() {
|
||||
const canSubmit = !isSubmitting && isDirty && isValid;
|
||||
|
||||
return (
|
||||
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} data-testid='url-preset-form'>
|
||||
<Panel.Section
|
||||
as='form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
onKeyDown={(event) => preventEscape(event, onReset)}
|
||||
data-testid='url-preset-form'
|
||||
>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
URL presets
|
||||
<div className={style.actionButtons}>
|
||||
<Panel.InlineElements>
|
||||
<Button variant='ontime-ghosted' size='sm' onClick={onReset} isDisabled={!canSubmit}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button variant='ontime-filled' size='sm' type='submit' isDisabled={!canSubmit} isLoading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Alert status='info' variant='ontime-on-dark-info'>
|
||||
@@ -192,7 +198,7 @@ export default function UrlPresetsForm() {
|
||||
/>
|
||||
<Panel.Error>{maybeUrlError}</Panel.Error>
|
||||
</td>
|
||||
<td className={style.flex}>
|
||||
<Panel.InlineElements relation='inner' as='td'>
|
||||
<TooltipActionBtn
|
||||
size='sm'
|
||||
isDisabled={!canTest}
|
||||
@@ -213,7 +219,7 @@ export default function UrlPresetsForm() {
|
||||
aria-label='Delete entry'
|
||||
data-testid={`field__delete_${index}`}
|
||||
/>
|
||||
</td>
|
||||
</Panel.InlineElements>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
+3
-2
@@ -6,6 +6,7 @@ import { CustomField, CustomFieldLabel } from 'ontime-types';
|
||||
|
||||
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
|
||||
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import CustomFieldForm from './CustomFieldForm';
|
||||
|
||||
@@ -55,7 +56,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
||||
{field}
|
||||
</CopyTag>
|
||||
</td>
|
||||
<td className={style.actions}>
|
||||
<Panel.InlineElements relation='inner' as='td'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='ontime-ghosted'
|
||||
@@ -72,7 +73,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
||||
aria-label='Delete entry'
|
||||
onClick={() => onDelete(field)}
|
||||
/>
|
||||
</td>
|
||||
</Panel.InlineElements>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
+8
-3
@@ -7,6 +7,7 @@ import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
|
||||
import { maybeAxiosError } from '../../../../../common/api/utils';
|
||||
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
|
||||
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||
import { preventEscape } from '../../../../../common/utils/keyEvent';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import style from '../FeatureSettings.module.scss';
|
||||
@@ -71,7 +72,11 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
||||
const isEditMode = initialKey !== undefined;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}>
|
||||
<form
|
||||
onSubmit={handleSubmit(setupSubmit)}
|
||||
className={style.fieldForm}
|
||||
onKeyDown={(event) => preventEscape(event, onCancel)}
|
||||
>
|
||||
<div className={style.twoCols}>
|
||||
<div>
|
||||
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
|
||||
@@ -107,14 +112,14 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
||||
</div>
|
||||
|
||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<div className={style.buttonRow}>
|
||||
<Panel.InlineElements relation='inner' align='end'>
|
||||
<Button size='sm' variant='ontime-ghosted' onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size='sm' type='submit' variant='ontime-filled' isDisabled={!canSubmit} isLoading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.InlineElements>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
.actionButtons {
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
}
|
||||
@@ -6,13 +6,12 @@ import { Settings } from 'ontime-types';
|
||||
import { postSettings } from '../../../../common/api/settings';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import useSettings from '../../../../common/hooks-query/useSettings';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { isOnlyNumbers } from '../../../../common/utils/regex';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import GeneralPinInput from './GeneralPinInput';
|
||||
|
||||
import style from './GeneralPanel.module.scss';
|
||||
|
||||
export type GeneralPanelFormValues = {
|
||||
filename: string;
|
||||
};
|
||||
@@ -62,11 +61,16 @@ export default function GeneralPanelForm() {
|
||||
const isLoading = status === 'pending';
|
||||
|
||||
return (
|
||||
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} id='app-settings'>
|
||||
<Panel.Section
|
||||
as='form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
onKeyDown={(event) => preventEscape(event, onReset)}
|
||||
id='app-settings'
|
||||
>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
General settings
|
||||
<div className={style.actionButtons}>
|
||||
<Panel.InlineElements>
|
||||
<Button isDisabled={!isDirty || isSubmitting} variant='ontime-ghosted' size='sm' onClick={onReset}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
@@ -80,7 +84,7 @@ export default function GeneralPanelForm() {
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
{submitError && <Panel.Error>{submitError}</Panel.Error>}
|
||||
<Panel.Divider />
|
||||
|
||||
@@ -9,10 +9,9 @@ import ExternalLink from '../../../../common/components/external-link/ExternalLi
|
||||
import { SwatchPickerRHF } from '../../../../common/components/input/colour-input/SwatchPicker';
|
||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||
import useViewSettings from '../../../../common/hooks-query/useViewSettings';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './GeneralPanel.module.scss';
|
||||
|
||||
const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/';
|
||||
|
||||
export default function ViewSettingsForm() {
|
||||
@@ -67,18 +66,23 @@ export default function ViewSettingsForm() {
|
||||
const isLoading = status === 'pending' || infoStatus === 'pending';
|
||||
|
||||
return (
|
||||
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} id='view-settings'>
|
||||
<Panel.Section
|
||||
as='form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
onKeyDown={(event) => preventEscape(event, onReset)}
|
||||
id='view-settings'
|
||||
>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
View settings
|
||||
<div className={style.actionButtons}>
|
||||
<Panel.InlineElements>
|
||||
<Button isDisabled={!isDirty} variant='ontime-ghosted' size='sm' onClick={onReset}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button type='submit' isLoading={isSubmitting} isDisabled={!isDirty} variant='ontime-filled' size='sm'>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Alert status='info' variant='ontime-on-dark-info'>
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
||||
import { Button, IconButton, Input, Select, Switch } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
import { HttpSettings } from 'ontime-types';
|
||||
import { generateId } from 'ontime-utils';
|
||||
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import { useHttpSettings, usePostHttpSettings } from '../../../../common/hooks-query/useHttpSettings';
|
||||
import { isKeyEscape } from '../../../../common/utils/keyEvent';
|
||||
import { startsWithHttp } from '../../../../common/utils/regex';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import { cycles } from './integrationUtils';
|
||||
|
||||
import style from './IntegrationsPanel.module.css';
|
||||
|
||||
export default function HttpIntegrations() {
|
||||
const { data, status } = useHttpSettings();
|
||||
const { mutateAsync } = usePostHttpSettings();
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
register,
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isDirty, isValid },
|
||||
} = useForm<HttpSettings>({
|
||||
mode: 'onChange',
|
||||
defaultValues: data,
|
||||
values: data,
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, prepend, remove } = useFieldArray({
|
||||
name: 'subscriptions',
|
||||
control,
|
||||
});
|
||||
|
||||
const onSubmit = async (values: HttpSettings) => {
|
||||
try {
|
||||
await mutateAsync(values);
|
||||
} catch (error) {
|
||||
setError('root', { message: maybeAxiosError(error) });
|
||||
}
|
||||
};
|
||||
|
||||
const preventEscape = (event: React.KeyboardEvent) => {
|
||||
if (isKeyEscape(event)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddNewSubscription = () => {
|
||||
prepend({
|
||||
id: generateId(),
|
||||
cycle: 'onLoad',
|
||||
message: '',
|
||||
enabled: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteSubscription = (index: number) => {
|
||||
remove(index);
|
||||
};
|
||||
|
||||
const canSubmit = !isSubmitting && isDirty && isValid;
|
||||
const isLoading = status === 'pending';
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
HTTP settings
|
||||
<div className={style.flex}>
|
||||
<Button variant='ontime-ghosted' size='sm' onClick={() => reset()} isDisabled={!canSubmit}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
type='submit'
|
||||
form='http-form'
|
||||
isDisabled={!canSubmit}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Section as='form' id='http-form' onSubmit={handleSubmit(onSubmit)} onKeyDown={preventEscape}>
|
||||
<Panel.Loader isLoading={isLoading} />
|
||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='HTTP Output' description='Provide feedback from Ontime through HTTP' />
|
||||
<Controller
|
||||
control={control}
|
||||
name='enabledOut'
|
||||
render={({ field: { onChange, value, ref } }) => (
|
||||
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
|
||||
)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
|
||||
<Panel.Divider />
|
||||
|
||||
<Panel.Title>
|
||||
HTTP Integration
|
||||
<Button variant='ontime-subtle' size='sm' rightIcon={<IoAdd />} onClick={handleAddNewSubscription}>
|
||||
New
|
||||
</Button>
|
||||
</Panel.Title>
|
||||
{fields.length > 0 && (
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Enabled</th>
|
||||
<th>Cycle</th>
|
||||
<th className={style.fullWidth}>Message</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fields.map((integration, index) => {
|
||||
// @ts-expect-error -- not sure why it is not finding the type, it is ok
|
||||
const maybeError = errors.subscriptions?.[index]?.message?.message;
|
||||
return (
|
||||
<tr key={integration.id}>
|
||||
<td>
|
||||
<Switch variant='ontime' {...register(`subscriptions.${index}.enabled`)} />
|
||||
</td>
|
||||
<td className={style.autoWidth}>
|
||||
<Select
|
||||
size='sm'
|
||||
variant='ontime'
|
||||
className={style.fitContents}
|
||||
{...register(`subscriptions.${index}.cycle`)}
|
||||
>
|
||||
{cycles.map((cycle) => (
|
||||
<option key={cycle.id} value={cycle.value}>
|
||||
{cycle.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</td>
|
||||
<td className={style.fullWidth}>
|
||||
<Input
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
autoComplete='off'
|
||||
placeholder='http://third-party/vt1/{{timer.current}}'
|
||||
{...register(`subscriptions.${index}.message`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
pattern: {
|
||||
value: startsWithHttp,
|
||||
message: 'HTTP messages should start with http://',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{maybeError && <Panel.Error>{maybeError}</Panel.Error>}
|
||||
</td>
|
||||
<td>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='ontime-ghosted'
|
||||
color='#FA5656' // $red-500
|
||||
icon={<IoTrash />}
|
||||
aria-label='Delete entry'
|
||||
onClick={() => handleDeleteSubscription(index)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
)}
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.halfWidth {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.fitContents.fitContents {
|
||||
width: max-content; /* override chakra */
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
|
||||
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import HttpIntegrations from './HttpIntegrations';
|
||||
import OscIntegrations from './OscIntegrations';
|
||||
|
||||
const integrationDocsUrl = 'https://docs.getontime.no/api/integrations/';
|
||||
|
||||
export default function IntegrationsPanel({ location }: PanelBaseProps) {
|
||||
const oscRef = useScrollIntoView<HTMLDivElement>('osc', location);
|
||||
const httpRef = useScrollIntoView<HTMLDivElement>('http', location);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Integration settings</Panel.Header>
|
||||
<Panel.Section>
|
||||
<Alert status='info' variant='ontime-on-dark-info'>
|
||||
<AlertIcon />
|
||||
<AlertDescription>
|
||||
Integrations allow Ontime to receive commands or send its data to other systems in your workflow. <br />
|
||||
<br />
|
||||
Currently supported protocols are OSC (Open Sound Control), HTTP and Websockets. <br />
|
||||
WebSockets are used for Ontime and cannot be configured independently. <br />
|
||||
<ExternalLink href={integrationDocsUrl}>See the docs</ExternalLink>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
<div ref={oscRef}>
|
||||
<OscIntegrations />
|
||||
</div>
|
||||
<div ref={httpRef}>
|
||||
<HttpIntegrations />
|
||||
</div>
|
||||
</Panel.Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
||||
import { Button, IconButton, Input, Select, Switch } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
import { OSCSettings } from 'ontime-types';
|
||||
import { generateId } from 'ontime-utils';
|
||||
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { isASCII, isASCIIorEmpty, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
|
||||
import { isOntimeCloud } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import { cycles } from './integrationUtils';
|
||||
|
||||
import style from './IntegrationsPanel.module.css';
|
||||
|
||||
export default function OscIntegrations() {
|
||||
const { data, status } = useOscSettings();
|
||||
const { mutateAsync } = useOscSettingsMutation();
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
register,
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isDirty, isValid },
|
||||
} = useForm<OSCSettings>({
|
||||
mode: 'onChange',
|
||||
defaultValues: data,
|
||||
values: data,
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, prepend, remove } = useFieldArray({
|
||||
name: 'subscriptions',
|
||||
control,
|
||||
});
|
||||
|
||||
// update form if we get new data from server
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
reset(data);
|
||||
}
|
||||
}, [data, reset]);
|
||||
|
||||
const onSubmit = async (values: OSCSettings) => {
|
||||
if (values.portIn === values.portOut) {
|
||||
setError('portIn', { message: 'OSC IN and OUT Ports cant be the same' });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedValues = { ...values, portIn: Number(values.portIn), portOut: Number(values.portOut) };
|
||||
try {
|
||||
await mutateAsync(parsedValues);
|
||||
} catch (error) {
|
||||
setError('root', { message: maybeAxiosError(error) });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddNewSubscription = () => {
|
||||
prepend({
|
||||
id: generateId(),
|
||||
cycle: 'onLoad',
|
||||
address: '',
|
||||
payload: '',
|
||||
enabled: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteSubscription = (index: number) => {
|
||||
remove(index);
|
||||
};
|
||||
|
||||
const canSubmit = !isSubmitting && isDirty && isValid;
|
||||
const isLoading = status === 'pending';
|
||||
|
||||
return (
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
OSC settings
|
||||
<div className={style.flex}>
|
||||
<Button variant='ontime-ghosted' size='sm' onClick={() => reset()} isDisabled={!canSubmit}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
type='submit'
|
||||
form='osc-form'
|
||||
isDisabled={!canSubmit}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.SubHeader>
|
||||
{isOntimeCloud && (
|
||||
<Panel.Highlight>For security reasons OSC integrations are not available in the cloud service.</Panel.Highlight>
|
||||
)}
|
||||
|
||||
<Panel.Divider />
|
||||
|
||||
<Panel.Section as='form' id='osc-form' onSubmit={handleSubmit(onSubmit)} onKeyDown={preventEscape}>
|
||||
<Panel.Loader isLoading={isLoading} />
|
||||
<Panel.Title>General OSC settings</Panel.Title>
|
||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='OSC input' description='Allow control of Ontime through OSC' />
|
||||
<Controller
|
||||
control={control}
|
||||
name='enabledIn'
|
||||
render={({ field: { onChange, value, ref } }) => (
|
||||
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
|
||||
)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Listen on port'
|
||||
description='Port for incoming OSC. Default: 8888'
|
||||
error={errors.portIn?.message}
|
||||
/>
|
||||
<Input
|
||||
id='portIn'
|
||||
placeholder='8888'
|
||||
width='5rem'
|
||||
maxLength={5}
|
||||
size='sm'
|
||||
textAlign='right'
|
||||
variant='ontime-filled'
|
||||
type='number'
|
||||
autoComplete='off'
|
||||
{...register('portIn', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
|
||||
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
|
||||
pattern: {
|
||||
value: isOnlyNumbers,
|
||||
message: 'Value should be numeric',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='OSC output' description='Provide feedback from Ontime with OSC' />
|
||||
<Controller
|
||||
control={control}
|
||||
name='enabledOut'
|
||||
render={({ field: { onChange, value, ref } }) => (
|
||||
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
|
||||
)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='OSC target IP'
|
||||
description='IP address Ontime will send OSC messages to'
|
||||
error={errors.targetIP?.message}
|
||||
/>
|
||||
<Input
|
||||
id='targetIP'
|
||||
placeholder='127.0.0.1'
|
||||
width='9rem'
|
||||
size='sm'
|
||||
textAlign='right'
|
||||
variant='ontime-filled'
|
||||
autoComplete='off'
|
||||
{...register('targetIP', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
pattern: {
|
||||
value: isIPAddress,
|
||||
message: 'Invalid IP address',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='OSC target port'
|
||||
description='Port number Ontime will send OSC messages to'
|
||||
error={errors.portOut?.message}
|
||||
/>
|
||||
<Input
|
||||
id='portOut'
|
||||
placeholder='8888'
|
||||
width='75px'
|
||||
size='sm'
|
||||
textAlign='right'
|
||||
variant='ontime-filled'
|
||||
autoComplete='off'
|
||||
{...register('portOut', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
|
||||
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
|
||||
pattern: {
|
||||
value: isOnlyNumbers,
|
||||
message: 'Value should be numeric',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
|
||||
<Panel.Divider />
|
||||
|
||||
<Panel.Title>
|
||||
OSC integrations
|
||||
<Button variant='ontime-subtle' size='sm' rightIcon={<IoAdd />} onClick={handleAddNewSubscription}>
|
||||
Add
|
||||
</Button>
|
||||
</Panel.Title>
|
||||
|
||||
{fields.length > 0 && (
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Enabled</th>
|
||||
<th>Cycle</th>
|
||||
<th className={style.halfWidth}>Address</th>
|
||||
<th className={style.halfWidth}>Arguments</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fields.map((field, index) => {
|
||||
const maybeAddressError = errors.subscriptions?.[index]?.address?.message;
|
||||
const maybePayloadError = errors.subscriptions?.[index]?.payload?.message;
|
||||
return (
|
||||
<tr key={field.id}>
|
||||
<td>
|
||||
<Switch variant='ontime' {...register(`subscriptions.${index}.enabled`)} />
|
||||
</td>
|
||||
<td className={style.autoWidth}>
|
||||
<Select
|
||||
size='sm'
|
||||
variant='ontime'
|
||||
className={style.fitContents}
|
||||
{...register(`subscriptions.${index}.cycle`)}
|
||||
>
|
||||
{cycles.map((cycle) => (
|
||||
<option key={cycle.id} value={cycle.value}>
|
||||
{cycle.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</td>
|
||||
<td className={style.halfWidth}>
|
||||
<Input
|
||||
key={field.id}
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
autoComplete='off'
|
||||
placeholder='/from-ontime/'
|
||||
{...register(`subscriptions.${index}.address`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
validate: {
|
||||
oscStartsWithSlash: (value) =>
|
||||
startsWithSlash.test(value) || 'OSC address should start with a forward slash',
|
||||
oscStringIsAscii: (value) =>
|
||||
isASCII.test(value) || 'OSC address only allow ASCII characters',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{maybeAddressError && <Panel.Error>{maybeAddressError}</Panel.Error>}
|
||||
</td>
|
||||
<td className={style.halfWidth}>
|
||||
<Input
|
||||
key={field.id}
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
autoComplete='off'
|
||||
placeholder='{{timer.current}}'
|
||||
{...register(`subscriptions.${index}.payload`, {
|
||||
validate: {
|
||||
oscStringIsAscii: (value) =>
|
||||
isASCIIorEmpty.test(value) || 'OSC arguments only allow ASCII characters',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{maybePayloadError && <Panel.Error>{maybePayloadError}</Panel.Error>}
|
||||
</td>
|
||||
<td>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='ontime-ghosted'
|
||||
color='#FA5656' // $red-500
|
||||
icon={<IoTrash />}
|
||||
aria-label='Delete entry'
|
||||
onClick={() => handleDeleteSubscription(index)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
)}
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { TimerLifeCycle } from 'ontime-types';
|
||||
|
||||
type CycleLabel = {
|
||||
id: number;
|
||||
label: string;
|
||||
value: keyof typeof TimerLifeCycle;
|
||||
};
|
||||
|
||||
export const cycles: CycleLabel[] = [
|
||||
{ id: 1, label: 'On Load', value: 'onLoad' },
|
||||
{ id: 2, label: 'On Start', value: 'onStart' },
|
||||
{ id: 3, label: 'On Pause', value: 'onPause' },
|
||||
{ id: 4, label: 'On Stop', value: 'onStop' },
|
||||
{ id: 5, label: 'Every second', value: 'onClock' },
|
||||
{ id: 5, label: 'On Timer Update', value: 'onUpdate' },
|
||||
{ id: 6, label: 'On Finish', value: 'onFinish' },
|
||||
{ id: 7, label: 'On Warning', value: 'onWarning' },
|
||||
{ id: 8, label: 'On Danger', value: 'onDanger' },
|
||||
];
|
||||
@@ -0,0 +1,5 @@
|
||||
.qrCode {
|
||||
padding: 0.5rem;
|
||||
background: $ui-white;
|
||||
border-radius: 3px;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import QRCode from 'react-qr-code';
|
||||
import { Button, Select, Switch } from '@chakra-ui/react';
|
||||
|
||||
import { generateUrl } from '../../../../common/api/session';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
|
||||
import copyToClipboard from '../../../../common/utils/copyToClipboard';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { linkToOtherHost } from '../../../../common/utils/linkUtils';
|
||||
import { serverURL } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './GenerateLinkForm.module.scss';
|
||||
|
||||
interface GenerateLinkFormOptions {
|
||||
baseUrl: string;
|
||||
path: string;
|
||||
lock: boolean;
|
||||
authenticate: boolean;
|
||||
}
|
||||
|
||||
type GenerateLinkState = 'pending' | 'loading' | 'success' | 'error';
|
||||
|
||||
export default function GenerateLinkForm() {
|
||||
const { data: infoData } = useInfo();
|
||||
const { data: urlPresetData } = useUrlPresets();
|
||||
const [formState, setFormState] = useState<GenerateLinkState>('pending');
|
||||
const [url, setUrl] = useState(serverURL);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
setError,
|
||||
formState: { errors },
|
||||
} = useForm<GenerateLinkFormOptions>({
|
||||
mode: 'onChange',
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (options: GenerateLinkFormOptions) => {
|
||||
try {
|
||||
setFormState('loading');
|
||||
const baseUrl = linkToOtherHost(options.baseUrl);
|
||||
const url = await generateUrl(baseUrl, options.path, options.lock, options.authenticate);
|
||||
await copyToClipboard(url);
|
||||
setUrl(url);
|
||||
setFormState('success');
|
||||
setTimeout(() => {
|
||||
setFormState('pending');
|
||||
}, 4000);
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
setError('root', { message });
|
||||
setFormState('error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} onKeyDown={(event) => preventEscape(event)}>
|
||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Info>
|
||||
<Panel.Paragraph>
|
||||
You can generate a link to share with your team or to use in automation (such as companion).
|
||||
</Panel.Paragraph>
|
||||
</Info>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Host IP' description='Which IP address will be used' />
|
||||
<Select variant='ontime' size='sm' {...register('baseUrl')}>
|
||||
{infoData.networkInterfaces.map((nif) => {
|
||||
return (
|
||||
<option key={nif.name} value={nif.address}>
|
||||
{`${nif.name} - ${nif.address}`}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='URL Preset'
|
||||
description='Which preset will the link point to (will default to /timer if none is given)'
|
||||
/>
|
||||
<Select variant='ontime' size='sm' {...register('path')}>
|
||||
<option key='timer' value='timer'>
|
||||
Timer
|
||||
</option>
|
||||
<option key='companion' value=''>
|
||||
Companion
|
||||
</option>
|
||||
{urlPresetData.map((preset) => {
|
||||
return (
|
||||
<option key={preset.alias} value={preset.alias}>
|
||||
{`Preset: ${preset.alias}`}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Lock navigation'
|
||||
description='Prevent showing navigation (will only work for non production URLs)'
|
||||
/>
|
||||
<Switch variant='ontime' size='lg' {...register('lock')} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Authenticate' description='Whether the URL should be pre-authenticated' />
|
||||
<Switch variant='ontime' size='lg' {...register('authenticate')} />
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Generate link' description='Fill form and generate link and QR code' />
|
||||
<Button
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
isLoading={formState === 'loading'}
|
||||
type='submit'
|
||||
style={{ alignSelf: 'end' }}
|
||||
>
|
||||
{formState === 'success' ? 'Link copied to clipboard!' : 'Update share link'}
|
||||
</Button>
|
||||
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||
<QRCode size={172} value={url} className={style.qrCode} />
|
||||
<ExternalLink href={url}>{url}</ExternalLink>
|
||||
</div>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
-7
@@ -1,10 +1,3 @@
|
||||
.interfaces {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $section-spacing;
|
||||
row-gap: $element-inner-spacing;
|
||||
}
|
||||
|
||||
.goIcon {
|
||||
@include rotate-fourty-five;
|
||||
margin-left: 0.25rem;
|
||||
|
||||
@@ -4,6 +4,7 @@ import CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||
import { linkToOtherHost, openLink } from '../../../../common/utils/linkUtils';
|
||||
import { isLocalhost } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './NetworkInterfaces.module.scss';
|
||||
|
||||
@@ -13,7 +14,7 @@ export default function InfoNif() {
|
||||
const handleClick = (address: string) => openLink(address);
|
||||
|
||||
return (
|
||||
<div className={style.interfaces}>
|
||||
<Panel.InlineElements>
|
||||
{data.networkInterfaces.map((nif) => {
|
||||
// interfaces outside localhost wont have access
|
||||
if (nif.name === 'localhost' && !isLocalhost) return null;
|
||||
@@ -30,6 +31,6 @@ export default function InfoNif() {
|
||||
</CopyTag>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Panel.InlineElements>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,21 +8,34 @@ import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import ClientControlPanel from '../client-control-panel/ClientControlPanel';
|
||||
|
||||
import GenerateLinkForm from './GenerateLinkForm';
|
||||
import InfoNif from './NetworkInterfaces';
|
||||
import LogExport from './NetworkLogExport';
|
||||
|
||||
export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
||||
const linkRef = useScrollIntoView<HTMLDivElement>('link', location);
|
||||
const clientsRef = useScrollIntoView<HTMLDivElement>('clients', location);
|
||||
const logRef = useScrollIntoView<HTMLDivElement>('log', location);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Network</Panel.Header>
|
||||
<Panel.Section>
|
||||
{isDockerImage && <OntimeCloudStats />}
|
||||
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
|
||||
</Panel.Section>
|
||||
<InfoNif />
|
||||
{isDockerImage && (
|
||||
<Panel.Section>
|
||||
<OntimeCloudStats />
|
||||
</Panel.Section>
|
||||
)}
|
||||
<div ref={linkRef}>
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>Share Ontime Link</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
|
||||
<InfoNif />
|
||||
<GenerateLinkForm />
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
</div>
|
||||
<div ref={logRef}>
|
||||
<LogExport />
|
||||
</div>
|
||||
|
||||
@@ -11,8 +11,6 @@ import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import ProjectCreateForm from './ProjectCreateForm';
|
||||
import ProjectList from './ProjectList';
|
||||
|
||||
import style from './ProjectPanel.module.scss';
|
||||
|
||||
export default function ManageProjects() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [error, setError] = useState('');
|
||||
@@ -70,7 +68,7 @@ export default function ManageProjects() {
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Manage projects
|
||||
<div className={style.headerButtons}>
|
||||
<Panel.InlineElements>
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
onClick={handleSelectFile}
|
||||
@@ -89,7 +87,7 @@ export default function ManageProjects() {
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
<Panel.Divider />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import { PROJECT_LIST } from '../../../../common/api/constants';
|
||||
import { createProject } from '../../../../common/api/db';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { documentationUrl, websiteUrl } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
@@ -66,17 +67,21 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Section as='form' onSubmit={handleSubmit(handleSubmitCreate)}>
|
||||
<Panel.Section
|
||||
as='form'
|
||||
onSubmit={handleSubmit(handleSubmitCreate)}
|
||||
onKeyDown={(event) => preventEscape(event, onClose)}
|
||||
>
|
||||
<Panel.Title>
|
||||
Create new project
|
||||
<div className={style.createActionButtons}>
|
||||
<Panel.InlineElements>
|
||||
<Button onClick={onClose} variant='ontime-ghosted' size='sm' isDisabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button isDisabled={!isValid} type='submit' isLoading={isSubmitting} variant='ontime-filled' size='sm'>
|
||||
Create
|
||||
Create project
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.InlineElements>
|
||||
</Panel.Title>
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
<Panel.Section className={style.innerColumn}>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { projectLogoPath } from '../../../../common/api/constants';
|
||||
import { postProjectData, uploadProjectLogo } from '../../../../common/api/project';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import useProjectData from '../../../../common/hooks-query/useProjectData';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { validateLogo } from '../../../../common/utils/uploadUtils';
|
||||
import { documentationUrl, websiteUrl } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
@@ -96,11 +97,11 @@ export default function ProjectData() {
|
||||
const isLoading = status === 'pending';
|
||||
|
||||
return (
|
||||
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)}>
|
||||
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} onKeyDown={(event) => preventEscape(event, onReset)}>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Project data
|
||||
<div className={style.headerButtons}>
|
||||
<Panel.InlineElements>
|
||||
<Button variant='ontime-ghosted' size='sm' onClick={onReset} isDisabled={isSubmitting || !isDirty}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
@@ -113,7 +114,7 @@ export default function ProjectData() {
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
|
||||
@@ -2,6 +2,9 @@ import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Button, Input } from '@chakra-ui/react';
|
||||
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './ProjectPanel.module.scss';
|
||||
|
||||
export type ProjectFormValues = {
|
||||
@@ -34,7 +37,11 @@ export default function ProjectForm({ action, filename, onSubmit, onCancel }: Pr
|
||||
}, [setFocus]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={style.form}>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
onKeyDown={(event) => preventEscape(event, onCancel)}
|
||||
className={style.form}
|
||||
>
|
||||
<Input
|
||||
className={style.formInput}
|
||||
id='filename'
|
||||
@@ -45,7 +52,7 @@ export default function ProjectForm({ action, filename, onSubmit, onCancel }: Pr
|
||||
autoComplete='off'
|
||||
{...register('filename', { required: true })}
|
||||
/>
|
||||
<div className={style.actionButtons}>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button onClick={onCancel} size='sm' variant='ontime-ghosted' disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -58,7 +65,7 @@ export default function ProjectForm({ action, filename, onSubmit, onCancel }: Pr
|
||||
>
|
||||
{action}
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.InlineElements>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ export default function ProjectListItem({
|
||||
<>
|
||||
<td className={style.containCell}>{filename}</td>
|
||||
<td>{current ? 'Currently loaded' : new Date(updatedAt).toLocaleString()}</td>
|
||||
<td className={style.actionButton}>
|
||||
<td>
|
||||
<ActionMenu
|
||||
current={current}
|
||||
filename={filename}
|
||||
|
||||
@@ -23,8 +23,7 @@ type ProjectMergeFormValues = {
|
||||
rundown: boolean;
|
||||
viewSettings: boolean;
|
||||
urlPresets: boolean;
|
||||
osc: boolean;
|
||||
http: boolean;
|
||||
automation: boolean;
|
||||
};
|
||||
|
||||
export default function ProjectMergeForm(props: ProjectMergeFromProps) {
|
||||
@@ -42,8 +41,7 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
|
||||
rundown: false,
|
||||
viewSettings: false,
|
||||
urlPresets: false,
|
||||
osc: false,
|
||||
http: false,
|
||||
automation: false,
|
||||
},
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
@@ -77,7 +75,7 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
|
||||
<Panel.Section as='form' onSubmit={handleSubmit(handleSubmitCreate)}>
|
||||
<Panel.Title>
|
||||
Merge {`"${fileName}"`}
|
||||
<div className={style.createActionButtons}>
|
||||
<Panel.InlineElements>
|
||||
<Button onClick={onClose} variant='ontime-ghosted' size='sm' isDisabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -90,7 +88,7 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
|
||||
>
|
||||
Merge
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.InlineElements>
|
||||
</Panel.Title>
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
<Panel.Section className={cx([style.innerColumn, style.inlineLabels])}>
|
||||
@@ -115,12 +113,8 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
|
||||
URL Presets
|
||||
</label>
|
||||
<label>
|
||||
<Switch variant='ontime' {...register('osc')} />
|
||||
OSC Integration
|
||||
</label>
|
||||
<label>
|
||||
<Switch variant='ontime' {...register('http')} />
|
||||
HTTP Integration
|
||||
<Switch variant='ontime' {...register('automation')} />
|
||||
Automation Settings
|
||||
</label>
|
||||
</Panel.Section>
|
||||
</Panel.Section>
|
||||
|
||||
@@ -6,39 +6,18 @@
|
||||
color: $blue-500;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.formInput {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
.headerButtons,
|
||||
.actionButtons,
|
||||
.createActionButtons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.createActionButtons {
|
||||
margin-left: 1rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.saveButton {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
@@ -62,12 +41,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.uploadLogoCard {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
|
||||
@@ -12,8 +12,6 @@ import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import useGoogleSheet from './useGoogleSheet';
|
||||
import { useSheetStore } from './useSheetStore';
|
||||
|
||||
import style from './SourcesPanel.module.scss';
|
||||
|
||||
interface GSheetSetupProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
@@ -176,7 +174,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
||||
</Panel.ListGroup>
|
||||
{!canAuthenticate ? (
|
||||
<Panel.ListGroup>
|
||||
<div className={style.buttonRow}>
|
||||
<Panel.InlineElements>
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
size='sm'
|
||||
@@ -187,11 +185,11 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.InlineElements>
|
||||
</Panel.ListGroup>
|
||||
) : (
|
||||
<Panel.ListGroup>
|
||||
<div className={style.buttonRow}>
|
||||
<Panel.InlineElements>
|
||||
{isAuthenticating && <Spinner />}
|
||||
<CopyTag copyValue={authKey ?? ''} label='Google Auth Key' disabled={!canAuthenticate} size='sm'>
|
||||
{authKey ? authKey : 'Upload files to generate Auth Key'}
|
||||
@@ -205,7 +203,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
||||
>
|
||||
Authenticate
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.InlineElements>
|
||||
</Panel.ListGroup>
|
||||
)}
|
||||
</Panel.Section>
|
||||
|
||||
@@ -8,8 +8,6 @@ import PreviewSpreadsheet from './preview/PreviewRundown';
|
||||
import useGoogleSheet from './useGoogleSheet';
|
||||
import { useSheetStore } from './useSheetStore';
|
||||
|
||||
import style from './SourcesPanel.module.scss';
|
||||
|
||||
interface ImportReviewProps {
|
||||
rundown: OntimeRundown;
|
||||
customFields: CustomFields;
|
||||
@@ -40,14 +38,14 @@ export default function ImportReview(props: ImportReviewProps) {
|
||||
<Panel.Section>
|
||||
<Panel.Title>
|
||||
Review Rundown
|
||||
<div className={style.buttonRow}>
|
||||
<Panel.InlineElements>
|
||||
<Button onClick={handleCancel} variant='ontime-ghosted' size='sm' isDisabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={applyImport} variant='ontime-filled' size='sm' isLoading={loading}>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.InlineElements>
|
||||
</Panel.Title>
|
||||
<PreviewSpreadsheet rundown={rundown} customFields={customFields} />
|
||||
</Panel.Section>
|
||||
|
||||
@@ -29,17 +29,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.buttonRow {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.inputContainer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.singleActionCell {
|
||||
width: 50px;
|
||||
text-align: center;
|
||||
|
||||
+4
-4
@@ -84,7 +84,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
|
||||
<Panel.Section as='form' id='import-map'>
|
||||
<Panel.Title>
|
||||
Import options
|
||||
<div className={style.buttonRow}>
|
||||
<Panel.InlineElements>
|
||||
{!isSpreadsheet && (
|
||||
<Tooltip label='Revoke the google authentication'>
|
||||
<Button variant='ontime-subtle' size='sm' onClick={handleRevoke} isDisabled={isLoading}>
|
||||
@@ -115,7 +115,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
|
||||
>
|
||||
Import preview
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.InlineElements>
|
||||
</Panel.Title>
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
@@ -224,11 +224,11 @@ export default function ImportMapForm(props: ImportMapFormProps) {
|
||||
})}
|
||||
<tr>
|
||||
<td />
|
||||
<td className={style.buttonRow} colSpan={99}>
|
||||
<Panel.InlineElements as='td' align='end'>
|
||||
<Button size='sm' variant='ontime-subtle' rightIcon={<IoAdd />} onClick={addCustomImport}>
|
||||
Add custom field
|
||||
</Button>
|
||||
</td>
|
||||
</Panel.InlineElements>
|
||||
<td />
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -47,11 +47,12 @@ const staticOptions = [
|
||||
split: true,
|
||||
},
|
||||
{
|
||||
id: 'integrations',
|
||||
label: 'Integrations',
|
||||
id: 'automation',
|
||||
label: 'Automation',
|
||||
secondary: [
|
||||
{ id: 'integrations__osc', label: 'OSC settings' },
|
||||
{ id: 'integrations__http', label: 'HTTP settings' },
|
||||
{ id: 'automation__settings', label: 'Automation settings' },
|
||||
{ id: 'automation__triggers', label: 'Manage triggers' },
|
||||
{ id: 'automation__automations', label: 'Manage automations' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -59,6 +60,10 @@ const staticOptions = [
|
||||
label: 'Network',
|
||||
split: true,
|
||||
secondary: [
|
||||
{
|
||||
id: 'network__link',
|
||||
label: 'Share link',
|
||||
},
|
||||
{
|
||||
id: 'network__log',
|
||||
label: 'Event log',
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: end;
|
||||
|
||||
:first-child {
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.tableContainer {
|
||||
@@ -38,14 +42,20 @@
|
||||
tbody {
|
||||
background-color: $gray-1300;
|
||||
tr {
|
||||
&:has(:hover) {
|
||||
&:hover:not(.current) {
|
||||
background-color: $gray-1350;
|
||||
}
|
||||
.current {
|
||||
&:hover {
|
||||
background-color: $blue-900;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tr {
|
||||
height: 2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
th,
|
||||
@@ -62,4 +72,8 @@
|
||||
|
||||
.current {
|
||||
background-color: $blue-700;
|
||||
|
||||
&:hover {
|
||||
background-color: $blue-900;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalOverlay } from '@chakra-ui/react';
|
||||
import { Button, Checkbox, Modal, ModalBody, ModalCloseButton, ModalContent, ModalOverlay } from '@chakra-ui/react';
|
||||
|
||||
import { loadDemo, loadProject } from '../../../common/api/db';
|
||||
import { postShowWelcomeDialog } from '../../../common/api/settings';
|
||||
import { invalidateAllCaches } from '../../../common/api/utils';
|
||||
import ExternalLink from '../../../common/components/external-link/ExternalLink';
|
||||
import { appVersion, discordUrl, documentationUrl, websiteUrl } from '../../../externals';
|
||||
import * as Editor from '../editor-utils/EditorUtils';
|
||||
|
||||
import ImportProjectButton from './composite/ImportProjectButton';
|
||||
import WelcomeProjectList from './composite/WelcomeProjectList';
|
||||
@@ -68,30 +70,37 @@ export default function Welcome(props: WelcomeProps) {
|
||||
</div>
|
||||
<div className={style.column}>
|
||||
<div className={style.header}>Welcome to Ontime</div>
|
||||
<Editor.Title>Select project</Editor.Title>
|
||||
<div className={style.tableContainer}>
|
||||
<table className={style.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project Name</th>
|
||||
<th>Last Used</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<WelcomeProjectList loadProject={handleLoadProject} onClose={handleClose} />
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className={style.buttonRow}>
|
||||
<ImportProjectButton onFinish={handleClose} />
|
||||
<Button size='sm' variant='ontime-subtle' onClick={handleLoadDemo}>
|
||||
Load demo project
|
||||
</Button>
|
||||
<Button size='sm' variant='ontime-filled' onClick={handleCallCreate}>
|
||||
Create new...
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.buttonRow}>
|
||||
<Button size='sm' variant='ontime-subtle' onClick={handleLoadDemo}>
|
||||
Load demo project
|
||||
</Button>
|
||||
<ImportProjectButton onFinish={handleClose} />
|
||||
<Button size='sm' variant='ontime-filled' onClick={handleCallCreate}>
|
||||
Create new...
|
||||
</Button>
|
||||
</div>
|
||||
<Checkbox
|
||||
size='sm'
|
||||
variant='ontime-ondark'
|
||||
defaultChecked
|
||||
onChange={(event) => postShowWelcomeDialog(event.target.checked)}
|
||||
>
|
||||
Show this modal on next startup
|
||||
</Checkbox>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Button } from '@chakra-ui/react';
|
||||
|
||||
import { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList';
|
||||
|
||||
import style from '../Welcome.module.scss';
|
||||
@@ -18,26 +16,16 @@ export default function WelcomeProjectList(props: WelcomeProjectListProps) {
|
||||
{data.reorderedProjectFiles.map((project) => {
|
||||
if (project.filename === data.lastLoadedProject) {
|
||||
return (
|
||||
<tr className={style.current} key={project.filename}>
|
||||
<tr className={style.current} key={project.filename} onClick={onClose}>
|
||||
<td>{project.filename}</td>
|
||||
<td>Loaded from last session</td>
|
||||
<td>
|
||||
<Button variant='ontime-subtle' size='sm' onClick={onClose}>
|
||||
Continue
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<tr key={project.filename}>
|
||||
<tr key={project.filename} onClick={() => loadProject(project.filename)}>
|
||||
<td>{project.filename}</td>
|
||||
<td>{new Date(project.updatedAt).toLocaleString()}</td>
|
||||
<td>
|
||||
<Button variant='ontime-subtle' size='sm' onClick={() => loadProject(project.filename)}>
|
||||
Load
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -42,8 +42,6 @@ $info-hover: $section-white;
|
||||
}
|
||||
|
||||
.buttonBar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1em;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button } from '@chakra-ui/react';
|
||||
import { LogOrigin } from 'ontime-types';
|
||||
|
||||
import { clearLogs, useLogData } from '../../common/stores/logger';
|
||||
import * as Panel from '../app-settings/panel-utils/PanelUtils';
|
||||
|
||||
import style from './Log.module.scss';
|
||||
|
||||
@@ -49,7 +50,7 @@ export default function Log() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={style.buttonBar}>
|
||||
<Panel.InlineElements className={style.buttonBar}>
|
||||
<Button
|
||||
variant={showUser ? 'ontime-filled' : 'ontime-outlined'}
|
||||
size='xs'
|
||||
@@ -107,7 +108,7 @@ export default function Log() {
|
||||
<Button variant='ontime-subtle' size='xs' onClick={clearLogs}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.InlineElements>
|
||||
<ul className={style.log}>
|
||||
{filteredData.map((logEntry) => (
|
||||
<li key={logEntry.id} className={`${style.logEntry} ${style[logEntry.level]} `}>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { CustomFields } from 'ontime-types';
|
||||
|
||||
import { getTimeOption, makeOptionsFromCustomFields } from '../../common/components/view-params-editor/constants';
|
||||
import {
|
||||
getTimeOption,
|
||||
makeOptionsFromCustomFields,
|
||||
OptionTitle,
|
||||
} from '../../common/components/view-params-editor/constants';
|
||||
import { ViewOption } from '../../common/components/view-params-editor/types';
|
||||
|
||||
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
|
||||
@@ -14,47 +18,55 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
|
||||
}, {});
|
||||
|
||||
return [
|
||||
{ section: 'Clock Options' },
|
||||
getTimeOption(timeFormat),
|
||||
{ section: 'Data sources' },
|
||||
|
||||
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
|
||||
{
|
||||
id: 'main',
|
||||
title: 'Main data field',
|
||||
description: 'Field to be shown in the first line of text',
|
||||
type: 'option',
|
||||
values: fieldOptions,
|
||||
defaultValue: 'title',
|
||||
title: OptionTitle.DataSources,
|
||||
collapsible: true,
|
||||
options: [
|
||||
{
|
||||
id: 'main',
|
||||
title: 'Main data field',
|
||||
description: 'Field to be shown in the first line of text',
|
||||
type: 'option',
|
||||
values: fieldOptions,
|
||||
defaultValue: 'title',
|
||||
},
|
||||
{
|
||||
id: 'secondary',
|
||||
title: 'Secondary data field',
|
||||
description: 'Field to be shown in the second line of text',
|
||||
type: 'option',
|
||||
values: fieldOptions,
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
id: 'subscribe',
|
||||
title: 'Highlight Field',
|
||||
description: 'Choose a custom field to highlight',
|
||||
type: 'multi-option',
|
||||
values: customFieldSelect,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'secondary',
|
||||
title: 'Secondary data field',
|
||||
description: 'Field to be shown in the second line of text',
|
||||
type: 'option',
|
||||
values: fieldOptions,
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
id: 'subscribe',
|
||||
title: 'Highlight Field',
|
||||
description: 'Choose a custom field to highlight',
|
||||
type: 'multi-option',
|
||||
values: customFieldSelect,
|
||||
},
|
||||
{ section: 'Element visibility' },
|
||||
{
|
||||
id: 'hidepast',
|
||||
title: 'Hide Past Events',
|
||||
description: 'Whether to hide events that have passed',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'shouldEdit',
|
||||
title: 'Edit custom field',
|
||||
description: 'Allows editing an events selected custom field by long pressing.',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
title: OptionTitle.ElementVisibility,
|
||||
collapsible: true,
|
||||
options: [
|
||||
{
|
||||
id: 'hidepast',
|
||||
title: 'Hide Past Events',
|
||||
description: 'Whether to hide events that have passed',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'shouldEdit',
|
||||
title: 'Edit custom field',
|
||||
description: 'Allows editing an events selected custom field by long pressing.',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -105,5 +105,6 @@
|
||||
}
|
||||
|
||||
.progressOverride {
|
||||
height: 1rem;
|
||||
--progress-bar-br: 0;
|
||||
}
|
||||
|
||||
@@ -1,44 +1,58 @@
|
||||
import { CustomFields } from 'ontime-types';
|
||||
|
||||
import { getTimeOption, makeOptionsFromCustomFields } from '../../../common/components/view-params-editor/constants';
|
||||
import {
|
||||
getTimeOption,
|
||||
makeOptionsFromCustomFields,
|
||||
OptionTitle,
|
||||
} from '../../../common/components/view-params-editor/constants';
|
||||
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
||||
|
||||
export const getBackstageOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
|
||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
|
||||
|
||||
return [
|
||||
{ section: 'Clock Options' },
|
||||
getTimeOption(timeFormat),
|
||||
{ section: 'Data sources' },
|
||||
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
|
||||
{
|
||||
id: 'secondary-src',
|
||||
title: 'Event secondary text',
|
||||
description: 'Select the data source for auxiliary text shown in now and next cards',
|
||||
type: 'option',
|
||||
values: secondaryOptions,
|
||||
defaultValue: '',
|
||||
title: OptionTitle.DataSources,
|
||||
collapsible: true,
|
||||
options: [
|
||||
{
|
||||
id: 'secondary-src',
|
||||
title: 'Event secondary text',
|
||||
description: 'Select the data source for auxiliary text shown in now and next cards',
|
||||
type: 'option',
|
||||
values: secondaryOptions,
|
||||
defaultValue: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ section: 'Schedule options' },
|
||||
|
||||
{
|
||||
id: 'eventsPerPage',
|
||||
title: 'Events per page',
|
||||
description: 'Sets the number of events on the page, can cause overflow',
|
||||
type: 'number',
|
||||
placeholder: '8 (default)',
|
||||
},
|
||||
{
|
||||
id: 'hidePast',
|
||||
title: 'Hide past events',
|
||||
description: 'Scheduler will only show upcoming events',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'stopCycle',
|
||||
title: 'Stop cycling through event pages',
|
||||
description: 'Schedule will not auto-cycle through events',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
title: OptionTitle.Schedule,
|
||||
collapsible: true,
|
||||
options: [
|
||||
{
|
||||
id: 'eventsPerPage',
|
||||
title: 'Events per page',
|
||||
description: 'Sets the number of events on the page, can cause overflow',
|
||||
type: 'number',
|
||||
placeholder: '8 (default)',
|
||||
},
|
||||
{
|
||||
id: 'hidePast',
|
||||
title: 'Hide past events',
|
||||
description: 'Scheduler will only show upcoming events',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'stopCycle',
|
||||
title: 'Stop cycling through event pages',
|
||||
description: 'Schedule will not auto-cycle through events',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -1,73 +1,77 @@
|
||||
import { getTimeOption } from '../../../common/components/view-params-editor/constants';
|
||||
import { getTimeOption, OptionTitle } from '../../../common/components/view-params-editor/constants';
|
||||
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
||||
|
||||
export const getClockOptions = (timeFormat: string): ViewOption[] => [
|
||||
{ section: 'Clock Options' },
|
||||
getTimeOption(timeFormat),
|
||||
{ section: 'View style override' },
|
||||
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
|
||||
{
|
||||
id: 'key',
|
||||
title: 'Key Colour',
|
||||
description: 'Background or key colour for entire view. Default: #000000',
|
||||
type: 'colour',
|
||||
defaultValue: '000000',
|
||||
},
|
||||
{
|
||||
id: 'text',
|
||||
title: 'Text Colour',
|
||||
description: 'Text colour. Default: #FFFFFF',
|
||||
type: 'colour',
|
||||
defaultValue: 'FFFFFF',
|
||||
},
|
||||
{
|
||||
id: 'textbg',
|
||||
title: 'Text Background',
|
||||
description: 'Background colour for timer text. Default: #FFF0 (transparent)',
|
||||
type: 'colour',
|
||||
defaultValue: 'FFF0',
|
||||
},
|
||||
{
|
||||
id: 'font',
|
||||
title: 'Font',
|
||||
description: 'Font family, will use the fonts available in the system',
|
||||
type: 'string',
|
||||
placeholder: 'Arial Black (default)',
|
||||
},
|
||||
{
|
||||
id: 'size',
|
||||
title: 'Text Size',
|
||||
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
|
||||
type: 'number',
|
||||
placeholder: '1 (default)',
|
||||
},
|
||||
{
|
||||
id: 'alignx',
|
||||
title: 'Align Horizontal',
|
||||
description: 'Moves the horizontally in page to start = left | center | end = right',
|
||||
type: 'option',
|
||||
values: { start: 'Start', center: 'Center', end: 'End' },
|
||||
defaultValue: 'center',
|
||||
},
|
||||
{
|
||||
id: 'offsetx',
|
||||
title: 'Offset Horizontal',
|
||||
description: 'Offsets the timer horizontal position by a given amount in pixels',
|
||||
type: 'number',
|
||||
placeholder: '0 (default)',
|
||||
},
|
||||
{
|
||||
id: 'aligny',
|
||||
title: 'Align Vertical',
|
||||
description: 'Moves the vertically in page to start = left | center | end = right',
|
||||
type: 'option',
|
||||
values: { start: 'Start', center: 'Center', end: 'End' },
|
||||
defaultValue: 'center',
|
||||
},
|
||||
{
|
||||
id: 'offsety',
|
||||
title: 'Offset Vertical',
|
||||
description: 'Offsets the timer vertical position by a given amount in pixels',
|
||||
type: 'number',
|
||||
placeholder: '0 (default)',
|
||||
title: OptionTitle.ClockOptions,
|
||||
collapsible: true,
|
||||
options: [
|
||||
{
|
||||
id: 'key',
|
||||
title: 'Key Colour',
|
||||
description: 'Background or key colour for entire view. Default: #000000',
|
||||
type: 'colour',
|
||||
defaultValue: '000000',
|
||||
},
|
||||
{
|
||||
id: 'text',
|
||||
title: 'Text Colour',
|
||||
description: 'Text colour. Default: #FFFFFF',
|
||||
type: 'colour',
|
||||
defaultValue: 'FFFFFF',
|
||||
},
|
||||
{
|
||||
id: 'textbg',
|
||||
title: 'Text Background',
|
||||
description: 'Background colour for timer text. Default: #FFF0 (transparent)',
|
||||
type: 'colour',
|
||||
defaultValue: 'FFF0',
|
||||
},
|
||||
{
|
||||
id: 'font',
|
||||
title: 'Font',
|
||||
description: 'Font family, will use the fonts available in the system',
|
||||
type: 'string',
|
||||
placeholder: 'Arial Black (default)',
|
||||
},
|
||||
{
|
||||
id: 'size',
|
||||
title: 'Text Size',
|
||||
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
|
||||
type: 'number',
|
||||
placeholder: '1 (default)',
|
||||
},
|
||||
{
|
||||
id: 'alignx',
|
||||
title: 'Align Horizontal',
|
||||
description: 'Moves the horizontally in page to start = left | center | end = right',
|
||||
type: 'option',
|
||||
values: { start: 'Start', center: 'Center', end: 'End' },
|
||||
defaultValue: 'center',
|
||||
},
|
||||
{
|
||||
id: 'offsetx',
|
||||
title: 'Offset Horizontal',
|
||||
description: 'Offsets the timer horizontal position by a given amount in pixels',
|
||||
type: 'number',
|
||||
placeholder: '0 (default)',
|
||||
},
|
||||
{
|
||||
id: 'aligny',
|
||||
title: 'Align Vertical',
|
||||
description: 'Moves the vertically in page to start = left | center | end = right',
|
||||
type: 'option',
|
||||
values: { start: 'Start', center: 'Center', end: 'End' },
|
||||
defaultValue: 'center',
|
||||
},
|
||||
{
|
||||
id: 'offsety',
|
||||
title: 'Offset Vertical',
|
||||
description: 'Offsets the timer vertical position by a given amount in pixels',
|
||||
type: 'number',
|
||||
placeholder: '0 (default)',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,3 +2,7 @@ sup.period {
|
||||
top: -1em;
|
||||
font-size: 0.4em;
|
||||
}
|
||||
|
||||
.subscript {
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
@@ -9,17 +9,32 @@ interface SuperscriptTimeProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* @description receives a string like 12:00 AM and adds the period part to the superscript
|
||||
* When the timer includes seconds, we want to split it from the rest
|
||||
*/
|
||||
function getTimerParts(time: string) {
|
||||
if (time.length !== 8) {
|
||||
return [time, ''];
|
||||
}
|
||||
|
||||
return [time.slice(0, 5), time.slice(5)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives a time string and formats it with a subscript or superscript
|
||||
* @example 12:00 AM -> AM becomes a superscript
|
||||
* @example 12:00:10 -> the seconds become a subscript
|
||||
*/
|
||||
export default function SuperscriptTime(props: SuperscriptTimeProps) {
|
||||
const { time, className, style } = props;
|
||||
|
||||
// we assume anything after space is a period tag
|
||||
const [timeString, period] = time.split(' ');
|
||||
const [mainTime, subscript] = getTimerParts(timeString);
|
||||
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
{timeString}
|
||||
{mainTime}
|
||||
{subscript && <span className='subscript'>{subscript}</span>}
|
||||
{period && <sup className='period'>{period}</sup>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,11 +7,17 @@ import { formatTime } from '../../../common/utils/time';
|
||||
|
||||
type TimerTypeParams = Pick<ViewExtendedTimer, 'countToEnd' | 'timerType' | 'current' | 'elapsed' | 'clock'>;
|
||||
|
||||
export function getTimerByType(freezeEnd: boolean, timerObject?: TimerTypeParams): number | null {
|
||||
export function getTimerByType(
|
||||
freezeEnd: boolean,
|
||||
timerObject?: TimerTypeParams,
|
||||
timerTypeOverride?: TimerType,
|
||||
): number | null {
|
||||
if (!timerObject) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const viewTimerType = timerTypeOverride ?? timerObject.timerType;
|
||||
|
||||
if (timerObject.countToEnd) {
|
||||
if (timerObject.current === null) {
|
||||
return null;
|
||||
@@ -19,7 +25,7 @@ export function getTimerByType(freezeEnd: boolean, timerObject?: TimerTypeParams
|
||||
return freezeEnd ? Math.max(timerObject.current, 0) : timerObject.current;
|
||||
}
|
||||
|
||||
switch (timerObject.timerType) {
|
||||
switch (viewTimerType) {
|
||||
case TimerType.CountDown:
|
||||
if (timerObject.current === null) {
|
||||
return null;
|
||||
@@ -31,10 +37,8 @@ export function getTimerByType(freezeEnd: boolean, timerObject?: TimerTypeParams
|
||||
return timerObject.clock;
|
||||
case TimerType.None:
|
||||
return null;
|
||||
default: {
|
||||
const exhaustiveCheck: never = timerObject.timerType;
|
||||
return exhaustiveCheck;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user