mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-18 13:44:12 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b13d5792f3 | |||
| d0027d4016 | |||
| ae1639802c | |||
| 9167a50eda | |||
| 8adca908b3 | |||
| bf99646ad5 | |||
| 308285ce3e | |||
| dd781a32f6 | |||
| c2a6de6195 | |||
| 0ac2ce8e84 | |||
| 3bdd8424eb | |||
| b3a099255f | |||
| 570ff59cfc | |||
| d0b4c6a279 | |||
| e90161aa33 | |||
| e37254107e | |||
| 9d6bc0a137 | |||
| 4cd2dcbab0 | |||
| 8c593b3487 | |||
| ba74622569 | |||
| f76c0d1a6d | |||
| 8e8d3a7824 | |||
| 6c3ae1f914 | |||
| b6507c27a6 | |||
| 21877e4bff | |||
| b8d7324be0 | |||
| 88b8e83494 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "3.12.0",
|
"version": "3.13.1",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "3.12.0",
|
"version": "3.13.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const SHEET_STATE = ['sheetState'];
|
|||||||
export const URL_PRESETS = ['urlpresets'];
|
export const URL_PRESETS = ['urlpresets'];
|
||||||
export const VIEW_SETTINGS = ['viewSettings'];
|
export const VIEW_SETTINGS = ['viewSettings'];
|
||||||
export const CLIENT_LIST = ['clientList'];
|
export const CLIENT_LIST = ['clientList'];
|
||||||
|
export const REPORT = ['report'];
|
||||||
|
|
||||||
// API URLs
|
// API URLs
|
||||||
export const apiEntryUrl = `${serverURL}/data`;
|
export const apiEntryUrl = `${serverURL}/data`;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import axios, { AxiosResponse } from 'axios';
|
import axios, { AxiosResponse } from 'axios';
|
||||||
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
|
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
|
||||||
|
|
||||||
import { makeCSV, makeTable } from '../../views/cuesheet/cuesheet.utils';
|
import { makeTable } from '../../views/cuesheet/cuesheet.utils';
|
||||||
|
import { makeCSVFromArrayOfArrays } from '../utils/csv';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
import { createBlob, downloadBlob } from './utils';
|
import { createBlob, downloadBlob } from './utils';
|
||||||
@@ -42,7 +43,7 @@ export async function downloadCSV(fileName: string = 'rundown') {
|
|||||||
const { project, rundown, customFields } = data;
|
const { project, rundown, customFields } = data;
|
||||||
|
|
||||||
const sheetData = makeTable(project, rundown, customFields);
|
const sheetData = makeTable(project, rundown, customFields);
|
||||||
const fileContent = makeCSV(sheetData);
|
const fileContent = makeCSVFromArrayOfArrays(sheetData);
|
||||||
|
|
||||||
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
|
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
|
||||||
downloadBlob(blob, `${name}.csv`);
|
downloadBlob(blob, `${name}.csv`);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { OntimeReport } from 'ontime-types';
|
||||||
|
|
||||||
|
import { ontimeQueryClient } from '../../common/queryClient';
|
||||||
|
|
||||||
|
import { apiEntryUrl, REPORT } from './constants';
|
||||||
|
|
||||||
|
export const reportUrl = `${apiEntryUrl}/report`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to fetch all reports
|
||||||
|
*/
|
||||||
|
export async function fetchReport(): Promise<OntimeReport> {
|
||||||
|
const res = await axios.get(`${reportUrl}/`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteReport(id: string) {
|
||||||
|
await axios.delete(`${reportUrl}/${id}`);
|
||||||
|
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAllReport() {
|
||||||
|
await axios.delete(`${reportUrl}/all`);
|
||||||
|
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
@use "../../../theme/mixins" as *;
|
|
||||||
|
|
||||||
.link {
|
|
||||||
@include action-link;
|
|
||||||
font-size: $inner-section-text-size;
|
|
||||||
}
|
|
||||||
|
|
||||||
.linkIcon {
|
|
||||||
margin-left: $element-inner-spacing;
|
|
||||||
display: inline-block;
|
|
||||||
@include rotate-fourty-five;
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { MouseEvent, ReactNode } from 'react';
|
|
||||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
|
||||||
|
|
||||||
import { openLink } from '../../utils/linkUtils';
|
|
||||||
|
|
||||||
import style from './AppLink.module.scss';
|
|
||||||
|
|
||||||
interface AppLinkProps {
|
|
||||||
href: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AppLink(props: AppLinkProps) {
|
|
||||||
const { href, children } = props;
|
|
||||||
|
|
||||||
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
openLink(href);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a href='#!' target='_blank' rel='noreferrer' className={style.link} onClick={handleClick}>
|
|
||||||
{children} <IoArrowUp className={style.linkIcon} />
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
.subtle {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
height: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
|
||||||
|
background: $gray-1050;
|
||||||
|
color: $blue-400;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 3px;
|
||||||
|
|
||||||
|
&:hover:not(:disabled):not(:active) {
|
||||||
|
background: $gray-1000;
|
||||||
|
color: $blue-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: $gray-1100;
|
||||||
|
border-color: $gray-1250;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: $gray-1050;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { ButtonHTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
|
||||||
|
import style from './IconButton.module.scss';
|
||||||
|
|
||||||
|
export default function IconButton(props: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||||
|
const { className, children, ...buttonProps } = props;
|
||||||
|
return (
|
||||||
|
<button className={cx([style.subtle, className])} {...buttonProps}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
.textEntry {
|
||||||
|
grid-area: input;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: $label-gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inlineEntry {
|
||||||
|
display: grid;
|
||||||
|
grid-template-areas:
|
||||||
|
'label label'
|
||||||
|
'input btn';
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
column-gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redirect {
|
||||||
|
grid-area: btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: $label-gray;
|
||||||
|
}
|
||||||
@@ -1,64 +1,125 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
IconButton,
|
||||||
Input,
|
Input,
|
||||||
InputGroup,
|
|
||||||
InputLeftAddon,
|
|
||||||
Modal,
|
Modal,
|
||||||
ModalBody,
|
ModalBody,
|
||||||
ModalCloseButton,
|
ModalCloseButton,
|
||||||
ModalContent,
|
ModalContent,
|
||||||
ModalFooter,
|
|
||||||
ModalHeader,
|
ModalHeader,
|
||||||
ModalOverlay,
|
ModalOverlay,
|
||||||
|
Select,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
|
import { IoArrowForward } from '@react-icons/all-files/io5/IoArrowForward';
|
||||||
|
|
||||||
|
import { navigatorConstants } from '../../../viewerConfig';
|
||||||
import { setClientRemote } from '../../hooks/useSocket';
|
import { setClientRemote } from '../../hooks/useSocket';
|
||||||
|
import useUrlPresets from '../../hooks-query/useUrlPresets';
|
||||||
|
import Info from '../info/Info';
|
||||||
|
import AppLink from '../link/app-link/AppLink';
|
||||||
|
|
||||||
|
import style from './RedirectClientModal.module.scss';
|
||||||
|
|
||||||
interface RedirectClientModalProps {
|
interface RedirectClientModalProps {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name: string;
|
||||||
path?: string;
|
currentPath: string;
|
||||||
|
origin: string;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RedirectClientModal(props: RedirectClientModalProps) {
|
export function RedirectClientModal(props: RedirectClientModalProps) {
|
||||||
const { id, isOpen, name = '', path: currentPath = '', onClose } = props;
|
const { id, isOpen, name, currentPath, origin, onClose } = props;
|
||||||
|
const { data } = useUrlPresets();
|
||||||
const [path, setPath] = useState(currentPath);
|
const [path, setPath] = useState(currentPath);
|
||||||
|
const [selected, setSelected] = useState('/');
|
||||||
|
|
||||||
const { setRedirect } = setClientRemote;
|
const { setRedirect } = setClientRemote;
|
||||||
|
|
||||||
const handleRedirect = () => {
|
const handleRedirect = (newPath: string) => {
|
||||||
if (path !== currentPath && path !== '') {
|
if (newPath === '/' || newPath === currentPath) {
|
||||||
setRedirect({ target: id, redirect: path });
|
return;
|
||||||
}
|
}
|
||||||
|
setRedirect({ target: id, redirect: newPath });
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const host = window.location.origin;
|
const enabledPresets = data.filter((preset) => preset.enabled);
|
||||||
const canSubmit = path !== currentPath && path !== '';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
|
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
|
||||||
<ModalOverlay />
|
<ModalOverlay />
|
||||||
<ModalContent>
|
<ModalContent maxWidth='max(480px, 35vw)'>
|
||||||
<ModalHeader>Redirect: {name}</ModalHeader>
|
<ModalHeader>Redirect: {name}</ModalHeader>
|
||||||
<ModalCloseButton />
|
<ModalCloseButton />
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<InputGroup variant='ontime-filled' size='md'>
|
<Info>
|
||||||
<InputLeftAddon>{host}</InputLeftAddon>
|
Remotely redirect the client to a different URL. <br />
|
||||||
<Input placeholder='minimal?key=0000ffff' value={path} onChange={(event) => setPath(event.target.value)} />
|
Either by selecting a URL Preset or entering a custom path.
|
||||||
</InputGroup>
|
<br />
|
||||||
|
<br />
|
||||||
|
<AppLink search='settings=feature_settings__urlpresets'>Manage URL Presets</AppLink>
|
||||||
|
</Info>
|
||||||
|
<div>
|
||||||
|
<span className={style.label}>Select View or URL Preset</span>
|
||||||
|
<div className={style.textEntry}>
|
||||||
|
<Select
|
||||||
|
size='md'
|
||||||
|
variant='ontime'
|
||||||
|
isDisabled={enabledPresets.length === 0}
|
||||||
|
onChange={(event) => setSelected(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value='/'>Select view or preset</option>
|
||||||
|
{navigatorConstants.map((view) => {
|
||||||
|
return (
|
||||||
|
<option key={view.url} value={`/${view.url}`}>
|
||||||
|
{view.label}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{enabledPresets.map((preset) => {
|
||||||
|
return (
|
||||||
|
<option key={preset.pathAndParams} value={preset.pathAndParams}>
|
||||||
|
{`Preset: ${preset.alias}`}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Select>
|
||||||
|
<IconButton
|
||||||
|
variant='ontime-filled'
|
||||||
|
size='md'
|
||||||
|
aria-label='Redirect to preset'
|
||||||
|
className={style.redirect}
|
||||||
|
icon={<IoArrowForward />}
|
||||||
|
isDisabled={enabledPresets.length === 0 || selected === '/'}
|
||||||
|
onClick={() => handleRedirect(selected)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.inlineEntry}>
|
||||||
|
<span className={style.label}>Enter custom path</span>
|
||||||
|
<label className={style.textEntry}>
|
||||||
|
{origin}
|
||||||
|
<Input
|
||||||
|
variant='ontime-filled'
|
||||||
|
size='md'
|
||||||
|
placeholder='eg. /minimal?key=0000ffff'
|
||||||
|
value={path}
|
||||||
|
onChange={(event) => setPath(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<IconButton
|
||||||
|
variant='ontime-filled'
|
||||||
|
size='md'
|
||||||
|
aria-label='Redirect'
|
||||||
|
isDisabled={path === currentPath || path === ''}
|
||||||
|
className={style.redirect}
|
||||||
|
icon={<IoArrowForward />}
|
||||||
|
onClick={() => handleRedirect(path)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
|
||||||
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}>
|
|
||||||
Submit
|
|
||||||
</Button>
|
|
||||||
</ModalFooter>
|
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
|
min-width: 1.5rem;
|
||||||
align-self: start;
|
align-self: start;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
color: $info-blue;
|
color: $info-blue;
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.link {
|
||||||
|
color: $label-gray;
|
||||||
|
font-size: $text-body-size;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $blue-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { type PropsWithChildren } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { cx } from '../../../utils/styleUtils';
|
||||||
|
|
||||||
|
import style from './AppLink.module.scss';
|
||||||
|
|
||||||
|
interface AppLinkProps {
|
||||||
|
className?: string;
|
||||||
|
search: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component used to navigate to an editor link inside the same window
|
||||||
|
* Handles the path to respect Ontime Clouds base URL
|
||||||
|
*/
|
||||||
|
export default function AppLink(props: PropsWithChildren<AppLinkProps>) {
|
||||||
|
const { className, search, children } = props;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleClick = () => navigate({ search });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={handleClick} className={cx([style.link, className])}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
import { MouseEvent, ReactNode } from 'react';
|
import { MouseEvent, ReactNode } from 'react';
|
||||||
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
|
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
|
||||||
|
|
||||||
import { openLink } from '../../utils/linkUtils';
|
import { openLink } from '../../../utils/linkUtils';
|
||||||
import { cx } from '../../utils/styleUtils';
|
import { cx } from '../../../utils/styleUtils';
|
||||||
|
|
||||||
import style from './ExternalLink.module.scss';
|
import style from './ExternalLink.module.scss';
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
.viewLogo {
|
.viewLogo {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 75px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { CustomFields } from 'ontime-types';
|
||||||
|
|
||||||
|
import { makeOptionsFromCustomFields } from '../constants';
|
||||||
|
|
||||||
|
describe('makeOptionsFromCustomFields', () => {
|
||||||
|
const testCustomFields: CustomFields = {
|
||||||
|
field1: { label: 'Field 1', colour: 'red', type: 'string' },
|
||||||
|
field2: { label: 'Field 2', colour: 'blue', type: 'string' },
|
||||||
|
};
|
||||||
|
|
||||||
|
it('creates a record of keys for the given custom fields', () => {
|
||||||
|
const result = makeOptionsFromCustomFields(testCustomFields);
|
||||||
|
expect(result).toStrictEqual({
|
||||||
|
'custom-field1': 'Custom: Field 1',
|
||||||
|
'custom-field2': 'Custom: Field 2',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends additional data', () => {
|
||||||
|
const additionalData = {
|
||||||
|
test1: 'test1',
|
||||||
|
test2: 'test2',
|
||||||
|
};
|
||||||
|
const result = makeOptionsFromCustomFields(testCustomFields, additionalData);
|
||||||
|
expect(result).toStrictEqual({
|
||||||
|
'custom-field1': 'Custom: Field 1',
|
||||||
|
'custom-field2': 'Custom: Field 2',
|
||||||
|
test1: 'test1',
|
||||||
|
test2: 'test2',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filtersImageTypes', () => {
|
||||||
|
const customFieldsWIthImage: CustomFields = {
|
||||||
|
...testCustomFields,
|
||||||
|
field3: { label: 'Field 3', colour: 'green', type: 'image' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = makeOptionsFromCustomFields(customFieldsWIthImage);
|
||||||
|
expect(result).toStrictEqual({
|
||||||
|
'custom-field1': 'Custom: Field 1',
|
||||||
|
'custom-field2': 'Custom: Field 2',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,11 +5,17 @@ import type { ParamField } from './types';
|
|||||||
export const makeOptionsFromCustomFields = (
|
export const makeOptionsFromCustomFields = (
|
||||||
customFields: CustomFields,
|
customFields: CustomFields,
|
||||||
additionalOptions: Record<string, string> = {},
|
additionalOptions: Record<string, string> = {},
|
||||||
|
filterImageType = true,
|
||||||
) => {
|
) => {
|
||||||
return Object.entries(customFields).reduce((options, [key, value]) => {
|
const options = structuredClone(additionalOptions);
|
||||||
|
for (const [key, value] of Object.entries(customFields)) {
|
||||||
|
if (filterImageType && value.type === 'image') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
options[`custom-${key}`] = `Custom: ${value.label}`;
|
options[`custom-${key}`] = `Custom: ${value.label}`;
|
||||||
return options;
|
}
|
||||||
}, additionalOptions);
|
return options;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getTimeOption = (timeFormat: string): ParamField => {
|
export const getTimeOption = (timeFormat: string): ParamField => {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { OntimeReport } from 'ontime-types';
|
||||||
|
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { REPORT } from '../api/constants';
|
||||||
|
import { fetchReport } from '../api/report';
|
||||||
|
|
||||||
|
export default function useReport() {
|
||||||
|
const { data, refetch } = useQuery<OntimeReport>({
|
||||||
|
queryKey: REPORT,
|
||||||
|
queryFn: fetchReport,
|
||||||
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
|
retry: 5,
|
||||||
|
retryDelay: (attempt) => attempt * 2500,
|
||||||
|
networkMode: 'always',
|
||||||
|
staleTime: MILLIS_PER_HOUR,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { data: data ?? {}, refetch };
|
||||||
|
}
|
||||||
@@ -41,6 +41,17 @@ export const useExternalMessageInput = createSelector((state: RuntimeStore) => (
|
|||||||
visible: state.message.timer.secondarySource === 'external',
|
visible: state.message.timer.secondarySource === 'external',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const useTimerSchedule = createSelector((state: RuntimeStore) => ({
|
||||||
|
startedAt: state.timer.startedAt,
|
||||||
|
expectedFinish: state.timer.expectedFinish,
|
||||||
|
phase: state.timer.phase,
|
||||||
|
playback: state.timer.playback,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const useTimerCurrent = createSelector((state: RuntimeStore) => ({
|
||||||
|
current: state.timer.current,
|
||||||
|
}));
|
||||||
|
|
||||||
export const useMessagePreview = createSelector((state: RuntimeStore) => ({
|
export const useMessagePreview = createSelector((state: RuntimeStore) => ({
|
||||||
blink: state.message.timer.blink,
|
blink: state.message.timer.blink,
|
||||||
blackout: state.message.timer.blackout,
|
blackout: state.message.timer.blackout,
|
||||||
@@ -114,6 +125,15 @@ export const setAuxTimer = {
|
|||||||
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
|
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useTimerSpeed = createSelector((state: RuntimeStore) => ({
|
||||||
|
speed: state.timer.speed,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const setTimerSpeed = {
|
||||||
|
getSpeed: () => socketSendJson('get-speed'),
|
||||||
|
setSpeed: (speed: number) => socketSendJson('set-speed', speed),
|
||||||
|
};
|
||||||
|
|
||||||
export const useSelectedEventId = createSelector((state: RuntimeStore) => ({
|
export const useSelectedEventId = createSelector((state: RuntimeStore) => ({
|
||||||
selectedEventId: state.eventNow?.id ?? null,
|
selectedEventId: state.eventNow?.id ?? null,
|
||||||
}));
|
}));
|
||||||
@@ -182,3 +202,11 @@ export const usePing = createSelector((state: RuntimeStore) => ({
|
|||||||
export const useIsOnline = createSelector((state: RuntimeStore) => ({
|
export const useIsOnline = createSelector((state: RuntimeStore) => ({
|
||||||
isOnline: state.ping > 0,
|
isOnline: state.ping > 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const usePlayback = () => {
|
||||||
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
|
playback: state.timer.playback,
|
||||||
|
});
|
||||||
|
|
||||||
|
return useRuntimeStore(featureSelector);
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { makeCSVFromArrayOfArrays } from '../csv';
|
||||||
|
|
||||||
|
describe('makeCSVFromArrayOfArrays()', () => {
|
||||||
|
it('joins an array of arrays with commas and newlines', () => {
|
||||||
|
const testdata = [['field'], ['after newline', 'after comma'], ['', 'after empty']];
|
||||||
|
expect(makeCSVFromArrayOfArrays(testdata)).toMatchInlineSnapshot(`
|
||||||
|
"field
|
||||||
|
after newline,after comma
|
||||||
|
,after empty
|
||||||
|
"
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { stringify } from 'csv-stringify/browser/esm/sync';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Converts an array of arrays to a CSV file
|
||||||
|
* @param {string[][]} arrayOfArrays
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string {
|
||||||
|
return stringify(arrayOfArrays);
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import { baseURI, serverURL } from '../../externals';
|
|||||||
* Open an external URLs: specifically for a electron / browser case
|
* Open an external URLs: specifically for a electron / browser case
|
||||||
* If electron: ask main process to call a new browser window
|
* If electron: ask main process to call a new browser window
|
||||||
* If browser: open in new tab
|
* If browser: open in new tab
|
||||||
* @param url
|
|
||||||
*/
|
*/
|
||||||
export function openLink(url: string) {
|
export function openLink(url: string) {
|
||||||
if (window.process?.type === 'renderer') {
|
if (window.process?.type === 'renderer') {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Log, RundownCached, RuntimeStore } from 'ontime-types';
|
import { Log, RundownCached, RuntimeStore } from 'ontime-types';
|
||||||
|
|
||||||
import { isProduction, websocketUrl } from '../../externals';
|
import { isProduction, websocketUrl } from '../../externals';
|
||||||
import { CLIENT_LIST, CUSTOM_FIELDS, RUNDOWN, RUNTIME } from '../api/constants';
|
import { CLIENT_LIST, CUSTOM_FIELDS, REPORT, RUNDOWN, RUNTIME } from '../api/constants';
|
||||||
import { invalidateAllCaches } from '../api/utils';
|
import { invalidateAllCaches } from '../api/utils';
|
||||||
import { ontimeQueryClient } from '../queryClient';
|
import { ontimeQueryClient } from '../queryClient';
|
||||||
import {
|
import {
|
||||||
@@ -34,12 +34,16 @@ export const connectSocket = () => {
|
|||||||
hasConnected = true;
|
hasConnected = true;
|
||||||
reconnectAttempts = 0;
|
reconnectAttempts = 0;
|
||||||
|
|
||||||
|
socketSendJson('set-client-patch', {
|
||||||
|
type: 'ontime',
|
||||||
|
origin: window.location.origin,
|
||||||
|
path: window.location.pathname + window.location.search,
|
||||||
|
});
|
||||||
|
setOnlineStatus(true);
|
||||||
|
|
||||||
if (preferredClientName) {
|
if (preferredClientName) {
|
||||||
socketSendJson('set-client-name', preferredClientName);
|
socketSendJson('set-client-name', preferredClientName);
|
||||||
}
|
}
|
||||||
|
|
||||||
socketSendJson('set-client-type', 'ontime');
|
|
||||||
setOnlineStatus(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
websocket.onclose = () => {
|
websocket.onclose = () => {
|
||||||
@@ -78,16 +82,14 @@ export const connectSocket = () => {
|
|||||||
updateDevTools({ ping: offset });
|
updateDevTools({ ping: offset });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'client-id': {
|
case 'client': {
|
||||||
if (typeof payload === 'string') {
|
if (typeof payload === 'object' || payload !== null) {
|
||||||
setClientId(payload);
|
if (payload.clientId && payload.clientName) {
|
||||||
}
|
setClientId(payload.clientId);
|
||||||
break;
|
if (!preferredClientName) {
|
||||||
}
|
setClientName(payload.clientName);
|
||||||
|
}
|
||||||
case 'client-name': {
|
}
|
||||||
if (typeof payload === 'string') {
|
|
||||||
setClientName(payload);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -196,19 +198,23 @@ export const connectSocket = () => {
|
|||||||
}
|
}
|
||||||
case 'ontime-refetch': {
|
case 'ontime-refetch': {
|
||||||
// the refetch message signals that the rundown has changed in the server side
|
// the refetch message signals that the rundown has changed in the server side
|
||||||
const { revision, reload } = payload;
|
const { reload, target } = payload;
|
||||||
const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1;
|
|
||||||
|
|
||||||
if (reload) {
|
if (reload) {
|
||||||
invalidateAllCaches();
|
invalidateAllCaches();
|
||||||
} else if (revision > currentRevision) {
|
} else if (target === 'RUNDOWN') {
|
||||||
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
|
const { revision } = payload;
|
||||||
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
|
const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1;
|
||||||
|
if (revision > currentRevision) {
|
||||||
|
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||||
|
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
|
||||||
|
}
|
||||||
|
} else if (target === 'REPORT') {
|
||||||
|
ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'ontime-flush': {
|
case 'ontime-flush': {
|
||||||
flushBatchUpdates()
|
flushBatchUpdates();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ $inner-padding: 1rem;
|
|||||||
color: $muted-gray;
|
color: $muted-gray;
|
||||||
|
|
||||||
td {
|
td {
|
||||||
padding-block: 1rem;
|
padding-block: 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
|
|||||||
@@ -67,9 +67,17 @@ export function TableEmpty({ label, handleClick }: { label?: string; handleClick
|
|||||||
<tr className={style.empty}>
|
<tr className={style.empty}>
|
||||||
<td colSpan={99}>
|
<td colSpan={99}>
|
||||||
<div>{label ?? 'No data yet'}</div>
|
<div>{label ?? 'No data yet'}</div>
|
||||||
<Button onClick={handleClick} isDisabled={!handleClick} variant='ontime-filled' rightIcon={<IoAdd />} size='sm'>
|
{handleClick && (
|
||||||
New
|
<Button
|
||||||
</Button>
|
onClick={handleClick}
|
||||||
|
isDisabled={!handleClick}
|
||||||
|
variant='ontime-filled'
|
||||||
|
rightIcon={<IoAdd />}
|
||||||
|
size='sm'
|
||||||
|
>
|
||||||
|
New
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||||
import {
|
import {
|
||||||
buyMeACoffeeUrl,
|
buyMeACoffeeUrl,
|
||||||
discordUrl,
|
discordUrl,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||||
import useAppVersion from '../../../../common/hooks-query/useAppVersion';
|
import useAppVersion from '../../../../common/hooks-query/useAppVersion';
|
||||||
import { appVersion } from '../../../../externals';
|
import { appVersion } from '../../../../externals';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import {
|
|||||||
|
|
||||||
import { addAutomation, editAutomation, testOutput } from '../../../../common/api/automation';
|
import { addAutomation, editAutomation, testOutput } from '../../../../common/api/automation';
|
||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
|
||||||
import Info from '../../../../common/components/info/Info';
|
import Info from '../../../../common/components/info/Info';
|
||||||
|
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||||
import Tag from '../../../../common/components/tag/Tag';
|
import Tag from '../../../../common/components/tag/Tag';
|
||||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||||
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
|
||||||
|
|||||||
+1
-1
@@ -3,8 +3,8 @@ import { Button, Input, Switch } from '@chakra-ui/react';
|
|||||||
|
|
||||||
import { editAutomationSettings } from '../../../../common/api/automation';
|
import { editAutomationSettings } from '../../../../common/api/automation';
|
||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
|
||||||
import Info from '../../../../common/components/info/Info';
|
import Info from '../../../../common/components/info/Info';
|
||||||
|
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||||
import { isOnlyNumbers } from '../../../../common/utils/regex';
|
import { isOnlyNumbers } from '../../../../common/utils/regex';
|
||||||
import { isOntimeCloud } from '../../../../externals';
|
import { isOntimeCloud } from '../../../../externals';
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export default function TriggersList(props: TriggersListProps) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{!showForm && triggers.length === 0 && (
|
{!showForm && triggers.length === 0 && (
|
||||||
<Panel.TableEmpty
|
<Panel.TableEmpty
|
||||||
label='Create an automation before to attach triggers to'
|
label='Create an automation to attach triggers to'
|
||||||
handleClick={canAdd ? () => setShowForm(true) : undefined}
|
handleClick={canAdd ? () => setShowForm(true) : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Badge, Button, useDisclosure } from '@chakra-ui/react';
|
import { Badge, Button, useDisclosure } from '@chakra-ui/react';
|
||||||
|
import { Client } from 'ontime-types';
|
||||||
|
|
||||||
import { RedirectClientModal } from '../../../../common/components/client-modal/RedirectClientModal';
|
import { RedirectClientModal } from '../../../../common/components/client-modal/RedirectClientModal';
|
||||||
import { RenameClientModal } from '../../../../common/components/client-modal/RenameClientModal';
|
import { RenameClientModal } from '../../../../common/components/client-modal/RenameClientModal';
|
||||||
@@ -31,15 +32,16 @@ export default function ClientList() {
|
|||||||
const ontimeClients = Object.entries(clients).filter(([_, { type }]) => type === 'ontime');
|
const ontimeClients = Object.entries(clients).filter(([_, { type }]) => type === 'ontime');
|
||||||
const otherClients = Object.entries(clients).filter(([_, { type }]) => type !== 'ontime');
|
const otherClients = Object.entries(clients).filter(([_, { type }]) => type !== 'ontime');
|
||||||
|
|
||||||
const targetClient = clients[targetId];
|
const targetClient: Client | undefined = clients[targetId];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isOpenRedirect && (
|
{isOpenRedirect && targetClient !== undefined && (
|
||||||
<RedirectClientModal
|
<RedirectClientModal
|
||||||
id={targetId}
|
id={targetId}
|
||||||
name={targetClient?.name}
|
name={targetClient.name}
|
||||||
path={targetClient?.path}
|
origin={targetClient.origin}
|
||||||
|
currentPath={targetClient.path}
|
||||||
isOpen={isOpenRedirect}
|
isOpen={isOpenRedirect}
|
||||||
onClose={onCloseRedirect}
|
onClose={onCloseRedirect}
|
||||||
/>
|
/>
|
||||||
@@ -52,7 +54,7 @@ export default function ClientList() {
|
|||||||
<Panel.Table>
|
<Panel.Table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<td className={style.halfWidth}>Client Name (Connection ID)</td>
|
<td className={style.halfWidth}>Client Name</td>
|
||||||
<td className={style.fullWidth}>Path</td>
|
<td className={style.fullWidth}>Path</td>
|
||||||
<td />
|
<td />
|
||||||
</tr>
|
</tr>
|
||||||
@@ -64,9 +66,6 @@ export default function ClientList() {
|
|||||||
return (
|
return (
|
||||||
<tr key={key}>
|
<tr key={key}>
|
||||||
<Panel.InlineElements relation='inner' as='td'>
|
<Panel.InlineElements relation='inner' as='td'>
|
||||||
<Badge variant='outline' size='xs'>
|
|
||||||
{key}
|
|
||||||
</Badge>
|
|
||||||
{isCurrent && (
|
{isCurrent && (
|
||||||
<Badge variant='outline' colorScheme='yellow' size='xs'>
|
<Badge variant='outline' colorScheme='yellow' size='xs'>
|
||||||
self
|
self
|
||||||
@@ -119,7 +118,7 @@ export default function ClientList() {
|
|||||||
<Panel.Table>
|
<Panel.Table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<td className={style.halfWidthNoWrap}>Client Name (Connection ID)</td>
|
<td className={style.halfWidthNoWrap}>Client Name</td>
|
||||||
<td className={style.halfWidthNoWrap}>Client type</td>
|
<td className={style.halfWidthNoWrap}>Client type</td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -129,12 +128,7 @@ export default function ClientList() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={key}>
|
<tr key={key}>
|
||||||
<Panel.InlineElements relation='inner' as='td'>
|
<td>{name}</td>
|
||||||
<Badge variant='outline' size='sx'>
|
|
||||||
{key}
|
|
||||||
</Badge>
|
|
||||||
{name}
|
|
||||||
</Panel.InlineElements>
|
|
||||||
<td>{type}</td>
|
<td>{type}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|||||||
+6
@@ -3,11 +3,13 @@ import type { PanelBaseProps } from '../../panel-list/PanelList';
|
|||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import CustomFields from './custom-fields/CustomFields';
|
import CustomFields from './custom-fields/CustomFields';
|
||||||
|
import ReportSettings from './ReportSettings';
|
||||||
import UrlPresetsForm from './UrlPresetsForm';
|
import UrlPresetsForm from './UrlPresetsForm';
|
||||||
|
|
||||||
export default function FeatureSettingsPanel({ location }: PanelBaseProps) {
|
export default function FeatureSettingsPanel({ location }: PanelBaseProps) {
|
||||||
const customFieldsRef = useScrollIntoView<HTMLDivElement>('custom', location);
|
const customFieldsRef = useScrollIntoView<HTMLDivElement>('custom', location);
|
||||||
const urlPresetsRef = useScrollIntoView<HTMLDivElement>('urlpresets', location);
|
const urlPresetsRef = useScrollIntoView<HTMLDivElement>('urlpresets', location);
|
||||||
|
const reportRef = useScrollIntoView<HTMLDivElement>('report', location);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -19,6 +21,10 @@ export default function FeatureSettingsPanel({ location }: PanelBaseProps) {
|
|||||||
<div ref={urlPresetsRef}>
|
<div ref={urlPresetsRef}>
|
||||||
<UrlPresetsForm />
|
<UrlPresetsForm />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div ref={reportRef}>
|
||||||
|
<ReportSettings />
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
th.over {
|
||||||
|
color: $ontime-delay-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
th.under {
|
||||||
|
color: $playback-ahead;
|
||||||
|
}
|
||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Button } from '@chakra-ui/react';
|
||||||
|
import { IoTrashBin } from '@react-icons/all-files/io5/IoTrashBin';
|
||||||
|
|
||||||
|
import { deleteAllReport } from '../../../../common/api/report';
|
||||||
|
import { createBlob, downloadBlob } from '../../../../common/api/utils';
|
||||||
|
import useReport from '../../../../common/hooks-query/useReport';
|
||||||
|
import useRundown from '../../../../common/hooks-query/useRundown';
|
||||||
|
import { cx } from '../../../../common/utils/styleUtils';
|
||||||
|
import { formatTime } from '../../../../common/utils/time';
|
||||||
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
|
import { CombinedReport, getCombinedReport, makeReportCSV } from './reportSettings.utils';
|
||||||
|
|
||||||
|
import style from './ReportSettings.module.scss';
|
||||||
|
|
||||||
|
export default function ReportSettings() {
|
||||||
|
const { data: reportData } = useReport();
|
||||||
|
const { data } = useRundown();
|
||||||
|
|
||||||
|
const clearReport = async () => await deleteAllReport();
|
||||||
|
const downloadCSV = (combinedReport: CombinedReport[]) => {
|
||||||
|
if (!combinedReport) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const csv = makeReportCSV(combinedReport);
|
||||||
|
const blob = createBlob(csv, 'text/csv;charset=utf-8;');
|
||||||
|
downloadBlob(blob, 'ontime-report.csv');
|
||||||
|
};
|
||||||
|
|
||||||
|
const combinedReport = useMemo(() => {
|
||||||
|
return getCombinedReport(reportData, data.rundown, data.order);
|
||||||
|
}, [reportData, data.rundown, data.order]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel.Section>
|
||||||
|
<Panel.Card>
|
||||||
|
<Panel.SubHeader>Report</Panel.SubHeader>
|
||||||
|
<Panel.Divider />
|
||||||
|
<Panel.Section>
|
||||||
|
<Panel.Title>
|
||||||
|
Manage report
|
||||||
|
<Panel.InlineElements>
|
||||||
|
<Button
|
||||||
|
variant='ontime-subtle'
|
||||||
|
leftIcon={<IoTrashBin />}
|
||||||
|
size='sm'
|
||||||
|
onClick={() => downloadCSV(combinedReport)}
|
||||||
|
isDisabled={combinedReport.length === 0}
|
||||||
|
>
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='ontime-subtle'
|
||||||
|
leftIcon={<IoTrashBin />}
|
||||||
|
size='sm'
|
||||||
|
color='#FA5656'
|
||||||
|
onClick={clearReport}
|
||||||
|
isDisabled={combinedReport.length === 0}
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
</Panel.InlineElements>
|
||||||
|
</Panel.Title>
|
||||||
|
</Panel.Section>
|
||||||
|
<Panel.Section>
|
||||||
|
<Panel.Table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Cue</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Scheduled Start</th>
|
||||||
|
<th>Actual Start</th>
|
||||||
|
<th>Scheduled End</th>
|
||||||
|
<th>Actual End</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{combinedReport.length === 0 && (
|
||||||
|
<Panel.TableEmpty label='Reports are generated when running through the show.' />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{combinedReport.map((entry) => {
|
||||||
|
const start = (() => {
|
||||||
|
if (entry.actualStart === null) return null;
|
||||||
|
if (entry.actualStart <= entry.scheduledStart) return 'under';
|
||||||
|
return 'over';
|
||||||
|
})();
|
||||||
|
const end = (() => {
|
||||||
|
if (entry.actualEnd === null) return null;
|
||||||
|
if (entry.actualEnd <= entry.scheduledEnd) return 'under';
|
||||||
|
return 'over';
|
||||||
|
})();
|
||||||
|
return (
|
||||||
|
<tr key={entry.index}>
|
||||||
|
<th>{entry.index}</th>
|
||||||
|
<th>{entry.cue}</th>
|
||||||
|
<th>{entry.title}</th>
|
||||||
|
<th className={cx([start && style[start]])}>{formatTime(entry.scheduledStart)}</th>
|
||||||
|
<th className={cx([start && style[start]])}>{formatTime(entry.actualStart)}</th>
|
||||||
|
<th className={cx([end && style[end]])}>{formatTime(entry.scheduledEnd)}</th>
|
||||||
|
<th className={cx([end && style[end]])}>{formatTime(entry.actualEnd)}</th>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</Panel.Table>
|
||||||
|
</Panel.Section>
|
||||||
|
</Panel.Card>
|
||||||
|
</Panel.Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-1
@@ -9,8 +9,8 @@ import { URLPreset } from 'ontime-types';
|
|||||||
import { postUrlPresets } from '../../../../common/api/urlPresets';
|
import { postUrlPresets } from '../../../../common/api/urlPresets';
|
||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import TooltipActionBtn from '../../../../common/components/buttons/TooltipActionBtn';
|
import TooltipActionBtn from '../../../../common/components/buttons/TooltipActionBtn';
|
||||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
|
||||||
import Info from '../../../../common/components/info/Info';
|
import Info from '../../../../common/components/info/Info';
|
||||||
|
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||||
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
|
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
|
||||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||||
import { handleLinks } from '../../../../common/utils/linkUtils';
|
import { handleLinks } from '../../../../common/utils/linkUtils';
|
||||||
|
|||||||
+12
-7
@@ -6,6 +6,7 @@ import { CustomField, CustomFieldLabel } from 'ontime-types';
|
|||||||
|
|
||||||
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
|
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
|
||||||
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
|
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
|
||||||
|
import Tag from '../../../../../common/components/tag/Tag';
|
||||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import CustomFieldForm from './CustomFieldForm';
|
import CustomFieldForm from './CustomFieldForm';
|
||||||
@@ -13,19 +14,20 @@ import CustomFieldForm from './CustomFieldForm';
|
|||||||
import style from '../FeatureSettings.module.scss';
|
import style from '../FeatureSettings.module.scss';
|
||||||
|
|
||||||
interface CustomFieldEntryProps {
|
interface CustomFieldEntryProps {
|
||||||
field: string;
|
|
||||||
colour: string;
|
colour: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
fieldKey: string;
|
||||||
|
type: 'string' | 'image';
|
||||||
onEdit: (label: CustomFieldLabel, patch: CustomField) => Promise<void>;
|
onEdit: (label: CustomFieldLabel, patch: CustomField) => Promise<void>;
|
||||||
onDelete: (label: CustomFieldLabel) => Promise<void>;
|
onDelete: (label: CustomFieldLabel) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
||||||
const { colour, label, onEdit, onDelete, field } = props;
|
const { colour, label, fieldKey, type, onEdit, onDelete } = props;
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
|
||||||
const handleEdit = async (patch: CustomField) => {
|
const handleEdit = async (patch: CustomField) => {
|
||||||
await onEdit(field, patch);
|
await onEdit(fieldKey, patch);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,7 +40,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
|||||||
onSubmit={handleEdit}
|
onSubmit={handleEdit}
|
||||||
initialColour={colour}
|
initialColour={colour}
|
||||||
initialLabel={label}
|
initialLabel={label}
|
||||||
initialKey={field}
|
initialKey={fieldKey}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -50,10 +52,13 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
|||||||
<td>
|
<td>
|
||||||
<Swatch color={colour} />
|
<Swatch color={colour} />
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<Tag>{type}</Tag>
|
||||||
|
</td>
|
||||||
<td className={style.halfWidth}>{label}</td>
|
<td className={style.halfWidth}>{label}</td>
|
||||||
<td className={style.fullWidth}>
|
<td className={style.fullWidth}>
|
||||||
<CopyTag label='Copy key to use in integrations' copyValue={field}>
|
<CopyTag label='Copy key to use in integrations' copyValue={fieldKey}>
|
||||||
{field}
|
{fieldKey}
|
||||||
</CopyTag>
|
</CopyTag>
|
||||||
</td>
|
</td>
|
||||||
<Panel.InlineElements relation='inner' as='td'>
|
<Panel.InlineElements relation='inner' as='td'>
|
||||||
@@ -71,7 +76,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
|||||||
color='#FA5656' // $red-500
|
color='#FA5656' // $red-500
|
||||||
icon={<IoTrash />}
|
icon={<IoTrash />}
|
||||||
aria-label='Delete entry'
|
aria-label='Delete entry'
|
||||||
onClick={() => onDelete(field)}
|
onClick={() => onDelete(fieldKey)}
|
||||||
/>
|
/>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
+31
-9
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
import { Button, Input } from '@chakra-ui/react';
|
import { Button, Input, Radio, RadioGroup } from '@chakra-ui/react';
|
||||||
import { CustomField } from 'ontime-types';
|
import { CustomField } from 'ontime-types';
|
||||||
import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
|
import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
|
||||||
|
|
||||||
import { maybeAxiosError } from '../../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../../common/api/utils';
|
||||||
|
import Info from '../../../../../common/components/info/Info';
|
||||||
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
|
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
|
||||||
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||||
import { preventEscape } from '../../../../../common/utils/keyEvent';
|
import { preventEscape } from '../../../../../common/utils/keyEvent';
|
||||||
@@ -20,6 +21,8 @@ interface CustomFieldsFormProps {
|
|||||||
initialKey?: string;
|
initialKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CustomFieldFormData = CustomField & { key: string };
|
||||||
|
|
||||||
export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
||||||
const { onSubmit, onCancel, initialColour, initialLabel, initialKey } = props;
|
const { onSubmit, onCancel, initialColour, initialLabel, initialKey } = props;
|
||||||
const { data } = useCustomFields();
|
const { data } = useCustomFields();
|
||||||
@@ -28,6 +31,7 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
|||||||
const [_, setColour] = useState(initialColour || '');
|
const [_, setColour] = useState(initialColour || '');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
register,
|
register,
|
||||||
setFocus,
|
setFocus,
|
||||||
@@ -35,17 +39,17 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
|||||||
setValue,
|
setValue,
|
||||||
getValues,
|
getValues,
|
||||||
formState: { errors, isSubmitting, isValid, isDirty },
|
formState: { errors, isSubmitting, isValid, isDirty },
|
||||||
} = useForm({
|
} = useForm<CustomFieldFormData>({
|
||||||
defaultValues: { label: initialLabel || '', colour: initialColour || '', key: initialKey || '' },
|
defaultValues: { type: 'string', label: initialLabel || '', colour: initialColour || '' },
|
||||||
resetOptions: {
|
resetOptions: {
|
||||||
keepDirtyValues: true,
|
keepDirtyValues: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const setupSubmit = async (values: { label: string; colour: string }) => {
|
const setupSubmit = async (values: CustomFieldFormData) => {
|
||||||
const { label, colour } = values;
|
const { type, label, colour } = values;
|
||||||
const newField: CustomField = {
|
const newField: CustomField = {
|
||||||
type: 'string', // type is not user definable yet
|
type,
|
||||||
colour,
|
colour,
|
||||||
label,
|
label,
|
||||||
};
|
};
|
||||||
@@ -77,6 +81,26 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
|||||||
className={style.fieldForm}
|
className={style.fieldForm}
|
||||||
onKeyDown={(event) => preventEscape(event, onCancel)}
|
onKeyDown={(event) => preventEscape(event, onCancel)}
|
||||||
>
|
>
|
||||||
|
<Info>
|
||||||
|
Please note that images can quickly deteriorate your app's performance.
|
||||||
|
<br />
|
||||||
|
Prefer using small, and compressed images.
|
||||||
|
</Info>
|
||||||
|
<div>
|
||||||
|
<Panel.Description>Type</Panel.Description>
|
||||||
|
<Controller
|
||||||
|
name='type'
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<RadioGroup {...field} size='sm' isDisabled={isEditMode} variant='ontime'>
|
||||||
|
<Panel.InlineElements relation='component'>
|
||||||
|
<Radio value='string'>Text</Radio>
|
||||||
|
<Radio value='image'>Image</Radio>
|
||||||
|
</Panel.InlineElements>
|
||||||
|
</RadioGroup>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className={style.twoCols}>
|
<div className={style.twoCols}>
|
||||||
<div>
|
<div>
|
||||||
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
|
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
|
||||||
@@ -105,12 +129,10 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
|||||||
<Input {...register('key')} disabled size='sm' variant='ontime-filled' autoComplete='off' />
|
<Input {...register('key')} disabled size='sm' variant='ontime-filled' autoComplete='off' />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Panel.Description>Colour</Panel.Description>
|
<Panel.Description>Colour</Panel.Description>
|
||||||
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
|
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||||
<Panel.InlineElements relation='inner' align='end'>
|
<Panel.InlineElements relation='inner' align='end'>
|
||||||
<Button size='sm' variant='ontime-ghosted' onClick={onCancel}>
|
<Button size='sm' variant='ontime-ghosted' onClick={onCancel}>
|
||||||
|
|||||||
+5
-3
@@ -4,8 +4,8 @@ import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
|||||||
import { CustomField, CustomFieldLabel } from 'ontime-types';
|
import { CustomField, CustomFieldLabel } from 'ontime-types';
|
||||||
|
|
||||||
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
|
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
|
||||||
import ExternalLink from '../../../../../common/components/external-link/ExternalLink';
|
|
||||||
import Info from '../../../../../common/components/info/Info';
|
import Info from '../../../../../common/components/info/Info';
|
||||||
|
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
|
||||||
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||||
import { customFieldsDocsUrl } from '../../../../../externals';
|
import { customFieldsDocsUrl } from '../../../../../externals';
|
||||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||||
@@ -74,19 +74,21 @@ export default function CustomFields() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Colour</th>
|
<th>Colour</th>
|
||||||
|
<th>Type</th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Key (used in Integrations)</th>
|
<th>Key (used in Integrations)</th>
|
||||||
<th />
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{Object.entries(data).map(([key, { colour, label }]) => {
|
{Object.entries(data).map(([key, { colour, label, type }]) => {
|
||||||
return (
|
return (
|
||||||
<CustomFieldEntry
|
<CustomFieldEntry
|
||||||
key={key}
|
key={key}
|
||||||
field={key}
|
fieldKey={key}
|
||||||
colour={colour}
|
colour={colour}
|
||||||
label={label}
|
label={label}
|
||||||
|
type={type}
|
||||||
onEdit={handleEditField}
|
onEdit={handleEditField}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
import { isOntimeEvent, MaybeNumber, NormalisedRundown, OntimeReport } from 'ontime-types';
|
||||||
|
|
||||||
|
import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv';
|
||||||
|
import { formatTime } from '../../../../common/utils/time';
|
||||||
|
|
||||||
|
export type CombinedReport = {
|
||||||
|
index: number;
|
||||||
|
title: string;
|
||||||
|
cue: string;
|
||||||
|
scheduledStart: number;
|
||||||
|
actualStart: MaybeNumber;
|
||||||
|
scheduledEnd: number;
|
||||||
|
actualEnd: MaybeNumber;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a combined report with the rundown data
|
||||||
|
*/
|
||||||
|
export function getCombinedReport(report: OntimeReport, rundown: NormalisedRundown, order: string[]): CombinedReport[] {
|
||||||
|
if (Object.keys(report).length === 0) return [];
|
||||||
|
if (order.length === 0) return [];
|
||||||
|
|
||||||
|
const combinedReport: CombinedReport[] = [];
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(report)) {
|
||||||
|
if (!rundown[key] || !isOntimeEvent(rundown[key])) continue;
|
||||||
|
|
||||||
|
combinedReport.push({
|
||||||
|
index: order.findIndex((id) => id === key),
|
||||||
|
title: rundown[key].title,
|
||||||
|
cue: rundown[key].cue,
|
||||||
|
scheduledStart: rundown[key].timeStart,
|
||||||
|
actualEnd: value.endedAt,
|
||||||
|
scheduledEnd: rundown[key].timeEnd,
|
||||||
|
actualStart: value.startedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return combinedReport;
|
||||||
|
}
|
||||||
|
|
||||||
|
const csvHeader = ['Index', 'Title', 'Cue', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms a CombinedReport into a CSV string
|
||||||
|
*/
|
||||||
|
export function makeReportCSV(combinedReport: CombinedReport[]) {
|
||||||
|
const csv: string[][] = [];
|
||||||
|
csv.push(csvHeader);
|
||||||
|
|
||||||
|
for (const entry of combinedReport) {
|
||||||
|
csv.push([
|
||||||
|
String(entry.index),
|
||||||
|
entry.title,
|
||||||
|
entry.cue,
|
||||||
|
formatTime(entry.scheduledStart),
|
||||||
|
formatTime(entry.actualStart),
|
||||||
|
formatTime(entry.scheduledEnd),
|
||||||
|
formatTime(entry.actualEnd),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return makeCSVFromArrayOfArrays(csv);
|
||||||
|
}
|
||||||
@@ -5,9 +5,9 @@ import { ViewSettings } from 'ontime-types';
|
|||||||
|
|
||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import { postViewSettings } from '../../../../common/api/viewSettings';
|
import { postViewSettings } from '../../../../common/api/viewSettings';
|
||||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
|
||||||
import Info from '../../../../common/components/info/Info';
|
import Info from '../../../../common/components/info/Info';
|
||||||
import { SwatchPickerRHF } from '../../../../common/components/input/colour-input/SwatchPicker';
|
import { SwatchPickerRHF } from '../../../../common/components/input/colour-input/SwatchPicker';
|
||||||
|
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||||
import useViewSettings from '../../../../common/hooks-query/useViewSettings';
|
import useViewSettings from '../../../../common/hooks-query/useViewSettings';
|
||||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { Button, Select, Switch } from '@chakra-ui/react';
|
|||||||
|
|
||||||
import { generateUrl } from '../../../../common/api/session';
|
import { generateUrl } from '../../../../common/api/session';
|
||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
|
||||||
import Info from '../../../../common/components/info/Info';
|
import Info from '../../../../common/components/info/Info';
|
||||||
|
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||||
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
|
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
|
||||||
import copyToClipboard from '../../../../common/utils/copyToClipboard';
|
import copyToClipboard from '../../../../common/utils/copyToClipboard';
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default function ProjectList() {
|
|||||||
<Panel.Table>
|
<Panel.Table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className={style.containCell}>Project Name</th>
|
<th className={style.containCell}>File Name</th>
|
||||||
<th>Last Used</th>
|
<th>Last Used</th>
|
||||||
<th />
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
|
||||||
import Info from '../../../../common/components/info/Info';
|
import Info from '../../../../common/components/info/Info';
|
||||||
|
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||||
|
|
||||||
const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadsheet-gsheet/';
|
const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadsheet-gsheet/';
|
||||||
|
|
||||||
export default function GSheetInfo() {
|
export default function GSheetInfo() {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const namedImportMap = {
|
|||||||
'Timer type': 'timer type',
|
'Timer type': 'timer type',
|
||||||
'Time warning': 'warning time',
|
'Time warning': 'warning time',
|
||||||
'Time danger': 'danger time',
|
'Time danger': 'danger time',
|
||||||
|
ID: 'id',
|
||||||
custom: [] as ImportCustom[],
|
custom: [] as ImportCustom[],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
|
|||||||
timeWarning: namedImportMap['Time warning'],
|
timeWarning: namedImportMap['Time warning'],
|
||||||
timeDanger: namedImportMap['Time danger'],
|
timeDanger: namedImportMap['Time danger'],
|
||||||
custom,
|
custom,
|
||||||
|
entryId: namedImportMap.ID,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
|||||||
{fieldLabels.map((label) => (
|
{fieldLabels.map((label) => (
|
||||||
<th key={label}>{label}</th>
|
<th key={label}>{label}</th>
|
||||||
))}
|
))}
|
||||||
|
<th>ID</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -113,6 +114,9 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
|||||||
}
|
}
|
||||||
return <td key={field}>{value}</td>;
|
return <td key={field}>{value}</td>;
|
||||||
})}
|
})}
|
||||||
|
<td className={style.center}>
|
||||||
|
<Tag>{event.id}</Tag>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{event.note && (
|
{event.note && (
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const staticOptions = [
|
|||||||
secondary: [
|
secondary: [
|
||||||
{ id: 'feature_settings__custom', label: 'Custom fields' },
|
{ id: 'feature_settings__custom', label: 'Custom fields' },
|
||||||
{ id: 'feature_settings__urlpresets', label: 'URL Presets' },
|
{ id: 'feature_settings__urlpresets', label: 'URL Presets' },
|
||||||
|
{ id: 'feature_settings__report', label: 'Report' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import AddTime from './add-time/AddTime';
|
|||||||
import { AuxTimer } from './aux-timer/AuxTimer';
|
import { AuxTimer } from './aux-timer/AuxTimer';
|
||||||
import PlaybackButtons from './playback-buttons/PlaybackButtons';
|
import PlaybackButtons from './playback-buttons/PlaybackButtons';
|
||||||
import PlaybackTimer from './playback-timer/PlaybackTimer';
|
import PlaybackTimer from './playback-timer/PlaybackTimer';
|
||||||
|
import TimerSpeed from './timer-speed/TimerSpeed';
|
||||||
|
|
||||||
import style from './PlaybackControl.module.scss';
|
import style from './PlaybackControl.module.scss';
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ export default function PlaybackControl() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.mainContainer}>
|
<div className={style.mainContainer}>
|
||||||
<PlaybackTimer playback={data.playback as Playback}>
|
<PlaybackTimer playback={data.playback}>
|
||||||
<AddTime playback={data.playback} />
|
<AddTime playback={data.playback} />
|
||||||
</PlaybackTimer>
|
</PlaybackTimer>
|
||||||
<PlaybackButtons
|
<PlaybackButtons
|
||||||
@@ -23,6 +24,7 @@ export default function PlaybackControl() {
|
|||||||
selectedEventIndex={data.selectedEventIndex}
|
selectedEventIndex={data.selectedEventIndex}
|
||||||
timerPhase={data.timerPhase}
|
timerPhase={data.timerPhase}
|
||||||
/>
|
/>
|
||||||
|
<TimerSpeed isPlaying={data.playback === Playback.Play} eventIndex={data.selectedEventIndex} />
|
||||||
<AuxTimer />
|
<AuxTimer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
import { Tooltip } from '@chakra-ui/react';
|
import { Tooltip } from '@chakra-ui/react';
|
||||||
import { useLocalStorage } from '@mantine/hooks';
|
import { useLocalStorage } from '@mantine/hooks';
|
||||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||||
@@ -16,7 +17,9 @@ interface AddTimeProps {
|
|||||||
playback: Playback;
|
playback: Playback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AddTime(props: AddTimeProps) {
|
export default memo(AddTime);
|
||||||
|
|
||||||
|
function AddTime(props: AddTimeProps) {
|
||||||
const { playback } = props;
|
const { playback } = props;
|
||||||
const [time, setTime] = useLocalStorage({ key: 'add-time', defaultValue: 300_000 }); // 5 minutes
|
const [time, setTime] = useLocalStorage({ key: 'add-time', defaultValue: 300_000 }); // 5 minutes
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import { Tooltip } from '@chakra-ui/react';
|
import { Tooltip } from '@chakra-ui/react';
|
||||||
import { IoPause } from '@react-icons/all-files/io5/IoPause';
|
import { IoPause } from '@react-icons/all-files/io5/IoPause';
|
||||||
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
|
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
|
||||||
@@ -34,7 +35,7 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
|
|||||||
const isLast = selectedEventIndex === numEvents - 1;
|
const isLast = selectedEventIndex === numEvents - 1;
|
||||||
const noEvents = numEvents === 0;
|
const noEvents = numEvents === 0;
|
||||||
|
|
||||||
const disableGo = isRolling || noEvents || (isLast && !isArmed);
|
const disableGo = isRolling || noEvents;
|
||||||
const disableNext = isRolling || noEvents || isLast;
|
const disableNext = isRolling || noEvents || isLast;
|
||||||
const disablePrev = isRolling || noEvents || isFirst;
|
const disablePrev = isRolling || noEvents || isFirst;
|
||||||
|
|
||||||
@@ -45,14 +46,16 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
|
|||||||
const disableStop = !playbackCan.stop;
|
const disableStop = !playbackCan.stop;
|
||||||
const disableReload = !playbackCan.reload;
|
const disableReload = !playbackCan.reload;
|
||||||
|
|
||||||
const goModeText = selectedEventIndex === null || isArmed ? 'Start' : 'Next';
|
const [goModeAction, goModeText] = useMemo(() => {
|
||||||
const goModeAction = () => {
|
|
||||||
if (isArmed) {
|
if (isArmed) {
|
||||||
setPlayback.start();
|
return [setPlayback.start, 'Start'];
|
||||||
} else {
|
} else if (isLast) {
|
||||||
setPlayback.startNext();
|
return [setPlayback.stop, 'Finish'];
|
||||||
|
} else if (selectedEventIndex === null) {
|
||||||
|
return [setPlayback.startNext, 'Start'];
|
||||||
}
|
}
|
||||||
};
|
return [setPlayback.startNext, 'Next'];
|
||||||
|
}, [isArmed, isLast, selectedEventIndex]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.buttonContainer}>
|
<div className={style.buttonContainer}>
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
.timeContainer {
|
.timeContainer {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-areas:
|
grid-template-areas: 'indicators timer addtime';
|
||||||
'indicators timer addtime'
|
|
||||||
'status status addtime';
|
|
||||||
grid-template-rows: 1fr auto;
|
|
||||||
grid-template-columns: 1.25rem 1fr 6.5rem;
|
grid-template-columns: 1.25rem 1fr 6.5rem;
|
||||||
gap: $element-inner-spacing;
|
gap: $element-inner-spacing;
|
||||||
}
|
}
|
||||||
@@ -50,29 +47,3 @@
|
|||||||
background-color: $playback-negative;
|
background-color: $playback-negative;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------> LABELS
|
|
||||||
|
|
||||||
.status {
|
|
||||||
grid-area: status;
|
|
||||||
height: 1.5rem;
|
|
||||||
display: flex;
|
|
||||||
gap: $section-spacing;
|
|
||||||
margin-left: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag {
|
|
||||||
color: $label-gray;
|
|
||||||
font-size: calc(1rem - 2px);
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time {
|
|
||||||
color: $section-white;
|
|
||||||
font-size: $text-body-size;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rolltag {
|
|
||||||
color: $ontime-roll;
|
|
||||||
font-size: $text-body-size;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
import { Tooltip } from '@chakra-ui/react';
|
import { Tooltip } from '@chakra-ui/react';
|
||||||
import { Playback, TimerPhase } from 'ontime-types';
|
import { Playback, TimerPhase } from 'ontime-types';
|
||||||
import { dayInMs, millisToString } from 'ontime-utils';
|
|
||||||
|
|
||||||
import { useTimer } from '../../../../common/hooks/useSocket';
|
import { useTimer } from '../../../../common/hooks/useSocket';
|
||||||
import { formatDuration } from '../../../../common/utils/time';
|
import { formatDuration } from '../../../../common/utils/time';
|
||||||
@@ -29,16 +28,21 @@ export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProp
|
|||||||
const { playback, children } = props;
|
const { playback, children } = props;
|
||||||
const timer = useTimer();
|
const timer = useTimer();
|
||||||
|
|
||||||
const started = millisToString(timer.startedAt);
|
|
||||||
const expectedFinish = timer.expectedFinish !== null ? timer.expectedFinish % dayInMs : null;
|
|
||||||
const finish = millisToString(expectedFinish);
|
|
||||||
|
|
||||||
const isRolling = playback === Playback.Roll;
|
const isRolling = playback === Playback.Roll;
|
||||||
const isWaiting = timer.phase === TimerPhase.Pending;
|
const isWaiting = timer.phase === TimerPhase.Pending;
|
||||||
const isOvertime = timer.phase === TimerPhase.Overtime;
|
const isOvertime = timer.phase === TimerPhase.Overtime;
|
||||||
const hasAddedTime = Boolean(timer.addedTime);
|
const hasAddedTime = Boolean(timer.addedTime);
|
||||||
|
|
||||||
const rollLabel = isRolling ? 'Roll mode active' : '';
|
const rollLabel = (() => {
|
||||||
|
if (!isRolling) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (isWaiting) {
|
||||||
|
return 'Roll: Countdown to start';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Roll mode active';
|
||||||
|
})();
|
||||||
|
|
||||||
const addedTimeLabel = resolveAddedTimeLabel(timer.addedTime);
|
const addedTimeLabel = resolveAddedTimeLabel(timer.addedTime);
|
||||||
|
|
||||||
@@ -54,22 +58,6 @@ export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProp
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<TimerDisplay time={isWaiting ? timer.secondaryTimer : timer.current} />
|
<TimerDisplay time={isWaiting ? timer.secondaryTimer : timer.current} />
|
||||||
<div className={style.status}>
|
|
||||||
{isWaiting ? (
|
|
||||||
<span className={style.rolltag}>Roll: Countdown to start</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className={style.start}>
|
|
||||||
<span className={style.tag}>Started at</span>
|
|
||||||
<span className={style.time}>{started}</span>
|
|
||||||
</span>
|
|
||||||
<span className={style.finish}>
|
|
||||||
<span className={style.tag}>Expect end</span>
|
|
||||||
<span className={style.time}>{finish}</span>
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
color: $timer-color;
|
color: $timer-color;
|
||||||
line-height: 0.9em;
|
line-height: 0.9em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
align-self: center;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 3.5rem;
|
font-size: 3.5rem;
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { Playback, TimerPhase } from 'ontime-types';
|
||||||
|
import { dayInMs } from 'ontime-utils';
|
||||||
|
|
||||||
|
import AppLink from '../../../../common/components/link/app-link/AppLink';
|
||||||
|
import { useClock, useTimerCurrent, useTimerSchedule } from '../../../../common/hooks/useSocket';
|
||||||
|
import useReport from '../../../../common/hooks-query/useReport';
|
||||||
|
import { cx } from '../../../../common/utils/styleUtils';
|
||||||
|
import { formatTime } from '../../../../common/utils/time';
|
||||||
|
|
||||||
|
import style from './TimerSpeed.module.scss';
|
||||||
|
|
||||||
|
interface MeetScheduleProps {
|
||||||
|
speed: number;
|
||||||
|
newSpeed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We isolate this component since it has the potential to re-render on every second
|
||||||
|
*/
|
||||||
|
export default function TimerSchedule(props: MeetScheduleProps) {
|
||||||
|
const { speed, newSpeed } = props;
|
||||||
|
const { startedAt, expectedFinish, phase, playback } = useTimerSchedule();
|
||||||
|
|
||||||
|
const started = formatTime(startedAt);
|
||||||
|
const normalisedExpectedEnd = expectedFinish !== null ? expectedFinish % dayInMs : null;
|
||||||
|
const endTime = formatTime(normalisedExpectedEnd);
|
||||||
|
|
||||||
|
const isWaiting = phase === TimerPhase.Pending;
|
||||||
|
const hasNewSpeed = speed !== newSpeed;
|
||||||
|
|
||||||
|
if (isWaiting) {
|
||||||
|
return <div className={cx([style.entry, style.roll])}>Roll: Countdown to start</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playback === Playback.Stop) {
|
||||||
|
return <StoppedStatus />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.timers}>
|
||||||
|
<div className={style.entry}>
|
||||||
|
<div className={style.timerLabel}>Started at</div>
|
||||||
|
<div>{started}</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.entry}>
|
||||||
|
<div className={style.timerLabel}>Expected end</div>
|
||||||
|
{hasNewSpeed ? <SpeedFinish newSpeed={newSpeed} /> : <div>{endTime}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: extract and test
|
||||||
|
// calculate the new finish time
|
||||||
|
function useExpectedTime(remainingTimeMs: number, speedFactor: number): number {
|
||||||
|
const { clock } = useClock();
|
||||||
|
const adjustedRemainingTimeMs = remainingTimeMs / speedFactor;
|
||||||
|
const newFinishTimeMs = clock + adjustedRemainingTimeMs;
|
||||||
|
return newFinishTimeMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StoppedStatus() {
|
||||||
|
const { data } = useReport();
|
||||||
|
const hasReport = Object.keys(data).length > 0;
|
||||||
|
|
||||||
|
if (hasReport) {
|
||||||
|
return (
|
||||||
|
<AppLink className={style.entry} search='settings=feature_settings__report'>
|
||||||
|
Go to report management
|
||||||
|
</AppLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={style.entry}>No running playback</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpeedFinishProps {
|
||||||
|
newSpeed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpeedFinish(props: SpeedFinishProps) {
|
||||||
|
const { newSpeed } = props;
|
||||||
|
const { current } = useTimerCurrent();
|
||||||
|
|
||||||
|
const newFinish = formatTime(useExpectedTime(current ?? 0, newSpeed));
|
||||||
|
|
||||||
|
return <div className={style.highlight}>{newFinish}</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
.panelContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inlineApart {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inlineSiblings {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timers {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
height: 1.2rem;
|
||||||
|
align-items: end;
|
||||||
|
line-height: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timerLabel {
|
||||||
|
color: $label-gray;
|
||||||
|
font-size: calc(1rem - 3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roll {
|
||||||
|
color: $ontime-roll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight {
|
||||||
|
color: $highlight-orange;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled {
|
||||||
|
opacity: $opacity-disabled;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { memo, useEffect, useState } from 'react';
|
||||||
|
import { Button, Slider, SliderFilledTrack, SliderMark, SliderThumb, SliderTrack } from '@chakra-ui/react';
|
||||||
|
import { MaybeNumber } from 'ontime-types';
|
||||||
|
|
||||||
|
import { setTimerSpeed, useTimerSpeed } from '../../../../common/hooks/useSocket';
|
||||||
|
import { cx } from '../../../../common/utils/styleUtils';
|
||||||
|
|
||||||
|
import TimerSchedule from './TimerSchedule';
|
||||||
|
|
||||||
|
import style from './TimerSpeed.module.scss';
|
||||||
|
|
||||||
|
interface TimerSpeedProps {
|
||||||
|
isPlaying: boolean;
|
||||||
|
eventIndex: MaybeNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(TimerSpeed);
|
||||||
|
|
||||||
|
function TimerSpeed(props: TimerSpeedProps) {
|
||||||
|
const { isPlaying, eventIndex } = props;
|
||||||
|
const { speed } = useTimerSpeed();
|
||||||
|
const [newSpeed, setNewSpeed] = useState(1);
|
||||||
|
const { setSpeed } = setTimerSpeed;
|
||||||
|
|
||||||
|
// when a new timer is set, we want to reset the speed
|
||||||
|
useEffect(() => {
|
||||||
|
setNewSpeed(1);
|
||||||
|
}, [eventIndex]);
|
||||||
|
|
||||||
|
const handleApply = () => setSpeed(newSpeed);
|
||||||
|
const handleReset = () => {
|
||||||
|
setNewSpeed(1.0);
|
||||||
|
setSpeed(1.0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const canReset = isPlaying && speed === newSpeed && newSpeed !== 1;
|
||||||
|
const canApply = isPlaying && speed !== newSpeed;
|
||||||
|
const willChangeSpeed = speed === 1 && !canApply;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.panelContainer}>
|
||||||
|
<TimerSchedule speed={speed} newSpeed={newSpeed} />
|
||||||
|
<div className={style.inlineApart}>
|
||||||
|
<div className={style.entry}>
|
||||||
|
<span className={cx([willChangeSpeed && style.disabled])}>{`${speed}x`}</span>
|
||||||
|
{newSpeed !== speed && <span className={style.highlight}>{` ⇢ ${newSpeed}x`}</span>}
|
||||||
|
</div>
|
||||||
|
<div className={style.inlineSiblings}>
|
||||||
|
<Button size='sm' variant='ontime-subtle-white' onClick={handleReset} isDisabled={!canReset}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button size='sm' variant='ontime-subtle-white' onClick={handleApply} isDisabled={!canApply}>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
variant={newSpeed === 1 ? 'ontime' : 'ontime-highlight'}
|
||||||
|
defaultValue={newSpeed}
|
||||||
|
min={0.5}
|
||||||
|
max={2.0}
|
||||||
|
step={0.01}
|
||||||
|
onChange={(v) => setNewSpeed(v)}
|
||||||
|
value={newSpeed}
|
||||||
|
isDisabled={!isPlaying}
|
||||||
|
>
|
||||||
|
<SliderMark value={0.5}>0.5x</SliderMark>
|
||||||
|
<SliderMark value={1.0}>1.0x</SliderMark>
|
||||||
|
<SliderMark value={1.5}>1.5x</SliderMark>
|
||||||
|
<SliderMark value={2.0}>2.0x</SliderMark>
|
||||||
|
<SliderTrack>
|
||||||
|
<SliderFilledTrack />
|
||||||
|
</SliderTrack>
|
||||||
|
<SliderThumb />
|
||||||
|
</Slider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { Button, Checkbox, Modal, ModalBody, ModalCloseButton, ModalContent, Mod
|
|||||||
import { loadDemo, loadProject } from '../../../common/api/db';
|
import { loadDemo, loadProject } from '../../../common/api/db';
|
||||||
import { postShowWelcomeDialog } from '../../../common/api/settings';
|
import { postShowWelcomeDialog } from '../../../common/api/settings';
|
||||||
import { invalidateAllCaches } from '../../../common/api/utils';
|
import { invalidateAllCaches } from '../../../common/api/utils';
|
||||||
import ExternalLink from '../../../common/components/external-link/ExternalLink';
|
import ExternalLink from '../../../common/components/link/external-link/ExternalLink';
|
||||||
import { appVersion, discordUrl, documentationUrl, websiteUrl } from '../../../externals';
|
import { appVersion, discordUrl, documentationUrl, websiteUrl } from '../../../externals';
|
||||||
import * as Editor from '../editor-utils/EditorUtils';
|
import * as Editor from '../editor-utils/EditorUtils';
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ export default function Welcome(props: WelcomeProps) {
|
|||||||
<table className={style.table}>
|
<table className={style.table}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Project Name</th>
|
<th>File Name</th>
|
||||||
<th>Last Used</th>
|
<th>Last Used</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ahead {
|
.ahead {
|
||||||
color: $green-500;
|
color: $playback-ahead;
|
||||||
}
|
}
|
||||||
|
|
||||||
.behind {
|
.behind {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
isOntimeEvent,
|
isOntimeEvent,
|
||||||
isPlayableEvent,
|
isPlayableEvent,
|
||||||
MaybeString,
|
MaybeString,
|
||||||
|
OntimeEvent,
|
||||||
PlayableEvent,
|
PlayableEvent,
|
||||||
Playback,
|
Playback,
|
||||||
RundownCached,
|
RundownCached,
|
||||||
@@ -269,8 +270,9 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
// all events before the current selected are in the past
|
// all events before the current selected are in the past
|
||||||
let isPast = Boolean(featureData?.selectedEventId);
|
let isPast = Boolean(featureData?.selectedEventId);
|
||||||
let isNextDay = false;
|
let isNextDay = false;
|
||||||
|
let totalGap = 0;
|
||||||
const isEditMode = appMode === AppMode.Edit;
|
const isEditMode = appMode === AppMode.Edit;
|
||||||
|
let currentDay = 0;
|
||||||
return (
|
return (
|
||||||
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
|
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
|
||||||
<DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}>
|
<DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}>
|
||||||
@@ -297,6 +299,7 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
|
|
||||||
if (isPlayableEvent(entry)) {
|
if (isPlayableEvent(entry)) {
|
||||||
isNextDay = checkIsNextDay(entry, lastEvent);
|
isNextDay = checkIsNextDay(entry, lastEvent);
|
||||||
|
totalGap += !isPast ? entry.gap : 0;
|
||||||
if (isNewLatest(entry, lastEvent)) {
|
if (isNewLatest(entry, lastEvent)) {
|
||||||
// populate previous entry
|
// populate previous entry
|
||||||
thisEvent = entry;
|
thisEvent = entry;
|
||||||
@@ -310,6 +313,7 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
const hasCursor = entry.id === cursor;
|
const hasCursor = entry.id === cursor;
|
||||||
if (isLoaded) {
|
if (isLoaded) {
|
||||||
isPast = false;
|
isPast = false;
|
||||||
|
currentDay = (entry as OntimeEvent).dayOffset;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -331,6 +335,8 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
playback={isLoaded ? featureData.playback : undefined}
|
playback={isLoaded ? featureData.playback : undefined}
|
||||||
isRolling={featureData.playback === Playback.Roll}
|
isRolling={featureData.playback === Playback.Roll}
|
||||||
isNextDay={isNextDay}
|
isNextDay={isNextDay}
|
||||||
|
totalGap={totalGap}
|
||||||
|
currentDay={currentDay}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ export type EventItemActions =
|
|||||||
| 'delete'
|
| 'delete'
|
||||||
| 'clone'
|
| 'clone'
|
||||||
| 'update'
|
| 'update'
|
||||||
| 'swap';
|
| 'swap'
|
||||||
|
| 'clear-report';
|
||||||
|
|
||||||
interface RundownEntryProps {
|
interface RundownEntryProps {
|
||||||
type: SupportedEvent;
|
type: SupportedEvent;
|
||||||
@@ -37,6 +38,8 @@ interface RundownEntryProps {
|
|||||||
previousEventId?: string;
|
previousEventId?: string;
|
||||||
playback?: Playback; // we only care about this if this event is playing
|
playback?: Playback; // we only care about this if this event is playing
|
||||||
isRolling: boolean; // we need to know even if not related to this event
|
isRolling: boolean; // we need to know even if not related to this event
|
||||||
|
totalGap: number;
|
||||||
|
currentDay: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RundownEntry(props: RundownEntryProps) {
|
export default function RundownEntry(props: RundownEntryProps) {
|
||||||
@@ -52,6 +55,8 @@ export default function RundownEntry(props: RundownEntryProps) {
|
|||||||
isRolling,
|
isRolling,
|
||||||
eventIndex,
|
eventIndex,
|
||||||
isNextDay,
|
isNextDay,
|
||||||
|
totalGap,
|
||||||
|
currentDay,
|
||||||
} = props;
|
} = props;
|
||||||
const { emitError } = useEmitLog();
|
const { emitError } = useEmitLog();
|
||||||
const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction();
|
const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction();
|
||||||
@@ -173,6 +178,8 @@ export default function RundownEntry(props: RundownEntryProps) {
|
|||||||
isRolling={isRolling}
|
isRolling={isRolling}
|
||||||
gap={data.gap}
|
gap={data.gap}
|
||||||
isNextDay={isNextDay}
|
isNextDay={isNextDay}
|
||||||
|
dayOffset={data.dayOffset - currentDay}
|
||||||
|
totalGap={totalGap}
|
||||||
actionHandler={actionHandler}
|
actionHandler={actionHandler}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ $skip-opacity: 0.2;
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
'binder ... ... ...'
|
'binder ... ... ...'
|
||||||
'binder pb-actions times ...'
|
'binder pb-actions times chip'
|
||||||
'binder pb-actions title title'
|
'binder pb-actions title title'
|
||||||
'binder pb-actions estatus estatus'
|
'binder pb-actions estatus estatus'
|
||||||
'binder ... ... ...';
|
'binder ... ... ...';
|
||||||
@@ -131,6 +131,10 @@ $skip-opacity: 0.2;
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chipSection {
|
||||||
|
grid-area: chip;
|
||||||
|
}
|
||||||
|
|
||||||
.titleSection {
|
.titleSection {
|
||||||
grid-area: title;
|
grid-area: title;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ interface EventBlockProps {
|
|||||||
isRolling: boolean;
|
isRolling: boolean;
|
||||||
gap: number;
|
gap: number;
|
||||||
isNextDay: boolean;
|
isNextDay: boolean;
|
||||||
|
dayOffset: number;
|
||||||
|
totalGap: number;
|
||||||
actionHandler: (
|
actionHandler: (
|
||||||
action: EventItemActions,
|
action: EventItemActions,
|
||||||
payload?:
|
payload?:
|
||||||
@@ -87,6 +89,8 @@ export default function EventBlock(props: EventBlockProps) {
|
|||||||
isRolling,
|
isRolling,
|
||||||
gap,
|
gap,
|
||||||
isNextDay,
|
isNextDay,
|
||||||
|
dayOffset,
|
||||||
|
totalGap,
|
||||||
actionHandler,
|
actionHandler,
|
||||||
} = props;
|
} = props;
|
||||||
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
|
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
|
||||||
@@ -171,7 +175,7 @@ export default function EventBlock(props: EventBlockProps) {
|
|||||||
},
|
},
|
||||||
isDisabled: selectedEventId == null || selectedEventId === eventId,
|
isDisabled: selectedEventId == null || selectedEventId === eventId,
|
||||||
},
|
},
|
||||||
{ withDivider: true, label: 'Clone', icon: IoDuplicateOutline, onClick: () => actionHandler('clone') },
|
{ withDivider: false, label: 'Clone', icon: IoDuplicateOutline, onClick: () => actionHandler('clone') },
|
||||||
{ withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
|
{ withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -303,6 +307,9 @@ export default function EventBlock(props: EventBlockProps) {
|
|||||||
loaded={loaded}
|
loaded={loaded}
|
||||||
playback={playback}
|
playback={playback}
|
||||||
isRolling={isRolling}
|
isRolling={isRolling}
|
||||||
|
dayOffset={dayOffset}
|
||||||
|
isPast={isPast}
|
||||||
|
totalGap={totalGap}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward'
|
|||||||
import { IoStop } from '@react-icons/all-files/io5/IoStop';
|
import { IoStop } from '@react-icons/all-files/io5/IoStop';
|
||||||
import { IoTime } from '@react-icons/all-files/io5/IoTime';
|
import { IoTime } from '@react-icons/all-files/io5/IoTime';
|
||||||
import { EndAction, MaybeString, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
import { EndAction, MaybeString, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||||
|
import { dayInMs } from 'ontime-utils';
|
||||||
|
|
||||||
import { cx } from '../../../common/utils/styleUtils';
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||||
import EditableBlockTitle from '../common/EditableBlockTitle';
|
import EditableBlockTitle from '../common/EditableBlockTitle';
|
||||||
import TimeInputFlow from '../time-input-flow/TimeInputFlow';
|
import TimeInputFlow from '../time-input-flow/TimeInputFlow';
|
||||||
|
|
||||||
|
import EventBlockChip from './composite/EventBlockChip';
|
||||||
import EventBlockPlayback from './composite/EventBlockPlayback';
|
import EventBlockPlayback from './composite/EventBlockPlayback';
|
||||||
import EventBlockProgressBar from './composite/EventBlockProgressBar';
|
import EventBlockProgressBar from './composite/EventBlockProgressBar';
|
||||||
|
|
||||||
@@ -42,6 +44,9 @@ interface EventBlockInnerProps {
|
|||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
playback?: Playback;
|
playback?: Playback;
|
||||||
isRolling: boolean;
|
isRolling: boolean;
|
||||||
|
dayOffset: number;
|
||||||
|
isPast: boolean;
|
||||||
|
totalGap: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventBlockInner(props: EventBlockInnerProps) {
|
function EventBlockInner(props: EventBlockInnerProps) {
|
||||||
@@ -64,6 +69,9 @@ function EventBlockInner(props: EventBlockInnerProps) {
|
|||||||
loaded,
|
loaded,
|
||||||
playback,
|
playback,
|
||||||
isRolling,
|
isRolling,
|
||||||
|
dayOffset,
|
||||||
|
isPast,
|
||||||
|
totalGap,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const [renderInner, setRenderInner] = useState(false);
|
const [renderInner, setRenderInner] = useState(false);
|
||||||
@@ -108,6 +116,18 @@ function EventBlockInner(props: EventBlockInnerProps) {
|
|||||||
loaded={loaded}
|
loaded={loaded}
|
||||||
disablePlayback={skip || isRolling}
|
disablePlayback={skip || isRolling}
|
||||||
/>
|
/>
|
||||||
|
{!skip && (
|
||||||
|
<EventBlockChip
|
||||||
|
className={style.chipSection}
|
||||||
|
id={eventId}
|
||||||
|
trueTimeStart={timeStart + dayOffset * dayInMs}
|
||||||
|
isPast={isPast}
|
||||||
|
isLoaded={loaded}
|
||||||
|
totalGap={totalGap}
|
||||||
|
isLinkedAndNext={isNext && linkStart !== null}
|
||||||
|
duration={duration}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className={style.statusElements} id='block-status' data-ispublic={isPublic}>
|
<div className={style.statusElements} id='block-status' data-ispublic={isPublic}>
|
||||||
<span className={style.eventNote}>{note}</span>
|
<span className={style.eventNote}>{note}</span>
|
||||||
<div className={loaded ? style.progressBg : `${style.progressBg} ${style.hidden}`}>
|
<div className={loaded ? style.progressBg : `${style.progressBg} ${style.hidden}`}>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
.chip {
|
||||||
|
background-color: $gray-1100;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
font-size: calc(1rem - 3px);
|
||||||
|
color: $label-gray;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
|
||||||
|
&.over {
|
||||||
|
color: $ontime-delay-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.under {
|
||||||
|
color: $playback-ahead;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.due {
|
||||||
|
color: $warning-orange;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Tooltip } from '@chakra-ui/react';
|
||||||
|
import { IoCheckmarkCircle } from '@react-icons/all-files/io5/IoCheckmarkCircle';
|
||||||
|
import { isPlaybackActive, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { usePlayback, useTimelineStatus } from '../../../../common/hooks/useSocket';
|
||||||
|
import useReport from '../../../../common/hooks-query/useReport';
|
||||||
|
import { cx } from '../../../../common/utils/styleUtils';
|
||||||
|
import { formatDuration, formatTime } from '../../../../common/utils/time';
|
||||||
|
import { tooltipDelayFast } from '../../../../ontimeConfig';
|
||||||
|
|
||||||
|
import style from './EventBlockChip.module.scss';
|
||||||
|
|
||||||
|
interface EventBlockChipProps {
|
||||||
|
id: string;
|
||||||
|
trueTimeStart: number;
|
||||||
|
isPast: boolean;
|
||||||
|
isLoaded: boolean;
|
||||||
|
className: string;
|
||||||
|
totalGap: number;
|
||||||
|
isLinkedAndNext: boolean;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventBlockChip(props: EventBlockChipProps) {
|
||||||
|
const { trueTimeStart, isPast, isLoaded, className, totalGap, isLinkedAndNext, id, duration } = props;
|
||||||
|
const { playback } = usePlayback();
|
||||||
|
|
||||||
|
if (isLoaded) {
|
||||||
|
return null; //TODO: the is a small flash of 'DUE' on the loaded event as clock data arrives before isLoaded propagates
|
||||||
|
}
|
||||||
|
|
||||||
|
const playbackActive = isPlaybackActive(playback);
|
||||||
|
|
||||||
|
if (!playbackActive || isPast) {
|
||||||
|
return <EventReport className={className} id={id} duration={duration} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playbackActive) {
|
||||||
|
// we extracted the component to avoid unnecessary calculations and re-renders
|
||||||
|
return (
|
||||||
|
<Tooltip label='Expected time until start' openDelay={tooltipDelayFast}>
|
||||||
|
<div className={className}>
|
||||||
|
<EventUntil trueTimeStart={trueTimeStart} totalGap={totalGap} isLinkedAndNext={isLinkedAndNext} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventUntilProps {
|
||||||
|
trueTimeStart: number;
|
||||||
|
totalGap: number;
|
||||||
|
isLinkedAndNext: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventUntil(props: EventUntilProps) {
|
||||||
|
const { trueTimeStart, totalGap, isLinkedAndNext } = props;
|
||||||
|
const { clock, offset } = useTimelineStatus();
|
||||||
|
|
||||||
|
const consumedOffset = isLinkedAndNext ? offset : Math.min(offset + totalGap, 0);
|
||||||
|
const offsetTimestart = trueTimeStart - consumedOffset;
|
||||||
|
const timeUntil = offsetTimestart - clock;
|
||||||
|
const isDue = timeUntil < MILLIS_PER_SECOND;
|
||||||
|
|
||||||
|
const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`;
|
||||||
|
|
||||||
|
return <div className={cx([style.chip, isDue && style.due])}>{timeUntilString}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventReportProps {
|
||||||
|
className: string;
|
||||||
|
id: string;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventReport(props: EventReportProps) {
|
||||||
|
const { className, id, duration } = props;
|
||||||
|
const { data } = useReport();
|
||||||
|
const currentReport = data[id];
|
||||||
|
|
||||||
|
const [value, overUnderStyle, tooltip] = useMemo(() => {
|
||||||
|
if (!currentReport) {
|
||||||
|
return [null, 'none', ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startedAt, endedAt } = currentReport;
|
||||||
|
if (!startedAt || !endedAt) {
|
||||||
|
return [null, 'none', ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualDuration = endedAt - startedAt;
|
||||||
|
const difference = actualDuration - duration;
|
||||||
|
const absDifference = Math.abs(difference);
|
||||||
|
|
||||||
|
if (absDifference < MILLIS_PER_SECOND) {
|
||||||
|
return ['ontime', 'ontime', 'Event finished ontime'];
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOver = difference > 0;
|
||||||
|
|
||||||
|
const fullTimeValue = formatTime(absDifference);
|
||||||
|
|
||||||
|
const tooltip = `Event ran ${isOver ? 'over' : 'under'} time by ${fullTimeValue}`;
|
||||||
|
|
||||||
|
const value = `${isOver ? '+' : '-'}${formatDuration(absDifference, absDifference > 2 * MILLIS_PER_MINUTE)}`;
|
||||||
|
return [value, isOver ? 'over' : 'under', tooltip];
|
||||||
|
}, [currentReport, duration]);
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip label={tooltip} openDelay={tooltipDelayFast}>
|
||||||
|
<div className={cx([style.chip, style[overUnderStyle], className])}>
|
||||||
|
{value === 'ontime' ? <IoCheckmarkCircle size='1.1rem' /> : value}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -78,3 +78,9 @@
|
|||||||
font-size: 1.25em;
|
font-size: 1.25em;
|
||||||
margin-left: 0.25em;
|
margin-left: 0.25em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.customImage {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 72px;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { CSSProperties, useCallback } from 'react';
|
import { CSSProperties, useCallback } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
|
||||||
import { Button } from '@chakra-ui/react';
|
|
||||||
import { CustomFieldLabel, OntimeEvent } from 'ontime-types';
|
import { CustomFieldLabel, OntimeEvent } from 'ontime-types';
|
||||||
|
|
||||||
|
import AppLink from '../../../common/components/link/app-link/AppLink';
|
||||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||||
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
||||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||||
import * as Editor from '../../editors/editor-utils/EditorUtils';
|
import * as Editor from '../../editors/editor-utils/EditorUtils';
|
||||||
|
|
||||||
|
import EventEditorImage from './composite/EventEditorImage';
|
||||||
import EventEditorTimes from './composite/EventEditorTimes';
|
import EventEditorTimes from './composite/EventEditorTimes';
|
||||||
import EventEditorTitles from './composite/EventEditorTitles';
|
import EventEditorTitles from './composite/EventEditorTitles';
|
||||||
import EventTextArea from './composite/EventTextArea';
|
import EventTextArea from './composite/EventTextArea';
|
||||||
|
import EventTextInput from './composite/EventTextInput';
|
||||||
import EventEditorEmpty from './EventEditorEmpty';
|
import EventEditorEmpty from './EventEditorEmpty';
|
||||||
|
|
||||||
import style from './EventEditor.module.scss';
|
import style from './EventEditor.module.scss';
|
||||||
@@ -27,7 +28,6 @@ export default function EventEditor(props: EventEditorProps) {
|
|||||||
const { event } = props;
|
const { event } = props;
|
||||||
const { data: customFields } = useCustomFields();
|
const { data: customFields } = useCustomFields();
|
||||||
const { updateEvent } = useEventAction();
|
const { updateEvent } = useEventAction();
|
||||||
const [_searchParams, setSearchParams] = useSearchParams();
|
|
||||||
|
|
||||||
const isEditor = window.location.pathname.includes('editor');
|
const isEditor = window.location.pathname.includes('editor');
|
||||||
|
|
||||||
@@ -43,10 +43,6 @@ export default function EventEditor(props: EventEditorProps) {
|
|||||||
[event?.id, updateEvent],
|
[event?.id, updateEvent],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenCustomManager = () => {
|
|
||||||
setSearchParams({ settings: 'feature_settings__custom' });
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return <EventEditorEmpty />;
|
return <EventEditorEmpty />;
|
||||||
}
|
}
|
||||||
@@ -81,12 +77,9 @@ export default function EventEditor(props: EventEditorProps) {
|
|||||||
<div className={style.column}>
|
<div className={style.column}>
|
||||||
<Editor.Title>
|
<Editor.Title>
|
||||||
Custom Fields
|
Custom Fields
|
||||||
{isEditor && (
|
{isEditor && <AppLink search='settings=feature_settings__custom'>Manage</AppLink>}
|
||||||
<Button variant='ontime-subtle' size='sm' onClick={handleOpenCustomManager}>
|
|
||||||
Manage
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Editor.Title>
|
</Editor.Title>
|
||||||
|
|
||||||
{Object.keys(customFields).map((fieldKey) => {
|
{Object.keys(customFields).map((fieldKey) => {
|
||||||
const key = `${event.id}-${fieldKey}`;
|
const key = `${event.id}-${fieldKey}`;
|
||||||
const fieldName = `custom-${fieldKey}`;
|
const fieldName = `custom-${fieldKey}`;
|
||||||
@@ -94,17 +87,41 @@ export default function EventEditor(props: EventEditorProps) {
|
|||||||
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
|
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
|
||||||
const labelText = customFields[fieldKey].label;
|
const labelText = customFields[fieldKey].label;
|
||||||
|
|
||||||
return (
|
if (customFields[fieldKey].type === 'string') {
|
||||||
<EventTextArea
|
return (
|
||||||
key={key}
|
<EventTextArea
|
||||||
field={fieldName}
|
key={key}
|
||||||
label={labelText}
|
field={fieldName}
|
||||||
initialValue={initialValue}
|
label={labelText}
|
||||||
submitHandler={handleSubmit}
|
initialValue={initialValue}
|
||||||
className={style.decorated}
|
submitHandler={handleSubmit}
|
||||||
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
|
className={style.decorated}
|
||||||
/>
|
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
|
||||||
);
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customFields[fieldKey].type === 'image') {
|
||||||
|
return (
|
||||||
|
<div key={key} className={style.customImage}>
|
||||||
|
<EventTextInput
|
||||||
|
key={key}
|
||||||
|
field={fieldName}
|
||||||
|
label={labelText}
|
||||||
|
initialValue={initialValue}
|
||||||
|
placeholder='Paste image URL'
|
||||||
|
submitHandler={handleSubmit}
|
||||||
|
className={style.decorated}
|
||||||
|
maxLength={255}
|
||||||
|
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
|
||||||
|
/>
|
||||||
|
<EventEditorImage src={initialValue} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// we should have exhausted all types by now
|
||||||
|
return null;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
.imageContainer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: $gray-1250;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageOverlay {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import style from './EventEditorImage.module.scss';
|
||||||
|
|
||||||
|
interface EventEditorImageProps {
|
||||||
|
src: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventEditorImage(props: EventEditorImageProps) {
|
||||||
|
const { src } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.imageContainer}>
|
||||||
|
<img loading='lazy' src={src} />
|
||||||
|
<div className={style.imageOverlay} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,11 +9,12 @@ interface EventTextInputProps extends InputProps {
|
|||||||
field: EditorUpdateFields;
|
field: EditorUpdateFields;
|
||||||
label: string;
|
label: string;
|
||||||
initialValue: string;
|
initialValue: string;
|
||||||
|
placeholder?: string;
|
||||||
submitHandler: (field: EditorUpdateFields, value: string) => void;
|
submitHandler: (field: EditorUpdateFields, value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EventTextInput(props: EventTextInputProps) {
|
export default function EventTextInput(props: EventTextInputProps) {
|
||||||
const { field, label, initialValue, submitHandler, maxLength } = props;
|
const { className, field, label, initialValue, style: givenStyles, submitHandler, maxLength, placeholder } = props;
|
||||||
const ref = useRef<HTMLInputElement | null>(null);
|
const ref = useRef<HTMLInputElement | null>(null);
|
||||||
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
|
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
|
||||||
|
|
||||||
@@ -23,7 +24,9 @@ export default function EventTextInput(props: EventTextInputProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Editor.Label htmlFor={field}>{label}</Editor.Label>
|
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
|
||||||
|
{label}
|
||||||
|
</Editor.Label>
|
||||||
<Input
|
<Input
|
||||||
id={field}
|
id={field}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -32,6 +35,7 @@ export default function EventTextInput(props: EventTextInputProps) {
|
|||||||
data-testid='input-textfield'
|
data-testid='input-textfield'
|
||||||
value={value}
|
value={value}
|
||||||
maxLength={maxLength || 100}
|
maxLength={maxLength || 100}
|
||||||
|
placeholder={placeholder}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
|
|||||||
@@ -25,6 +25,14 @@
|
|||||||
top: 2vw;
|
top: 2vw;
|
||||||
left: 2vw;
|
left: 2vw;
|
||||||
max-width: min(200px, 20vw);
|
max-width: min(200px, 20vw);
|
||||||
max-height: min(100px, 20vh);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =================== MOBILE ===================*/
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.clock-view {
|
||||||
|
.logo img {
|
||||||
|
height: min(50px, 10vh);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,6 @@
|
|||||||
top: 2vw;
|
top: 2vw;
|
||||||
left: 2vw;
|
left: 2vw;
|
||||||
max-width: min(200px, 20vw);
|
max-width: min(200px, 20vw);
|
||||||
max-height: min(100px, 20vh);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =================== MAIN - EVENT CONTAINER ===================*/
|
/* =================== MAIN - EVENT CONTAINER ===================*/
|
||||||
@@ -175,3 +174,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* =================== MOBILE ===================*/
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.countdown {
|
||||||
|
.logo img {
|
||||||
|
height: min(50px, 10vh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,6 +56,15 @@
|
|||||||
top: 2vw;
|
top: 2vw;
|
||||||
left: 2vw;
|
left: 2vw;
|
||||||
max-width: min(200px, 20vw);
|
max-width: min(200px, 20vw);
|
||||||
max-height: min(100px, 20vh);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* =================== MOBILE ===================*/
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.minimal-timer {
|
||||||
|
.logo img {
|
||||||
|
height: min(50px, 10vh);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ $white-60: rgba(255, 255, 255, 0.60);
|
|||||||
$white-90: rgba(255, 255, 255, 0.90);
|
$white-90: rgba(255, 255, 255, 0.90);
|
||||||
|
|
||||||
$black-10: rgba(0, 0, 0, 0.10);
|
$black-10: rgba(0, 0, 0, 0.10);
|
||||||
|
$black-60: rgba(0, 0, 0, 0.60);
|
||||||
|
|
||||||
$gray-50: #f6f6f6;
|
$gray-50: #f6f6f6;
|
||||||
$gray-100: #ececec;
|
$gray-100: #ececec;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ $warning-orange: $orange-500;
|
|||||||
$info-blue: $blue-500;
|
$info-blue: $blue-500;
|
||||||
$opacity-disabled: 0.4;
|
$opacity-disabled: 0.4;
|
||||||
$active-red: $red-700;
|
$active-red: $red-700;
|
||||||
|
$highlight-orange: $orange-600;
|
||||||
|
|
||||||
// playback colours
|
// playback colours
|
||||||
$playback-start: $green-600;
|
$playback-start: $green-600;
|
||||||
@@ -25,6 +26,7 @@ $ontime-delay-text: #E69056;
|
|||||||
$ontime-paused: #c05621;
|
$ontime-paused: #c05621;
|
||||||
$ontime-stop: #E4281E;
|
$ontime-stop: #E4281E;
|
||||||
$playback-negative: $red-500;
|
$playback-negative: $red-500;
|
||||||
|
$playback-ahead: $green-500;
|
||||||
$active-indicator: #8bb33d;
|
$active-indicator: #8bb33d;
|
||||||
$text-black: $gray-1350;
|
$text-black: $gray-1350;
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const ontimeButtonFilled = {
|
|||||||
|
|
||||||
export const ontimeButtonOutlined = {
|
export const ontimeButtonOutlined = {
|
||||||
backgroundColor: '#2d2d2d', // $gray-1100
|
backgroundColor: '#2d2d2d', // $gray-1100
|
||||||
color: '#e2e2e2', // $blue-400
|
color: '#e2e2e2', // $gray-200
|
||||||
border: '1px solid rgba(255, 255, 255, 0.10)', // white-10
|
border: '1px solid rgba(255, 255, 255, 0.10)', // white-10
|
||||||
_hover: {
|
_hover: {
|
||||||
backgroundColor: '#404040', // $gray-1000
|
backgroundColor: '#404040', // $gray-1000
|
||||||
@@ -74,5 +74,4 @@ export const ontimeButtonGhosted = {
|
|||||||
export const ontimeButtonSubtleWhite = {
|
export const ontimeButtonSubtleWhite = {
|
||||||
...ontimeButtonSubtle,
|
...ontimeButtonSubtle,
|
||||||
color: '#f6f6f6', // $gray-50
|
color: '#f6f6f6', // $gray-50
|
||||||
fontWeight: 600,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export const ontimeSlider = {
|
||||||
|
thumb: {
|
||||||
|
background: '#f6f6f6', // $ui-white
|
||||||
|
width: '0.25rem',
|
||||||
|
},
|
||||||
|
filledTrack: {
|
||||||
|
background: '#779BE7', // $blue-400
|
||||||
|
},
|
||||||
|
track: {
|
||||||
|
background: '#303030', // $gray-1050
|
||||||
|
},
|
||||||
|
mark: {
|
||||||
|
color: '#b1b1b1', // $label-gray
|
||||||
|
mt: '2',
|
||||||
|
ml: '-2.5',
|
||||||
|
fontSize: 'calc(1rem - 3px)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ontimeHighlightSlider = {
|
||||||
|
...ontimeSlider,
|
||||||
|
filledTrack: {
|
||||||
|
background: '#FFAB33', // $highlight-orange
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -16,6 +16,7 @@ import { ontimeMenuOnDark } from './ontimeMenu';
|
|||||||
import { ontimeModal } from './ontimeModal';
|
import { ontimeModal } from './ontimeModal';
|
||||||
import { ontimeBlockRadio, ontimeRadio } from './ontimeRadio';
|
import { ontimeBlockRadio, ontimeRadio } from './ontimeRadio';
|
||||||
import { ontimeSelect } from './ontimeSelect';
|
import { ontimeSelect } from './ontimeSelect';
|
||||||
|
import { ontimeHighlightSlider, ontimeSlider } from './ontimeSlider';
|
||||||
import { ontimeSwitch } from './ontimeSwitch';
|
import { ontimeSwitch } from './ontimeSwitch';
|
||||||
import { ontimeTab } from './ontimeTab';
|
import { ontimeTab } from './ontimeTab';
|
||||||
import {
|
import {
|
||||||
@@ -140,6 +141,12 @@ const theme = extendTheme({
|
|||||||
ontime: { ...ontimeSelect },
|
ontime: { ...ontimeSelect },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Slider: {
|
||||||
|
variants: {
|
||||||
|
ontime: { ...ontimeSlider },
|
||||||
|
'ontime-highlight': { ...ontimeHighlightSlider },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -24,4 +24,10 @@ export const langDe: TranslationObject = {
|
|||||||
'timeline.done': 'Beendet',
|
'timeline.done': 'Beendet',
|
||||||
'timeline.due': 'fällig',
|
'timeline.due': 'fällig',
|
||||||
'timeline.followedby': 'Gefolgt von',
|
'timeline.followedby': 'Gefolgt von',
|
||||||
|
'project.title': 'Titel',
|
||||||
|
'project.description': 'Beschreibung',
|
||||||
|
'project.backstage_info': 'Backstage-Informationen',
|
||||||
|
'project.backstage_url': 'Backstage-URL',
|
||||||
|
'project.public_info': 'Öffentliche Informationen',
|
||||||
|
'project.public_url': 'Öffentliche URL',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ export const langEn = {
|
|||||||
'timeline.done': 'done',
|
'timeline.done': 'done',
|
||||||
'timeline.due': 'due',
|
'timeline.due': 'due',
|
||||||
'timeline.followedby': 'Followed by',
|
'timeline.followedby': 'Followed by',
|
||||||
|
'project.title': 'Title',
|
||||||
|
'project.description': 'Description',
|
||||||
|
'project.backstage_info': 'Backstage Info',
|
||||||
|
'project.backstage_url': 'Backstage URL',
|
||||||
|
'project.public_info': 'Public Info',
|
||||||
|
'project.public_url': 'Public URL',
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TranslationObject = Record<keyof typeof langEn, string>;
|
export type TranslationObject = Record<keyof typeof langEn, string>;
|
||||||
|
|||||||
@@ -24,4 +24,10 @@ export const langEs: TranslationObject = {
|
|||||||
'timeline.done': 'Terminado',
|
'timeline.done': 'Terminado',
|
||||||
'timeline.due': 'pendiente',
|
'timeline.due': 'pendiente',
|
||||||
'timeline.followedby': 'Seguido por',
|
'timeline.followedby': 'Seguido por',
|
||||||
|
'project.title': 'Título',
|
||||||
|
'project.description': 'Descripción',
|
||||||
|
'project.backstage_info': 'Información de backstage',
|
||||||
|
'project.backstage_url': 'URL de backstage',
|
||||||
|
'project.public_info': 'Información pública',
|
||||||
|
'project.public_url': 'URL pública',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,4 +24,10 @@ export const langFr: TranslationObject = {
|
|||||||
'timeline.done': 'Terminé',
|
'timeline.done': 'Terminé',
|
||||||
'timeline.due': 'dû',
|
'timeline.due': 'dû',
|
||||||
'timeline.followedby': 'Suivi de',
|
'timeline.followedby': 'Suivi de',
|
||||||
|
'project.title': 'Titre',
|
||||||
|
'project.description': 'Description',
|
||||||
|
'project.backstage_info': 'Informations des coulisses',
|
||||||
|
'project.backstage_url': 'URL des coulisses',
|
||||||
|
'project.public_info': 'Informations publiques',
|
||||||
|
'project.public_url': 'URL publique',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,4 +24,10 @@ export const langHu: TranslationObject = {
|
|||||||
'timeline.done': 'kész',
|
'timeline.done': 'kész',
|
||||||
'timeline.due': 'esedékes',
|
'timeline.due': 'esedékes',
|
||||||
'timeline.followedby': 'Követi',
|
'timeline.followedby': 'Követi',
|
||||||
|
'project.title': 'Cím',
|
||||||
|
'project.description': 'Leírás',
|
||||||
|
'project.backstage_info': 'Kulisszák mögötti információ',
|
||||||
|
'project.backstage_url': 'Kulisszák mögötti URL',
|
||||||
|
'project.public_info': 'Nyilvános információ',
|
||||||
|
'project.public_url': 'Nyilvános URL',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,4 +24,10 @@ export const langIt: TranslationObject = {
|
|||||||
'timeline.done': 'Terminato',
|
'timeline.done': 'Terminato',
|
||||||
'timeline.due': 'previsto',
|
'timeline.due': 'previsto',
|
||||||
'timeline.followedby': 'Seguito da',
|
'timeline.followedby': 'Seguito da',
|
||||||
|
'project.title': 'Titolo',
|
||||||
|
'project.description': 'Descrizione',
|
||||||
|
'project.backstage_info': 'Informazioni di backstage',
|
||||||
|
'project.backstage_url': 'URL di backstage',
|
||||||
|
'project.public_info': 'Informazioni pubbliche',
|
||||||
|
'project.public_url': 'URL pubblico',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,4 +24,10 @@ export const langNo: TranslationObject = {
|
|||||||
'timeline.done': 'Ferdig',
|
'timeline.done': 'Ferdig',
|
||||||
'timeline.due': 'Venter',
|
'timeline.due': 'Venter',
|
||||||
'timeline.followedby': 'Etterfulgt av',
|
'timeline.followedby': 'Etterfulgt av',
|
||||||
|
'project.title': 'Tittel',
|
||||||
|
'project.description': 'Beskrivelse',
|
||||||
|
'project.backstage_info': 'Backstage-informasjon',
|
||||||
|
'project.backstage_url': 'Backstage-URL',
|
||||||
|
'project.public_info': 'Offentlig informasjon',
|
||||||
|
'project.public_url': 'Offentlig URL',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,4 +24,10 @@ export const langPl: TranslationObject = {
|
|||||||
'timeline.done': 'Zakończony',
|
'timeline.done': 'Zakończony',
|
||||||
'timeline.due': 'termin',
|
'timeline.due': 'termin',
|
||||||
'timeline.followedby': 'Następnie',
|
'timeline.followedby': 'Następnie',
|
||||||
|
'project.title': 'Tytuł',
|
||||||
|
'project.description': 'Opis',
|
||||||
|
'project.backstage_info': 'Informacje zaplecza',
|
||||||
|
'project.backstage_url': 'URL zaplecza',
|
||||||
|
'project.public_info': 'Informacje publiczne',
|
||||||
|
'project.public_url': 'URL publiczny',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,4 +24,10 @@ export const langPt: TranslationObject = {
|
|||||||
'timeline.done': 'Concluído',
|
'timeline.done': 'Concluído',
|
||||||
'timeline.due': 'Pendente',
|
'timeline.due': 'Pendente',
|
||||||
'timeline.followedby': 'Seguido por',
|
'timeline.followedby': 'Seguido por',
|
||||||
|
'project.title': 'Título',
|
||||||
|
'project.description': 'Descrição',
|
||||||
|
'project.backstage_info': 'Informações de bastidores',
|
||||||
|
'project.backstage_url': 'URL de bastidores',
|
||||||
|
'project.public_info': 'Informações públicas',
|
||||||
|
'project.public_url': 'URL pública',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,4 +24,10 @@ export const langSv: TranslationObject = {
|
|||||||
'timeline.done': 'Avslutad',
|
'timeline.done': 'Avslutad',
|
||||||
'timeline.due': 'Väntande',
|
'timeline.due': 'Väntande',
|
||||||
'timeline.followedby': 'Följt av',
|
'timeline.followedby': 'Följt av',
|
||||||
|
'project.title': 'Titel',
|
||||||
|
'project.description': 'Beskrivning',
|
||||||
|
'project.backstage_info': 'Backstageinformation',
|
||||||
|
'project.backstage_url': 'Backstage-URL',
|
||||||
|
'project.public_info': 'Offentlig information',
|
||||||
|
'project.public_url': 'Offentlig URL',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,4 +24,10 @@ export const langZhCn: TranslationObject = {
|
|||||||
'timeline.done': '已结束',
|
'timeline.done': '已结束',
|
||||||
'timeline.due': '即将开始',
|
'timeline.due': '即将开始',
|
||||||
'timeline.followedby': '随后是',
|
'timeline.followedby': '随后是',
|
||||||
|
'project.title': '标题',
|
||||||
|
'project.description': '描述',
|
||||||
|
'project.backstage_info': '后台信息',
|
||||||
|
'project.backstage_url': '后台网址',
|
||||||
|
'project.public_info': '公开信息',
|
||||||
|
'project.public_url': '公开网址',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,7 +44,6 @@
|
|||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
max-width: min(200px, 20vw);
|
max-width: min(200px, 20vw);
|
||||||
max-height: min(100px, 20vh);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
@@ -184,7 +183,7 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo img {
|
||||||
height: min(50px, 10vh);
|
height: min(50px, 10vh);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export default function Backstage(props: BackstageProps) {
|
|||||||
// gather presentation styles
|
// gather presentation styles
|
||||||
const qrSize = Math.max(window.innerWidth / 15, 72);
|
const qrSize = Math.max(window.innerWidth / 15, 72);
|
||||||
const showProgress = getShowProgressBar(time.playback);
|
const showProgress = getShowProgressBar(time.playback);
|
||||||
const showSchedule = hasEvents && screenHeight > 700; // in vertical screens we may not have space
|
const showSchedule = hasEvents && screenHeight > 420; // in vertical screens we may not have space
|
||||||
|
|
||||||
// gather option data
|
// gather option data
|
||||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default memo(ScheduleExport);
|
|||||||
function ScheduleExport(props: ScheduleExportProps) {
|
function ScheduleExport(props: ScheduleExportProps) {
|
||||||
const { selectedId, isBackstage } = props;
|
const { selectedId, isBackstage } = props;
|
||||||
return (
|
return (
|
||||||
<ScheduleProvider selectedEventId={selectedId} isBackstage>
|
<ScheduleProvider selectedEventId={selectedId} isBackstage={isBackstage}>
|
||||||
<ScheduleNav className='schedule-nav-container' />
|
<ScheduleNav className='schedule-nav-container' />
|
||||||
<Schedule isProduction={isBackstage} className='schedule-container' />
|
<Schedule isProduction={isBackstage} className='schedule-container' />
|
||||||
</ScheduleProvider>
|
</ScheduleProvider>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ProjectData } from 'ontime-types';
|
import { ProjectData } from 'ontime-types';
|
||||||
|
|
||||||
import { makeCSV, makeTable, parseField } from '../cuesheet.utils';
|
import { makeTable, parseField } from '../cuesheet.utils';
|
||||||
|
|
||||||
describe('parseField()', () => {
|
describe('parseField()', () => {
|
||||||
it('returns a string from given millis on timeStart, TimeEnd and duration', () => {
|
it('returns a string from given millis on timeStart, TimeEnd and duration', () => {
|
||||||
@@ -114,15 +114,3 @@ describe('makeTable()', () => {
|
|||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('make CSV()', () => {
|
|
||||||
it('joins an array of arrays with commas and newlines', () => {
|
|
||||||
const testdata = [['field'], ['after newline', 'after comma'], ['', 'after empty']];
|
|
||||||
expect(makeCSV(testdata)).toMatchInlineSnapshot(`
|
|
||||||
"field
|
|
||||||
after newline,after comma
|
|
||||||
,after empty
|
|
||||||
"
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useRef } from 'react';
|
import { useCallback, useRef } from 'react';
|
||||||
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||||
import Color from 'color';
|
import Color from 'color';
|
||||||
import {
|
import {
|
||||||
isOntimeBlock,
|
isOntimeBlock,
|
||||||
@@ -165,7 +165,8 @@ export default function CuesheetTable(props: CuesheetTableProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<EventRow
|
<EventRow
|
||||||
key={key}
|
key={`${row.id}-${entry.revision}`}
|
||||||
|
rowId={row.id}
|
||||||
eventId={entry.id}
|
eventId={entry.id}
|
||||||
eventIndex={eventIndex}
|
eventIndex={eventIndex}
|
||||||
rowIndex={index}
|
rowIndex={index}
|
||||||
@@ -173,15 +174,10 @@ export default function CuesheetTable(props: CuesheetTableProps) {
|
|||||||
selectedRef={isSelected ? selectedRef : undefined}
|
selectedRef={isSelected ? selectedRef : undefined}
|
||||||
skip={entry.skip}
|
skip={entry.skip}
|
||||||
colour={entry.colour}
|
colour={entry.colour}
|
||||||
>
|
rowBgColour={rowBgColour}
|
||||||
{row.getVisibleCells().map((cell) => {
|
table={table}
|
||||||
return (
|
columnSizing={columnSizing}
|
||||||
<td key={cell.id} style={{ width: cell.column.getSize(), backgroundColor: rowBgColour }}>
|
/>
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</EventRow>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
.imageCell {
|
||||||
|
position: relative;
|
||||||
|
min-height: 2rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.overlay {
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
background-color: $black-60;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
background-color: $gray-1350;
|
||||||
|
min-height: 2rem;
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { Input } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import style from './EditableImage.module.scss';
|
||||||
|
|
||||||
|
interface EditableImageProps {
|
||||||
|
initialValue: string;
|
||||||
|
updateValue: (newValue: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(EditableImage);
|
||||||
|
|
||||||
|
function EditableImage(props: EditableImageProps) {
|
||||||
|
const { initialValue, updateValue } = props;
|
||||||
|
|
||||||
|
const handleUpdate = (newValue: string) => {
|
||||||
|
if (newValue === initialValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newValue !== '' && !newValue.startsWith('http')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateValue(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!initialValue) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
size='sm'
|
||||||
|
variant='ontime-transparent'
|
||||||
|
padding={0}
|
||||||
|
fontSize='md'
|
||||||
|
placeholder='Paste image URL'
|
||||||
|
onBlur={(event) => handleUpdate(event.currentTarget.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
handleUpdate(event.currentTarget.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
defaultValue={initialValue}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete='off'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.imageCell}>
|
||||||
|
<div className={style.overlay}>
|
||||||
|
<button onClick={() => handleUpdate('')}>Delete</button>
|
||||||
|
</div>
|
||||||
|
<img loading='lazy' src={initialValue} className={style.image} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+29
-12
@@ -1,8 +1,10 @@
|
|||||||
import { memo, MutableRefObject, PropsWithChildren, useLayoutEffect, useRef, useState } from 'react';
|
import { memo, MutableRefObject, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { IconButton } from '@chakra-ui/react';
|
|
||||||
import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHorizontal';
|
import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHorizontal';
|
||||||
|
import { flexRender, Table } from '@tanstack/react-table';
|
||||||
import Color from 'color';
|
import Color from 'color';
|
||||||
|
import { OntimeRundownEntry } from 'ontime-types';
|
||||||
|
|
||||||
|
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||||
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
|
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||||
import { useCuesheetOptions } from '../../cuesheet.options';
|
import { useCuesheetOptions } from '../../cuesheet.options';
|
||||||
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
||||||
@@ -10,6 +12,7 @@ import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMen
|
|||||||
import style from '../CuesheetTable.module.scss';
|
import style from '../CuesheetTable.module.scss';
|
||||||
|
|
||||||
interface EventRowProps {
|
interface EventRowProps {
|
||||||
|
rowId: string;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
eventIndex: number;
|
eventIndex: number;
|
||||||
rowIndex: number;
|
rowIndex: number;
|
||||||
@@ -17,15 +20,21 @@ interface EventRowProps {
|
|||||||
selectedRef?: MutableRefObject<HTMLTableRowElement | null>;
|
selectedRef?: MutableRefObject<HTMLTableRowElement | null>;
|
||||||
skip?: boolean;
|
skip?: boolean;
|
||||||
colour?: string;
|
colour?: string;
|
||||||
|
rowBgColour?: string;
|
||||||
|
table: Table<OntimeRundownEntry>;
|
||||||
|
/** hack to force re-rendering of the row when the column sizes change */
|
||||||
|
columnSizing: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventRow(props: PropsWithChildren<EventRowProps>) {
|
export default memo(EventRow);
|
||||||
const { children, eventId, eventIndex, rowIndex, isPast, selectedRef, skip, colour } = props;
|
|
||||||
|
function EventRow(props: EventRowProps) {
|
||||||
|
const { rowId, eventId, eventIndex, rowIndex, isPast, selectedRef, skip, colour, rowBgColour, table } = props;
|
||||||
const { hideIndexColumn, showActionMenu } = useCuesheetOptions();
|
const { hideIndexColumn, showActionMenu } = useCuesheetOptions();
|
||||||
const ownRef = useRef<HTMLTableRowElement>(null);
|
const ownRef = useRef<HTMLTableRowElement>(null);
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
const { openMenu } = useCuesheetTableMenu();
|
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
@@ -66,16 +75,15 @@ function EventRow(props: PropsWithChildren<EventRowProps>) {
|
|||||||
{showActionMenu && (
|
{showActionMenu && (
|
||||||
<td className={style.actionColumn}>
|
<td className={style.actionColumn}>
|
||||||
<IconButton
|
<IconButton
|
||||||
size='sm'
|
|
||||||
aria-label='Options'
|
aria-label='Options'
|
||||||
icon={<IoEllipsisHorizontal />}
|
|
||||||
variant='ontime-subtle'
|
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
const rect = event.currentTarget.getBoundingClientRect();
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
const yPos = 8 + rect.y + rect.height / 2;
|
const yPos = 8 + rect.y + rect.height / 2;
|
||||||
openMenu({ x: rect.x, y: yPos }, eventId, rowIndex);
|
openMenu({ x: rect.x, y: yPos }, eventId, rowIndex);
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<IoEllipsisHorizontal />
|
||||||
|
</IconButton>
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
{!hideIndexColumn && (
|
{!hideIndexColumn && (
|
||||||
@@ -83,9 +91,18 @@ function EventRow(props: PropsWithChildren<EventRowProps>) {
|
|||||||
{eventIndex}
|
{eventIndex}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
{isVisible ? children : null}
|
{isVisible
|
||||||
|
? table
|
||||||
|
.getRow(rowId)
|
||||||
|
?.getVisibleCells()
|
||||||
|
.map((cell) => {
|
||||||
|
return (
|
||||||
|
<td key={cell.id} style={{ width: cell.column.getSize(), backgroundColor: rowBgColour }}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: null}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(EventRow);
|
|
||||||
|
|||||||
+21
-3
@@ -6,6 +6,7 @@ import { millisToString, removeSeconds } from 'ontime-utils';
|
|||||||
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
|
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
|
||||||
import { formatDuration } from '../../../../common/utils/time';
|
import { formatDuration } from '../../../../common/utils/time';
|
||||||
|
|
||||||
|
import EditableImage from './EditableImage';
|
||||||
import MultiLineCell from './MultiLineCell';
|
import MultiLineCell from './MultiLineCell';
|
||||||
import SingleLineCell from './SingleLineCell';
|
import SingleLineCell from './SingleLineCell';
|
||||||
import TimeInput from './TimeInput';
|
import TimeInput from './TimeInput';
|
||||||
@@ -105,6 +106,24 @@ function MakeMultiLineField({ row, column, table }: CellContext<OntimeRundownEnt
|
|||||||
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
|
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LazyImage({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
|
||||||
|
const update = useCallback(
|
||||||
|
(newValue: string) => {
|
||||||
|
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
|
||||||
|
[column.id, row.index],
|
||||||
|
);
|
||||||
|
|
||||||
|
const event = row.original;
|
||||||
|
if (!isOntimeEvent(event)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialValue = event.custom[column.id];
|
||||||
|
return <EditableImage initialValue={initialValue} updateValue={update} />;
|
||||||
|
}
|
||||||
|
|
||||||
function MakeSingleLineField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
|
function MakeSingleLineField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
|
||||||
const update = useCallback(
|
const update = useCallback(
|
||||||
(newValue: string) => {
|
(newValue: string) => {
|
||||||
@@ -139,7 +158,6 @@ function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const initialValue = event.custom[column.id] ?? '';
|
const initialValue = event.custom[column.id] ?? '';
|
||||||
|
|
||||||
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
|
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,8 +166,8 @@ export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<Ontim
|
|||||||
accessorKey: key,
|
accessorKey: key,
|
||||||
id: key,
|
id: key,
|
||||||
header: customFields[key].label,
|
header: customFields[key].label,
|
||||||
meta: { colour: customFields[key].colour },
|
meta: { colour: customFields[key].colour, type: customFields[key].type },
|
||||||
cell: MakeCustomField,
|
cell: customFields[key].type === 'string' ? MakeCustomField : LazyImage,
|
||||||
size: 250,
|
size: 250,
|
||||||
minSize: 75,
|
minSize: 75,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { stringify } from 'csv-stringify/browser/esm/sync';
|
|
||||||
import {
|
import {
|
||||||
CustomFields,
|
CustomFields,
|
||||||
isOntimeDelay,
|
isOntimeDelay,
|
||||||
@@ -103,13 +102,3 @@ export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, custo
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* @description Converts an array of arrays to a csv file
|
|
||||||
* @param {string[][]} arrayOfArrays
|
|
||||||
* @return {string}
|
|
||||||
*/
|
|
||||||
export const makeCSV = (arrayOfArrays: string[][]): string => {
|
|
||||||
const stringifiedData = stringify(arrayOfArrays);
|
|
||||||
return stringifiedData;
|
|
||||||
};
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user