mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-15 04:13:47 +00:00
V3 (#657)
* refactor: cleanup routes * style: smaller base font * chore: upgrade dependencies * chore: lock node version to electron * refactor: pass HTTP to integration controller (#652) * refactor: deprecate onair control * refactor: remove playback router * Several project files user folder (#617) * chore: automated screenshots (#667) * feat: app settings (#658) * refactor: remove deprecated event data (#674) * Studio clock (#663) --------- Co-authored-by: Carlos Valente <carlosvalente@pm.me> * Feat: reorder events with alt+ctrl + arrow up/down (#645) * Warning and danger per event (#677) --------- Co-authored-by: Fabian Posenau <fabian@fphome.de> * refactor: stabilise actionHandler (#683) Co-authored-by: Fabian Posenau <fabian@fphome.de> * improvement: hide seconds (#675) * wip: overview (#688) * fix: focus cursor (#695) * refactor: update lower third (#665) * Refactor/time formatting (#696) --------- Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Co-authored-by: Carlos Valente <carlosvalente@pm.me> * feat: multiple selection (#703) --------- Co-authored-by: asharonbaltazar <asharonbaltazar@outlook.com> Co-authored-by: Alex <ac@omnivox.dk> * fix: test - go to `Edit mode` befor tying to click `Event options` button (#708) * refactor: runtime service (#715) * fix: issue with loosing cursor position on message (#719) * remove info panel (#721) * Event editor continue (#722) * update API - part (#709) --------- Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Co-authored-by: Carlos Valente <carlosvalente@pm.me> * refactor: update timers (#729) * feat: many timers (#706) --------- Co-authored-by: arc-alex <ac@omnivox.dk> * refactor: excel cleanup (#734) * refactor: allow import of blocks and skip import (#735) * Project manager (#697) * refactor: UI for linking events (#763) * upgraded pipeline actions (#777) * Over under (#771) * custom fields (#744) --------- Co-authored-by: Carlos Valente <carlosvalente@pm.me> * Sheets settings (#774) --------- Co-authored-by: arc-alex <ac@omnivox.dk> * style: tweaks to lower thirds (#785) * refactor: delays account for gaps (#784) * refactor: partial state updates (#780) * feat: generate crash report (#787) * Sheet use limited input device auth flow (#782) --------- Co-authored-by: cv <34649812+cpvalente@users.noreply.github.com> Co-authored-by: Carlos Valente <carlosvalente@pm.me> * Custom fields views (#789) * refactor: deprecate presenter and subtitle (#795) * refactor: organise API around resources (#798) --------- Co-authored-by: Bianca Procopio <biancahprocopio@gmail.com> * Time to end (#804) * Skip fixes (#805) * fix: onair derives from playback * Param nav (#822) --------- Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk> * refactor: download files from interface (#831) * Quick options (#814) * End pause (#832) * chore: bump node version in docker (#834) * refactor: follow in run mode (#840) * fix: uncaught error in http integration (#837) * Apply project (#843) Co-authored-by: Matteo Gheza <matteo.gheza07@gmail.com> Co-authored-by: Ary <arylmoraesn@gmail.com> Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk> Co-authored-by: Fabian Posenau <19673098+kellhogs@users.noreply.github.com> Co-authored-by: Fabian Posenau <fabian@fphome.de> Co-authored-by: Alex Rohleder <alexrohleder96@gmail.com> Co-authored-by: asharonbaltazar <asharonbaltazar@outlook.com> Co-authored-by: Bianca Procopio <biancahprocopio@gmail.com> Co-authored-by: Fabian Posenau <fabianpos99+github@gmail.com>
This commit is contained in:
@@ -1,8 +1,6 @@
|
||||
@use '../theme/v2Styles' as *;
|
||||
|
||||
.wrapper {
|
||||
background: $bg-container-l1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: max(16px, 2vh);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -2,12 +2,12 @@
|
||||
import { ComponentType, useEffect } from 'react';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import useAliases from '../common/hooks-query/useAliases';
|
||||
import { getAliasRoute } from '../common/utils/aliases';
|
||||
import useUrlPresets from '../common/hooks-query/useUrlPresets';
|
||||
import { getRouteFromPreset } from '../common/utils/urlPresets';
|
||||
|
||||
const withAlias = <P extends object>(Component: ComponentType<P>) => {
|
||||
const withPreset = <P extends object>(Component: ComponentType<P>) => {
|
||||
return (props: Partial<P>) => {
|
||||
const { data } = useAliases();
|
||||
const { data } = useUrlPresets();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -15,7 +15,7 @@ const withAlias = <P extends object>(Component: ComponentType<P>) => {
|
||||
// navigate if is alias route
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
const url = getAliasRoute(location, data, searchParams);
|
||||
const url = getRouteFromPreset(location, data, searchParams);
|
||||
// navigate to this route if its not empty
|
||||
if (url) {
|
||||
navigate(url);
|
||||
@@ -26,4 +26,4 @@ const withAlias = <P extends object>(Component: ComponentType<P>) => {
|
||||
};
|
||||
};
|
||||
|
||||
export default withAlias;
|
||||
export default withPreset;
|
||||
@@ -0,0 +1,11 @@
|
||||
.container {
|
||||
grid-area: main;
|
||||
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
|
||||
overflow: hidden;
|
||||
background-color: black;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ErrorBoundary } from '@sentry/react';
|
||||
|
||||
import { useKeyDown } from '../../common/hooks/useKeyDown';
|
||||
|
||||
import AboutPanel from './panel/about-panel/AboutPanel';
|
||||
import FeatureSettingsPanel from './panel/feature-settings-panel/FeatureSettingsPanel';
|
||||
import GeneralPanel from './panel/general-panel/GeneralPanel';
|
||||
import IntegrationsPanel from './panel/integrations-panel/IntegrationsPanel';
|
||||
import LogPanel from './panel/log-panel/LogPanel';
|
||||
import ProjectPanel from './panel/project-panel/ProjectPanel';
|
||||
import ShutdownPanel from './panel/shutdown-panel/ShutdownPanel';
|
||||
import SourcesPanel from './panel/sources-panel/SourcesPanel';
|
||||
import PanelContent from './panel-content/PanelContent';
|
||||
import PanelList from './panel-list/PanelList';
|
||||
import useAppSettingsNavigation from './useAppSettingsNavigation';
|
||||
|
||||
import style from './AppSettings.module.scss';
|
||||
|
||||
export default function AppSettings() {
|
||||
const { close, panel, location } = useAppSettingsNavigation();
|
||||
useKeyDown(close, 'Escape');
|
||||
|
||||
return (
|
||||
<div className={style.container}>
|
||||
<ErrorBoundary>
|
||||
<PanelList selectedPanel={panel} location={location} />
|
||||
<PanelContent onClose={close}>
|
||||
{panel === 'project' && <ProjectPanel location={location} />}
|
||||
{panel === 'general' && <GeneralPanel location={location} />}
|
||||
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
|
||||
{panel === 'sources' && <SourcesPanel />}
|
||||
{panel === 'integrations' && <IntegrationsPanel location={location} />}
|
||||
{panel === 'about' && <AboutPanel />}
|
||||
{panel === 'log' && <LogPanel />}
|
||||
{panel === 'shutdown' && <ShutdownPanel />}
|
||||
</PanelContent>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
.corner {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 2rem;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.contentWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin: 1rem;
|
||||
overflow-y: auto;
|
||||
flex-grow: 1;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { IoClose } from '@react-icons/all-files/io5/IoClose';
|
||||
|
||||
import style from './PanelContent.module.scss';
|
||||
|
||||
interface PanelContentProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PanelContent(props: PropsWithChildren<PanelContentProps>) {
|
||||
const { onClose, children } = props;
|
||||
|
||||
return (
|
||||
<div className={style.contentWrapper}>
|
||||
<div className={style.corner}>
|
||||
<Button onClick={onClose} aria-label='close' rightIcon={<IoClose />} variant='ontime-subtle'>
|
||||
Close settings
|
||||
</Button>
|
||||
</div>
|
||||
<div className={style.content}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
.tabs,
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
width: min(30vw, 300px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.primary,
|
||||
.secondary {
|
||||
padding: 0.25rem 1rem;
|
||||
margin-right: 1rem;
|
||||
|
||||
&:focus {
|
||||
background-color: $gray-1000;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $gray-1000;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.primary {
|
||||
font-size: 1rem;
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
&.active {
|
||||
color: $blue-400;
|
||||
background-color: $gray-1100;
|
||||
}
|
||||
|
||||
&.unsaved::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background-color: $blue-400;
|
||||
}
|
||||
|
||||
&.split {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.secondary {
|
||||
margin-left: 1rem;
|
||||
color: $secondary-text-gray;
|
||||
border-left: 1px solid $white-10;
|
||||
font-size: $inner-section-text-size;
|
||||
|
||||
&.active {
|
||||
color: $blue-400;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Fragment } from 'react';
|
||||
|
||||
import { isKeyEnter } from '../../../common/utils/keyEvent';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { PanelBaseProps, settingPanels, useSettingsStore } from '../settingsStore';
|
||||
import useAppSettingsNavigation from '../useAppSettingsNavigation';
|
||||
|
||||
import style from './PanelList.module.scss';
|
||||
|
||||
interface PanelListProps extends PanelBaseProps {
|
||||
selectedPanel: string;
|
||||
}
|
||||
|
||||
export default function PanelList({ selectedPanel, location }: PanelListProps) {
|
||||
const { setLocation } = useAppSettingsNavigation();
|
||||
const { hasUnsavedChanges } = useSettingsStore();
|
||||
|
||||
return (
|
||||
<ul className={style.tabs}>
|
||||
{settingPanels.map((panel) => {
|
||||
const unsaved = hasUnsavedChanges(panel.id);
|
||||
|
||||
const classes = cx([
|
||||
style.primary,
|
||||
selectedPanel === panel.id ? style.active : null,
|
||||
panel.split ? style.split : null,
|
||||
unsaved ? style.unsaved : null,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Fragment key={panel.id}>
|
||||
<li
|
||||
key={panel.id}
|
||||
onClick={() => setLocation(panel.id)}
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && setLocation(panel.id);
|
||||
}}
|
||||
className={classes}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
>
|
||||
{panel.label}
|
||||
</li>
|
||||
{panel.secondary?.map((secondary) => {
|
||||
const id = secondary.id.split('__')[1];
|
||||
const secondaryClasses = cx([style.secondary, location === id ? style.active : null]);
|
||||
return (
|
||||
<li
|
||||
key={secondary.id}
|
||||
onClick={() => setLocation(secondary.id)}
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && setLocation(secondary.id);
|
||||
}}
|
||||
className={secondaryClasses}
|
||||
role='button'
|
||||
>
|
||||
{secondary.label}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
$inner-padding: 1rem;
|
||||
|
||||
.header {
|
||||
font-size: 2rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid $white-10;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subheader {
|
||||
font-size: 1.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.375rem;
|
||||
padding: 0 2rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: $gray-300;
|
||||
}
|
||||
|
||||
.section {
|
||||
position: relative;
|
||||
margin-top: 2rem;
|
||||
font-size: calc(1rem - 1px);
|
||||
max-width: 800px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
.paragraph {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
padding: 2rem;
|
||||
background-color: $white-3;
|
||||
border: 1px solid $gray-1100;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: $inner-section-text-size;
|
||||
display: block;
|
||||
color: $error-red;
|
||||
}
|
||||
|
||||
.pad {
|
||||
padding: 0 2rem;
|
||||
max-height: 550px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: calc(1rem - 2px);
|
||||
text-align: left;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
box-shadow: 0 1px $white-10;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 400;
|
||||
color: $gray-400;
|
||||
background-color: $gray-1350;
|
||||
white-space: nowrap;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background-color: $white-1;
|
||||
}
|
||||
}
|
||||
|
||||
.listGroup {
|
||||
padding: 0 2rem;
|
||||
|
||||
> li:not(:last-child) {
|
||||
border-bottom: 1px solid $white-10;
|
||||
}
|
||||
}
|
||||
|
||||
.listItem {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.fieldTitle {
|
||||
color: $ui-white;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.fieldDescription {
|
||||
font-size: calc(1rem - 2px);
|
||||
color: $gray-400;
|
||||
}
|
||||
|
||||
.fieldError {
|
||||
font-size: calc(1rem - 2px);
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
.divider {
|
||||
border-top: 1px solid $white-10;
|
||||
margin: 1rem -2rem;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
backdrop-filter: blur(2px);
|
||||
display: grid;
|
||||
place-content: center;
|
||||
background-color: $black-10;
|
||||
}
|
||||
|
||||
.loader {
|
||||
$loader-size: 4rem;
|
||||
width: $loader-size;
|
||||
height: $loader-size;
|
||||
background: $blue-500;
|
||||
display: inline-block;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
animation: animloader 1s ease-in infinite;
|
||||
}
|
||||
|
||||
@keyframes animloader {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0.6;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes animloader {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0.6;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
|
||||
import style from './Panel.module.scss';
|
||||
|
||||
export function Header({ children }: { children: ReactNode }) {
|
||||
return <h2 className={style.header}>{children}</h2>;
|
||||
}
|
||||
|
||||
export function SubHeader({ children }: { children: ReactNode }) {
|
||||
return <h3 className={style.subheader}>{children}</h3>;
|
||||
}
|
||||
|
||||
export function Title({ children }: { children: ReactNode }) {
|
||||
return <h4 className={style.title}>{children}</h4>;
|
||||
}
|
||||
|
||||
type AllowedTags = 'div' | 'form';
|
||||
type SectionProps<C extends AllowedTags> = {
|
||||
as?: C;
|
||||
children: ReactNode;
|
||||
} & JSX.IntrinsicElements[C];
|
||||
|
||||
export function Section<C extends AllowedTags = 'div'>({ as, children, ...props }: SectionProps<C>) {
|
||||
const Element = as ?? 'div';
|
||||
return (
|
||||
<Element className={style.section} {...(props as HTMLAttributes<HTMLElement>)}>
|
||||
{children}
|
||||
</Element>
|
||||
);
|
||||
}
|
||||
|
||||
export function Paragraph({ children }: { children: ReactNode }) {
|
||||
return <p className={style.paragraph}>{children}</p>;
|
||||
}
|
||||
|
||||
export function Card({ children, ...props }: { children: ReactNode } & JSX.IntrinsicElements['div']) {
|
||||
return (
|
||||
<div className={style.card} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListGroup({ children }: { children: ReactNode }) {
|
||||
return <ul className={style.listGroup}>{children}</ul>;
|
||||
}
|
||||
|
||||
export function ListItem({ children }: { children: ReactNode }) {
|
||||
return <li className={style.listItem}>{children}</li>;
|
||||
}
|
||||
|
||||
export function Field({ title, description, error }: { title: string; description: string; error?: string }) {
|
||||
return (
|
||||
<div className={style.fieldTitle}>
|
||||
{title}
|
||||
{error && <Error>{error}</Error>}
|
||||
{!error && description && <Description>{description}</Description>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Description({ children }: { children: ReactNode }) {
|
||||
return <div className={style.fieldDescription}>{children}</div>;
|
||||
}
|
||||
|
||||
export function Error({ children }: { children: ReactNode }) {
|
||||
return <div className={style.fieldError}>{children}</div>;
|
||||
}
|
||||
|
||||
export function Divider() {
|
||||
return <hr className={style.divider} />;
|
||||
}
|
||||
|
||||
export function Loader({ isLoading }: { isLoading: boolean }) {
|
||||
if (!isLoading) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className={style.overlay}>
|
||||
<div className={style.loader} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { version } from '../../../../../package.json';
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import { documentationUrl, githubUrl, websiteUrl } from '../../../../externals';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import CheckUpdatesButton from './CheckUpdatesButton';
|
||||
|
||||
export default function AboutPanel() {
|
||||
return (
|
||||
<>
|
||||
<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.SubHeader>Links</Panel.SubHeader>
|
||||
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
|
||||
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
<Panel.SubHeader>Current version</Panel.SubHeader>
|
||||
<Panel.Paragraph>{`You are currently using Ontime ${version}`}</Panel.Paragraph>
|
||||
<CheckUpdatesButton version={version} />
|
||||
</Panel.Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+21
-13
@@ -1,14 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
|
||||
import { getLatestVersion, HasUpdate } from '../../../common/api/ontimeApi';
|
||||
import ModalLink from '../ModalLink';
|
||||
import { getLatestVersion, HasUpdate } from '../../../../common/api/external';
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
|
||||
import styles from '../Modal.module.scss';
|
||||
|
||||
interface UpdateCheckerProps {
|
||||
version: string;
|
||||
}
|
||||
import style from '../Panel.module.scss';
|
||||
|
||||
type CheckFail = {
|
||||
error: string;
|
||||
@@ -20,8 +16,13 @@ type CheckIsLatest = {
|
||||
|
||||
type CheckRemote = CheckFail | CheckIsLatest | HasUpdate;
|
||||
|
||||
export default function UpdateChecker(props: UpdateCheckerProps) {
|
||||
interface CheckUpdatesButtonProps {
|
||||
version: string;
|
||||
}
|
||||
|
||||
export default function CheckUpdatesButton(props: CheckUpdatesButtonProps) {
|
||||
const { version } = props;
|
||||
|
||||
const [updateMessage, setUpdateMessage] = useState<CheckRemote | null>(null);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
|
||||
@@ -50,12 +51,19 @@ export default function UpdateChecker(props: UpdateCheckerProps) {
|
||||
const disableButton = Boolean(updateMessage && 'version' in updateMessage);
|
||||
|
||||
return (
|
||||
<div className={styles.updateSection}>
|
||||
<Button onClick={versionCheck} variant='ontime-filled' isLoading={isFetching} isDisabled={disableButton}>
|
||||
<>
|
||||
<Button
|
||||
onClick={versionCheck}
|
||||
variant='ontime-filled'
|
||||
isLoading={isFetching}
|
||||
isDisabled={disableButton}
|
||||
size='sm'
|
||||
maxWidth='max-content'
|
||||
>
|
||||
Check for updates
|
||||
</Button>
|
||||
<ResolveUpdateMessage updateMessage={updateMessage} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,10 +71,10 @@ function ResolveUpdateMessage(props: { updateMessage: CheckRemote | null }) {
|
||||
const { updateMessage } = props;
|
||||
|
||||
if (updateMessage && 'error' in updateMessage) {
|
||||
return <span className={styles.error}>{updateMessage.error}</span>;
|
||||
return <span className={style.error}>{updateMessage.error}</span>;
|
||||
}
|
||||
if (updateMessage && 'url' in updateMessage) {
|
||||
return <ModalLink href={updateMessage?.url}>{`New version available: ${updateMessage.version}`}</ModalLink>;
|
||||
return <ExternalLink href={updateMessage?.url}>{`New version available: ${updateMessage.version}`}</ExternalLink>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5px;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.fieldForm {
|
||||
padding: 1rem;
|
||||
background-color: $gray-1350;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.buttonRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.fit {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.aliasConstrain {
|
||||
min-width: 12em;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { PanelBaseProps } from '../../settingsStore';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import CustomFields from './custom-fields/CustomFields';
|
||||
import UrlPresetsForm from './UrlPresetsForm';
|
||||
|
||||
export default function FeatureSettingsPanel({ location }: PanelBaseProps) {
|
||||
const customFieldsRef = useScrollIntoView<HTMLDivElement>('custom', location);
|
||||
const urlPresetsRef = useScrollIntoView<HTMLDivElement>('urlpresets', location);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Feature Settings</Panel.Header>
|
||||
<div ref={customFieldsRef}>
|
||||
<CustomFields />
|
||||
</div>
|
||||
|
||||
<div ref={urlPresetsRef}>
|
||||
<UrlPresetsForm />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { Alert, AlertDescription, AlertIcon, Button, IconButton, Input, Switch } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
import { URLPreset } from 'ontime-types';
|
||||
|
||||
import { postUrlPresets } from '../../../../common/api/urlPresets';
|
||||
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 { handleLinks } from '../../../../common/utils/linkUtils';
|
||||
import { validateUrlPresetPath } from '../../../../common/utils/urlPresets';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import style from './FeatureSettings.module.scss';
|
||||
|
||||
const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/';
|
||||
|
||||
type FormData = {
|
||||
data: URLPreset[];
|
||||
};
|
||||
|
||||
export default function UrlPresetsForm() {
|
||||
const { data, status, refetch } = useUrlPresets();
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
setError,
|
||||
formState: { isSubmitting, isDirty, isValid, errors },
|
||||
} = useForm<FormData>({
|
||||
mode: 'onBlur',
|
||||
defaultValues: { data },
|
||||
values: { data },
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
const { fields, prepend, remove } = useFieldArray({
|
||||
name: 'data',
|
||||
control,
|
||||
});
|
||||
|
||||
// reset form if we get new data from backend
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
reset({ data });
|
||||
}
|
||||
}, [data, reset]);
|
||||
|
||||
const onSubmit = async (formData: FormData) => {
|
||||
for (let i = 0; i < formData.data.length; i++) {
|
||||
const preset = formData.data[i];
|
||||
const { isValid, message } = validateUrlPresetPath(preset.pathAndParams);
|
||||
if (!isValid) {
|
||||
setError(`data.${i}.pathAndParams`, { message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await postUrlPresets(formData.data);
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
setError('root', { message });
|
||||
} finally {
|
||||
await refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const onReset = () => {
|
||||
reset({ data });
|
||||
};
|
||||
|
||||
const addNew = () => {
|
||||
prepend({
|
||||
enabled: false,
|
||||
alias: '',
|
||||
pathAndParams: '',
|
||||
});
|
||||
};
|
||||
|
||||
const isLoading = status === 'pending';
|
||||
const canSubmit = !isSubmitting && isDirty && isValid;
|
||||
|
||||
return (
|
||||
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} data-testid='url-preset-form'>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
URL presets
|
||||
<div className={style.actionButtons}>
|
||||
<Button variant='ontime-ghosted' size='md' onClick={onReset} isDisabled={!canSubmit}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button variant='ontime-filled' size='md' type='submit' isDisabled={!canSubmit} isLoading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Alert status='info' variant='ontime-on-dark-info'>
|
||||
<AlertIcon />
|
||||
<AlertDescription>
|
||||
URL Presets
|
||||
<br />
|
||||
<br />
|
||||
Custom presets allow providing a short name for any ontime URL. <br />
|
||||
- Providing dynamic URLs for automation or unattended screens <br />- Simplifying complex URLs
|
||||
<br />
|
||||
<br />
|
||||
<ExternalLink href={urlPresetsDocs}>See the docs</ExternalLink>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={isLoading} />
|
||||
<Panel.Title>
|
||||
Manage presets
|
||||
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={addNew}>
|
||||
New
|
||||
</Button>
|
||||
</Panel.Title>
|
||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
{errors?.data && <Panel.Error>{errors.data.message}</Panel.Error>}
|
||||
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={style.fit}>Active</th>
|
||||
<th className={style.aliasConstrain}>Preset</th>
|
||||
<th className={style.fullWidth}>URL</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fields.map((preset, index) => {
|
||||
const maybeAliasError = errors.data?.[index]?.alias?.message;
|
||||
const maybeUrlError = errors.data?.[index]?.pathAndParams?.message;
|
||||
return (
|
||||
<tr key={preset.id}>
|
||||
<td className={style.fit}>
|
||||
<Switch
|
||||
{...register(`data.${index}.enabled`)}
|
||||
variant='ontime'
|
||||
data-testid={`field__enable_${index}`}
|
||||
/>
|
||||
</td>
|
||||
<td className={style.aliasConstrain}>
|
||||
<Input
|
||||
{...register(`data.${index}.alias`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
})}
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
placeholder='URL Preset'
|
||||
data-testid={`field__alias_${index}`}
|
||||
autoComplete='off'
|
||||
/>
|
||||
<Panel.Error>{maybeAliasError}</Panel.Error>
|
||||
</td>
|
||||
<td className={style.fullWidth}>
|
||||
<Input
|
||||
{...register(`data.${index}.pathAndParams`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
})}
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
placeholder='URL (portion after ontime Port)'
|
||||
data-testid={`field__url_${index}`}
|
||||
autoComplete='off'
|
||||
/>
|
||||
<Panel.Error>{maybeUrlError}</Panel.Error>
|
||||
</td>
|
||||
<td className={style.flex}>
|
||||
<TooltipActionBtn
|
||||
size='sm'
|
||||
clickHandler={(event) => handleLinks(event, preset.alias)}
|
||||
tooltip='Test preset'
|
||||
aria-label='Test preset'
|
||||
variant='ontime-ghosted'
|
||||
color='#e2e2e2' // $gray-200
|
||||
icon={<IoOpenOutline />}
|
||||
data-testid={`field__test_${index}`}
|
||||
/>
|
||||
<IconButton
|
||||
size='sm'
|
||||
onClick={() => remove(index)}
|
||||
variant='ontime-ghosted'
|
||||
color='#FA5656' // $red-500
|
||||
icon={<IoTrash />}
|
||||
aria-label='Delete entry'
|
||||
data-testid={`field__delete_${index}`}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
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 { CustomField, CustomFieldLabel } from 'ontime-types';
|
||||
|
||||
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
|
||||
|
||||
import CustomFieldForm from './CustomFieldForm';
|
||||
|
||||
import style from '../FeatureSettings.module.scss';
|
||||
|
||||
interface CustomFieldEntryProps {
|
||||
field: string;
|
||||
colour: string;
|
||||
label: string;
|
||||
onEdit: (label: CustomFieldLabel, patch: CustomField) => Promise<void>;
|
||||
onDelete: (label: CustomFieldLabel) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
||||
const { colour, label, onEdit, onDelete, field } = props;
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const handleEdit = async (patch: CustomField) => {
|
||||
await onEdit(field, patch);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={99}>
|
||||
<CustomFieldForm
|
||||
onCancel={() => setIsEditing(false)}
|
||||
onSubmit={handleEdit}
|
||||
initialColour={colour}
|
||||
initialLabel={label}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
<Swatch color={colour} />
|
||||
</td>
|
||||
<td className={style.fullWidth}>{label}</td>
|
||||
<td className={style.actions}>
|
||||
<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={() => onDelete(field)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Button, Input } from '@chakra-ui/react';
|
||||
import { CustomField } from 'ontime-types';
|
||||
import { isAlphanumeric } from 'ontime-utils';
|
||||
|
||||
import { maybeAxiosError } from '../../../../../common/api/utils';
|
||||
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
|
||||
import * as Panel from '../../PanelUtils';
|
||||
|
||||
import style from '../FeatureSettings.module.scss';
|
||||
|
||||
interface CustomFieldsFormProps {
|
||||
onSubmit: (field: CustomField) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
initialColour?: string;
|
||||
initialLabel?: string;
|
||||
}
|
||||
|
||||
export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
||||
const { onSubmit, onCancel, initialColour, initialLabel } = props;
|
||||
// we use this to force an update
|
||||
const [_, setColour] = useState(initialColour || '');
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
setFocus,
|
||||
setError,
|
||||
setValue,
|
||||
getValues,
|
||||
formState: { errors, isSubmitting, isValid, isDirty },
|
||||
} = useForm({
|
||||
defaultValues: { label: initialLabel || '', colour: initialColour || '' },
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
const setupSubmit = async (values: { label: string; colour: string }) => {
|
||||
const { label, colour } = values;
|
||||
const newField: CustomField = {
|
||||
type: 'string', // type is not user definable yet
|
||||
colour,
|
||||
label,
|
||||
};
|
||||
try {
|
||||
await onSubmit(newField);
|
||||
} catch (error) {
|
||||
setError('root', { type: 'custom', message: maybeAxiosError(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// give initial focus to the label
|
||||
useEffect(() => {
|
||||
setFocus('label');
|
||||
}, [setFocus]);
|
||||
|
||||
const handleSelectColour = (colour: string) => {
|
||||
setColour(colour);
|
||||
setValue('colour', colour, { shouldDirty: true });
|
||||
};
|
||||
|
||||
const colour = getValues('colour');
|
||||
const canSubmit = isDirty && isValid;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}>
|
||||
<div className={style.column}>
|
||||
<Panel.Description>Label</Panel.Description>
|
||||
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
|
||||
<Input
|
||||
{...register('label', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
validate: (value) => {
|
||||
if (value.trim().length === 0) return 'Required field';
|
||||
if (!isAlphanumeric(value)) return 'Only alphanumeric characters are allowed';
|
||||
return true;
|
||||
},
|
||||
})}
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
autoComplete='off'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Panel.Description>Colour</Panel.Description>
|
||||
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
|
||||
</div>
|
||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<div className={style.buttonRow}>
|
||||
<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>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, AlertDescription, AlertIcon, Button } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { CustomField, CustomFieldLabel } from 'ontime-types';
|
||||
|
||||
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
|
||||
import ExternalLink from '../../../../../common/components/external-link/ExternalLink';
|
||||
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||
import * as Panel from '../../PanelUtils';
|
||||
|
||||
import CustomFieldEntry from './CustomFieldEntry';
|
||||
import CustomFieldForm from './CustomFieldForm';
|
||||
|
||||
const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
|
||||
|
||||
export default function CustomFields() {
|
||||
const { data, refetch } = useCustomFields();
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
const handleInitiateCreate = () => {
|
||||
setIsAdding(true);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsAdding(false);
|
||||
};
|
||||
|
||||
const handleCreate = async (customField: CustomField) => {
|
||||
await postCustomField(customField);
|
||||
refetch();
|
||||
setIsAdding(false);
|
||||
};
|
||||
|
||||
const handleEditField = async (label: CustomFieldLabel, customField: CustomField) => {
|
||||
await editCustomField(label, customField);
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleDelete = async (label: string) => {
|
||||
try {
|
||||
await deleteCustomField(label);
|
||||
refetch();
|
||||
} catch (_error) {
|
||||
/** we do not handle errors here */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Custom fields
|
||||
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleInitiateCreate}>
|
||||
New
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Alert status='info' variant='ontime-on-dark-info'>
|
||||
<AlertIcon />
|
||||
<AlertDescription>
|
||||
Custom fields allow for additional information to be added to an event (eg. light, sound, camera). <br />
|
||||
<br />
|
||||
This data is not used by Ontime.
|
||||
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
{isAdding && <CustomFieldForm onSubmit={handleCreate} onCancel={handleCancel} />}
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Colour</th>
|
||||
<th>Name</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(data).map(([key, { colour, label }]) => {
|
||||
return (
|
||||
<CustomFieldEntry
|
||||
key={key}
|
||||
field={key}
|
||||
colour={colour}
|
||||
label={label}
|
||||
onEdit={handleEditField}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.actionButtons {
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { PanelBaseProps } from '../../settingsStore';
|
||||
import EditorSettingsForm from '../interface-panel/EditorSettingsForm';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import GeneralPanelForm from './GeneralPanelForm';
|
||||
import ViewSettingsForm from './ViewSettingsForm';
|
||||
|
||||
export default function GeneralPanel({ location }: PanelBaseProps) {
|
||||
const generalRef = useScrollIntoView<HTMLDivElement>('settings', location);
|
||||
const editorRef = useScrollIntoView<HTMLDivElement>('editor', location);
|
||||
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>App Settings</Panel.Header>
|
||||
<div ref={generalRef}>
|
||||
<GeneralPanelForm />
|
||||
</div>
|
||||
<div ref={editorRef}>
|
||||
<EditorSettingsForm />
|
||||
</div>
|
||||
<div ref={viewRef}>
|
||||
<ViewSettingsForm />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Button, Input, Select } from '@chakra-ui/react';
|
||||
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 { isOnlyNumbers } from '../../../../common/utils/regex';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import GeneralPinInput from './GeneralPinInput';
|
||||
|
||||
import style from './GeneralPanel.module.scss';
|
||||
|
||||
export type GeneralPanelFormValues = {
|
||||
filename: string;
|
||||
};
|
||||
|
||||
export default function GeneralPanelForm() {
|
||||
const { data, status, refetch } = useSettings();
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
setError,
|
||||
formState: { isSubmitting, isDirty, isValid, errors },
|
||||
} = useForm<Settings>({
|
||||
defaultValues: data,
|
||||
values: data,
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
// update form if we get new data from server
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
reset(data);
|
||||
}
|
||||
}, [data, reset]);
|
||||
|
||||
const onSubmit = async (formData: Settings) => {
|
||||
try {
|
||||
await postSettings(formData);
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
setError('root', { message });
|
||||
} finally {
|
||||
await refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const disableInputs = status === 'pending';
|
||||
const disableSubmit = isSubmitting || !isDirty || !isValid;
|
||||
const submitError = '';
|
||||
|
||||
const onReset = () => {
|
||||
reset(data);
|
||||
};
|
||||
|
||||
const isLoading = status === 'pending';
|
||||
|
||||
return (
|
||||
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} id='app-settings'>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
General settings
|
||||
<div className={style.actionButtons}>
|
||||
<Button isDisabled={!isDirty || isSubmitting} variant='ontime-ghosted' size='sm' onClick={onReset}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
form='app-settings'
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={disableSubmit}
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.SubHeader>
|
||||
{submitError && <Panel.Error>{submitError}</Panel.Error>}
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={isLoading} />
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Ontime server port'
|
||||
description='Port ontime server listens in. Defaults to 4001 (needs app restart)'
|
||||
error={errors.serverPort?.message}
|
||||
/>
|
||||
<Input
|
||||
id='serverPort'
|
||||
size='sm'
|
||||
type='number'
|
||||
variant='ontime-filled'
|
||||
maxLength={5}
|
||||
width='75px'
|
||||
{...register('serverPort', {
|
||||
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.ListItem>
|
||||
<Panel.Field
|
||||
title='Editor pin code'
|
||||
description='Protect the editor view with a pin code'
|
||||
error={errors.editorKey?.message}
|
||||
/>
|
||||
<GeneralPinInput register={register} formName='editorKey' isDisabled={disableInputs} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Operator pin code'
|
||||
description='Protect the operator and cuesheet views with a pin code'
|
||||
error={errors.operatorKey?.message}
|
||||
/>
|
||||
<GeneralPinInput register={register} formName='operatorKey' isDisabled={disableInputs} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Time format'
|
||||
description='Default time format to show in views 12 /24 hours'
|
||||
error={errors.timeFormat?.message}
|
||||
/>
|
||||
<Select variant='ontime' size='sm' width='auto' isDisabled={disableInputs} {...register('timeFormat')}>
|
||||
<option value='12'>12 hours 11:00:10 PM</option>
|
||||
<option value='24'>24 hours 23:00:10</option>
|
||||
</Select>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Views language'
|
||||
description='Language to be displayed in views'
|
||||
error={errors.language?.message}
|
||||
/>
|
||||
<Select variant='ontime' size='sm' width='auto' isDisabled={disableInputs} {...register('language')}>
|
||||
<option value='en'>English</option>
|
||||
<option value='fr'>French</option>
|
||||
<option value='de'>German</option>
|
||||
<option value='it'>Italian</option>
|
||||
<option value='no'>Norwegian</option>
|
||||
<option value='pt'>Portuguese</option>
|
||||
<option value='es'>Spanish</option>
|
||||
<option value='sv'>Swedish</option>
|
||||
</Select>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
+10
-10
@@ -1,23 +1,23 @@
|
||||
import { useState } from 'react';
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { UseFormRegister } from 'react-hook-form';
|
||||
import { IconButton, Input, InputGroup, InputRightElement } from '@chakra-ui/react';
|
||||
import { IoEyeOutline } from '@react-icons/all-files/io5/IoEyeOutline';
|
||||
import { Settings } from 'ontime-types';
|
||||
|
||||
interface FormInput {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
interface ModalPinInputProps {
|
||||
register: UseFormRegister<FormInput>;
|
||||
formName: string;
|
||||
interface GeneralPinInputProps {
|
||||
register: UseFormRegister<Settings>;
|
||||
formName: keyof Settings;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export default function ModalPinInput({ register, formName, isDisabled }: ModalPinInputProps) {
|
||||
export default function GeneralPinInput(props: PropsWithChildren<GeneralPinInputProps>) {
|
||||
const { register, formName, isDisabled } = props;
|
||||
const [isVisible, setVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<InputGroup size='sm' width='100px'>
|
||||
<Input
|
||||
variant='ontime-filled'
|
||||
type={isVisible ? 'text' : 'password'}
|
||||
maxLength={4}
|
||||
{...register(formName)}
|
||||
@@ -29,7 +29,7 @@ export default function ModalPinInput({ register, formName, isDisabled }: ModalP
|
||||
onMouseDown={() => setVisible(true)}
|
||||
onMouseUp={() => setVisible(false)}
|
||||
size='sm'
|
||||
variant='ontime-ghost-on-light'
|
||||
variant='ontime-ghosted'
|
||||
icon={<IoEyeOutline />}
|
||||
aria-label='Show pin code'
|
||||
/>
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { Alert, AlertDescription, AlertIcon, Button, Input, Switch } from '@chakra-ui/react';
|
||||
import { ViewSettings } from 'ontime-types';
|
||||
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import { postViewSettings } from '../../../../common/api/viewSettings';
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import { PopoverPickerRHF } from '../../../../common/components/input/popover-picker/PopoverPicker';
|
||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||
import useViewSettings from '../../../../common/hooks-query/useViewSettings';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import style from './GeneralPanel.module.scss';
|
||||
|
||||
const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/';
|
||||
|
||||
export default function ViewSettingsForm() {
|
||||
const { data, status, refetch } = useViewSettings();
|
||||
const { data: info, status: infoStatus } = useInfo();
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
setError,
|
||||
formState: { isSubmitting, isDirty },
|
||||
} = useForm<ViewSettings>({
|
||||
defaultValues: data,
|
||||
values: data,
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
// update form if we get new data from server
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
reset(data);
|
||||
}
|
||||
}, [data, reset]);
|
||||
|
||||
const onSubmit = async (formData: ViewSettings) => {
|
||||
const newData = {
|
||||
...formData,
|
||||
};
|
||||
|
||||
try {
|
||||
await postViewSettings(newData);
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
setError('root', { message });
|
||||
} finally {
|
||||
await refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const onReset = () => {
|
||||
reset(data);
|
||||
};
|
||||
|
||||
if (!control) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isLoading = status === 'pending' || infoStatus === 'pending';
|
||||
|
||||
return (
|
||||
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} id='view-settings'>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
View settings
|
||||
<div className={style.actionButtons}>
|
||||
<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.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Alert status='info' variant='ontime-on-dark-info'>
|
||||
<AlertIcon />
|
||||
<AlertDescription>
|
||||
You can override the styles of the viewers with a custom CSS file. <br />
|
||||
{info?.cssOverride && `In your installation the file is at ${info?.cssOverride}`}
|
||||
<br />
|
||||
<br />
|
||||
<ExternalLink href={cssOverrideDocsUrl}>See the docs</ExternalLink>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={isLoading} />
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Override CSS styles'
|
||||
description='Enables overriding view styles with custom stylesheet'
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='overrideStyles'
|
||||
render={({ field: { onChange, value, ref } }) => (
|
||||
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
|
||||
)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Timer colour' description='Default colour of a running timer' />
|
||||
<PopoverPickerRHF name='normalColor' control={control} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Warning colour' description='Colour of a running timer in warning mode' />
|
||||
<PopoverPickerRHF name='warningColor' control={control} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Danger colour' description='Colour of a running timer in danger mode' />
|
||||
<PopoverPickerRHF name='dangerColor' control={control} />
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Freeze timer on end'
|
||||
description='Timer in views will stop from going negative after reaching'
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='freezeEnd'
|
||||
render={({ field: { onChange, value, ref } }) => (
|
||||
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
|
||||
)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='End message'
|
||||
description='Message to show on negative timers if not frozen. If not provided, timer will continue'
|
||||
/>
|
||||
<Input
|
||||
size='sm'
|
||||
autoComplete='off'
|
||||
variant='ontime-filled'
|
||||
maxLength={150}
|
||||
width='275px'
|
||||
placeholder='Message shown when timer reaches end'
|
||||
{...register('endMessage')}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
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 '../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: 'onBlur',
|
||||
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: false,
|
||||
});
|
||||
};
|
||||
|
||||
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
@@ -0,0 +1,16 @@
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.halfWidth {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.fitContents.fitContents {
|
||||
width: max-content; /* override chakra */
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
|
||||
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { PanelBaseProps } from '../../settingsStore';
|
||||
import * as Panel from '../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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
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 { isKeyEscape } from '../../../../common/utils/keyEvent';
|
||||
import { isASCII, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
|
||||
import * as Panel from '../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: 'onBlur',
|
||||
defaultValues: data,
|
||||
values: data,
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, prepend, remove } = useFieldArray({
|
||||
name: 'subscriptions',
|
||||
control,
|
||||
});
|
||||
|
||||
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 preventEscape = (event: React.KeyboardEvent) => {
|
||||
if (isKeyEscape(event)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddNewSubscription = () => {
|
||||
prepend({
|
||||
id: generateId(),
|
||||
cycle: 'onLoad',
|
||||
address: '',
|
||||
payload: '',
|
||||
enabled: false,
|
||||
});
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<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}>Payload</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) =>
|
||||
isASCII.test(value) || 'OSC payloads 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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' },
|
||||
];
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Switch } from '@chakra-ui/react';
|
||||
|
||||
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
||||
import { useEditorSettings } from '../../../../common/stores/editorSettings';
|
||||
import { forgivingStringToMillis } from '../../../../common/utils/dateConfig';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
export default function EditorSettingsForm() {
|
||||
const eventSettings = useEditorSettings((state) => state.eventSettings);
|
||||
const setLinkPrevious = useEditorSettings((state) => state.setLinkPrevious);
|
||||
const setDefaultPublic = useEditorSettings((state) => state.setDefaultPublic);
|
||||
const setDefaultDuration = useEditorSettings((state) => state.setDefaultDuration);
|
||||
|
||||
const durationInMs = forgivingStringToMillis(eventSettings.defaultDuration);
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>Editor settings</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Panel.Title>Rundown options</Panel.Title>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Default duration'
|
||||
description='When creating a new event, what is the default duration'
|
||||
/>
|
||||
<TimeInput<'defaultDuration'>
|
||||
name='defaultDuration'
|
||||
submitHandler={(_field, value) => setDefaultDuration(value)}
|
||||
time={durationInMs}
|
||||
placeholder='00:10:00'
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Link previous'
|
||||
description='New events start time will be linked to the previous event'
|
||||
/>
|
||||
<Switch
|
||||
variant='ontime'
|
||||
size='lg'
|
||||
defaultChecked={eventSettings.linkPrevious}
|
||||
onChange={(event) => setLinkPrevious(event.target.checked)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Default public' description='New events will be public' />
|
||||
<Switch
|
||||
variant='ontime'
|
||||
size='lg'
|
||||
defaultChecked={eventSettings.defaultPublic}
|
||||
onChange={(event) => setDefaultPublic(event.target.checked)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
<Panel.Title>Play mode</Panel.Title>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Show quick entry'
|
||||
description='Whether the quick entry buttons show above / under selected event'
|
||||
/>
|
||||
<Switch variant='ontime' size='lg' defaultChecked={false} isDisabled />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Follow playback'
|
||||
description='Whether view automatically follows the event being played'
|
||||
/>
|
||||
<Switch variant='ontime' size='lg' defaultChecked isDisabled />
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
<Panel.Title>Edit mode</Panel.Title>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Show quick entry'
|
||||
description='Whether the quick entry buttons show above / under selected event'
|
||||
/>
|
||||
<Switch variant='ontime' size='lg' defaultChecked isDisabled />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Follow playback'
|
||||
description='Whether view automatically follows the event being played'
|
||||
/>
|
||||
<Switch variant='ontime' size='lg' defaultChecked={false} isDisabled />
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
|
||||
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import EditorSettingsForm from './EditorSettingsForm';
|
||||
|
||||
export default function InterfacePanel() {
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Interface</Panel.Header>
|
||||
<Panel.Section>
|
||||
<Alert status='info' variant='ontime-on-dark-info'>
|
||||
<AlertIcon />
|
||||
<AlertDescription>
|
||||
Interface settings
|
||||
<br />
|
||||
<br />
|
||||
These concern settings that are applied to this user in this browser.
|
||||
<br />
|
||||
It will not affect other users or other browsers.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</Panel.Section>
|
||||
<EditorSettingsForm />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.iconRotate {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MouseEvent } from 'react';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
|
||||
import { handleLinks } from '../../../../common/utils/linkUtils';
|
||||
import Log from '../../../log/Log';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import style from './LogExport.module.scss';
|
||||
|
||||
export default function LogExport() {
|
||||
const extract = (event: MouseEvent) => {
|
||||
handleLinks(event, 'log');
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Network log
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
size='sm'
|
||||
rightIcon={<IoArrowUp className={style.iconRotate} />}
|
||||
onClick={extract}
|
||||
>
|
||||
Extract
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Log />
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import LogExport from './LogExport';
|
||||
import InfoNif from './NetworkInterfaces';
|
||||
|
||||
export default function LogPanel() {
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Log</Panel.Header>
|
||||
<Panel.Section>
|
||||
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
|
||||
</Panel.Section>
|
||||
<InfoNif />
|
||||
<LogExport />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
.interfaces {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $section-spacing;
|
||||
row-gap: $element-inner-spacing;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { serverPort } from '../../../../common/api/constants';
|
||||
import AppLink from '../../../../common/components/app-link/AppLink';
|
||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||
|
||||
import style from './NetworkInterfaces.module.scss';
|
||||
|
||||
export default function InfoNif() {
|
||||
const { data } = useInfo();
|
||||
|
||||
return (
|
||||
<div className={style.interfaces}>
|
||||
{data?.networkInterfaces?.map((nif) => (
|
||||
<AppLink key={nif.address} href={`http://${nif.address}:${serverPort}`}>
|
||||
{`${nif.name} - ${nif.address}`}
|
||||
</AppLink>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ChangeEvent, useRef, useState } from 'react';
|
||||
import { Button, Input } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
|
||||
import { uploadProjectFile } from '../../../../common/api/db';
|
||||
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
|
||||
import { validateProjectFile } from '../../../../common/utils/uploadUtils';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import ProjectCreateForm from './ProjectCreateForm';
|
||||
import ProjectList from './ProjectList';
|
||||
|
||||
import style from './ProjectPanel.module.scss';
|
||||
|
||||
export default function ManageProjects() {
|
||||
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState<'import' | null>(null);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleToggleCreate = () => {
|
||||
setIsCreatingProject((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleSelectFile = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleImport = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = event.target?.files?.[0];
|
||||
if (!selectedFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading('import');
|
||||
|
||||
try {
|
||||
validateProjectFile(selectedFile);
|
||||
await uploadProjectFile(selectedFile);
|
||||
} catch (error) {
|
||||
const errorMessage = maybeAxiosError(error);
|
||||
setError(`Error uploading file: ${errorMessage}`);
|
||||
} finally {
|
||||
invalidateAllCaches();
|
||||
}
|
||||
|
||||
setLoading(null);
|
||||
};
|
||||
|
||||
const handleCloseForm = () => {
|
||||
setIsCreatingProject(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
type='file'
|
||||
onChange={handleImport}
|
||||
accept='.json'
|
||||
data-testid='file-input'
|
||||
/>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Manage projects
|
||||
<div className={style.headerButtons}>
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
onClick={handleSelectFile}
|
||||
size='sm'
|
||||
isDisabled={Boolean(loading) || isCreatingProject}
|
||||
isLoading={loading === 'import'}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
onClick={handleToggleCreate}
|
||||
size='sm'
|
||||
isDisabled={Boolean(loading) || isCreatingProject}
|
||||
rightIcon={<IoAdd />}
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.SubHeader>
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
<Panel.Divider />
|
||||
{isCreatingProject && <ProjectCreateForm onClose={handleCloseForm} />}
|
||||
<ProjectList />
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Button, Input, Textarea } from '@chakra-ui/react';
|
||||
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 * as Panel from '../PanelUtils';
|
||||
|
||||
import style from './ProjectPanel.module.scss';
|
||||
|
||||
interface ProjectCreateFromProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type ProjectCreateFormValues = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
publicInfo?: string;
|
||||
publicUrl?: string;
|
||||
backstageInfo?: string;
|
||||
backstageUrl?: string;
|
||||
};
|
||||
|
||||
export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
||||
const { onClose } = props;
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { isSubmitting, isValid },
|
||||
setFocus,
|
||||
} = useForm<ProjectCreateFormValues>({
|
||||
defaultValues: { title: '' },
|
||||
values: { title: '' },
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
// set focus to first field
|
||||
useEffect(() => {
|
||||
setFocus('title');
|
||||
}, [setFocus]);
|
||||
|
||||
const handleSubmitCreate = async (values: ProjectCreateFormValues) => {
|
||||
try {
|
||||
setError(null);
|
||||
const filename = values.title?.trim();
|
||||
|
||||
await createProject({
|
||||
...values,
|
||||
filename,
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: PROJECT_LIST });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setError(maybeAxiosError(error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Section as='form' onSubmit={handleSubmit(handleSubmitCreate)}>
|
||||
<Panel.Title>
|
||||
Create new project
|
||||
<div className={style.createActionButtons}>
|
||||
<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
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.Title>
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
<div className={style.innerColumn}>
|
||||
<label>
|
||||
Project title
|
||||
<Input
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
maxLength={50}
|
||||
placeholder='Your project name'
|
||||
autoComplete='off'
|
||||
{...register('title')}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Project description
|
||||
<Input
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
maxLength={100}
|
||||
placeholder='Euro Love, Malmö 2024'
|
||||
autoComplete='off'
|
||||
{...register('description')}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Public info
|
||||
<Textarea
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
maxLength={150}
|
||||
placeholder='Shows always start ontime'
|
||||
autoComplete='off'
|
||||
resize='none'
|
||||
{...register('publicInfo')}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Public QR code Url
|
||||
<Input
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='www.getontime.no'
|
||||
autoComplete='off'
|
||||
{...register('publicUrl')}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Backstage info
|
||||
<Textarea
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
maxLength={150}
|
||||
placeholder='Wi-Fi password: 1234'
|
||||
autoComplete='off'
|
||||
resize='none'
|
||||
{...register('backstageInfo')}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Backstage QR code Url
|
||||
<Input
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='http://docs.getontime.no'
|
||||
autoComplete='off'
|
||||
{...register('backstageUrl')}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Button, Input, Textarea } from '@chakra-ui/react';
|
||||
import { type ProjectData } from 'ontime-types';
|
||||
|
||||
import { postProjectData } from '../../../../common/api/project';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import useProjectData from '../../../../common/hooks-query/useProjectData';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import style from './ProjectPanel.module.scss';
|
||||
|
||||
export default function ProjectData() {
|
||||
const { data, status, refetch } = useProjectData();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
formState: { isSubmitting, isValid, isDirty },
|
||||
setError,
|
||||
} = useForm({
|
||||
defaultValues: data,
|
||||
values: data,
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
// reset form values if data changes
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
reset(data);
|
||||
}
|
||||
}, [data, reset]);
|
||||
|
||||
const onSubmit = async (formData: ProjectData) => {
|
||||
try {
|
||||
await postProjectData(formData);
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
setError('root', { message });
|
||||
} finally {
|
||||
await refetch();
|
||||
}
|
||||
};
|
||||
|
||||
// populate with new data if we get an update
|
||||
const onReset = () => {
|
||||
reset(data);
|
||||
};
|
||||
|
||||
const isLoading = status === 'pending';
|
||||
|
||||
return (
|
||||
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)}>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Project data
|
||||
<div className={style.headerButtons}>
|
||||
<Button variant='ontime-ghosted' size='sm' onClick={onReset} isDisabled={isSubmitting || !isDirty}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
type='submit'
|
||||
isDisabled={!isDirty || !isValid}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={isLoading} />
|
||||
<label>
|
||||
Project title
|
||||
<Input
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
maxLength={50}
|
||||
placeholder='Your project name'
|
||||
autoComplete='off'
|
||||
{...register('title')}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Project description
|
||||
<Input
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
maxLength={100}
|
||||
placeholder='Euro Love, Malmö 2024'
|
||||
autoComplete='off'
|
||||
{...register('description')}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Public info
|
||||
<Textarea
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
maxLength={150}
|
||||
placeholder='Shows always start ontime'
|
||||
autoComplete='off'
|
||||
resize='none'
|
||||
{...register('publicInfo')}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Public QR code URL
|
||||
<Input
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='www.getontime.no'
|
||||
autoComplete='off'
|
||||
{...register('publicUrl')}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Backstage info
|
||||
<Textarea
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
maxLength={150}
|
||||
placeholder='Wi-Fi password: 1234'
|
||||
autoComplete='off'
|
||||
resize='none'
|
||||
{...register('backstageInfo')}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Backstage QR code URL
|
||||
<Input
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='http://docs.getontime.no'
|
||||
autoComplete='off'
|
||||
{...register('backstageUrl')}
|
||||
/>
|
||||
</label>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Button, Input } from '@chakra-ui/react';
|
||||
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import style from './ProjectPanel.module.scss';
|
||||
|
||||
export type ProjectFormValues = {
|
||||
filename: string;
|
||||
};
|
||||
|
||||
interface ProjectFormProps {
|
||||
action: 'duplicate' | 'rename';
|
||||
filename: string;
|
||||
onCancel: () => void;
|
||||
onSubmit: (values: ProjectFormValues) => Promise<void>;
|
||||
submitError: string | null;
|
||||
}
|
||||
|
||||
export default function ProjectForm({ action, filename, onSubmit, onCancel, submitError }: ProjectFormProps) {
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { isSubmitting, isDirty, isValid },
|
||||
setFocus,
|
||||
} = useForm<ProjectFormValues>({
|
||||
defaultValues: { filename },
|
||||
values: { filename },
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setFocus('filename');
|
||||
}, [setFocus]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={style.form}>
|
||||
<Input
|
||||
className={style.formInput}
|
||||
id='filename'
|
||||
size='sm'
|
||||
type='text'
|
||||
variant='ontime-filled'
|
||||
placeholder='Enter new name'
|
||||
autoComplete='off'
|
||||
{...register('filename')}
|
||||
/>
|
||||
<div className={style.actionButtons}>
|
||||
<Button onClick={onCancel} size='sm' variant='ontime-ghosted' disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
isDisabled={!isDirty || !isValid || isSubmitting}
|
||||
type='submit'
|
||||
className={style.saveButton}
|
||||
>
|
||||
{action}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{submitError && <Panel.Error>{submitError}</Panel.Error>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useProjectList } from '../../../../common/hooks-query/useProjectList';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import ProjectListItem, { EditMode } from './ProjectListItem';
|
||||
|
||||
import style from './ProjectPanel.module.scss';
|
||||
|
||||
export default function ProjectList() {
|
||||
const { data, refetch } = useProjectList();
|
||||
const { files, lastLoadedProject } = data;
|
||||
|
||||
const [editingMode, setEditingMode] = useState<EditMode | null>(null);
|
||||
const [editingFilename, setEditingFilename] = useState<string | null>(null);
|
||||
|
||||
const handleToggleEditMode = (editMode: EditMode, filename: string | null) => {
|
||||
setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode));
|
||||
setEditingFilename(filename);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setEditingMode(null);
|
||||
setEditingFilename(null);
|
||||
};
|
||||
|
||||
const handleRefetch = async () => {
|
||||
await refetch();
|
||||
};
|
||||
|
||||
const reorderedProjectFiles = useMemo(() => {
|
||||
if (!data.files?.length) return [];
|
||||
|
||||
const currentlyLoadedIndex = files.findIndex((project) => project.filename === lastLoadedProject);
|
||||
const projectFiles = [...files];
|
||||
const current = projectFiles.splice(currentlyLoadedIndex, 1)?.[0];
|
||||
|
||||
return [current, ...projectFiles];
|
||||
}, [data.files?.length, files, lastLoadedProject]);
|
||||
|
||||
return (
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={style.containCell}>Project Name</th>
|
||||
<th>Last Used</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{reorderedProjectFiles.map((project) => (
|
||||
<ProjectListItem
|
||||
key={project.filename}
|
||||
filename={project.filename}
|
||||
updatedAt={project.updatedAt}
|
||||
onToggleEditMode={handleToggleEditMode}
|
||||
onSubmit={handleClear}
|
||||
onRefetch={handleRefetch}
|
||||
editingFilename={editingFilename}
|
||||
editingMode={editingMode}
|
||||
current={project.filename === lastLoadedProject}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useState } from 'react';
|
||||
import { IconButton, Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react';
|
||||
import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHorizontal';
|
||||
|
||||
import {
|
||||
deleteProject,
|
||||
downloadCSV,
|
||||
downloadProject,
|
||||
duplicateProject,
|
||||
loadProject,
|
||||
renameProject,
|
||||
} from '../../../../common/api/db';
|
||||
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
|
||||
|
||||
import ProjectForm, { ProjectFormValues } from './ProjectForm';
|
||||
|
||||
import style from './ProjectPanel.module.scss';
|
||||
|
||||
export type EditMode = 'rename' | 'duplicate' | null;
|
||||
|
||||
interface ProjectListItemProps {
|
||||
current?: boolean;
|
||||
filename: string;
|
||||
updatedAt: string;
|
||||
onToggleEditMode: (editMode: EditMode, filename: string | null) => void;
|
||||
onSubmit: () => void;
|
||||
onRefetch: () => Promise<void>;
|
||||
editingFilename: string | null;
|
||||
editingMode: EditMode | null;
|
||||
}
|
||||
|
||||
export default function ProjectListItem({
|
||||
current,
|
||||
updatedAt,
|
||||
editingFilename,
|
||||
editingMode,
|
||||
filename,
|
||||
onRefetch,
|
||||
onSubmit,
|
||||
onToggleEditMode,
|
||||
}: ProjectListItemProps) {
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmitRename = async (values: ProjectFormValues) => {
|
||||
try {
|
||||
setSubmitError(null);
|
||||
|
||||
if (!values.filename) {
|
||||
setSubmitError('Filename cannot be blank');
|
||||
return;
|
||||
}
|
||||
await renameProject(filename, values.filename);
|
||||
await onRefetch();
|
||||
onSubmit();
|
||||
} catch (error) {
|
||||
setSubmitError(maybeAxiosError(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitDuplicate = async (values: ProjectFormValues) => {
|
||||
try {
|
||||
setSubmitError(null);
|
||||
|
||||
if (!values.filename) {
|
||||
setSubmitError('Filename cannot be blank');
|
||||
return;
|
||||
}
|
||||
await duplicateProject(filename, values.filename);
|
||||
await onRefetch();
|
||||
onSubmit();
|
||||
} catch (error) {
|
||||
setSubmitError(maybeAxiosError(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEditMode = (editMode: EditMode, filename: string | null) => {
|
||||
setSubmitError(null);
|
||||
onToggleEditMode(editMode, filename);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
handleToggleEditMode(null, null);
|
||||
};
|
||||
|
||||
const isCurrentlyBeingEdited = editingMode && filename === editingFilename;
|
||||
const classes = current && !isCurrentlyBeingEdited ? style.current : undefined;
|
||||
|
||||
return (
|
||||
<tr key={filename} className={classes}>
|
||||
{isCurrentlyBeingEdited ? (
|
||||
<td colSpan={99}>
|
||||
<ProjectForm
|
||||
action={editingMode}
|
||||
filename={filename}
|
||||
onSubmit={editingMode === 'duplicate' ? handleSubmitDuplicate : handleSubmitRename}
|
||||
onCancel={handleCancel}
|
||||
submitError={submitError}
|
||||
/>
|
||||
</td>
|
||||
) : (
|
||||
<>
|
||||
<td className={style.containCell}>{filename}</td>
|
||||
<td>{new Date(updatedAt).toLocaleString()}</td>
|
||||
<td className={style.actionButton}>
|
||||
<ActionMenu
|
||||
current={current}
|
||||
filename={filename}
|
||||
onChangeEditMode={handleToggleEditMode}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionMenu({
|
||||
current,
|
||||
filename,
|
||||
onChangeEditMode,
|
||||
onRefetch,
|
||||
}: {
|
||||
current?: boolean;
|
||||
filename: string;
|
||||
onChangeEditMode: (editMode: EditMode, filename: string) => void;
|
||||
onRefetch: () => Promise<void>;
|
||||
}) {
|
||||
const handleLoad = async () => {
|
||||
await loadProject(filename);
|
||||
await invalidateAllCaches();
|
||||
};
|
||||
|
||||
const handleRename = () => {
|
||||
onChangeEditMode('rename', filename);
|
||||
};
|
||||
|
||||
const handleDuplicate = () => {
|
||||
onChangeEditMode('duplicate', filename);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
await deleteProject(filename);
|
||||
await onRefetch();
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
await downloadProject(filename);
|
||||
};
|
||||
|
||||
const handleExportCSV = async () => {
|
||||
await downloadCSV(filename);
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu variant='ontime-on-dark' size='sm'>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
aria-label='Options'
|
||||
icon={<IoEllipsisHorizontal />}
|
||||
color='#e2e2e2' // $gray-200
|
||||
variant='ontime-ghosted'
|
||||
size='sm'
|
||||
/>
|
||||
<MenuList>
|
||||
<MenuItem onClick={handleLoad} isDisabled={current}>
|
||||
Load
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleRename}>Rename</MenuItem>
|
||||
<MenuItem onClick={handleDuplicate}>Duplicate</MenuItem>
|
||||
<MenuItem onClick={handleDownload}>Download</MenuItem>
|
||||
{current && <MenuItem onClick={handleExportCSV}>Export CSV Rundown</MenuItem>}
|
||||
<MenuItem isDisabled={current} onClick={handleDelete}>
|
||||
Delete
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
.current {
|
||||
background-color: $blue-1100;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.containCell {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.innerColumn {
|
||||
margin: 0 2rem;
|
||||
margin-bottom: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1em;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { PanelBaseProps } from '../../settingsStore';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import ManageProjects from './ManageProjects';
|
||||
import ProjectData from './ProjectData';
|
||||
|
||||
export default function ProjectPanel({ location }: PanelBaseProps) {
|
||||
const projectRef = useScrollIntoView<HTMLDivElement>('data', location);
|
||||
const manageRef = useScrollIntoView<HTMLDivElement>('manage', location);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Project</Panel.Header>
|
||||
<div ref={projectRef}>
|
||||
<ProjectData />
|
||||
</div>
|
||||
<div ref={manageRef}>
|
||||
<ManageProjects />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Button } from '@chakra-ui/react';
|
||||
|
||||
import useElectronEvent from '../../../../common/hooks/useElectronEvent';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
export default function ShutdownPanel() {
|
||||
const { isElectron, sendToElectron } = useElectronEvent();
|
||||
|
||||
const sendShutdown = () => {
|
||||
sendToElectron('shutdown', 'now');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Shutdown Ontime</Panel.Header>
|
||||
<Panel.Section>
|
||||
<Panel.Paragraph>
|
||||
This will shutdown the Ontime server. <br />
|
||||
The runtime state will be lost, but your project is kept for next time.
|
||||
</Panel.Paragraph>
|
||||
<Button colorScheme='red' onClick={sendShutdown} isDisabled={!isElectron}>
|
||||
Shutdown ontime
|
||||
</Button>
|
||||
</Panel.Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
|
||||
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadsheet-gsheet/';
|
||||
|
||||
export default function GSheetInfo() {
|
||||
return (
|
||||
<Alert status='info' variant='ontime-on-dark-info'>
|
||||
<AlertIcon />
|
||||
<AlertDescription>
|
||||
Ontime allows you to synchronize your rundown with a Google Sheet.
|
||||
<br />
|
||||
<br />
|
||||
To enable this feature, you will need to generate tokens in your Google account and provide them to Ontime.
|
||||
<br />
|
||||
Once set up, you will be able to synchronize data between Ontime and your Google Sheet. <br />
|
||||
<ExternalLink href={googleSheetDocsUrl}>See the docs</ExternalLink>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { ChangeEvent, useEffect, useState } from 'react';
|
||||
import { Button, Input, Spinner } from '@chakra-ui/react';
|
||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
||||
import { IoShieldCheckmarkOutline } from '@react-icons/all-files/io5/IoShieldCheckmarkOutline';
|
||||
|
||||
import { getWorksheetNames } from '../../../../common/api/sheets';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
||||
import { openLink } from '../../../../common/utils/linkUtils';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import useGoogleSheet from './useGoogleSheet';
|
||||
import { useSheetStore } from './useSheetStore';
|
||||
|
||||
import style from './SourcesPanel.module.scss';
|
||||
|
||||
interface GSheetSetupProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function GSheetSetup(props: GSheetSetupProps) {
|
||||
const { onCancel } = props;
|
||||
|
||||
const { revoke, connect, verifyAuth } = useGoogleSheet();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [authKey, setAuthKey] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState<'' | 'cancel' | 'connect' | 'authenticate'>('');
|
||||
const [authLink, setAuthLink] = useState('');
|
||||
|
||||
const sheetId = useSheetStore((state) => state.sheetId);
|
||||
const setSheetId = useSheetStore((state) => state.setSheetId);
|
||||
const setWorksheets = useSheetStore((state) => state.setWorksheets);
|
||||
const patchStepData = useSheetStore((state) => state.patchStepData);
|
||||
const authenticationStatus = useSheetStore((state) => state.authenticationStatus);
|
||||
const setAuthenticationStatus = useSheetStore((state) => state.setAuthenticationStatus);
|
||||
|
||||
/** Check if we are authenticated */
|
||||
const getAuthStatus = async () => {
|
||||
const result = await verifyAuth();
|
||||
if (result) {
|
||||
setAuthenticationStatus(result.authenticated);
|
||||
}
|
||||
};
|
||||
|
||||
/** check if the current session has been authenticated */
|
||||
useEffect(() => {
|
||||
untilAuthenticated();
|
||||
}, []);
|
||||
|
||||
// user cancels the flow
|
||||
const handleRevoke = async () => {
|
||||
setLoading('cancel');
|
||||
await revoke();
|
||||
await getAuthStatus();
|
||||
setLoading('');
|
||||
};
|
||||
|
||||
const handleCancelFlow = async () => {
|
||||
onCancel();
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets file from input
|
||||
* @param event
|
||||
*/
|
||||
const handleClientSecret = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!event.target.files?.length) {
|
||||
return;
|
||||
}
|
||||
setFile(event.target.files[0]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Requests connection to google auth
|
||||
*/
|
||||
const handleConnect = async () => {
|
||||
if (!file) return;
|
||||
if (!sheetId) return;
|
||||
|
||||
setLoading('connect');
|
||||
const result = await connect(file, sheetId);
|
||||
if (result) {
|
||||
setAuthLink(result.verification_url);
|
||||
setAuthKey(result.user_code);
|
||||
}
|
||||
setLoading('');
|
||||
};
|
||||
|
||||
const untilAuthenticated = async (attempts: number = 0) => {
|
||||
const result = await verifyAuth();
|
||||
if (result?.authenticated) {
|
||||
setAuthenticationStatus(result.authenticated);
|
||||
if (result.authenticated !== 'pending') {
|
||||
if (result.authenticated == 'authenticated') {
|
||||
try {
|
||||
const names = await getWorksheetNames(result.sheetId);
|
||||
setWorksheets(names);
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
patchStepData({ worksheet: { available: false, error: message } });
|
||||
}
|
||||
}
|
||||
setLoading('');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (attempts <= 10) {
|
||||
setTimeout(() => untilAuthenticated(attempts + 1), 2000);
|
||||
return;
|
||||
}
|
||||
setLoading('');
|
||||
};
|
||||
|
||||
/**
|
||||
* Open google auth
|
||||
*/
|
||||
const handleAuthenticate = async () => {
|
||||
setLoading('authenticate');
|
||||
|
||||
// open link and schedule a check for when the user focuses again
|
||||
openLink(authLink);
|
||||
window.addEventListener(
|
||||
'focus',
|
||||
async () => {
|
||||
untilAuthenticated();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
};
|
||||
|
||||
const canConnect = file && sheetId;
|
||||
const canAuthenticate = Boolean(authKey) && Boolean(authLink);
|
||||
const isLoading = Boolean(loading);
|
||||
const isAuthenticated = authenticationStatus === 'authenticated';
|
||||
const isAuthenticating = authenticationStatus === 'pending';
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Title>
|
||||
Sync with Google Sheet (experimental)
|
||||
{isAuthenticated ? (
|
||||
<Button variant='ontime-subtle' size='sm' onClick={handleRevoke} isLoading={loading === 'cancel'}>
|
||||
Revoke Authentication
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant='ontime-subtle' size='sm' onClick={handleCancelFlow}>
|
||||
Go Back
|
||||
</Button>
|
||||
)}
|
||||
</Panel.Title>
|
||||
<Panel.ListGroup>
|
||||
<Panel.Description>Upload Client Secret provided by Google</Panel.Description>
|
||||
<Panel.Error>{undefined}</Panel.Error>
|
||||
<Input
|
||||
type='file'
|
||||
onChange={handleClientSecret}
|
||||
accept='.json'
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
isDisabled={isLoading || canAuthenticate}
|
||||
/>
|
||||
</Panel.ListGroup>
|
||||
<Panel.ListGroup>
|
||||
<Panel.Description>Enter ID of sheet to synchronise</Panel.Description>
|
||||
<Panel.Error>{undefined}</Panel.Error>
|
||||
<Input
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
autoComplete='off'
|
||||
placeholder='Sheet ID'
|
||||
onChange={(event) => setSheetId(event.target.value)}
|
||||
isDisabled={isLoading || canAuthenticate}
|
||||
/>
|
||||
</Panel.ListGroup>
|
||||
{!canAuthenticate ? (
|
||||
<Panel.ListGroup>
|
||||
<div className={style.buttonRow}>
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
size='sm'
|
||||
leftIcon={<IoCheckmark />}
|
||||
onClick={handleConnect}
|
||||
isDisabled={!canConnect || isLoading}
|
||||
isLoading={loading === 'connect'}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.ListGroup>
|
||||
) : (
|
||||
<Panel.ListGroup>
|
||||
<div className={style.buttonRow}>
|
||||
{isAuthenticating && <Spinner />}
|
||||
<CopyTag label='Google Auth Key' disabled={!canAuthenticate} size='sm'>
|
||||
{authKey ? authKey : 'Upload files to generate Auth Key'}
|
||||
</CopyTag>
|
||||
<Button
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
leftIcon={<IoShieldCheckmarkOutline />}
|
||||
onClick={handleAuthenticate}
|
||||
isDisabled={!canAuthenticate}
|
||||
>
|
||||
Authenticate
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.ListGroup>
|
||||
)}
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { CustomFields, OntimeRundown } from 'ontime-types';
|
||||
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
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;
|
||||
onFinished: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function ImportReview(props: ImportReviewProps) {
|
||||
const { rundown, customFields, onFinished, onCancel } = props;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { importRundown } = useGoogleSheet();
|
||||
const resetPreview = useSheetStore((state) => state.resetPreview);
|
||||
|
||||
const handleCancel = () => {
|
||||
resetPreview();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const applyImport = async () => {
|
||||
setLoading(true);
|
||||
await importRundown(rundown, customFields);
|
||||
setLoading(false);
|
||||
onFinished();
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Title>
|
||||
Review Rundown
|
||||
<div className={style.buttonRow}>
|
||||
<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.Title>
|
||||
<PreviewSpreadsheet rundown={rundown} customFields={customFields} />
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
.uploadSection,
|
||||
.successSection {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
padding: 3rem 1rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: $gray-1350;
|
||||
border: 1px solid $white-10;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.uploadSection {
|
||||
flex-direction: row;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.successSection {
|
||||
color: $green-500;
|
||||
font-size: 1.5rem;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.buttonRow {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.inputContainer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.singleActionCell {
|
||||
width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { ChangeEvent, useRef, useState } from 'react';
|
||||
import { Button, Input } from '@chakra-ui/react';
|
||||
import { IoCloudOutline } from '@react-icons/all-files/io5/IoCloudOutline';
|
||||
import { IoDownloadOutline } from '@react-icons/all-files/io5/IoDownloadOutline';
|
||||
import { getErrorMessage, ImportMap } from 'ontime-utils';
|
||||
|
||||
import {
|
||||
getWorksheetNames as getWorksheetNamesExcel,
|
||||
importRundownPreview as importRundownPreviewExcel,
|
||||
upload as uploadExcel,
|
||||
} from '../../../../common/api/excel';
|
||||
import { getWorksheetNames } from '../../../../common/api/sheets';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import { validateExcelImport } from '../../../../common/utils/uploadUtils';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import ImportMapForm from './import-map/ImportMapForm';
|
||||
import GSheetInfo from './GSheetInfo';
|
||||
import GSheetSetup from './GSheetSetup';
|
||||
import ImportReview from './ImportReview';
|
||||
import useGoogleSheet from './useGoogleSheet';
|
||||
import { useSheetStore } from './useSheetStore';
|
||||
|
||||
import style from './SourcesPanel.module.scss';
|
||||
|
||||
export default function SourcesPanel() {
|
||||
const [importFlow, setImportFlow] = useState<'none' | 'excel' | 'gsheet' | 'finished'>('none');
|
||||
const [error, setError] = useState('');
|
||||
const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none');
|
||||
|
||||
const { exportRundown, importRundownPreview, verifyAuth } = useGoogleSheet();
|
||||
|
||||
const setWorksheets = useSheetStore((state) => state.setWorksheets);
|
||||
const authenticationStatus = useSheetStore((state) => state.authenticationStatus);
|
||||
const setAuthenticationStatus = useSheetStore((state) => state.setAuthenticationStatus);
|
||||
const rundown = useSheetStore((state) => state.rundown);
|
||||
const setRundown = useSheetStore((state) => state.setRundown);
|
||||
const customFields = useSheetStore((state) => state.customFields);
|
||||
const setCustomFields = useSheetStore((state) => state.setCustomFields);
|
||||
const setSheetId = useSheetStore((state) => state.setSheetId);
|
||||
const sheetId = useSheetStore((state) => state.sheetId);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFile = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const fileToUpload = event.target.files?.[0];
|
||||
|
||||
if (!fileToUpload) {
|
||||
setWorksheets(null);
|
||||
setHasFile('none');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setHasFile('loading');
|
||||
validateExcelImport(fileToUpload);
|
||||
await uploadExcel(fileToUpload);
|
||||
const names = await getWorksheetNamesExcel();
|
||||
setWorksheets(names);
|
||||
setImportFlow('excel');
|
||||
setHasFile('done');
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
setError(`Error uploading file: ${errorMessage}`);
|
||||
setWorksheets(null);
|
||||
setHasFile('none');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const openGSheetFlow = async () => {
|
||||
const result = await verifyAuth();
|
||||
if (result) {
|
||||
setAuthenticationStatus(result.authenticated);
|
||||
setSheetId(result.sheetId);
|
||||
if (result.authenticated === 'authenticated' && result.sheetId) {
|
||||
const names = await getWorksheetNames(result.sheetId);
|
||||
setWorksheets(names);
|
||||
}
|
||||
}
|
||||
setImportFlow('gsheet');
|
||||
};
|
||||
|
||||
const cancelGSheetFlow = () => {
|
||||
setImportFlow('none');
|
||||
};
|
||||
|
||||
const handleSubmitImportPreview = async (importMap: ImportMap) => {
|
||||
if (importFlow === 'excel') {
|
||||
try {
|
||||
const previewData = await importRundownPreviewExcel(importMap);
|
||||
setRundown(previewData.rundown);
|
||||
setCustomFields(previewData.customFields);
|
||||
} catch (error) {
|
||||
setError(maybeAxiosError(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (importFlow === 'gsheet') {
|
||||
if (!sheetId) return;
|
||||
await importRundownPreview(sheetId, importMap);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelImportMap = async () => {
|
||||
setImportFlow('none');
|
||||
setHasFile('none');
|
||||
setWorksheets(null);
|
||||
if (authenticationStatus === 'authenticated') {
|
||||
const result = await verifyAuth();
|
||||
if (result) {
|
||||
setAuthenticationStatus(result.authenticated);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinished = () => {
|
||||
setImportFlow('finished');
|
||||
setRundown(null);
|
||||
setHasFile('none');
|
||||
setWorksheets(null);
|
||||
setCustomFields(null);
|
||||
};
|
||||
|
||||
const handleSubmitExport = async (importMap: ImportMap) => {
|
||||
if (!sheetId) return;
|
||||
await exportRundown(sheetId, importMap);
|
||||
};
|
||||
|
||||
const isExcelFlow = importFlow === 'excel';
|
||||
const isGSheetFlow = importFlow === 'gsheet';
|
||||
const isAuthenticated = authenticationStatus === 'authenticated';
|
||||
const showInput = importFlow === 'none';
|
||||
const showSuccess = importFlow === 'finished';
|
||||
const showAuth = isGSheetFlow && !isAuthenticated;
|
||||
const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile === 'done');
|
||||
const showReview = rundown !== null && customFields !== null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Data sources</Panel.Header>
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>Synchronise your rundown with an external source</Panel.SubHeader>
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
{showInput && (
|
||||
<>
|
||||
<GSheetInfo />
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
type='file'
|
||||
onChange={handleFile}
|
||||
accept='.xlsx'
|
||||
data-testid='file-input'
|
||||
/>
|
||||
<div className={style.uploadSection}>
|
||||
<div>
|
||||
<Button
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
leftIcon={<IoDownloadOutline />}
|
||||
onClick={handleUpload}
|
||||
isLoading={hasFile === 'loading'}
|
||||
>
|
||||
Import from spreadsheet
|
||||
</Button>
|
||||
<Panel.Description>Accepts .xlsx files</Panel.Description>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
leftIcon={<IoCloudOutline />}
|
||||
onClick={openGSheetFlow}
|
||||
isDisabled={hasFile !== 'none'}
|
||||
>
|
||||
Synchronise with Google
|
||||
</Button>
|
||||
<Panel.Description>Start authentication process</Panel.Description>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{showSuccess && (
|
||||
<div className={style.successSection}>
|
||||
<span>Import successful</span>
|
||||
<Button variant='ontime-filled' size='sm' onClick={() => setImportFlow('none')}>
|
||||
Return
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{showAuth && <GSheetSetup onCancel={cancelGSheetFlow} />}
|
||||
{showImportMap && !showReview && (
|
||||
<ImportMapForm
|
||||
isSpreadsheet={isExcelFlow}
|
||||
onCancel={cancelImportMap}
|
||||
onSubmitExport={handleSubmitExport}
|
||||
onSubmitImport={handleSubmitImportPreview}
|
||||
/>
|
||||
)}
|
||||
{showReview && (
|
||||
<ImportReview
|
||||
rundown={rundown}
|
||||
customFields={customFields}
|
||||
onFinished={handleFinished}
|
||||
onCancel={cancelImportMap}
|
||||
/>
|
||||
)}
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
import { useState } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
|
||||
import { isAlphanumeric } from '../../../../../common/utils/regex';
|
||||
import * as Panel from '../../PanelUtils';
|
||||
import useGoogleSheet from '../useGoogleSheet';
|
||||
import { useSheetStore } from '../useSheetStore';
|
||||
|
||||
import { convertToImportMap, getPersistedOptions, NamedImportMap, persistImportMap } from './importMapUtils';
|
||||
|
||||
import style from '../SourcesPanel.module.scss';
|
||||
|
||||
interface ImportMapFormProps {
|
||||
isSpreadsheet?: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmitExport: (importMap: ImportMap) => Promise<void>;
|
||||
onSubmitImport: (importMap: ImportMap) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function ImportMapForm(props: ImportMapFormProps) {
|
||||
const { isSpreadsheet, onCancel, onSubmitExport, onSubmitImport } = props;
|
||||
const namedImportMap = getPersistedOptions();
|
||||
const { revoke } = useGoogleSheet();
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { errors, isValid },
|
||||
} = useForm<NamedImportMap>({
|
||||
mode: 'onBlur',
|
||||
defaultValues: namedImportMap,
|
||||
values: namedImportMap,
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: 'custom',
|
||||
});
|
||||
|
||||
const stepData = useSheetStore((state) => state.stepData);
|
||||
const worksheetNames = useSheetStore((state) => state.worksheetNames);
|
||||
|
||||
const [loading, setLoading] = useState<'' | 'export' | 'import'>('');
|
||||
|
||||
const handleExport = async (values: NamedImportMap) => {
|
||||
setLoading('export');
|
||||
const importMap = convertToImportMap(values);
|
||||
|
||||
await onSubmitExport(importMap);
|
||||
setLoading('');
|
||||
};
|
||||
|
||||
const handleRevoke = async () => {
|
||||
await revoke();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const handleImportPreview = async (values: NamedImportMap) => {
|
||||
setLoading('import');
|
||||
const importMap = convertToImportMap(values);
|
||||
persistImportMap(values);
|
||||
await onSubmitImport(importMap);
|
||||
setLoading('');
|
||||
};
|
||||
|
||||
const deleteCustomImport = (index: number) => {
|
||||
remove(index);
|
||||
};
|
||||
|
||||
const addCustomImport = () => {
|
||||
append({});
|
||||
};
|
||||
|
||||
const isLoading = Boolean(loading);
|
||||
const canSubmitSpreadsheet = isSpreadsheet && !isLoading;
|
||||
const canSubmitGSheet = !isLoading;
|
||||
const canSubmit = isValid && (canSubmitSpreadsheet || canSubmitGSheet);
|
||||
|
||||
return (
|
||||
<Panel.Section as='form' id='import-map'>
|
||||
<Panel.Title>
|
||||
Import options
|
||||
<div className={style.buttonRow}>
|
||||
{!isSpreadsheet && (
|
||||
<Tooltip label='Revoke the google authentication'>
|
||||
<Button variant='ontime-subtle' size='sm' onClick={handleRevoke} isDisabled={isLoading}>
|
||||
Revoke
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button variant='ontime-subtle' size='sm' onClick={onCancel} isDisabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
{!isSpreadsheet && (
|
||||
<Button
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
onClick={handleSubmit(handleExport)}
|
||||
isDisabled={!canSubmitGSheet}
|
||||
isLoading={loading === 'export'}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
onClick={handleSubmit(handleImportPreview)}
|
||||
isDisabled={!canSubmit}
|
||||
isLoading={loading === 'import'}
|
||||
>
|
||||
Import preview
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.Title>
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ontime field</th>
|
||||
<th>From spreadsheet name</th>
|
||||
<th className={style.singleActionCell} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(namedImportMap).map(([label, importName]) => {
|
||||
if (label === 'custom') {
|
||||
return null;
|
||||
}
|
||||
if (label === 'Worksheet') {
|
||||
return (
|
||||
<tr key={importName as string}>
|
||||
<td>{label}</td>
|
||||
<td>
|
||||
<Select
|
||||
variant='ontime'
|
||||
id={importName as string}
|
||||
size='sm'
|
||||
{...register(label as keyof NamedImportMap)}
|
||||
>
|
||||
{worksheetNames?.map((name) => {
|
||||
return (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</td>
|
||||
<td className={style.singleActionCell} />
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<tr key={importName as string}>
|
||||
<td>{label}</td>
|
||||
<td>
|
||||
<Input
|
||||
id={importName as string}
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
autoComplete='off'
|
||||
maxLength={25}
|
||||
defaultValue={importName as string}
|
||||
placeholder='Use default column name'
|
||||
{...register(label as keyof NamedImportMap)}
|
||||
/>
|
||||
</td>
|
||||
<td className={style.singleActionCell} />
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{fields.map((field, index) => {
|
||||
const ontimeName = field.ontimeName;
|
||||
const importName = field.importName;
|
||||
const maybeOntimeError = errors.custom?.[index]?.ontimeName?.message;
|
||||
const key = `custom.${index}.ontimeName`;
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td>
|
||||
<Input
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
autoComplete='off'
|
||||
maxLength={25}
|
||||
defaultValue={ontimeName}
|
||||
placeholder='Name of the field as shown in Ontime'
|
||||
{...register(`custom.${index}.ontimeName`, {
|
||||
pattern: {
|
||||
value: isAlphanumeric,
|
||||
message: 'Custom field name must be alphanumeric',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{maybeOntimeError && <Panel.Error>{maybeOntimeError}</Panel.Error>}
|
||||
</td>
|
||||
<td>
|
||||
<Input
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
autoComplete='off'
|
||||
maxLength={25}
|
||||
defaultValue={importName}
|
||||
placeholder='Name of the column in the spreadsheet'
|
||||
{...register(`custom.${index}.importName`)}
|
||||
/>
|
||||
</td>
|
||||
<td className={style.singleActionCell}>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='ontime-ghosted'
|
||||
color='#FA5656' // $red-500
|
||||
icon={<IoTrash />}
|
||||
aria-label='Delete entry'
|
||||
onClick={() => deleteCustomImport(index)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr>
|
||||
<td />
|
||||
<td className={style.buttonRow} colSpan={99}>
|
||||
<Button size='sm' variant='ontime-subtle' rightIcon={<IoAdd />} onClick={addCustomImport}>
|
||||
Add custom field
|
||||
</Button>
|
||||
</td>
|
||||
<td />
|
||||
</tr>
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
<Panel.Error>{stepData.worksheet.error}</Panel.Error>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { ImportCustom } from 'ontime-utils';
|
||||
|
||||
import { convertToImportMap } from '../importMapUtils';
|
||||
|
||||
describe('convertToImportMap', () => {
|
||||
it('converts a namedImportMap to a importMap', () => {
|
||||
const defaultNamedImporMap = {
|
||||
Worksheet: 'event schedule',
|
||||
Start: 'time start',
|
||||
End: 'time end',
|
||||
Duration: 'duration',
|
||||
Cue: 'cue',
|
||||
Title: 'title',
|
||||
'Is Public': 'public',
|
||||
Skip: 'skip',
|
||||
Note: 'notes',
|
||||
Colour: 'colour',
|
||||
'End action': 'end action',
|
||||
'Timer type': 'timer type',
|
||||
'Time warning': 'warning time',
|
||||
'Time danger': 'danger time',
|
||||
custom: [
|
||||
{ ontimeName: 'Custom1 ', importName: 'custom1' },
|
||||
{ ontimeName: 'Custom2', importName: 'custom2' },
|
||||
{ ontimeName: 'Custom3', importName: 'custom3' },
|
||||
{ ontimeName: 'EmptyImportName', importName: '' },
|
||||
{ ontimeName: '', importName: 'EmptyOntimeName' },
|
||||
] as ImportCustom[],
|
||||
};
|
||||
|
||||
const importMap = convertToImportMap(defaultNamedImporMap);
|
||||
expect(importMap.custom).toStrictEqual({
|
||||
Custom1: 'custom1',
|
||||
Custom2: 'custom2',
|
||||
Custom3: 'custom3',
|
||||
});
|
||||
});
|
||||
});
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { ImportCustom, ImportMap } from 'ontime-utils';
|
||||
|
||||
export type NamedImportMap = typeof namedImportMap;
|
||||
|
||||
// Record of label and import name
|
||||
export const namedImportMap = {
|
||||
Worksheet: 'event schedule',
|
||||
Start: 'time start',
|
||||
End: 'time end',
|
||||
Duration: 'duration',
|
||||
Cue: 'cue',
|
||||
Title: 'title',
|
||||
'Is Public': 'public',
|
||||
Skip: 'skip',
|
||||
Note: 'notes',
|
||||
Colour: 'colour',
|
||||
'End action': 'end action',
|
||||
'Timer type': 'timer type',
|
||||
'Time warning': 'warning time',
|
||||
'Time danger': 'danger time',
|
||||
custom: [] as ImportCustom[],
|
||||
};
|
||||
|
||||
export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
|
||||
const custom = namedImportMap.custom.reduce((accumulator, { ontimeName, importName }) => {
|
||||
if (ontimeName && importName) {
|
||||
accumulator[ontimeName.trim()] = importName.trim();
|
||||
}
|
||||
return accumulator;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
worksheet: namedImportMap.Worksheet,
|
||||
timeStart: namedImportMap.Start,
|
||||
timeEnd: namedImportMap.End,
|
||||
duration: namedImportMap.Duration,
|
||||
cue: namedImportMap.Cue,
|
||||
title: namedImportMap.Title,
|
||||
isPublic: namedImportMap['Is Public'],
|
||||
skip: namedImportMap.Skip,
|
||||
note: namedImportMap.Note,
|
||||
colour: namedImportMap.Colour,
|
||||
endAction: namedImportMap['End action'],
|
||||
timerType: namedImportMap['Timer type'],
|
||||
timeWarning: namedImportMap['Time warning'],
|
||||
timeDanger: namedImportMap['Time danger'],
|
||||
custom,
|
||||
};
|
||||
}
|
||||
|
||||
export function persistImportMap(options: NamedImportMap) {
|
||||
localStorage.setItem('ontime-import-options', JSON.stringify(options));
|
||||
}
|
||||
|
||||
export function getPersistedOptions(): NamedImportMap {
|
||||
const options = localStorage.getItem('ontime-import-options');
|
||||
if (!options) {
|
||||
return namedImportMap;
|
||||
}
|
||||
return JSON.parse(options);
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tr .secondaryRow {
|
||||
background-color: $white-7;
|
||||
padding-left: 2em;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Fragment } from 'react';
|
||||
import { CustomFields, isOntimeBlock, isOntimeEvent, OntimeRundown } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import Tag from '../../../../../common/components/tag/Tag';
|
||||
import { getAccessibleColour } from '../../../../../common/utils/styleUtils';
|
||||
import * as Panel from '../../PanelUtils';
|
||||
|
||||
import style from './PreviewRundown.module.scss';
|
||||
|
||||
interface PreviewRundownProps {
|
||||
rundown: OntimeRundown;
|
||||
customFields: CustomFields;
|
||||
}
|
||||
|
||||
function booleanToText(value?: boolean) {
|
||||
return value ? 'Yes' : undefined;
|
||||
}
|
||||
|
||||
export default function PreviewRundown(props: PreviewRundownProps) {
|
||||
const { rundown, customFields } = props;
|
||||
|
||||
// we only count Ontime Events which are 1 based in client
|
||||
let eventIndex = 0;
|
||||
|
||||
const fieldHeaders = Object.keys(customFields);
|
||||
|
||||
return (
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Type</th>
|
||||
<th>Cue</th>
|
||||
<th>Title</th>
|
||||
<th>Time Start</th>
|
||||
<th>Time End</th>
|
||||
<th>Duration</th>
|
||||
<th>Warning Time</th>
|
||||
<th>Danger Time</th>
|
||||
<th>Is Public</th>
|
||||
<th>Skip</th>
|
||||
<th>Colour</th>
|
||||
<th>Timer Type</th>
|
||||
<th>End Action</th>
|
||||
{fieldHeaders.map((field) => (
|
||||
<th key={field}>{field}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rundown.map((event) => {
|
||||
if (isOntimeBlock(event)) {
|
||||
return (
|
||||
<tr key={event.id}>
|
||||
<td className={style.center}>
|
||||
<Tag>-</Tag>
|
||||
</td>
|
||||
<td className={style.center}>
|
||||
<Tag>{event.type}</Tag>
|
||||
</td>
|
||||
<td />
|
||||
<td colSpan={99}>{event.title}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
if (!isOntimeEvent(event)) {
|
||||
return null;
|
||||
}
|
||||
eventIndex += 1;
|
||||
const colour = event.colour ? getAccessibleColour(event.colour) : {};
|
||||
const isPublic = booleanToText(event.isPublic);
|
||||
const skip = booleanToText(event.skip);
|
||||
|
||||
return (
|
||||
<Fragment key={event.id}>
|
||||
<tr>
|
||||
<td className={style.center}>
|
||||
<Tag>{eventIndex}</Tag>
|
||||
</td>
|
||||
<td className={style.center}>
|
||||
<Tag>{event.type}</Tag>
|
||||
</td>
|
||||
<td className={style.nowrap}>{event.cue}</td>
|
||||
<td>{event.title}</td>
|
||||
<td>{millisToString(event.timeStart)}</td>
|
||||
<td>{millisToString(event.timeEnd)}</td>
|
||||
<td>{millisToString(event.duration)}</td>
|
||||
<td>{millisToString(event.timeWarning)}</td>
|
||||
<td>{millisToString(event.timeDanger)}</td>
|
||||
<td className={style.center}>{isPublic && <Tag>{isPublic}</Tag>}</td>
|
||||
<td>{skip && <Tag>{skip}</Tag>}</td>
|
||||
<td style={{ ...colour }}>{event.colour}</td>
|
||||
<td className={style.center}>
|
||||
<Tag>{event.timerType}</Tag>
|
||||
</td>
|
||||
<td className={style.center}>
|
||||
<Tag>{event.endAction}</Tag>
|
||||
</td>
|
||||
{isOntimeEvent(event) &&
|
||||
fieldHeaders.map((field) => {
|
||||
let value = '';
|
||||
if (field in event.custom) {
|
||||
value = event.custom[field].value;
|
||||
}
|
||||
return <td key={field}>{value}</td>;
|
||||
})}
|
||||
</tr>
|
||||
{event.note && (
|
||||
<tr>
|
||||
<td colSpan={99} className={style.secondaryRow}>
|
||||
Note: {event.note}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types';
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
|
||||
import { CUSTOM_FIELDS, RUNDOWN } from '../../../../common/api/constants';
|
||||
import { patchData } from '../../../../common/api/db';
|
||||
import {
|
||||
previewRundown,
|
||||
requestConnection,
|
||||
revokeAuthentication,
|
||||
uploadRundown,
|
||||
verifyAuthenticationStatus,
|
||||
} from '../../../../common/api/sheets';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
|
||||
import { useSheetStore } from './useSheetStore';
|
||||
|
||||
export default function useGoogleSheet() {
|
||||
const queryClient = useQueryClient();
|
||||
// functions push data to store
|
||||
const patchStepData = useSheetStore((state) => state.patchStepData);
|
||||
const setRundown = useSheetStore((state) => state.setRundown);
|
||||
const setCustomFields = useSheetStore((state) => state.setCustomFields);
|
||||
|
||||
/** whether the current session has been authenticated */
|
||||
const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus; sheetId: string } | void> => {
|
||||
try {
|
||||
return verifyAuthenticationStatus();
|
||||
} catch (_error) {
|
||||
/** we do not handle errors here */
|
||||
}
|
||||
};
|
||||
|
||||
/** requests connection to a google sheet */
|
||||
const connect = async (
|
||||
file: File,
|
||||
sheetId: string,
|
||||
): Promise<{ verification_url: string; user_code: string } | void> => {
|
||||
try {
|
||||
return requestConnection(file, sheetId);
|
||||
} catch (_error) {
|
||||
/** we do not handle errors here */
|
||||
}
|
||||
};
|
||||
|
||||
/** requests the revoking of an existing authenticated session */
|
||||
const revoke = async (): Promise<{ authenticated: AuthenticationStatus } | void> => {
|
||||
try {
|
||||
return revokeAuthentication();
|
||||
} catch (_error) {
|
||||
/** we do not handle errors here */
|
||||
}
|
||||
};
|
||||
|
||||
/** fetches data from a worksheet by its ID */
|
||||
const importRundownPreview = async (sheetId: string, fileOptions: ImportMap) => {
|
||||
try {
|
||||
const data = await previewRundown(sheetId, fileOptions);
|
||||
setRundown(data.rundown);
|
||||
setCustomFields(data.customFields);
|
||||
} catch (error) {
|
||||
patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } });
|
||||
}
|
||||
};
|
||||
|
||||
/** writes data to a worksheet by its ID */
|
||||
const exportRundown = async (sheetId: string, fileOptions: ImportMap) => {
|
||||
try {
|
||||
// write data to google
|
||||
await uploadRundown(sheetId, fileOptions);
|
||||
patchStepData({ pullPush: { available: false, error: '' } });
|
||||
} catch (error) {
|
||||
patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } });
|
||||
}
|
||||
};
|
||||
|
||||
/** applies rundown and customFields to current project */
|
||||
const importRundown = async (rundown: OntimeRundown, customFields: CustomFields) => {
|
||||
try {
|
||||
await patchData({ rundown, customFields });
|
||||
// we are unable to optimistically set the rundown since we need
|
||||
// it to be normalised
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [RUNDOWN, CUSTOM_FIELDS],
|
||||
});
|
||||
} catch (error) {
|
||||
patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
connect,
|
||||
revoke,
|
||||
verifyAuth,
|
||||
|
||||
importRundownPreview,
|
||||
importRundown,
|
||||
exportRundown,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types';
|
||||
import { defaultImportMap, ImportMap } from 'ontime-utils';
|
||||
import { create } from 'zustand';
|
||||
|
||||
type SheetStore = {
|
||||
stepData: typeof initialStepData;
|
||||
patchStepData: (patch: Partial<typeof initialStepData>) => void;
|
||||
setWorksheets: (worksheetNames: string[] | null) => void;
|
||||
worksheetNames: string[] | null;
|
||||
|
||||
//gSheet
|
||||
sheetId: string | null;
|
||||
setSheetId: (sheetId: string | null) => void;
|
||||
authenticationStatus: AuthenticationStatus;
|
||||
setAuthenticationStatus: (status: AuthenticationStatus) => void;
|
||||
|
||||
// we get this from a preview response
|
||||
rundown: OntimeRundown | null;
|
||||
setRundown: (rundown: OntimeRundown | null) => void;
|
||||
|
||||
// we get this from a preview response
|
||||
customFields: CustomFields | null;
|
||||
setCustomFields: (customFields: CustomFields | null) => void;
|
||||
|
||||
spreadsheetImportMap: ImportMap;
|
||||
patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => void;
|
||||
|
||||
reset: () => void;
|
||||
resetPreview: () => void;
|
||||
};
|
||||
|
||||
const initialStepData = {
|
||||
authenticate: { available: false, error: '' },
|
||||
sheetId: { available: false, error: '' },
|
||||
worksheet: { available: false, error: '' },
|
||||
pullPush: { available: false, error: '' },
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
stepData: initialStepData,
|
||||
worksheetNames: null,
|
||||
sheetId: null,
|
||||
authenticationStatus: 'not_authenticated' as AuthenticationStatus,
|
||||
rundown: null,
|
||||
customFields: null,
|
||||
spreadsheetImportMap: defaultImportMap,
|
||||
};
|
||||
|
||||
export const useSheetStore = create<SheetStore>((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
patchStepData: (patch: Partial<typeof initialStepData>) => {
|
||||
const stepData = get().stepData;
|
||||
set({ stepData: { ...stepData, ...patch } });
|
||||
},
|
||||
|
||||
setWorksheets: (worksheetNames: string[] | null) => set({ worksheetNames }),
|
||||
|
||||
setSheetId: (sheetId: string | null) => set({ sheetId }),
|
||||
|
||||
setAuthenticationStatus: (status: AuthenticationStatus) => set({ authenticationStatus: status }),
|
||||
|
||||
setRundown: (rundown: OntimeRundown | null) => set({ rundown }),
|
||||
|
||||
setCustomFields: (customFields: CustomFields | null) => set({ customFields }),
|
||||
|
||||
patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => {
|
||||
const currentImportMap = get().spreadsheetImportMap;
|
||||
if (currentImportMap[field] !== value) {
|
||||
currentImportMap[field] = value;
|
||||
}
|
||||
},
|
||||
|
||||
reset: () => set(initialState),
|
||||
resetPreview: () => set({ rundown: null, customFields: null }),
|
||||
}));
|
||||
@@ -0,0 +1,92 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export type SettingsOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
secondary?: Readonly<SettingsOption[]>;
|
||||
split?: boolean;
|
||||
};
|
||||
|
||||
export const settingPanels: Readonly<SettingsOption[]> = [
|
||||
{
|
||||
id: 'project',
|
||||
label: 'Project',
|
||||
secondary: [
|
||||
{ id: 'project__data', label: 'Project data' },
|
||||
{ id: 'project__manage', label: 'Manage projects' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'general',
|
||||
label: 'App Settings',
|
||||
secondary: [
|
||||
{ id: 'general__settings', label: 'General settings' },
|
||||
{ id: 'general__editor', label: 'Editor settings' },
|
||||
{ id: 'general__view', label: 'View settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'feature_settings',
|
||||
label: 'Feature Settings',
|
||||
secondary: [
|
||||
{ id: 'feature_settings__custom', label: 'Custom fields' },
|
||||
{ id: 'feature_settings__urlpresets', label: 'URL Presets' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sources',
|
||||
label: 'Data Sources',
|
||||
secondary: [
|
||||
{ id: 'sources__xlsx', label: 'Import spreadsheet' },
|
||||
{ id: 'sources__gsheet', label: 'Sync with Google Sheet' },
|
||||
],
|
||||
split: true,
|
||||
},
|
||||
{
|
||||
id: 'integrations',
|
||||
label: 'Integrations',
|
||||
secondary: [
|
||||
{ id: 'integrations__osc', label: 'OSC settings' },
|
||||
{ id: 'integrations__http', label: 'HTTP settings' },
|
||||
],
|
||||
},
|
||||
{ id: 'log', label: 'Log', split: true },
|
||||
{
|
||||
id: 'about',
|
||||
label: 'About',
|
||||
split: true,
|
||||
},
|
||||
{
|
||||
id: 'shutdown',
|
||||
label: 'Shutdown',
|
||||
split: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type SettingsOptionId = (typeof settingPanels)[number]['id'];
|
||||
|
||||
export interface PanelBaseProps {
|
||||
location?: string;
|
||||
}
|
||||
|
||||
type SettingsStore = {
|
||||
unsavedChanges: Set<SettingsOptionId>;
|
||||
hasUnsavedChanges: (panelId: SettingsOptionId) => boolean;
|
||||
addUnsavedChanges: (panelId: SettingsOptionId) => void;
|
||||
removeUnsavedChanges: (panelId: SettingsOptionId) => void;
|
||||
};
|
||||
|
||||
export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
unsavedChanges: new Set(),
|
||||
hasUnsavedChanges: (panelId: SettingsOptionId) => get().unsavedChanges.has(panelId),
|
||||
addUnsavedChanges: (panelId: SettingsOptionId) =>
|
||||
set((state) => {
|
||||
state.unsavedChanges.add(panelId);
|
||||
return { unsavedChanges: new Set(state.unsavedChanges) };
|
||||
}),
|
||||
removeUnsavedChanges: (panelId: SettingsOptionId) =>
|
||||
set((state) => {
|
||||
state.unsavedChanges.delete(panelId);
|
||||
return { unsavedChanges: new Set(state.unsavedChanges) };
|
||||
}),
|
||||
}));
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { SettingsOptionId } from './settingsStore';
|
||||
|
||||
const settingsKey = 'settings';
|
||||
|
||||
export default function useAppSettingsNavigation() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const selectedPanel = useMemo(
|
||||
() => (searchParams.get(settingsKey) as SettingsOptionId | null) ?? 'project',
|
||||
[searchParams],
|
||||
);
|
||||
const isOpen = useMemo(() => Boolean(searchParams.get(settingsKey)), [searchParams]);
|
||||
const [panel, location] = selectedPanel.split('__');
|
||||
|
||||
const close = useCallback(() => {
|
||||
searchParams.delete(settingsKey);
|
||||
setSearchParams(searchParams);
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const setLocation = useCallback(
|
||||
(panelId: SettingsOptionId) => {
|
||||
searchParams.set(settingsKey, panelId);
|
||||
setSearchParams(searchParams);
|
||||
},
|
||||
[searchParams, setSearchParams],
|
||||
);
|
||||
|
||||
return { isOpen, panel, location, setLocation, close };
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
|
||||
.inputItems {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { IconButton, Input } from '@chakra-ui/react';
|
||||
import { IoEye } from '@react-icons/all-files/io5/IoEye';
|
||||
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
|
||||
@@ -22,9 +23,22 @@ interface InputRowProps {
|
||||
export default function InputRow(props: InputRowProps) {
|
||||
const { label, placeholder, text, visible, actionHandler, changeHandler, className, readonly } = props;
|
||||
|
||||
const handleInputChange = (newValue: string) => {
|
||||
changeHandler(newValue);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const cursorPositionRef = useRef(0);
|
||||
|
||||
// sync cursor position with text
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.selectionStart = cursorPositionRef.current;
|
||||
inputRef.current.selectionEnd = cursorPositionRef.current;
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
cursorPositionRef.current = event.target.selectionStart ?? 0;
|
||||
changeHandler(event.target.value);
|
||||
};
|
||||
|
||||
const classes = cx([style.inputRow, className]);
|
||||
|
||||
return (
|
||||
@@ -32,12 +46,13 @@ export default function InputRow(props: InputRowProps) {
|
||||
<label className={`${style.label} ${visible ? style.active : ''}`}>{label}</label>
|
||||
<div className={style.inputItems}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
readOnly={readonly}
|
||||
disabled={readonly}
|
||||
value={text}
|
||||
onChange={(event) => handleInputChange(event.target.value)}
|
||||
onChange={handleInputChange}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{readonly ? (
|
||||
|
||||
@@ -1,29 +1,12 @@
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
|
||||
.messageContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $section-spacing;
|
||||
}
|
||||
|
||||
.onAirSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $element-spacing;
|
||||
}
|
||||
|
||||
.buttonSection {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $element-spacing;
|
||||
margin-top: -0.5rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: $inner-section-text-size;
|
||||
color: $label-gray;
|
||||
|
||||
&.active {
|
||||
color: $action-text-color;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,89 +1,79 @@
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { IoEye } from '@react-icons/all-files/io5/IoEye';
|
||||
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
|
||||
import { IoMicOffOutline } from '@react-icons/all-files/io5/IoMicOffOutline';
|
||||
import { IoMicSharp } from '@react-icons/all-files/io5/IoMicSharp';
|
||||
import { IoSunny } from '@react-icons/all-files/io5/IoSunny';
|
||||
import { IoSunnyOutline } from '@react-icons/all-files/io5/IoSunnyOutline';
|
||||
|
||||
import { setMessage, useMessageControl } from '../../../common/hooks/useSocket';
|
||||
import { enDash } from '../../../common/utils/styleUtils';
|
||||
|
||||
import InputRow from './InputRow';
|
||||
|
||||
import style from './MessageControl.module.scss';
|
||||
|
||||
export default function MessageControl() {
|
||||
const data = useMessageControl();
|
||||
const noop = () => undefined;
|
||||
|
||||
export default function MessageControl() {
|
||||
const message = useMessageControl();
|
||||
const blink = message.timer.blink;
|
||||
const blackout = message.timer.blackout;
|
||||
return (
|
||||
<div className={style.messageContainer}>
|
||||
<InputRow
|
||||
label='Public / Backstage screen message'
|
||||
placeholder='Shown in public and backstage screens'
|
||||
text={data.publicMessage.text || ''}
|
||||
visible={data.publicMessage.visible || false}
|
||||
text={message.public.text || ''}
|
||||
visible={message.public.visible || false}
|
||||
changeHandler={(newValue) => setMessage.publicText(newValue)}
|
||||
actionHandler={() => setMessage.publicVisible(!data.publicMessage.visible)}
|
||||
actionHandler={() => setMessage.publicVisible(!message.public.visible)}
|
||||
/>
|
||||
<InputRow
|
||||
label='Lower third message'
|
||||
placeholder='Shown in lower third'
|
||||
text={data.lowerMessage.text || ''}
|
||||
visible={data.lowerMessage.visible || false}
|
||||
text={message.lower.text || ''}
|
||||
visible={message.lower.visible || false}
|
||||
changeHandler={(newValue) => setMessage.lowerText(newValue)}
|
||||
actionHandler={() => setMessage.lowerVisible(!data.lowerMessage.visible)}
|
||||
actionHandler={() => setMessage.lowerVisible(!message.lower.visible)}
|
||||
/>
|
||||
<InputRow
|
||||
label='Timer'
|
||||
placeholder='Message shown in stage timer'
|
||||
text={data.timerMessage.text || ''}
|
||||
visible={data.timerMessage.visible || false}
|
||||
changeHandler={(newValue) => setMessage.presenterText(newValue)}
|
||||
actionHandler={() => setMessage.presenterVisible(!data.timerMessage.visible)}
|
||||
text={message.timer.text || ''}
|
||||
visible={message.timer.visible || false}
|
||||
changeHandler={(newValue) => setMessage.timerText(newValue)}
|
||||
actionHandler={() => setMessage.timerVisible(!message.timer.visible)}
|
||||
/>
|
||||
<div className={style.buttonSection}>
|
||||
<Button
|
||||
size='sm'
|
||||
className={`${data.timerMessage.timerBlink ? style.blink : ''}`}
|
||||
variant={data.timerMessage.timerBlink ? 'ontime-filled' : 'ontime-subtle'}
|
||||
leftIcon={data.timerMessage.timerBlink ? <IoSunny size='1rem' /> : <IoSunnyOutline size='1rem' />}
|
||||
onClick={() => setMessage.timerBlink(!data.timerMessage.timerBlink)}
|
||||
className={`${blink ? style.blink : ''}`}
|
||||
variant={blink ? 'ontime-filled' : 'ontime-subtle'}
|
||||
leftIcon={blink ? <IoSunny size='1rem' /> : <IoSunnyOutline size='1rem' />}
|
||||
onClick={() => setMessage.timerBlink(!blink)}
|
||||
data-testid='toggle timer blink'
|
||||
>
|
||||
Blink message
|
||||
Blink
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
className={style.blackoutButton}
|
||||
variant={data.timerMessage.timerBlackout ? 'ontime-filled' : 'ontime-subtle'}
|
||||
leftIcon={data.timerMessage.timerBlackout ? <IoEye size='1rem' /> : <IoEyeOffOutline size='1rem' />}
|
||||
onClick={() => setMessage.timerBlackout(!data.timerMessage.timerBlackout)}
|
||||
variant={blackout ? 'ontime-filled' : 'ontime-subtle'}
|
||||
leftIcon={blackout ? <IoEye size='1rem' /> : <IoEyeOffOutline size='1rem' />}
|
||||
onClick={() => setMessage.timerBlackout(!blackout)}
|
||||
data-testid='toggle timer blackout'
|
||||
>
|
||||
Blackout screen
|
||||
</Button>
|
||||
</div>
|
||||
<InputRow
|
||||
label='External Message'
|
||||
placeholder='-'
|
||||
label='External Message (read only)'
|
||||
placeholder={enDash}
|
||||
readonly
|
||||
text={data.externalMessage.text || ''}
|
||||
visible={data.externalMessage.visible || false}
|
||||
changeHandler={() => undefined}
|
||||
actionHandler={() => undefined}
|
||||
text={message.external.text || ''}
|
||||
visible={message.external.visible || false}
|
||||
changeHandler={noop}
|
||||
actionHandler={noop}
|
||||
/>
|
||||
<div className={style.onAirSection}>
|
||||
<label className={style.label}>Toggle On Air state</label>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={data.onAir ? 'ontime-filled' : 'ontime-subtle'}
|
||||
leftIcon={data.onAir ? <IoMicSharp size='24px' /> : <IoMicOffOutline size='24px' />}
|
||||
onClick={() => setMessage.onAir(!data.onAir)}
|
||||
data-testid='toggle on air'
|
||||
>
|
||||
{data?.onAir ? 'Ontime is On Air' : 'Ontime is Off Air'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Playback } from 'ontime-types';
|
||||
|
||||
import { usePlaybackControl } from '../../../common/hooks/useSocket';
|
||||
|
||||
import { ExtraTimer } from './extra-timer/ExtraTimer';
|
||||
import PlaybackButtons from './playback-buttons/PlaybackButtons';
|
||||
import PlaybackTimer from './playback-timer/PlaybackTimer';
|
||||
|
||||
@@ -18,6 +19,7 @@ export default function PlaybackControl() {
|
||||
numEvents={data.numEvents}
|
||||
selectedEventIndex={data.selectedEventIndex}
|
||||
/>
|
||||
<ExtraTimer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.extraRow {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown';
|
||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
import { IoPause } from '@react-icons/all-files/io5/IoPause';
|
||||
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
|
||||
import { IoStop } from '@react-icons/all-files/io5/IoStop';
|
||||
import { Playback, SimpleDirection, SimplePlayback } from 'ontime-types';
|
||||
|
||||
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
||||
import { setExtraTimer, useExtraTimerControl, useExtraTimerTime } from '../../../../common/hooks/useSocket';
|
||||
import { forgivingStringToMillis } from '../../../../common/utils/dateConfig';
|
||||
import TapButton from '../tap-button/TapButton';
|
||||
|
||||
import style from './ExtraTimer.module.scss';
|
||||
|
||||
export function ExtraTimer() {
|
||||
const { playback, direction } = useExtraTimerControl();
|
||||
|
||||
const { start, pause, stop, setDirection } = setExtraTimer;
|
||||
|
||||
const toggleDirection = () => {
|
||||
const newDirection = direction === SimpleDirection.CountDown ? SimpleDirection.CountUp : SimpleDirection.CountDown;
|
||||
setDirection(newDirection);
|
||||
};
|
||||
|
||||
const userCan = {
|
||||
start: playback !== SimplePlayback.Start,
|
||||
pause: playback === SimplePlayback.Start,
|
||||
stop: playback !== SimplePlayback.Stop,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.extraRow}>
|
||||
<ExtraTimeInput />
|
||||
<TapButton onClick={toggleDirection} aspect='tight'>
|
||||
{direction === SimpleDirection.CountDown && <IoArrowDown data-testid='aux-timer-direction' />}
|
||||
{direction === SimpleDirection.CountUp && <IoArrowUp data-testid='aux-timer-direction' />}
|
||||
</TapButton>
|
||||
|
||||
<TapButton
|
||||
onClick={start}
|
||||
theme={Playback.Play}
|
||||
active={playback === SimplePlayback.Start}
|
||||
disabled={!userCan.start}
|
||||
>
|
||||
<IoPlay data-testid='aux-timer-start' />
|
||||
</TapButton>
|
||||
<TapButton
|
||||
onClick={pause}
|
||||
theme={Playback.Pause}
|
||||
active={playback === SimplePlayback.Pause}
|
||||
disabled={!userCan.pause}
|
||||
>
|
||||
<IoPause data-testid='aux-timer-pause' />
|
||||
</TapButton>
|
||||
<TapButton onClick={stop} theme={Playback.Stop} disabled={!userCan.stop}>
|
||||
<IoStop data-testid='aux-timer-stop' />
|
||||
</TapButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExtraTimeInput() {
|
||||
const time = useExtraTimerTime();
|
||||
const { setDuration } = setExtraTimer;
|
||||
|
||||
const handleTimeUpdate = (_field: string, value: string) => {
|
||||
const newTime = forgivingStringToMillis(value);
|
||||
setDuration(newTime / 1000); //frontend api is seconds based;
|
||||
};
|
||||
|
||||
return (
|
||||
<TimeInput<'auxTimer'> submitHandler={handleTimeUpdate} name='auxTimer' time={time} placeholder='Aux Timer 1' />
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
@use '../../../../theme/v2Styles' as *;
|
||||
|
||||
.buttonContainer {
|
||||
padding-top: $element-spacing;
|
||||
display: grid;
|
||||
|
||||
@@ -28,20 +28,21 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
|
||||
const isPlaying = playback === Playback.Play;
|
||||
const isPaused = playback === Playback.Pause;
|
||||
const isArmed = playback === Playback.Armed;
|
||||
const isStopped = playback === Playback.Stop;
|
||||
|
||||
const isFirst = selectedEventIndex === 0;
|
||||
const isLast = selectedEventIndex === numEvents - 1;
|
||||
const noEvents = numEvents === 0;
|
||||
|
||||
const disableGo = isRolling || noEvents || (isLast && !isArmed);
|
||||
const disablePrev = noEvents || isFirst;
|
||||
const disableNext = isRolling || noEvents || isLast;
|
||||
const disablePrev = isRolling || noEvents || isFirst;
|
||||
|
||||
const playbackCan = validatePlayback(playback);
|
||||
const disableStart = !playbackCan.start;
|
||||
const disablePause = !playbackCan.pause;
|
||||
const disableRoll = !playbackCan.roll || noEvents;
|
||||
const disableStop = !playbackCan.stop;
|
||||
const disableReload = !playbackCan.reload;
|
||||
|
||||
const goModeText = selectedEventIndex === null || isArmed ? 'Start' : 'Next';
|
||||
const goModeAction = () => {
|
||||
@@ -73,7 +74,7 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
|
||||
</TapButton>
|
||||
</Tooltip>
|
||||
<Tooltip label='Next event' openDelay={tooltipDelayMid}>
|
||||
<TapButton onClick={setPlayback.next} disabled={disableGo}>
|
||||
<TapButton onClick={setPlayback.next} disabled={disableNext}>
|
||||
<IoPlaySkipForward />
|
||||
</TapButton>
|
||||
</Tooltip>
|
||||
@@ -83,7 +84,7 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
|
||||
<IoTime />
|
||||
</TapButton>
|
||||
<Tooltip label='Reload event' openDelay={tooltipDelayMid}>
|
||||
<TapButton onClick={setPlayback.reload} disabled={isStopped}>
|
||||
<TapButton onClick={setPlayback.reload} disabled={disableReload}>
|
||||
<IoReload className={style.invertX} />
|
||||
</TapButton>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
@use '../../../../theme/v2Styles' as *;
|
||||
@use '../../../../theme/ontimeColours' as *;
|
||||
|
||||
.timeContainer {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
@@ -12,12 +9,6 @@
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.timer {
|
||||
grid-area: clk;
|
||||
white-space: nowrap;
|
||||
max-width: 18.75rem;
|
||||
}
|
||||
|
||||
.indicators {
|
||||
grid-area: ind;
|
||||
width: 100%;
|
||||
@@ -95,16 +86,17 @@
|
||||
grid-area: 2 / 2 / 2 / 4 ;
|
||||
}
|
||||
|
||||
.tag {
|
||||
color: $label-gray;
|
||||
font-size: calc(1rem - 2px);
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.time {
|
||||
color: $section-white;
|
||||
font-size: $text-body-size;
|
||||
}
|
||||
|
||||
.tag {
|
||||
color: $label-gray;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.rolltag {
|
||||
color: $ontime-roll;
|
||||
font-size: $text-body-size;
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { Playback } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
import { dayInMs, millisToMinutes, millisToSeconds, millisToString } from 'ontime-utils';
|
||||
|
||||
import TimerDisplay from '../../../../common/components/timer-display/TimerDisplay';
|
||||
import { setPlayback, useTimer } from '../../../../common/hooks/useSocket';
|
||||
import { millisToMinutes, millisToSeconds } from '../../../../common/utils/dateConfig';
|
||||
import { tooltipDelayMid } from '../../../../ontimeConfig';
|
||||
import TapButton from '../tap-button/TapButton';
|
||||
import TimerDisplay from '../timer-display/TimerDisplay';
|
||||
|
||||
import style from './PlaybackTimer.module.scss';
|
||||
|
||||
@@ -18,9 +17,10 @@ export default function PlaybackTimer(props: PlaybackTimerProps) {
|
||||
const { playback } = props;
|
||||
const timer = useTimer();
|
||||
|
||||
// TODO: checkout typescript in utilities
|
||||
const started = millisToString(timer.startedAt);
|
||||
const finish = millisToString(timer.expectedFinish);
|
||||
const expectedFinish = timer.expectedFinish !== null ? timer.expectedFinish % dayInMs : null;
|
||||
const finish = millisToString(expectedFinish);
|
||||
|
||||
const isRolling = playback === Playback.Roll;
|
||||
const isStopped = playback === Playback.Stop;
|
||||
const isWaiting = timer.secondaryTimer !== null && timer.secondaryTimer > 0 && timer.current === null;
|
||||
@@ -65,9 +65,7 @@ export default function PlaybackTimer(props: PlaybackTimerProps) {
|
||||
<div className={hasAddedTime ? style.indDelayActive : style.indDelay} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={style.timer}>
|
||||
<TimerDisplay time={isWaiting ? timer.secondaryTimer : timer.current} />
|
||||
</div>
|
||||
<TimerDisplay time={isWaiting ? timer.secondaryTimer : timer.current} />
|
||||
{isWaiting ? (
|
||||
<div className={style.roll}>
|
||||
<span className={style.rolltag}>Roll: Countdown to start</span>
|
||||
@@ -75,11 +73,11 @@ export default function PlaybackTimer(props: PlaybackTimerProps) {
|
||||
) : (
|
||||
<>
|
||||
<div className={style.start}>
|
||||
<span className={style.tag}>Started at </span>
|
||||
<span className={style.tag}>Started at</span>
|
||||
<span className={style.time}>{started}</span>
|
||||
</div>
|
||||
<div className={style.finish}>
|
||||
<span className={style.tag}>Finish at </span>
|
||||
<span className={style.tag}>Expect end</span>
|
||||
<span className={style.time}>{finish}</span>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
@use '../../../../theme/v2Styles' as *;
|
||||
@use '../../../../theme/ontimeColours' as *;
|
||||
|
||||
$button-bg-gray: $gray-1050;
|
||||
$button-color-white: $gray-50;
|
||||
|
||||
@@ -82,6 +79,11 @@ $button-color-white: $gray-50;
|
||||
font-size: calc(1rem - 2px);
|
||||
}
|
||||
|
||||
.tapButton.tight {
|
||||
padding-inline: 0.5rem;
|
||||
width: fit-content
|
||||
}
|
||||
|
||||
.tapButton.fill {
|
||||
aspect-ratio: unset;
|
||||
height: 100%;
|
||||
|
||||
@@ -7,8 +7,7 @@ import style from './TapButton.module.scss';
|
||||
|
||||
interface TapButtonProps {
|
||||
disabled?: boolean;
|
||||
aspect?: 'normal' | 'square' | 'fill';
|
||||
square?: boolean;
|
||||
aspect?: 'normal' | 'square' | 'fill' | 'tight';
|
||||
free?: boolean;
|
||||
onClick: () => void;
|
||||
theme?: Playback | 'neutral';
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
@use '../../../../theme/viewerDefs' as *;
|
||||
|
||||
.timer {
|
||||
grid-area: clk;
|
||||
white-space: nowrap;
|
||||
max-width: 18.75rem;
|
||||
min-width: 5em;
|
||||
|
||||
font-family: var(--font-family-override, $viewer-font-family);
|
||||
color: var(--timer-color-override, $timer-color);
|
||||
line-height: 0.9em;
|
||||
text-align: center;
|
||||
letter-spacing: 0.1em;
|
||||
font-weight: 600;
|
||||
font-size: 3.5rem;
|
||||
|
||||
&.finished {
|
||||
color: $timer-finished-color;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MaybeNumber } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import { cx, timerPlaceholder } from '../../../../common/utils/styleUtils';
|
||||
|
||||
import style from './TimerDisplay.module.scss';
|
||||
|
||||
interface TimerDisplayProps {
|
||||
time: MaybeNumber;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays time in ms in formatted timetag
|
||||
* Used in editor
|
||||
*/
|
||||
export default function TimerDisplay(props: TimerDisplayProps) {
|
||||
const { time } = props;
|
||||
|
||||
if (time == null) {
|
||||
return <div className={style.timer}>{timerPlaceholder}</div>;
|
||||
}
|
||||
|
||||
const isNegative = time < 0;
|
||||
const display = millisToString(Math.abs(time), { fallback: timerPlaceholder });
|
||||
const classes = cx([style.timer, isNegative ? style.finished : null]);
|
||||
|
||||
return <div className={classes}>{display}</div>;
|
||||
}
|
||||
@@ -1,15 +1,6 @@
|
||||
@use '../../theme/ontimeColours' as *;
|
||||
@use '../../theme/v2Styles' as *;
|
||||
|
||||
$table-font-size: calc(1rem - 2px);
|
||||
$table-header-font-size: calc(1rem - 3px);
|
||||
|
||||
@mixin ellipsis-overflow() {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cuesheetContainer {
|
||||
grid-area: table;
|
||||
display: flex;
|
||||
@@ -28,7 +19,8 @@ $table-header-font-size: calc(1rem - 3px);
|
||||
display: flex;
|
||||
}
|
||||
|
||||
th, td {
|
||||
th,
|
||||
td {
|
||||
margin: 1px;
|
||||
font-weight: inherit;
|
||||
font-size: inherit;
|
||||
@@ -105,6 +97,9 @@ $table-header-font-size: calc(1rem - 3px);
|
||||
position: sticky;
|
||||
left: 47.5%; // center of the screen, ish
|
||||
padding: 0.5rem 0;
|
||||
&:first-letter {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
@use '../../theme/v2Styles' as *;
|
||||
@use '../../theme/ontimeColours' as *;
|
||||
|
||||
.tableWrapper {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
padding: 1rem;
|
||||
padding: 1rem 0.5rem;
|
||||
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
grid-template-rows: 3rem auto 1fr;
|
||||
grid-template-areas:
|
||||
'header'
|
||||
'overview'
|
||||
'settings'
|
||||
'table';
|
||||
gap: 1rem;
|
||||
|
||||
background-color: $gray-1300;
|
||||
color: white;
|
||||
|
||||
& > * {
|
||||
border: 1px solid $white-10;
|
||||
border-radius: 3px;
|
||||
}
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
@@ -1,38 +1,45 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { OntimeRundownEntry, ProjectData } from 'ontime-types';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { IconButton, useDisclosure } from '@chakra-ui/react';
|
||||
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||
import { CustomFieldLabel, isOntimeEvent } from 'ontime-types';
|
||||
|
||||
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
|
||||
import Empty from '../../common/components/state/Empty';
|
||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||
import { useCuesheet } from '../../common/hooks/useSocket';
|
||||
import useRundown from '../../common/hooks-query/useRundown';
|
||||
import useUserFields from '../../common/hooks-query/useUserFields';
|
||||
import ExportModal, { ExportType } from '../modals/export-modal/ExportModal';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||
import { useFlatRundown } from '../../common/hooks-query/useRundown';
|
||||
import { CuesheetOverview } from '../overview/Overview';
|
||||
|
||||
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
|
||||
import CuesheetTableHeader from './cuesheet-table-header/CuesheetTableHeader';
|
||||
import { useCuesheetSettings } from './store/CuesheetSettings';
|
||||
import Cuesheet from './Cuesheet';
|
||||
import { makeCuesheetColumns } from './cuesheetCols';
|
||||
import { makeCSV, makeTable } from './cuesheetUtils';
|
||||
|
||||
import styles from './CuesheetWrapper.module.scss';
|
||||
|
||||
export default function CuesheetWrapper() {
|
||||
const { data: rundown } = useRundown();
|
||||
const { data: userFields } = useUserFields();
|
||||
const { updateEvent } = useEventAction();
|
||||
// TODO: can we use the normalised rundown for the table?
|
||||
const { data: flatRundown, status: rundownStatus } = useFlatRundown();
|
||||
const { data: customFields } = useCustomFields();
|
||||
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const { updateCustomField } = useEventAction();
|
||||
const featureData = useCuesheet();
|
||||
const columns = useMemo(() => makeCuesheetColumns(userFields), [userFields]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [headerData, setheaderData] = useState<ProjectData | null>(null);
|
||||
const columns = useMemo(() => makeCuesheetColumns(customFields), [customFields]);
|
||||
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
|
||||
|
||||
// Set window title
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Cuesheet';
|
||||
}, []);
|
||||
useWindowTitle('Cuesheet');
|
||||
|
||||
/**
|
||||
* Handles updating a field
|
||||
* Currently, only custom fields can be updated from the cuesheet
|
||||
*/
|
||||
const handleUpdate = useCallback(
|
||||
async (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: unknown) => {
|
||||
if (!rundown) {
|
||||
async (rowIndex: number, accessor: CustomFieldLabel, payload: unknown) => {
|
||||
if (!flatRundown || rundownStatus !== 'success') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -41,109 +48,66 @@ export default function CuesheetWrapper() {
|
||||
}
|
||||
|
||||
// check if value is the same
|
||||
const event = rundown[rowIndex];
|
||||
if (!event) {
|
||||
const event = flatRundown[rowIndex];
|
||||
if (!event || !isOntimeEvent(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event[accessor] === payload) {
|
||||
const previousValue = event.custom[accessor]?.value;
|
||||
|
||||
if (previousValue === payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
// check if value is valid
|
||||
// as of now, the fields do not have any validation
|
||||
// in anticipation to different types of event here
|
||||
if (typeof payload !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
// cleanup
|
||||
const cleanVal = payload.trim();
|
||||
const mutationObject = {
|
||||
id: event.id,
|
||||
[accessor]: cleanVal,
|
||||
};
|
||||
|
||||
// submit
|
||||
try {
|
||||
await updateEvent(mutationObject);
|
||||
await updateCustomField(event.id, accessor, cleanVal);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
[updateEvent, rundown],
|
||||
[flatRundown, rundownStatus, updateCustomField],
|
||||
);
|
||||
|
||||
const exportHandler = useCallback(
|
||||
(headerData: ProjectData, exportType: ExportType) => {
|
||||
if (!headerData || !rundown || !userFields) {
|
||||
return;
|
||||
}
|
||||
|
||||
let fileName = '';
|
||||
let url = '';
|
||||
|
||||
if (exportType === 'json') {
|
||||
const jsonContent = JSON.stringify({
|
||||
headerData,
|
||||
rundown,
|
||||
userFields,
|
||||
});
|
||||
|
||||
fileName = 'ontime export.json';
|
||||
|
||||
const blob = new Blob([jsonContent], { type: 'application/json;charset=utf-8;' });
|
||||
url = URL.createObjectURL(blob);
|
||||
} else if (exportType === 'csv') {
|
||||
const sheetData = makeTable(headerData, rundown, userFields);
|
||||
const csvContent = makeCSV(sheetData);
|
||||
|
||||
fileName = 'ontime export.csv';
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
url = URL.createObjectURL(blob);
|
||||
} else {
|
||||
console.error('Invalid export type: ', exportType);
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', fileName);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
// Clean up the URL.createObjectURL to release resources
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
},
|
||||
[rundown, userFields],
|
||||
);
|
||||
|
||||
const onModalClose = (exportType?: ExportType) => {
|
||||
setIsModalOpen(false);
|
||||
|
||||
if (!exportType) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (headerData) {
|
||||
exportHandler(headerData, exportType);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenModal = (projectData: ProjectData) => {
|
||||
setheaderData(projectData);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
if (!rundown || !userFields) {
|
||||
if (!customFields || !flatRundown || rundownStatus !== 'success') {
|
||||
return <Empty text='Loading...' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
||||
<CuesheetTableHeader handleExport={handleOpenModal} featureData={featureData} />
|
||||
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
|
||||
<CuesheetOverview>
|
||||
<IconButton
|
||||
aria-label='Toggle settings'
|
||||
variant='ontime-subtle-white'
|
||||
size='lg'
|
||||
icon={<IoApps />}
|
||||
onClick={onOpen}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label='Toggle navigation'
|
||||
variant='ontime-subtle-white'
|
||||
size='lg'
|
||||
icon={<IoSettingsOutline />}
|
||||
onClick={() => toggleSettings()}
|
||||
/>
|
||||
</CuesheetOverview>
|
||||
<CuesheetProgress />
|
||||
<Cuesheet data={rundown} columns={columns} handleUpdate={handleUpdate} selectedId={featureData.selectedEventId} />
|
||||
<ExportModal isOpen={isModalOpen} onClose={onModalClose} />
|
||||
<Cuesheet
|
||||
data={flatRundown}
|
||||
columns={columns}
|
||||
handleUpdate={handleUpdate}
|
||||
selectedId={featureData.selectedEventId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,61 +3,39 @@
|
||||
exports[`makeTable() > returns array of arrays with given fields 1`] = `
|
||||
[
|
||||
[
|
||||
"Ontime · Schedule Template",
|
||||
"Ontime · Rundown export",
|
||||
],
|
||||
[
|
||||
"Project Title",
|
||||
"",
|
||||
"Project title: test title",
|
||||
],
|
||||
[
|
||||
"Project Description",
|
||||
"",
|
||||
"Project description: test description",
|
||||
],
|
||||
[
|
||||
"Public URL",
|
||||
"",
|
||||
],
|
||||
[
|
||||
"Backstage URL",
|
||||
"",
|
||||
],
|
||||
[],
|
||||
[
|
||||
"Time Start",
|
||||
"Time End",
|
||||
"Event Title",
|
||||
"Presenter Name",
|
||||
"Event Subtitle",
|
||||
"Is Public? (x)",
|
||||
"Note",
|
||||
"Duration",
|
||||
"ID",
|
||||
"Colour",
|
||||
"End Action",
|
||||
"Timer Type",
|
||||
"Cue",
|
||||
"Title",
|
||||
"Note",
|
||||
"Is Public? (x)",
|
||||
"Skip?",
|
||||
"user0:test",
|
||||
"lighting",
|
||||
],
|
||||
[
|
||||
"00:00:00",
|
||||
"00:00:00",
|
||||
"...",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"test title 1",
|
||||
"",
|
||||
"",
|
||||
"x",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"test",
|
||||
"test",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
],
|
||||
]
|
||||
`;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { makeCSV, makeTable, parseField } from '../cuesheetUtils';
|
||||
|
||||
describe('parseField()', () => {
|
||||
it('returns a string from given millis on timeStart and TimeEnd', () => {
|
||||
it('returns a string from given millis on timeStart, TimeEnd and duration', () => {
|
||||
const testData1 = 1000;
|
||||
const testData2 = 60000;
|
||||
const testData3 = 600000;
|
||||
expect(parseField('timeStart', testData1)).toBe('00:00:01');
|
||||
expect(parseField('timeEnd', testData2)).toBe('00:01:00');
|
||||
expect(parseField('timeEnd', testData2)).toBe('00:01:00');
|
||||
expect(parseField('duration', testData3)).toBe('00:10:00');
|
||||
});
|
||||
|
||||
describe('returns an x when isPublic is truthy, empty string otherwise', () => {
|
||||
@@ -26,27 +27,15 @@ describe('parseField()', () => {
|
||||
});
|
||||
|
||||
it('returns an empty string on undefined fields', () => {
|
||||
expect(parseField('presenter')).toBe('');
|
||||
expect(parseField('title')).toBe('');
|
||||
});
|
||||
|
||||
describe('simply returns any other value in any other field', () => {
|
||||
const testFields = [
|
||||
{ field: 'nothing', value: 123 },
|
||||
{ field: 'nothing', value: '123' },
|
||||
{ field: 'title', value: 'test' },
|
||||
{ field: 'presenter', value: 'test' },
|
||||
{ field: 'subtitle', value: 'test' },
|
||||
{ field: 'note', value: 'test' },
|
||||
{ field: 'colour', value: 'test' },
|
||||
{ field: 'user0', value: 'test' },
|
||||
{ field: 'user1', value: 'test' },
|
||||
{ field: 'user2', value: 'test' },
|
||||
{ field: 'user3', value: 'test' },
|
||||
{ field: 'user4', value: 'test' },
|
||||
{ field: 'user5', value: 'test' },
|
||||
{ field: 'user6', value: 'test' },
|
||||
{ field: 'user7', value: 'test' },
|
||||
{ field: 'user8', value: 'test' },
|
||||
{ field: 'user9', value: 'test' },
|
||||
];
|
||||
|
||||
testFields.forEach((testCase) => {
|
||||
@@ -59,23 +48,25 @@ describe('parseField()', () => {
|
||||
|
||||
describe('makeTable()', () => {
|
||||
it('returns array of arrays with given fields', () => {
|
||||
const headerData = {};
|
||||
const headerData = {
|
||||
title: 'test title',
|
||||
description: 'test description',
|
||||
};
|
||||
const tableData = [
|
||||
{
|
||||
title: 'test title 1',
|
||||
presenter: '',
|
||||
timeStart: 0,
|
||||
timeEnd: 0,
|
||||
isPublic: 'x',
|
||||
user0: 'test',
|
||||
user1: 'test',
|
||||
lighting: { value: 'test lighting' },
|
||||
sound: { value: 'test sound' },
|
||||
},
|
||||
];
|
||||
const userFields = {
|
||||
user0: 'test',
|
||||
const customFields = {
|
||||
lighting: { label: 'test' },
|
||||
};
|
||||
|
||||
const table = makeTable(headerData, tableData, userFields);
|
||||
const table = makeTable(headerData, tableData, customFields);
|
||||
expect(table).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
||||
import { useTimer } from '../../../common/hooks/useSocket';
|
||||
import { useProgressData } from '../../../common/hooks/useSocket';
|
||||
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
||||
|
||||
import styles from "./CuesheetProgress.module.scss"
|
||||
import styles from './CuesheetProgress.module.scss';
|
||||
|
||||
export default function CuesheetProgress() {
|
||||
const { data } = useViewSettings();
|
||||
const timer = useTimer();
|
||||
const totalTime = (timer.duration ?? 0) + (timer.addedTime ?? 0);
|
||||
const { addedTime, current, duration, timeWarning, timeDanger } = useProgressData();
|
||||
const totalTime = (duration ?? 0) + (addedTime ?? 0);
|
||||
|
||||
return (
|
||||
<MultiPartProgressBar
|
||||
now={timer.current}
|
||||
now={current}
|
||||
complete={totalTime}
|
||||
normalColor={data!.normalColor}
|
||||
warning={data!.warningThreshold}
|
||||
warning={timeWarning}
|
||||
warningColor={data!.warningColor}
|
||||
danger={data!.dangerThreshold}
|
||||
danger={timeDanger}
|
||||
dangerColor={data!.dangerColor}
|
||||
className={styles.progressOverride}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import { memo, useEffect } from 'react';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import {
|
||||
closestCenter,
|
||||
@@ -15,6 +15,7 @@ import { flexRender, HeaderGroup } from '@tanstack/react-table';
|
||||
import { OntimeRundownEntry } from 'ontime-types';
|
||||
|
||||
import { useLocalStorage } from '../../../common/hooks/useLocalStorage';
|
||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
import { initialColumnOrder } from '../cuesheetCols';
|
||||
|
||||
@@ -30,6 +31,12 @@ function CuesheetHeader(props: CuesheetHeaderProps) {
|
||||
const { headerGroups } = props;
|
||||
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>('table-order', initialColumnOrder);
|
||||
|
||||
useEffect(() => {
|
||||
if (!localStorage.getItem('table-order')) {
|
||||
saveColumnOrder(initialColumnOrder);
|
||||
}
|
||||
}, [saveColumnOrder]);
|
||||
|
||||
const handleOnDragEnd = (event: DragEndEvent) => {
|
||||
const { delta, active, over } = event;
|
||||
|
||||
@@ -89,9 +96,17 @@ function CuesheetHeader(props: CuesheetHeaderProps) {
|
||||
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const width = header.getSize();
|
||||
// @ts-expect-error -- we inject this into react-table
|
||||
const customBackground = header.column.columnDef?.meta?.colour;
|
||||
|
||||
let customStyles = {};
|
||||
if (customBackground) {
|
||||
const customColour = getAccessibleColour(customBackground);
|
||||
customStyles = { backgroundColor: customColour.backgroundColor, color: customColour.color };
|
||||
}
|
||||
|
||||
return (
|
||||
<SortableCell key={header.column.columnDef.id} header={header} style={{ width }}>
|
||||
<SortableCell key={header.column.columnDef.id} header={header} style={{ width, ...customStyles }}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</SortableCell>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ interface DelayRowProps {
|
||||
|
||||
function DelayRow(props: DelayRowProps) {
|
||||
const { duration } = props;
|
||||
const delayTime = millisToDelayString(duration);
|
||||
const delayTime = millisToDelayString(duration, 'expanded');
|
||||
|
||||
return (
|
||||
<tr className={style.delayRow}>
|
||||
|
||||
+9
-8
@@ -1,6 +1,3 @@
|
||||
@use '../../../theme/ontimeColours' as *;
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
|
||||
$label-colour: $gray-700;
|
||||
$active-colour: $gray-500;
|
||||
|
||||
@@ -12,7 +9,7 @@ $active-colour: $gray-500;
|
||||
}
|
||||
|
||||
@mixin time {
|
||||
font-family: "Open Sans Light", $ontime-font-family;
|
||||
font-family: 'Open Sans Light', $ontime-font-family;
|
||||
font-size: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -25,8 +22,7 @@ $active-colour: $gray-500;
|
||||
height: max-content;
|
||||
column-gap: 2rem;
|
||||
|
||||
grid-template-areas:
|
||||
'event playback timer clock actions';
|
||||
grid-template-areas: 'event playback timer clock actions';
|
||||
grid-template-columns: 1fr auto auto auto auto;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
@@ -94,11 +90,12 @@ $active-colour: $gray-500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.125rem;
|
||||
color: $label-colour;
|
||||
height: 100%;
|
||||
font-size: 1rem;
|
||||
|
||||
.actionIcon {
|
||||
.actionIcon,
|
||||
.actionText {
|
||||
cursor: pointer;
|
||||
|
||||
&.enabled {
|
||||
@@ -109,6 +106,10 @@ $active-colour: $gray-500;
|
||||
color: $active-colour;
|
||||
}
|
||||
}
|
||||
|
||||
.actionIcon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { IoContract } from '@react-icons/all-files/io5/IoContract';
|
||||
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
|
||||
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
|
||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||
import { Playback, ProjectData } from 'ontime-types';
|
||||
|
||||
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
|
||||
import useFullscreen from '../../../common/hooks/useFullscreen';
|
||||
import useProjectData from '../../../common/hooks-query/useProjectData';
|
||||
import { cx, enDash } from '../../../common/utils/styleUtils';
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
import { useCuesheetSettings } from '../store/CuesheetSettings';
|
||||
|
||||
@@ -27,10 +24,7 @@ interface CuesheetTableHeaderProps {
|
||||
|
||||
export default function CuesheetTableHeader({ handleExport, featureData }: CuesheetTableHeaderProps) {
|
||||
const followSelected = useCuesheetSettings((state) => state.followSelected);
|
||||
const showSettings = useCuesheetSettings((state) => state.showSettings);
|
||||
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
|
||||
const toggleFollow = useCuesheetSettings((state) => state.toggleFollow);
|
||||
const { isFullScreen, toggleFullScreen } = useFullscreen();
|
||||
const { data: project } = useProjectData();
|
||||
|
||||
const exportProject = () => {
|
||||
@@ -41,15 +35,15 @@ export default function CuesheetTableHeader({ handleExport, featureData }: Cuesh
|
||||
|
||||
const selected = !featureData.numEvents
|
||||
? 'No events'
|
||||
: `Event ${featureData.selectedEventIndex != null ? featureData.selectedEventIndex + 1 : '-'}/${
|
||||
featureData.numEvents ? featureData.numEvents : '-'
|
||||
: `Event ${featureData.selectedEventIndex != null ? featureData.selectedEventIndex + 1 : enDash}/${
|
||||
featureData.numEvents ? featureData.numEvents : enDash
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className={style.header}>
|
||||
<div className={style.event}>
|
||||
<div className={style.title}>{project?.title || '-'}</div>
|
||||
<div className={style.eventNow}>{featureData?.titleNow || '-'}</div>
|
||||
<div className={style.title}>{project?.title || enDash}</div>
|
||||
<div className={style.eventNow}>{featureData?.titleNow || enDash}</div>
|
||||
</div>
|
||||
<div className={style.playback}>
|
||||
<div className={style.playbackLabel}>{selected}</div>
|
||||
@@ -58,23 +52,16 @@ export default function CuesheetTableHeader({ handleExport, featureData }: Cuesh
|
||||
<CuesheetTableHeaderTimers />
|
||||
<div className={style.headerActions}>
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Toggle follow'>
|
||||
<span onClick={() => toggleFollow()} className={`${style.actionIcon} ${followSelected ? style.enabled : ''}`}>
|
||||
<span
|
||||
onClick={() => toggleFollow()}
|
||||
className={cx([style.actionIcon, followSelected ? style.enabled : null])}
|
||||
>
|
||||
<IoLocate />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Toggle settings'>
|
||||
<span onClick={() => toggleSettings()} className={`${style.actionIcon} ${showSettings ? style.enabled : ''}`}>
|
||||
<IoSettingsOutline />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Toggle Fullscreen'>
|
||||
<span onClick={() => toggleFullScreen()} className={style.actionIcon}>
|
||||
{isFullScreen ? <IoContract /> : <IoExpand />}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Export rundown'>
|
||||
<span className={style.actionIcon} onClick={exportProject}>
|
||||
Export
|
||||
<span className={style.actionText} onClick={exportProject}>
|
||||
Export CSV
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
+7
-15
@@ -1,30 +1,22 @@
|
||||
import { formatDisplay } from 'ontime-utils';
|
||||
|
||||
import { useTimer } from '../../../common/hooks/useSocket';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
import { useClock, useTimer } from '../../../common/hooks/useSocket';
|
||||
import ClockTime from '../../viewers/common/clock-time/ClockTime';
|
||||
import RunningTime from '../../viewers/common/running-time/RunningTime';
|
||||
|
||||
import style from './CuesheetTableHeader.module.scss';
|
||||
|
||||
export default function CuesheetTableHeaderTimers() {
|
||||
const timer = useTimer();
|
||||
|
||||
// prepare presentation variables
|
||||
const isOvertime = (timer.current ?? 0) < 0;
|
||||
const timerNow = timer.current == null ? '-' : `${isOvertime ? '-' : ''}${formatDisplay(timer.current)}`;
|
||||
const timeNow = formatTime(timer.clock, {
|
||||
showSeconds: true,
|
||||
format: 'hh:mm:ss a',
|
||||
});
|
||||
const { current } = useTimer();
|
||||
const { clock } = useClock();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={style.timer}>
|
||||
<div className={style.timerLabel}>Running Timer</div>
|
||||
<div className={style.value}>{timerNow}</div>
|
||||
<RunningTime className={style.value} value={current} hideLeadingZero />
|
||||
</div>
|
||||
<div className={style.clock}>
|
||||
<div className={style.clockLabel}>Time Now</div>
|
||||
<div className={style.value}>{timeNow}</div>
|
||||
<ClockTime className={style.value} value={clock} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
-3
@@ -1,6 +1,3 @@
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
@use '../../../theme/ontimeColours' as *;
|
||||
|
||||
.tableSettings {
|
||||
grid-area: settings;
|
||||
padding: 1rem;
|
||||
|
||||
@@ -23,12 +23,14 @@ interface CuesheetTableSettingsProps {
|
||||
function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
|
||||
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
|
||||
const {
|
||||
followSelected,
|
||||
toggleFollow,
|
||||
showPrevious,
|
||||
toggleDelayVisibility,
|
||||
togglePreviousVisibility,
|
||||
showDelayBlock,
|
||||
showDelayedTimes,
|
||||
toggleDelayedTimes,
|
||||
togglePreviousVisibility,
|
||||
toggleDelayVisibility,
|
||||
} = useCuesheetSettings();
|
||||
|
||||
return (
|
||||
@@ -53,6 +55,10 @@ function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
|
||||
</div>
|
||||
<div className={style.sectionTitle}>Table Options</div>
|
||||
<div className={style.options}>
|
||||
<label className={style.option}>
|
||||
<Switch variant='ontime' size='sm' isChecked={followSelected} onChange={() => toggleFollow()} />
|
||||
Follow selected event
|
||||
</label>
|
||||
<label className={style.option}>
|
||||
<Switch variant='ontime' size='sm' isChecked={showPrevious} onChange={() => togglePreviousVisibility()} />
|
||||
Show past events
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
||||
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
||||
import { OntimeEvent, OntimeRundownEntry, UserFields } from 'ontime-types';
|
||||
import { CustomFields, isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator';
|
||||
import RunningTime from '../viewers/common/running-time/RunningTime';
|
||||
|
||||
import EditableCell from './cuesheet-table-elements/EditableCell';
|
||||
import { useCuesheetSettings } from './store/CuesheetSettings';
|
||||
@@ -24,30 +25,45 @@ function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEnt
|
||||
return (
|
||||
<span className={style.time}>
|
||||
<DelayIndicator delayValue={delayValue} />
|
||||
{millisToString(cellValue)}
|
||||
<RunningTime value={cellValue} />
|
||||
{delayValue !== 0 && showDelayedTimes && (
|
||||
<span className={style.delayedTime}>{` ${millisToString(cellValue + delayValue)}`}</span>
|
||||
<RunningTime className={style.delayedTime} value={cellValue + delayValue} />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MakeUserField({ getValue, row: { index }, column: { id }, table }: CellContext<OntimeRundownEntry, unknown>) {
|
||||
function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
|
||||
const update = useCallback(
|
||||
(newValue: string) => {
|
||||
// @ts-expect-error -- we inject this into react-table
|
||||
table.options.meta?.handleUpdate(index, id, newValue);
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
|
||||
[id, index],
|
||||
[column.id, row.index],
|
||||
);
|
||||
|
||||
const initialValue = getValue() as string;
|
||||
const event = row.original;
|
||||
if (!isOntimeEvent(event)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// events dont necessarily contain all custom fields
|
||||
const initialValue = event.custom[column.id]?.value ?? '';
|
||||
|
||||
return <EditableCell value={initialValue} handleUpdate={update} />;
|
||||
}
|
||||
|
||||
export function makeCuesheetColumns(userFields?: UserFields): ColumnDef<OntimeRundownEntry>[] {
|
||||
export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<OntimeRundownEntry>[] {
|
||||
const dynamicCustomFields = Object.keys(customFields).map((key) => ({
|
||||
accessorKey: key,
|
||||
id: key,
|
||||
header: customFields[key].label,
|
||||
meta: { colour: customFields[key].colour },
|
||||
cell: MakeCustomField,
|
||||
size: 250,
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
accessorKey: 'cue',
|
||||
@@ -89,86 +105,17 @@ export function makeCuesheetColumns(userFields?: UserFields): ColumnDef<OntimeRu
|
||||
id: 'title',
|
||||
header: 'Title',
|
||||
cell: (row) => row.getValue(),
|
||||
},
|
||||
{
|
||||
accessorKey: 'subtitle',
|
||||
id: 'subtitle',
|
||||
header: 'Subtitle',
|
||||
cell: (row) => row.getValue(),
|
||||
},
|
||||
{
|
||||
accessorKey: 'presenter',
|
||||
id: 'presenter',
|
||||
header: 'Presenter',
|
||||
cell: (row) => row.getValue(),
|
||||
size: 250,
|
||||
},
|
||||
{
|
||||
accessorKey: 'note',
|
||||
id: 'note',
|
||||
header: 'Note',
|
||||
cell: (row) => row.getValue(),
|
||||
size: 250,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user0',
|
||||
id: 'user0',
|
||||
header: userFields?.user0 || 'User 0',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user1',
|
||||
id: 'user1',
|
||||
header: userFields?.user1 || 'User 1',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user2',
|
||||
id: 'user2',
|
||||
header: userFields?.user2 || 'User 2',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user3',
|
||||
id: 'user3',
|
||||
header: userFields?.user3 || 'User 3',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user4',
|
||||
id: 'user4',
|
||||
header: userFields?.user4 || 'User 4',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user5',
|
||||
id: 'user5',
|
||||
header: userFields?.user5 || 'User 5',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user6',
|
||||
id: 'user6',
|
||||
header: userFields?.user6 || 'User 6',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user7',
|
||||
id: 'user7',
|
||||
header: userFields?.user7 || 'User 7',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user8',
|
||||
id: 'user8',
|
||||
header: userFields?.user8 || 'User 8',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user9',
|
||||
id: 'user9',
|
||||
header: userFields?.user9 || 'User 9',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
...dynamicCustomFields,
|
||||
];
|
||||
}
|
||||
|
||||
export const initialColumnOrder: string[] = makeCuesheetColumns().map((column) => column.id as string);
|
||||
export const initialColumnOrder: string[] = makeCuesheetColumns({}).map((column) => column.id as string);
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { stringify } from 'csv-stringify/browser/esm/sync';
|
||||
import { OntimeEntryCommonKeys, OntimeRundown, ProjectData, UserFields } from 'ontime-types';
|
||||
import {
|
||||
CustomFields,
|
||||
isOntimeDelay,
|
||||
isOntimeEvent,
|
||||
MaybeNumber,
|
||||
OntimeEntryCommonKeys,
|
||||
OntimeRundown,
|
||||
ProjectData,
|
||||
} from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
type CsvHeaderKey = OntimeEntryCommonKeys | keyof CustomFields;
|
||||
|
||||
/**
|
||||
* @description parses a field for export
|
||||
* @param {string} field
|
||||
@@ -9,95 +19,85 @@ import { millisToString } from 'ontime-utils';
|
||||
* @return {string}
|
||||
*/
|
||||
|
||||
export const parseField = <T extends OntimeEntryCommonKeys>(field: T, data: unknown): string => {
|
||||
let val;
|
||||
switch (field) {
|
||||
case 'timeStart':
|
||||
case 'timeEnd':
|
||||
val = millisToString(data as number | null);
|
||||
break;
|
||||
case 'isPublic':
|
||||
case 'skip':
|
||||
val = data ? 'x' : '';
|
||||
break;
|
||||
default:
|
||||
val = data;
|
||||
break;
|
||||
export const parseField = (field: CsvHeaderKey, data: unknown): string => {
|
||||
if (field === 'timeStart' || field === 'timeEnd' || field === 'duration') {
|
||||
return millisToString(data as MaybeNumber);
|
||||
}
|
||||
if (typeof data === 'undefined') {
|
||||
return '';
|
||||
|
||||
if (field === 'isPublic' || field === 'skip') {
|
||||
return data ? 'x' : '';
|
||||
}
|
||||
// all other values are strings
|
||||
return val as string;
|
||||
|
||||
return String(data ?? '');
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Creates an array of arrays usable by xlsx for export
|
||||
* @param {object} headerData
|
||||
* @param {array} rundown
|
||||
* @param {object} userFields
|
||||
* @param {ProjectData} headerData
|
||||
* @param {OntimeRundown} rundown
|
||||
* @param {CustomFields} customFields
|
||||
* @return {(string[])[]}
|
||||
*/
|
||||
export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, userFields: UserFields): string[][] => {
|
||||
const data = [
|
||||
['Ontime · Schedule Template'],
|
||||
['Project Title', headerData?.title || ''],
|
||||
['Project Description', headerData?.description || ''],
|
||||
['Public URL', headerData?.publicUrl || ''],
|
||||
['Backstage URL', headerData?.backstageUrl || ''],
|
||||
[],
|
||||
];
|
||||
export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, customFields: CustomFields): string[][] => {
|
||||
// create metadata header row
|
||||
const data = [['Ontime · Rundown export']];
|
||||
if (headerData.title) data.push([`Project title: ${headerData.title}`]);
|
||||
if (headerData.description) data.push([`Project description: ${headerData.description}`]);
|
||||
|
||||
const fieldOrder: OntimeEntryCommonKeys[] = [
|
||||
const customFieldKeys = Object.keys(customFields).map((key) => `custom-${key}`);
|
||||
const customFieldLabels = Object.keys(customFields);
|
||||
|
||||
// we chose not to expose internals of the application
|
||||
const fieldOrder: CsvHeaderKey[] = [
|
||||
'timeStart',
|
||||
'timeEnd',
|
||||
'title',
|
||||
'presenter',
|
||||
'subtitle',
|
||||
'isPublic',
|
||||
'note',
|
||||
'duration',
|
||||
'id',
|
||||
'colour',
|
||||
'endAction',
|
||||
'timerType',
|
||||
'cue',
|
||||
'title',
|
||||
'note',
|
||||
'isPublic',
|
||||
'skip',
|
||||
'user0',
|
||||
'user1',
|
||||
'user2',
|
||||
'user3',
|
||||
'user4',
|
||||
'user5',
|
||||
'user6',
|
||||
'user7',
|
||||
'user8',
|
||||
'user9',
|
||||
...customFieldKeys,
|
||||
];
|
||||
|
||||
const fieldTitles = [
|
||||
'Time Start',
|
||||
'Time End',
|
||||
'Event Title',
|
||||
'Presenter Name',
|
||||
'Event Subtitle',
|
||||
'Is Public? (x)',
|
||||
'Note',
|
||||
'Duration',
|
||||
'ID',
|
||||
'Colour',
|
||||
'End Action',
|
||||
'Timer Type',
|
||||
'Cue',
|
||||
'Title',
|
||||
'Note',
|
||||
'Is Public? (x)',
|
||||
'Skip?',
|
||||
...customFieldLabels,
|
||||
];
|
||||
|
||||
for (const field in userFields) {
|
||||
const fieldValue = userFields[field as keyof UserFields];
|
||||
const displayName = `${field}${fieldValue !== field && fieldValue !== '' ? `:${fieldValue}` : ''}`;
|
||||
fieldTitles.push(displayName);
|
||||
}
|
||||
|
||||
// add header row to data
|
||||
data.push(fieldTitles);
|
||||
|
||||
rundown.forEach((entry) => {
|
||||
if (isOntimeDelay(entry)) return;
|
||||
|
||||
const row: string[] = [];
|
||||
// @ts-expect-error -- not sure how to type this
|
||||
fieldOrder.forEach((field) => row.push(parseField(field, entry[field])));
|
||||
fieldOrder.forEach((field) => {
|
||||
if (isOntimeEvent(entry)) {
|
||||
// for custom fields, we need to extract the value from the custom object
|
||||
if (field.startsWith('custom-')) {
|
||||
const fieldLabel = field.split('custom-')[1];
|
||||
const value = entry.custom[fieldLabel]?.value;
|
||||
row.push(parseField(fieldLabel, value));
|
||||
} else {
|
||||
// @ts-expect-error -- it is ok, we will just not have the data for other fields
|
||||
row.push(parseField(field, entry[field]));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// @ts-expect-error -- it is ok, we will just not have the data for other fields
|
||||
row.push(parseField(field, entry[field]));
|
||||
});
|
||||
data.push(row);
|
||||
});
|
||||
|
||||
@@ -106,10 +106,10 @@ export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, userF
|
||||
|
||||
/**
|
||||
* @description Converts an array of arrays to a csv file
|
||||
* @param {array[]} arrayOfArrays
|
||||
* @param {string[][]} arrayOfArrays
|
||||
* @return {string}
|
||||
*/
|
||||
export const makeCSV = (arrayOfArrays: string[][]) => {
|
||||
export const makeCSV = (arrayOfArrays: string[][]): string => {
|
||||
const stringifiedData = stringify(arrayOfArrays);
|
||||
return stringifiedData;
|
||||
};
|
||||
|
||||
@@ -10,33 +10,10 @@ export const defaultColumnOrder: OntimeEntryCommonKeys[] = [
|
||||
'timeEnd',
|
||||
'duration',
|
||||
'title',
|
||||
'subtitle',
|
||||
'presenter',
|
||||
'note',
|
||||
'user0',
|
||||
'user1',
|
||||
'user2',
|
||||
'user3',
|
||||
'user4',
|
||||
'user5',
|
||||
'user6',
|
||||
'user7',
|
||||
'user8',
|
||||
'user9',
|
||||
];
|
||||
|
||||
/**
|
||||
* @description set default hidden columns
|
||||
*/
|
||||
export const defaultHiddenColumns: (keyof OntimeEvent)[] = [
|
||||
'user0',
|
||||
'user1',
|
||||
'user2',
|
||||
'user3',
|
||||
'user4',
|
||||
'user5',
|
||||
'user6',
|
||||
'user7',
|
||||
'user8',
|
||||
'user9',
|
||||
];
|
||||
export const defaultHiddenColumns: (keyof OntimeEvent)[] = [];
|
||||
|
||||
@@ -1,231 +1,64 @@
|
||||
@use '../../theme/ontimeColours' as *;
|
||||
@use '../../theme/v2Styles' as *;
|
||||
@use './EditorMixin' as editor;
|
||||
|
||||
$menu-width: 2.75rem;
|
||||
$rundown-width: 44.5rem;
|
||||
$playback-width: 26rem;
|
||||
|
||||
@mixin absolute-top-right($distance) {
|
||||
position: absolute;
|
||||
top: $distance;
|
||||
right: $distance;
|
||||
cursor: pointer;
|
||||
color: $ui-white;
|
||||
transition-property: color;
|
||||
transition-duration: $transition-time-action;
|
||||
&:hover {
|
||||
color: $ontime-color;
|
||||
}
|
||||
}
|
||||
$min-playback-width: 27rem;
|
||||
$max-playback-width: 35rem;
|
||||
$panel-gap: 0.5rem;
|
||||
|
||||
.corner {
|
||||
display: none;
|
||||
@include absolute-top-right(0.5rem);
|
||||
transform: rotate(45deg);
|
||||
@include editor.corner;
|
||||
}
|
||||
|
||||
.mainContainer {
|
||||
background: $ui-black;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: auto;
|
||||
color: $ui-white;
|
||||
padding: 1rem 0.5rem;
|
||||
font-family: $ontime-font-family;
|
||||
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-columns: $menu-width $rundown-width $playback-width auto;
|
||||
grid-template-columns: auto;
|
||||
grid-template-rows: 3rem 1fr;
|
||||
grid-template-areas:
|
||||
'sett rundown play info'
|
||||
'sett rundown mess info';
|
||||
gap: 0.5rem;
|
||||
'overview'
|
||||
'main';
|
||||
gap: $panel-gap;
|
||||
}
|
||||
|
||||
.panelContainer {
|
||||
grid-area: main;
|
||||
|
||||
display: flex;
|
||||
gap: $panel-gap;
|
||||
overflow: hidden;
|
||||
|
||||
.corner {
|
||||
/* we show this if the component hasnt been extracted */
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.rundown,
|
||||
.playback,
|
||||
.messages,
|
||||
.info,
|
||||
.settings {
|
||||
.messages {
|
||||
position: relative;
|
||||
|
||||
.corner {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 2/3 window, hide info */
|
||||
@media (max-width: 1450px) and (min-height: 700px) {
|
||||
.mainContainer {
|
||||
height: 100%;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-columns: $menu-width 1fr $playback-width;
|
||||
|
||||
.info {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 1/2 window, event list only */
|
||||
@media (max-width: 1100px) {
|
||||
.mainContainer {
|
||||
height: 100%;
|
||||
grid-template-rows: 100%;
|
||||
grid-template-columns: $menu-width $rundown-width;
|
||||
grid-template-areas:
|
||||
'sett rundown';
|
||||
|
||||
.info,
|
||||
.messages,
|
||||
.playback {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 1/3 window, show control only */
|
||||
@media (max-width: 850px) and (min-height: 500px) {
|
||||
.mainContainer {
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-columns: 100%;
|
||||
grid-template-areas:
|
||||
'play'
|
||||
'mess';
|
||||
|
||||
.playback,
|
||||
.messages {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.rundown,
|
||||
.info,
|
||||
.settings {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 1/3 corner window, playback only */
|
||||
@media (max-width: 850px) and (max-height: 500px) {
|
||||
.mainContainer {
|
||||
grid-template-rows: 100%;
|
||||
grid-template-columns: 100%;
|
||||
grid-template-areas: 'play';
|
||||
|
||||
.playback {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.rundown,
|
||||
.messages,
|
||||
.info,
|
||||
.settings {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mainContainer {
|
||||
.settings,
|
||||
.rundown,
|
||||
.messages,
|
||||
.playback,
|
||||
.info {
|
||||
border-radius: 8px;
|
||||
height: 100%;
|
||||
background-color: $bg-container-l2;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.eventEditor {
|
||||
border-radius: 8px 8px 0 0;
|
||||
background-color: $bg-container-l2;
|
||||
box-shadow: $large-bottom-drawer-shadow;
|
||||
border-top: 1px solid $white-20;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100vw;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
color: white;
|
||||
transition: bottom $transition-time-feedback;
|
||||
|
||||
&.noEvent {
|
||||
bottom: -500px;
|
||||
transition: bottom 0.7s;
|
||||
}
|
||||
|
||||
.eventEditorLayout {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: $gray-1250;
|
||||
padding: 0.5rem;
|
||||
border-left: 1px solid $white-10;
|
||||
border-radius: 0 8px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.rundown {
|
||||
grid-area: rundown;
|
||||
height: 100%;
|
||||
|
||||
.content {
|
||||
height: calc(100% - 1.5rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
grid-area: info;
|
||||
min-width: 17rem;
|
||||
max-width: 800px;
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.left {
|
||||
flex: 1 2 auto; /* flex-grow: 1, flex-shrink: 2, flex-basis: auto */
|
||||
min-width: $min-playback-width;
|
||||
max-width: $max-playback-width;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $panel-gap;
|
||||
}
|
||||
|
||||
.messages {
|
||||
grid-area: mess;
|
||||
min-width: 24rem;
|
||||
}
|
||||
|
||||
.playback {
|
||||
grid-area: play;
|
||||
max-height: 380px;
|
||||
min-width: 26rem;
|
||||
}
|
||||
|
||||
.mainContainer > .settings {
|
||||
grid-area: sett;
|
||||
background-color: transparent;
|
||||
margin: 0;
|
||||
padding: 0 0.5rem 0 0;
|
||||
width: fit-content;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
|
||||
.mainContainer > .rundown {
|
||||
padding: 1rem 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
.rundown {
|
||||
.content {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +1,97 @@
|
||||
import { lazy, useEffect } from 'react';
|
||||
import { useDisclosure } from '@chakra-ui/react';
|
||||
import { lazy, useCallback, useEffect } from 'react';
|
||||
import { IconButton, useDisclosure } from '@chakra-ui/react';
|
||||
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||
|
||||
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
|
||||
import MenuBar from '../menu/MenuBar';
|
||||
import AboutModal from '../modals/about-modal/AboutModal';
|
||||
import QuickStart from '../modals/quick-start/QuickStart';
|
||||
import SheetsModal from '../modals/sheets-modal/SheetsModal';
|
||||
import UploadModal from '../modals/upload-modal/UploadModal';
|
||||
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
|
||||
import useElectronEvent from '../../common/hooks/useElectronEvent';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import AppSettings from '../app-settings/AppSettings';
|
||||
import useAppSettingsNavigation from '../app-settings/useAppSettingsNavigation';
|
||||
import { EditorOverview } from '../overview/Overview';
|
||||
|
||||
import styles from './Editor.module.scss';
|
||||
|
||||
const Rundown = lazy(() => import('../rundown/RundownExport'));
|
||||
const TimerControl = lazy(() => import('../control/playback/TimerControlExport'));
|
||||
const MessageControl = lazy(() => import('../control/message/MessageControlExport'));
|
||||
const Info = lazy(() => import('../info/InfoExport'));
|
||||
const EventEditor = lazy(() => import('../event-editor/EventEditorExport'));
|
||||
|
||||
const IntegrationModal = lazy(() => import('../modals/integration-modal/IntegrationModal'));
|
||||
const SettingsModal = lazy(() => import('../modals/settings-modal/SettingsModal'));
|
||||
|
||||
export default function Editor() {
|
||||
const { isOpen: isSettingsOpen, onOpen: onSettingsOpen, onClose: onSettingsClose } = useDisclosure();
|
||||
const { isOpen: isUploadModalOpen, onOpen: onUploadModalOpen, onClose: onUploadModalClose } = useDisclosure();
|
||||
const {
|
||||
isOpen: isIntegrationModalOpen,
|
||||
onOpen: onIntegrationModalOpen,
|
||||
onClose: onIntegrationModalClose,
|
||||
} = useDisclosure();
|
||||
const { isOpen: isAboutModalOpen, onOpen: onAboutModalOpen, onClose: onAboutModalClose } = useDisclosure();
|
||||
const { isOpen: isQuickStartOpen, onOpen: onQuickStartOpen, onClose: onQuickStartClose } = useDisclosure();
|
||||
const { isOpen: isSheetsOpen, onOpen: onSheetsOpen, onClose: onSheetsClose } = useDisclosure();
|
||||
const { isOpen: isSettingsOpen, setLocation, close } = useAppSettingsNavigation();
|
||||
const { isElectron } = useElectronEvent();
|
||||
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
// Set window title
|
||||
const toggleSettings = useCallback(() => {
|
||||
if (isSettingsOpen) {
|
||||
close();
|
||||
} else {
|
||||
setLocation('project');
|
||||
}
|
||||
}, [close, isSettingsOpen, setLocation]);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyPress = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
// handle held key
|
||||
if (event.repeat) return;
|
||||
|
||||
// check if the ctrl key is pressed
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
// ctrl + , (settings)
|
||||
if (event.key === ',') {
|
||||
toggleSettings();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
},
|
||||
[toggleSettings],
|
||||
);
|
||||
|
||||
// register ctrl + , to open settings
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Editor';
|
||||
}, []);
|
||||
if (isElectron) {
|
||||
document.addEventListener('keydown', handleKeyPress);
|
||||
}
|
||||
return () => {
|
||||
if (isElectron) {
|
||||
document.removeEventListener('keydown', handleKeyPress);
|
||||
}
|
||||
};
|
||||
}, [handleKeyPress, isElectron]);
|
||||
|
||||
useWindowTitle('Editor');
|
||||
|
||||
return (
|
||||
<>
|
||||
<ErrorBoundary>
|
||||
<QuickStart onClose={onQuickStartClose} isOpen={isQuickStartOpen} />
|
||||
<UploadModal onClose={onUploadModalClose} isOpen={isUploadModalOpen} />
|
||||
<IntegrationModal onClose={onIntegrationModalClose} isOpen={isIntegrationModalOpen} />
|
||||
<AboutModal onClose={onAboutModalClose} isOpen={isAboutModalOpen} />
|
||||
<SettingsModal isOpen={isSettingsOpen} onClose={onSettingsClose} />
|
||||
<SheetsModal onClose={onSheetsClose} isOpen={isSheetsOpen} />
|
||||
</ErrorBoundary>
|
||||
<div className={styles.mainContainer} data-testid='event-editor'>
|
||||
<div id='settings' className={styles.settings}>
|
||||
<ErrorBoundary>
|
||||
<MenuBar
|
||||
onSettingsOpen={onSettingsOpen}
|
||||
isSettingsOpen={isSettingsOpen}
|
||||
onSettingsClose={onSettingsClose}
|
||||
isUploadOpen={isUploadModalOpen}
|
||||
onUploadOpen={onUploadModalOpen}
|
||||
isIntegrationOpen={isIntegrationModalOpen}
|
||||
onIntegrationOpen={onIntegrationModalOpen}
|
||||
isAboutOpen={isAboutModalOpen}
|
||||
onAboutOpen={onAboutModalOpen}
|
||||
isQuickStartOpen={isQuickStartOpen}
|
||||
onQuickStartOpen={onQuickStartOpen}
|
||||
isSheetsOpen={isSheetsOpen}
|
||||
onSheetsOpen={onSheetsOpen}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
<div className={styles.mainContainer} data-testid='event-editor'>
|
||||
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
|
||||
<EditorOverview>
|
||||
<IconButton
|
||||
aria-label='Toggle navigation'
|
||||
variant='ontime-subtle-white'
|
||||
size='lg'
|
||||
icon={<IoApps />}
|
||||
onClick={onOpen}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label='Toggle settings'
|
||||
variant='ontime-subtle-white'
|
||||
size='lg'
|
||||
icon={<IoSettingsOutline />}
|
||||
onClick={toggleSettings}
|
||||
/>
|
||||
</EditorOverview>
|
||||
{isSettingsOpen ? (
|
||||
<AppSettings />
|
||||
) : (
|
||||
<div id='panels' className={styles.panelContainer}>
|
||||
<div className={styles.left}>
|
||||
<TimerControl />
|
||||
<MessageControl />
|
||||
</div>
|
||||
<Rundown />
|
||||
</div>
|
||||
<Rundown />
|
||||
<MessageControl />
|
||||
<TimerControl />
|
||||
<Info />
|
||||
</div>
|
||||
<EventEditor />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
@use '../../theme/ontimeColours' as *;
|
||||
@use '../../theme/ontimeStyles' as *;
|
||||
|
||||
@mixin absolute-top-right($distance) {
|
||||
position: absolute;
|
||||
top: $distance;
|
||||
right: $distance;
|
||||
cursor: pointer;
|
||||
color: $ui-white;
|
||||
transition-property: color;
|
||||
transition-duration: $transition-time-action;
|
||||
|
||||
&:hover {
|
||||
color: $ontime-color;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin corner() {
|
||||
display: none;
|
||||
@include absolute-top-right(0.5rem);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
@mixin panel() {
|
||||
display: flex;
|
||||
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
height: 100%;
|
||||
background-color: $bg-container-l2;
|
||||
padding: 1rem;
|
||||
|
||||
.corner {
|
||||
/* we show this if the component hasnt been extracted */
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
@use '../../theme/v2Styles' as *;
|
||||
|
||||
.eventEditor {
|
||||
padding: 1rem 2rem 2rem 2rem;
|
||||
width: 100%;
|
||||
gap: max(1rem, 2vh);
|
||||
|
||||
display: grid;
|
||||
grid-template-areas: 'time left right';
|
||||
grid-template-columns: auto 1fr 1fr;
|
||||
}
|
||||
|
||||
.timeOptions {
|
||||
grid-area: time;
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
|
||||
.timers,
|
||||
.timeSettings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.left,
|
||||
.right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.left {
|
||||
grid-area: left;
|
||||
padding: 0 1rem;
|
||||
border-left: 1px solid $border-color-ondark;
|
||||
}
|
||||
|
||||
.right {
|
||||
padding-left: 1rem;
|
||||
grid-area: right;
|
||||
border-left: 1px solid $border-color-ondark;
|
||||
}
|
||||
|
||||
@mixin input-label() {
|
||||
font-size: calc(1rem - 3px);
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
.countedInput {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@include input-label;
|
||||
}
|
||||
|
||||
.inputLabel {
|
||||
display: block;
|
||||
@include input-label;
|
||||
|
||||
&.delayLabel {
|
||||
color: $ontime-delay-text;
|
||||
}
|
||||
|
||||
&.publicToggle {
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.eventActions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.splitTwo {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.padTop {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.fullHeight {
|
||||
height: 100%
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { isOntimeEvent, OntimeEvent } from 'ontime-types';
|
||||
|
||||
import CopyTag from '../../common/components/copy-tag/CopyTag';
|
||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||
import useRundown from '../../common/hooks-query/useRundown';
|
||||
import { useAppMode } from '../../common/stores/appModeStore';
|
||||
|
||||
import EventEditorDataLeft from './composite/EventEditorDataLeft';
|
||||
import EventEditorDataRight from './composite/EventEditorDataRight';
|
||||
import EventEditorTimes from './composite/EventEditorTimes';
|
||||
|
||||
import style from './EventEditor.module.scss';
|
||||
|
||||
export type EventEditorSubmitActions = keyof OntimeEvent;
|
||||
export type EditorUpdateFields = 'cue' | 'title' | 'presenter' | 'subtitle' | 'note' | 'colour';
|
||||
|
||||
export default function EventEditor() {
|
||||
const openId = useAppMode((state) => state.editId);
|
||||
const { data } = useRundown();
|
||||
const { updateEvent } = useEventAction();
|
||||
|
||||
const [event, setEvent] = useState<OntimeEvent | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || !openId) {
|
||||
setEvent(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const event = data.find((event) => event.id === openId);
|
||||
if (event && isOntimeEvent(event)) {
|
||||
setEvent(event);
|
||||
}
|
||||
}, [data, openId]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(field: EditorUpdateFields, value: string) => {
|
||||
updateEvent({ id: event?.id, [field]: value });
|
||||
},
|
||||
[event?.id, updateEvent],
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return <span>Loading...</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.eventEditor}>
|
||||
<EventEditorTimes
|
||||
eventId={event.id}
|
||||
timeStart={event.timeStart}
|
||||
timeEnd={event.timeEnd}
|
||||
duration={event.duration}
|
||||
delay={event.delay ?? 0}
|
||||
isPublic={event.isPublic}
|
||||
endAction={event.endAction}
|
||||
timerType={event.timerType}
|
||||
/>
|
||||
<EventEditorDataLeft
|
||||
key={`${event.id}-left`}
|
||||
eventId={event.id}
|
||||
cue={event.cue}
|
||||
title={event.title}
|
||||
presenter={event.presenter}
|
||||
subtitle={event.subtitle}
|
||||
handleSubmit={handleSubmit}
|
||||
/>
|
||||
<EventEditorDataRight
|
||||
key={`${event.id}-right`}
|
||||
note={event.note}
|
||||
colour={event.colour}
|
||||
handleSubmit={handleSubmit}
|
||||
>
|
||||
<CopyTag label='Event ID'>{event.id}</CopyTag>
|
||||
<CopyTag label='OSC trigger by id'>{`/ontime/gotoid "${event.id}"`}</CopyTag>
|
||||
<CopyTag label='OSC trigger by cue'>{`/ontime/gotocue "${event.cue}"`}</CopyTag>
|
||||
</EventEditorDataRight>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import { IconButton } from '@chakra-ui/react';
|
||||
import { IoClose } from '@react-icons/all-files/io5/IoClose';
|
||||
|
||||
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
|
||||
import { useAppMode } from '../../common/stores/appModeStore';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
|
||||
import EventEditor from './EventEditor';
|
||||
|
||||
import style from '../editors/Editor.module.scss';
|
||||
|
||||
/* Styling for action buttons */
|
||||
const closeBtnStyle = {
|
||||
size: 'md',
|
||||
variant: 'ghost',
|
||||
colorScheme: 'white',
|
||||
_hover: { bg: '#ebedf0', color: '#333' },
|
||||
};
|
||||
|
||||
const EventEditorExport = () => {
|
||||
const editId = useAppMode((state) => state.editId);
|
||||
const setEditId = useAppMode((state) => state.setEditId);
|
||||
|
||||
const editorStyle = cx([style.eventEditor, !editId ? style.noEvent : null]);
|
||||
const removeOpenEvent = () => setEditId(null);
|
||||
|
||||
return (
|
||||
<div className={editorStyle}>
|
||||
<ErrorBoundary>
|
||||
<div className={style.eventEditorLayout}>
|
||||
<EventEditor />
|
||||
<div className={style.header}>
|
||||
<IconButton aria-label='Close Menu' icon={<IoClose />} onClick={removeOpenEvent} {...closeBtnStyle} />
|
||||
</div>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(EventEditorExport);
|
||||
@@ -1,54 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import { Input } from '@chakra-ui/react';
|
||||
import { sanitiseCue } from 'ontime-utils';
|
||||
|
||||
import { type EditorUpdateFields } from '../EventEditor';
|
||||
|
||||
import CountedTextInput from './CountedTextInput';
|
||||
|
||||
import style from '../EventEditor.module.scss';
|
||||
|
||||
interface EventEditorLeftProps {
|
||||
eventId: string;
|
||||
cue: string;
|
||||
title: string;
|
||||
presenter: string;
|
||||
subtitle: string;
|
||||
handleSubmit: (field: EditorUpdateFields, value: string) => void;
|
||||
}
|
||||
|
||||
const EventEditorDataLeft = (props: EventEditorLeftProps) => {
|
||||
const { eventId, cue, title, presenter, subtitle, handleSubmit } = props;
|
||||
|
||||
const cueSubmitHandler = (_field: string, newValue: string) => {
|
||||
handleSubmit('cue', sanitiseCue(newValue));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.left}>
|
||||
<div className={style.splitTwo}>
|
||||
<div className={style.column}>
|
||||
<div className={style.countedInput}>
|
||||
<label className={style.inputLabel} htmlFor='eventId'>
|
||||
Event ID (read only)
|
||||
</label>
|
||||
</div>
|
||||
<Input
|
||||
id='eventId'
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
data-testid='input-textfield'
|
||||
value={eventId}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<CountedTextInput field='cue' label='Cue' initialValue={cue} submitHandler={cueSubmitHandler} maxLength={10} />
|
||||
</div>
|
||||
<CountedTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} />
|
||||
<CountedTextInput field='presenter' label='Presenter' initialValue={presenter} submitHandler={handleSubmit} />
|
||||
<CountedTextInput field='subtitle' label='Subtitle' initialValue={subtitle} submitHandler={handleSubmit} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(EventEditorDataLeft);
|
||||
@@ -1,33 +0,0 @@
|
||||
import { memo, PropsWithChildren } from 'react';
|
||||
|
||||
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
|
||||
import { EditorUpdateFields } from '../EventEditor';
|
||||
|
||||
import CountedTextArea from './CountedTextArea';
|
||||
|
||||
import style from '../EventEditor.module.scss';
|
||||
|
||||
interface EventEditorRightProps {
|
||||
note: string;
|
||||
colour: string;
|
||||
handleSubmit: (field: EditorUpdateFields, value: string) => void;
|
||||
}
|
||||
|
||||
const EventEditorDataRight = (props: PropsWithChildren<EventEditorRightProps>) => {
|
||||
const { children, note, colour, handleSubmit } = props;
|
||||
|
||||
return (
|
||||
<div className={style.right}>
|
||||
<div className={style.column}>
|
||||
<label className={style.inputLabel}>Colour</label>
|
||||
<div className={style.inline}>
|
||||
<SwatchSelect name='colour' value={colour} handleChange={handleSubmit} />
|
||||
</div>
|
||||
</div>
|
||||
<CountedTextArea field='note' label='Note' initialValue={note} submitHandler={handleSubmit} />
|
||||
<div className={style.eventActions}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(EventEditorDataRight);
|
||||
@@ -1,144 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import { Select, Switch } from '@chakra-ui/react';
|
||||
import { EndAction, OntimeEvent, TimerType } from 'ontime-types';
|
||||
import { calculateDuration, dayInMs, millisToString } from 'ontime-utils';
|
||||
|
||||
import TimeInput from '../../../common/components/input/time-input/TimeInput';
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import { millisToDelayString } from '../../../common/utils/dateConfig';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
|
||||
import style from '../EventEditor.module.scss';
|
||||
|
||||
interface EventEditorTimesProps {
|
||||
eventId: string;
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
duration: number;
|
||||
delay: number;
|
||||
isPublic: boolean;
|
||||
endAction: EndAction;
|
||||
timerType: TimerType;
|
||||
}
|
||||
|
||||
type TimeActions = 'timeStart' | 'timeEnd' | 'durationOverride' | 'timerType' | 'endAction' | 'isPublic';
|
||||
|
||||
// Todo: add previous end to TimeInput fields
|
||||
const EventEditorTimes = (props: EventEditorTimesProps) => {
|
||||
const { eventId, timeStart, timeEnd, duration, delay, isPublic, endAction, timerType } = props;
|
||||
const { updateEvent } = useEventAction();
|
||||
|
||||
const handleSubmit = (field: TimeActions, value: number | string | boolean) => {
|
||||
const newEventData: Partial<OntimeEvent> = { id: eventId };
|
||||
switch (field) {
|
||||
case 'durationOverride': {
|
||||
// duration defines timeEnd
|
||||
newEventData.duration = value as number;
|
||||
newEventData.timeEnd = timeStart + ((value as number) % dayInMs);
|
||||
break;
|
||||
}
|
||||
case 'timeStart': {
|
||||
newEventData.duration = calculateDuration(value as number, timeEnd);
|
||||
newEventData.timeStart = value as number;
|
||||
break;
|
||||
}
|
||||
case 'timeEnd': {
|
||||
newEventData.duration = calculateDuration(timeStart, value as number);
|
||||
newEventData.timeEnd = value as number;
|
||||
break;
|
||||
}
|
||||
case 'isPublic': {
|
||||
updateEvent({ id: eventId, isPublic: !(value as boolean) });
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
if (field === 'timerType' || field === 'endAction') {
|
||||
// @ts-expect-error -- not sure how to typecheck here
|
||||
newEventData[field as keyof OntimeEvent] = value as string;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
updateEvent(newEventData);
|
||||
};
|
||||
|
||||
const delayTime = delay !== 0 ? millisToDelayString(delay) : null;
|
||||
const startLabel = delayTime ? `New start ${millisToString(timeStart + delay)}` : 'Start time';
|
||||
const endLabel = delayTime ? `New end ${millisToString(timeEnd + delay)}` : 'End time';
|
||||
const inputTimeLabels = cx([style.inputLabel, delayTime ? style.delayLabel : null]);
|
||||
|
||||
return (
|
||||
<div className={style.timeOptions}>
|
||||
<div className={style.timers}>
|
||||
<label className={inputTimeLabels} htmlFor='timeStart'>
|
||||
{startLabel}
|
||||
</label>
|
||||
<TimeInput
|
||||
id='timeStart'
|
||||
name='timeStart'
|
||||
submitHandler={handleSubmit}
|
||||
time={timeStart}
|
||||
delay={delay}
|
||||
placeholder='Start'
|
||||
/>
|
||||
<label className={inputTimeLabels} htmlFor='timeEnd'>
|
||||
{endLabel}
|
||||
</label>
|
||||
<TimeInput
|
||||
id='timeEnd'
|
||||
name='timeEnd'
|
||||
submitHandler={handleSubmit}
|
||||
time={timeEnd}
|
||||
delay={delay}
|
||||
placeholder='End'
|
||||
/>
|
||||
<label className={style.inputLabel} htmlFor='durationOverride'>
|
||||
Duration
|
||||
</label>
|
||||
<TimeInput
|
||||
id='durationOverride'
|
||||
name='durationOverride'
|
||||
submitHandler={handleSubmit}
|
||||
time={duration}
|
||||
placeholder='Duration'
|
||||
/>
|
||||
</div>
|
||||
<div className={style.timeSettings}>
|
||||
<label className={style.inputLabel}>Timer Type</label>
|
||||
<Select
|
||||
size='sm'
|
||||
name='timerType'
|
||||
value={timerType}
|
||||
onChange={(event) => handleSubmit('timerType', event.target.value)}
|
||||
variant='ontime'
|
||||
>
|
||||
<option value={TimerType.CountDown}>Count down</option>
|
||||
<option value={TimerType.CountUp}>Count up</option>
|
||||
<option value={TimerType.TimeToEnd}>Time to end</option>
|
||||
<option value={TimerType.Clock}>Clock</option>
|
||||
</Select>
|
||||
<label className={style.inputLabel}>End Action</label>
|
||||
<Select
|
||||
size='sm'
|
||||
name='endAction'
|
||||
value={endAction}
|
||||
onChange={(event) => handleSubmit('endAction', event.target.value)}
|
||||
variant='ontime'
|
||||
>
|
||||
<option value={EndAction.None}>None</option>
|
||||
<option value={EndAction.Stop}>Stop</option>
|
||||
<option value={EndAction.LoadNext}>Load Next</option>
|
||||
<option value={EndAction.PlayNext}>Play Next</option>
|
||||
</Select>
|
||||
<span className={style.spacer} />
|
||||
<label className={`${style.inputLabel} ${style.publicToggle}`}>
|
||||
<Switch isChecked={isPublic} onChange={() => handleSubmit('isPublic', isPublic)} variant='ontime' />
|
||||
Event is public
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(EventEditorTimes);
|
||||
@@ -1,19 +0,0 @@
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
|
||||
import CollapseBar from '../../common/components/collapse-bar/CollapseBar';
|
||||
|
||||
interface CollapsableInfoProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function CollapsableInfo(props: PropsWithChildren<CollapsableInfoProps>) {
|
||||
const { title, children } = props;
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CollapseBar title={title} isCollapsed={collapsed} onClick={() => setCollapsed((prev) => !prev)} />
|
||||
{!collapsed && children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
@use '../../theme/mixins' as *;
|
||||
@use '../../theme/v2Styles' as *;
|
||||
|
||||
.panelHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
font-size: $inner-section-text-size;
|
||||
|
||||
.title {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.selected {
|
||||
min-width: max-content;
|
||||
color: $label-gray;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: $inner-section-text-size;
|
||||
color: $label-gray;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.labels {
|
||||
font-size: $inner-section-text-size;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $element-inner-spacing;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: $section-white;
|
||||
}
|
||||
|
||||
.content {
|
||||
color: $secondary-text-gray;
|
||||
margin-left: $element-inner-spacing;
|
||||
font-size: $text-body-size;
|
||||
}
|
||||
|
||||
.interfaceList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $section-spacing;
|
||||
row-gap: $element-inner-spacing;
|
||||
|
||||
.interface {
|
||||
@include action-link;
|
||||
font-size: $inner-section-text-size;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.linkIcon {
|
||||
margin-left: $element-inner-spacing;
|
||||
display: inline-block;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user