Compare commits

..

45 Commits

Author SHA1 Message Date
Bianca Procopio 4d1260321f update multiple events "defaults" 2024-04-08 14:46:28 -04:00
Bianca Procopio 7577f307bf adding "unkown" color to swatch 2024-04-08 09:34:29 -06:00
Bianca Procopio 997be35815 Merge branch 'v3' of https://github.com/cpvalente/ontime into bp/edit-multiple-events 2024-04-07 16:28:13 -06:00
Ary f9b1880e88 Fix to cuesheet table header not resizing (#852) 2024-04-06 17:40:03 +02:00
Alex Christoffer Rasmussen 8a1474e8d6 Improve error handling in server (#812)
* add functions to convert all errors to `ErrorResponse` type

* replace all `{ message: error.toString() }` with `toErrorResponse(error)`

* refactor: all controllers to to use getErrorMessage and add types to the Response

* 404 for nonexistent api routes
2024-04-05 23:06:40 +02:00
Carlos Valente d432f1e3ff Typescript null (#869)
* refactor: improve null checking

* fix: return result of mutation
2024-04-05 12:07:26 +02:00
Ary 9f207dd39a Fix: Removed updatedAt from project list (#853) 2024-04-03 21:35:18 +02:00
Alex Christoffer Rasmussen 8b8b3347fb fix: Sheet import error when user doesn't have correct permissions for the sheet (#865)
* refactor: better throw error message

* fix: await verify so wa catch the potential error

* fix: catch and display error about varification

* fix: don't block the Authenticate with a spinner

* center spinner

* propper boolean
2024-04-03 14:18:04 +02:00
Carlos Valente 01c2ef4c4d V3 feedback (#866)
* refactor: rename settings panel

* style: typo

* style: fix blackout position

* fix: offset time in time-to-end

* refactor: operator is protected

* style: tweaks to pincode

* refactor: close params editor on submit

* refactor: same navigation in all pages

* refactor: use cached response
2024-04-02 22:45:03 +02:00
Carlos Valente 10852eecdd Rundown modes (#864)
* refactor: simplify settings

* refactor: quick add around selection

* refactor: no action menu in delay block

* refactor: no action menu in event block

* style: context menu dark

* refactor: context menu actions
2024-04-02 20:09:27 +02:00
Bianca Procopio e221543dc7 initial setup for edit multiple events 2024-03-31 14:45:08 -06:00
Alex Christoffer Rasmussen 01000f8837 feat: chaching for static react files (#862)
* set immutable tag for static route

* captur more server timings in NODE_ENV === 'development'
2024-03-31 22:07:42 +02:00
Carlos Valente 9a03e4850a Navigation3 (#860) 2024-03-31 21:18:24 +02:00
Alex Christoffer Rasmussen 6bb3fb2931 fix: editor setting path (#859) 2024-03-29 17:09:18 +01:00
Alex Christoffer Rasmussen a86496ed27 fix: customFields was not persisted on import and force Custom Field keys to be lower case (#849)
* refactor: force Custom Field keys to be lower case

* fix: customFields was not persisted on import
2024-03-28 13:51:32 +01:00
Carlos Valente df8b76305e Alpha 3 cleanup (#857) 2024-03-28 13:35:36 +01:00
Carlos Valente ce14aabcd7 Navigation (#850) 2024-03-27 21:02:40 +01:00
Alex Christoffer Rasmussen f5e9a2549e Improve spreadsheet import flow (#839)
* feat: add dropdown to worksheet selection

* feat: move revoke to separate button

* refactor: extract excel flow to separate controller and service

* chore: show spinner when uploading sheet and disable the other option

* chore: don't show "success" when canceling the import

* chore: export boolean types properly to sheets
2024-03-22 21:33:11 +01:00
Carlos Valente f4692db021 refactor: allow transition from roll to playback (#847) 2024-03-22 13:32:36 +01:00
Carlos Valente dddb11ff40 refactor: granular updates to rundown (#848) 2024-03-22 13:21:49 +01:00
Carlos Valente 6178ed1e4e Apply project (#843) 2024-03-22 12:52:55 +01:00
Carlos Valente 8c5aaa0901 fix: account for midnight rollover (#846) 2024-03-22 08:04:23 +01:00
Carlos Valente b06ab15190 refactor: save object on restore (#842) 2024-03-21 22:10:54 +01:00
Ary a48be0f017 Fix to lastLoadedProject being null (#830) 2024-03-21 21:08:45 +01:00
Alex Christoffer Rasmussen c2280da61f fix: uncaught error in http integration (#837) 2024-03-21 21:07:39 +01:00
Carlos Valente fe30752130 refactor: follow in run mode (#840) 2024-03-21 21:07:15 +01:00
Alex Christoffer Rasmussen a20d63451b fix: use cross-env to allow windows to build local docker (#838) 2024-03-21 00:12:17 +01:00
Carlos Valente 93622e9aeb fix: prevent calculation with no timer (#835) 2024-03-20 12:52:32 +01:00
Carlos Valente 1568af97fc chore: bump node version in docker (#834) 2024-03-19 21:55:37 +01:00
Carlos Valente 4bb836b6f2 End pause (#832)
* refactor: trigger ahead

* refactor: refresh often

* fix: handle empty rundown updates

* fix: handle falsy values

* style: avoid overlap of components

* refactor: freeze end option
2024-03-19 18:04:05 +01:00
Carlos Valente 8156da03d5 Quick options (#814)
* refactor: link start is quick option

* refactor: add default duration

* chore: use last time by default
2024-03-19 17:23:32 +01:00
Carlos Valente dfade25a7c feat: url navigation for settings (#821) 2024-03-18 20:36:02 +01:00
Carlos Valente 4b21745fc7 refactor: download files from interface (#831) 2024-03-18 20:27:58 +01:00
Carlos Valente a91a8a6358 refactor: simplify time-to-end (#828)
* refactor: simplify time-to-end

* refactor: rundown metadata

* refactor: handle overflow in UI

* refactor: show gaps between days

---------

Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
2024-03-18 17:24:11 +01:00
Carlos Valente 4e6b833e10 refactor: obfuscated pincode in transit 2024-03-18 13:40:59 +01:00
Alex Christoffer Rasmussen 3fdb1bd1c8 partial ws update for demo (#813)
* partial ws update

* extract more of the timer formatting
2024-03-18 12:01:36 +01:00
Carlos Valente 4148a3835b style: clarify state of external message 2024-03-17 19:46:48 +01:00
Carlos Valente 4fb9c42aef refactor: skip expected end if not playing 2024-03-17 19:46:25 +01:00
Carlos Valente dd0dcfc2a2 fix: reset focus when deleting selected 2024-03-17 19:46:10 +01:00
Carlos Valente d41d1b054d fix: reset focus when deleting all 2024-03-17 19:46:10 +01:00
Carlos Valente 8b6e06150a fix: update cache on delete-all (#827) 2024-03-17 19:45:34 +01:00
Carlos Valente a9e0e2b091 refactor: tweaks to chakra theme config (#824) 2024-03-16 09:42:06 +01:00
Carlos Valente 0a8155b6e3 Param nav (#822)
* refactor: remove unused

* refactor: simplify logic
2024-03-15 16:52:33 +01:00
Carlos Valente 118c29e5c2 refactor: filter sentry errors (#816) 2024-03-15 16:13:26 +01:00
Carlos Valente 105f395e6e refactor: migrate fullscreen to mantine (#819) 2024-03-14 18:32:07 +01:00
211 changed files with 3425 additions and 2401 deletions
+1
View File
@@ -19,6 +19,7 @@
"rules": {
"no-useless-concat": "warn",
"prefer-template": "warn",
"no-throw-literal": "error",
"no-console": [
"warn",
{
+3
View File
@@ -45,3 +45,6 @@ apps/server/src/preloaded-db/db.json
# versioning file
**/ONTIME_VERSION.js
# temporary write files
**.tmp
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:16-alpine
FROM node:18.18-alpine
# Set environment variables
# Environment Variable to signal that we are running production
+2 -1
View File
@@ -10,6 +10,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@mantine/hooks": "^7.6.2",
"@react-icons/all-files": "^4.1.0",
"@sentry/react": "^7.92.0",
"@tanstack/react-query": "^5.17.9",
@@ -85,7 +86,7 @@
"ontime-utils": "workspace:*",
"prettier": "^3.0.3",
"sass": "^1.57.1",
"typescript": "^5.2.2",
"typescript": "^5.4.3",
"vite": "^5.1.0",
"vite-plugin-compression2": "^0.12.0",
"vite-plugin-svgr": "^4.2.0",
+1 -2
View File
@@ -47,9 +47,8 @@ function App() {
}
};
}, [isElectron, sendToElectron]);
return (
<ChakraProvider resetCSS theme={theme}>
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
<QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider>
<BrowserRouter>
+3 -3
View File
@@ -7,7 +7,7 @@ import withData from './features/viewers/ViewWrapper';
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
const Operator = lazy(() => import('./features/operator/Operator'));
const Operator = lazy(() => import('./features/operator/OperatorExport'));
const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
@@ -49,12 +49,12 @@ export default function AppRouter() {
<Route path='/backstage' element={<SBackstage />} />
<Route path='/public' element={<SPublic />} />
<Route path='/studio' element={<SStudio />} />
{/*/!* Lower cannot have fallback *!/*/}
<Route path='/lower' element={<SLowerThird />} />
<Route path='/op' element={<Operator />} />
<Route path='/operator' element={<Operator />} />
{/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} />
+54 -41
View File
@@ -1,36 +1,54 @@
import axios, { AxiosResponse } from 'axios';
import {
CustomFields,
DatabaseModel,
GetInfo,
MessageResponse,
OntimeRundown,
ProjectData,
ProjectFileListResponse,
} from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { DatabaseModel, GetInfo, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types';
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
import { apiEntryUrl } from './constants';
import fileDownload from './utils';
import { createBlob, downloadBlob } from './utils';
const dbPath = `${apiEntryUrl}/db`;
/**
* HTTP request to download db in JSON format
* HTTP request to the current DB
*/
export async function downloadRundown(fileName?: string) {
return fileDownload(
dbPath,
{ name: fileName ?? 'rundown', type: 'json' },
{ type: 'application/json;charset=utf-8;' },
);
async function getDb(): Promise<AxiosResponse<DatabaseModel>> {
return axios.get(`${dbPath}/download`);
}
/**
* HTTP request to download db in CSV format
* Request download of the current project file
* @param fileName
*/
export async function downloadCSV(fileName?: string) {
return fileDownload(dbPath, { name: fileName ?? 'rundown', type: 'csv' }, { type: 'text/csv;charset=utf-8;' });
export async function downloadProject(fileName: string = 'ontime-project') {
try {
const { data, name } = await fileDownload(fileName);
const fileContent = JSON.stringify(data, null, 2);
const blob = createBlob(fileContent, 'application/json;charset=utf-8;');
downloadBlob(blob, `${name}.json`);
} catch (error) {
console.error(error);
}
}
/**
* Request download of the current rundown as a CSV file
* @param fileName
*/
export async function downloadCSV(fileName: string = 'rundown') {
try {
const { data, name } = await fileDownload(fileName);
const { project, rundown, customFields } = data;
const sheetData = makeTable(project, rundown, customFields);
const fileContent = makeCSV(sheetData);
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
downloadBlob(blob, `${name}.csv`);
} catch (error) {
console.error(error);
}
}
/**
@@ -128,28 +146,23 @@ export async function getInfo(): Promise<GetInfo> {
return res.data;
}
type PreviewSpreadsheetResponse = {
rundown: OntimeRundown;
customFields: CustomFields;
};
/**
* Make patch changes to the objects in the db
* Utility function gets project from db
* @param fileName
* @returns
*/
export async function importSpreadsheetPreview(file: File, options: ImportMap): Promise<PreviewSpreadsheetResponse> {
const formData = new FormData();
formData.append('spreadsheet', file);
formData.append('options', JSON.stringify(options));
async function fileDownload(fileName: string): Promise<{ data: DatabaseModel; name: string }> {
const response = await getDb();
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(
`${dbPath}/spreadsheet/preview`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
},
);
const headerLine = response.headers['Content-Disposition'];
return response.data;
// try and get the filename from the response
let name = fileName;
if (headerLine != null) {
const startFileNameIndex = headerLine.indexOf('"') + 1;
const endFileNameIndex = headerLine.lastIndexOf('"');
name = headerLine.substring(startFileNameIndex, endFileNameIndex);
}
return { data: response.data, name };
}
+42
View File
@@ -0,0 +1,42 @@
import axios, { AxiosResponse } from 'axios';
import { CustomFields, OntimeRundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
const excelPath = `${apiEntryUrl}/excel`;
type PreviewSpreadsheetResponse = {
rundown: OntimeRundown;
customFields: CustomFields;
};
/**
* upload Excel file to server
* @return string - file ID op the uploaded file
*/
export async function upload(file: File) {
const formData = new FormData();
formData.append('excel', file);
await axios.post(`${excelPath}/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}
/**
* Get Worksheet names
* @return string[] - array of available worksheets
*/
export async function getWorksheetNames(): Promise<string[]> {
const response: AxiosResponse<string[]> = await axios.get(`${excelPath}/worksheets`);
return response.data;
}
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
options,
});
return response.data;
}
+10 -2
View File
@@ -1,4 +1,4 @@
import axios from 'axios';
import axios, { AxiosResponse } from 'axios';
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
@@ -9,7 +9,10 @@ const sheetsPath = `${apiEntryUrl}/sheets`;
/**
* HTTP request to verify whether we are authenticated with Google Sheet service
*/
export const verifyAuthenticationStatus = async (): Promise<{ authenticated: AuthenticationStatus }> => {
export const verifyAuthenticationStatus = async (): Promise<{
authenticated: AuthenticationStatus;
sheetId: string;
}> => {
const response = await axios.get(`${sheetsPath}/connect`);
return response.data;
};
@@ -58,6 +61,11 @@ export const previewRundown = async (
return response.data;
};
export const getWorksheetNames = async (sheetId: string): Promise<string[]> => {
const response: AxiosResponse<string[]> = await axios.post(`${sheetsPath}/${sheetId}/worksheets`);
return response.data;
};
/**
* HTTP request to upload the rundown to a google sheet
*/
+13 -48
View File
@@ -2,7 +2,6 @@ import axios, { AxiosError } from 'axios';
import { LogLevel } from 'ontime-types';
import { generateId, millisToString } from 'ontime-utils';
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
import { ontimeQueryClient } from '../queryClient';
import { addLog } from '../stores/logger';
import { nowInMillis } from '../utils/time';
@@ -56,63 +55,29 @@ export async function invalidateAllCaches() {
await ontimeQueryClient.invalidateQueries();
}
type FileOptions = {
name: string;
type: string;
};
type BlobOptions = {
type: string;
};
/**
* Gets DB from backend and prepares a file to be downloaded
* @param url
* @param fileOptions
* @param blobOptions
* Creates blob from content
* @param fileContent
* @param type
* @returns
*/
export default async function fileDownload(url: string, fileOptions: FileOptions, blobOptions: BlobOptions) {
const response = await axios({
url: `${url}/db`,
method: 'GET',
});
export function createBlob(fileContent: string, type: string): Blob {
return new Blob([fileContent], { type });
}
const headerLine = response.headers['Content-Disposition'];
let { name: fileName } = fileOptions;
const { type: fileType } = fileOptions;
const { project, rundown, customFields } = response.data;
// try and get the filename from the response
if (headerLine != null) {
const startFileNameIndex = headerLine.indexOf('"') + 1;
const endFileNameIndex = headerLine.lastIndexOf('"');
fileName = headerLine.substring(startFileNameIndex, endFileNameIndex);
}
let fileContent = '';
if (fileType === 'json') {
fileContent = JSON.stringify(response.data);
fileName += '.json';
}
if (fileType === 'csv') {
const sheetData = makeTable(project, rundown, customFields);
fileContent = makeCSV(sheetData);
fileName += '.csv';
}
const blob = new Blob([fileContent], { type: blobOptions.type });
/**
* downloads a blob
* @param downloadUrl
* @param fileName
*/
export function downloadBlob(blob: Blob, fileName: string) {
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', downloadUrl);
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
// Clean up the URL.createObjectURL to release resources
URL.revokeObjectURL(downloadUrl);
return;
}
@@ -25,5 +25,11 @@ export default function Swatch(props: SwatchProps) {
</div>
);
}
if (color === 'unkown') {
<div className={`${classes} ${style.center}`} onClick={handleClick}>
?
</div>;
}
return <div className={classes} style={{ backgroundColor: `${color}` }} onClick={handleClick} />;
}
@@ -8,10 +8,12 @@ interface ColourInputProps {
value: string;
name: 'colour';
handleChange: (newValue: 'colour', name: string) => void;
isMultiple?: boolean;
}
const colours = [
'',
'unkown',
'#FFCC78', // $orange-400
'#FFAB33', // $orange-600
'#77C785', // $green-400
@@ -27,7 +29,7 @@ const colours = [
];
export default function SwatchSelect(props: ColourInputProps) {
const { value, name, handleChange } = props;
const { value, name, handleChange, isMultiple } = props;
const setColour = useCallback(
(newValue: string) => {
@@ -40,9 +42,15 @@ export default function SwatchSelect(props: ColourInputProps) {
return (
<div className={style.list}>
{colours.map((colour) => (
<Swatch key={colour} color={colour} onClick={setColour} isSelected={value === colour} />
))}
{colours
.filter((colour) => {
// Only include unkown if it is multiple
if (!isMultiple && colour === 'unkown') return false;
return true;
})
.map((colour) => (
<Swatch key={colour} color={colour} onClick={setColour} isSelected={value === colour} />
))}
</div>
);
}
@@ -1,7 +1,7 @@
import { useCallback, useRef } from 'react';
import { Input, Textarea } from '@chakra-ui/react';
import { EventEditorSubmitActions } from '../../../../features/rundown/event-editor/EventEditor';
import { EventEditorSubmitActions } from '../../../../features/rundown/event-editor/EventEditorWrapper';
import { Size } from '../../../models/Util.type';
import useReactiveTextInput from './useReactiveTextInput';
@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { debounce } from '../../utils/debounce';
import style from './NavigationMenu.module.scss';
interface FloatingNavigationProps {
toggleMenu: () => void;
toggleSettings: () => void;
}
export default function FloatingNavigation(props: FloatingNavigationProps) {
const { toggleMenu, toggleSettings } = props;
const [showButton, setShowButton] = useState(false);
// show on mouse move
useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null;
const setShowMenuTrue = () => {
setShowButton(true);
if (fadeOut) {
clearTimeout(fadeOut);
}
fadeOut = setTimeout(() => setShowButton(false), 3000);
};
const debouncedShowMenu = debounce(setShowMenuTrue, 1000);
document.addEventListener('mousemove', debouncedShowMenu);
return () => {
document.removeEventListener('mousemove', debouncedShowMenu);
if (fadeOut) {
clearTimeout(fadeOut);
}
};
}, []);
return (
<div className={`${style.buttonContainer} ${!showButton ? style.hidden : ''}`}>
<button
onClick={toggleMenu}
aria-label='toggle menu'
className={style.navButton}
data-testid='navigation__toggle-menu'
>
<IoApps />
</button>
<button
className={style.button}
onClick={toggleSettings}
aria-label='toggle settings'
data-testid='navigation__toggle-settings'
>
<IoSettingsOutline />
</button>
</div>
);
}
@@ -1,12 +1,11 @@
@use '../../../theme/mixins' as *;
$menu-bg: $gray-1200;
$menu-hover-bg: $gray-1350;
$menu-focus-bg: $gray-1300;
$icon-color: $ui-white;
$button-bg: $gray-1050;
$button-size: 48px;
$button-size: 3rem;
.buttonContainer {
display: flex;
@@ -21,7 +20,7 @@ $button-size: 48px;
position: fixed;
left: 0;
top: 0;
z-index: 2;
z-index: 12;
&.hidden {
opacity: 0;
@@ -29,7 +28,7 @@ $button-size: 48px;
}
.button {
font-size: 24px;
font-size: 1.5rem;
color: $icon-color;
background-color: $button-bg;
width: $button-size;
@@ -44,31 +43,10 @@ $button-size: 48px;
z-index: 3;
}
.menuContainer {
top: 0;
left: 0;
height: fit-content;
position: absolute;
background-color: $menu-bg;
min-width: 200px;
border-radius: 0 0 24px 0;
border-right: 1px solid $border-color-ondark;
box-shadow: $box-shadow-l2;
padding-bottom: 1rem;
max-height: 100vh;
overflow-y: auto;
}
.buttonsContainer {
margin-top: calc(56px + 1rem);
}
.link {
@include action-link;
justify-content: space-between;
padding: 0.5rem 1rem;
padding: 0.75rem 1.5rem;
gap: 0.5rem;
&:hover {
background-color: $menu-hover-bg;
@@ -91,7 +69,7 @@ $button-size: 48px;
}
.linkIcon {
display: inline-block;
margin-left: auto;
transform: rotate(45deg);
}
@@ -1,17 +1,24 @@
import { memo, useEffect, useRef, useState } from 'react';
import { memo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { Link, useLocation, useSearchParams } from 'react-router-dom';
import { useDisclosure } from '@chakra-ui/react';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { Link } from 'react-router-dom';
import {
Drawer,
DrawerBody,
DrawerCloseButton,
DrawerContent,
DrawerHeader,
DrawerOverlay,
useDisclosure,
} from '@chakra-ui/react';
import { useFullscreen } from '@mantine/hooks';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoContract } from '@react-icons/all-files/io5/IoContract';
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoPencilSharp } from '@react-icons/all-files/io5/IoPencilSharp';
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { navigatorConstants } from '../../../viewerConfig';
import useClickOutside from '../../hooks/useClickOutside';
import useFullscreen from '../../hooks/useFullscreen';
import { useViewOptionsStore } from '../../stores/viewOptions';
import { isKeyEnter } from '../../utils/keyEvent';
@@ -19,81 +26,54 @@ import RenameClientModal from './rename-client-modal/RenameClientModal';
import style from './NavigationMenu.module.scss';
function NavigationMenu() {
const location = useLocation();
interface NavigationMenuProps {
isOpen: boolean;
onClose: () => void;
}
const { isFullScreen, toggleFullScreen } = useFullscreen();
function NavigationMenu(props: NavigationMenuProps) {
const { isOpen, onClose } = props;
const { isOpen: isRenameOpen, onOpen: onRenameOpen, onClose: onRenameClose } = useDisclosure();
const { fullscreen, toggle } = useFullscreen();
const { toggleMirror } = useViewOptionsStore();
const [showButton, setShowButton] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
useClickOutside(menuRef, () => setShowMenu(false));
const { isOpen, onOpen, onClose } = useDisclosure();
const toggleMenu = () => setShowMenu((prev) => !prev);
// show on mouse move
useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null;
const setShowMenuTrue = () => {
setShowButton(true);
if (fadeOut) {
clearTimeout(fadeOut);
}
fadeOut = setTimeout(() => setShowButton(false), 3000);
};
document.addEventListener('mousemove', setShowMenuTrue);
return () => {
document.removeEventListener('mousemove', setShowMenuTrue);
if (fadeOut) {
clearTimeout(fadeOut);
}
};
}, []);
const handleFullscreen = () => toggleFullScreen();
const handleMirror = () => toggleMirror();
const showEditFormDrawer = () => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
};
useClickOutside(menuRef, () => onClose);
return createPortal(
<div id='navigation-menu-portal' ref={menuRef}>
<RenameClientModal isOpen={isOpen} onClose={onClose} />
<div className={`${style.buttonContainer} ${!showButton && !showMenu ? style.hidden : ''}`}>
<button onClick={toggleMenu} aria-label='toggle menu' className={style.navButton}>
<IoApps />
</button>
<button className={style.button} onClick={showEditFormDrawer}>
<IoPencilSharp />
</button>
{showMenu && (
<div className={style.menuContainer} data-testid='navigation-menu'>
<RenameClientModal isOpen={isRenameOpen} onClose={onRenameClose} />
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
<DrawerOverlay />
<DrawerContent>
<DrawerHeader>
<DrawerCloseButton size='lg' />
Ontime
</DrawerHeader>
<DrawerBody padding={0}>
<div className={style.buttonsContainer}>
<div
className={style.link}
tabIndex={0}
role='button'
onClick={handleFullscreen}
onClick={toggle}
onKeyDown={(event) => {
isKeyEnter(event) && handleFullscreen();
isKeyEnter(event) && toggle();
}}
>
Toggle Fullscreen
{isFullScreen ? <IoContract /> : <IoExpand />}
{fullscreen ? <IoContract /> : <IoExpand />}
</div>
<div
className={style.link}
tabIndex={0}
role='button'
onClick={handleMirror}
onClick={() => toggleMirror()}
onKeyDown={(event) => {
isKeyEnter(event) && handleMirror();
isKeyEnter(event) && toggleMirror();
}}
>
Flip Screen
@@ -103,20 +83,35 @@ function NavigationMenu() {
className={style.link}
tabIndex={0}
role='button'
onClick={onOpen}
onClick={onRenameOpen}
onKeyDown={(event) => {
isKeyEnter(event) && onOpen();
isKeyEnter(event) && onRenameOpen();
}}
>
Rename Client
</div>
</div>
<hr className={style.separator} />
<Link to='/cuesheet' className={style.link} tabIndex={0}>
<Link
to='/editor'
className={`${style.link} ${location.pathname === '/editor' ? style.current : ''}`}
tabIndex={0}
>
<IoLockClosedOutline />
Editor
<IoArrowUp className={style.linkIcon} />
</Link>
<Link
to='/cuesheet'
className={`${style.link} ${location.pathname === '/cuesheet' ? style.current : ''}`}
tabIndex={0}
>
<IoLockClosedOutline />
Cuesheet
<IoArrowUp className={style.linkIcon} />
</Link>
<Link to='/op' className={style.link} tabIndex={0}>
<Link to='/op' className={`${style.link} ${location.pathname === '/op' ? style.current : ''}`} tabIndex={0}>
<IoLockClosedOutline />
Operator
<IoArrowUp className={style.linkIcon} />
</Link>
@@ -132,11 +127,10 @@ function NavigationMenu() {
<IoArrowUp className={style.linkIcon} />
</Link>
))}
</div>
)}
</div>
</DrawerBody>
</DrawerContent>
</Drawer>
</div>,
document.body,
);
}
@@ -0,0 +1,16 @@
import { memo } from 'react';
import NavigationMenu from './NavigationMenu';
interface ProductionNavigationMenuProps {
isMenuOpen: boolean;
onMenuClose: () => void;
}
function ProductionNavigationMenu(props: ProductionNavigationMenuProps) {
const { isMenuOpen, onMenuClose } = props;
return <NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />;
}
export default memo(ProductionNavigationMenu);
@@ -0,0 +1,27 @@
import { memo, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useDisclosure } from '@chakra-ui/react';
import FloatingNavigation from './FloatingNavigation';
import NavigationMenu from './NavigationMenu';
function ViewNavigationMenu() {
const [searchParams, setSearchParams] = useSearchParams();
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
return (
<>
<FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
<NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />
</>
);
}
export default memo(ViewNavigationMenu);
@@ -1,5 +0,0 @@
.modalBody {
display: flex;
flex-direction: column;
gap: 1rem;
}
@@ -13,8 +13,6 @@ import {
import { setClientName } from '../../../hooks/useSocket';
import { useSocketClientName } from '../../../stores/connectionName';
import style from './RenameClientModal.module.scss';
interface RenameClientModalProps {
isOpen: boolean;
onClose: () => void;
@@ -45,18 +43,18 @@ export default function RenameClientModal({ isOpen, onClose }: RenameClientModal
motionPreset='slideInBottom'
scrollBehavior='inside'
preserveScrollBarGap
variant='ontime-small'
variant='ontime'
>
<ModalOverlay />
<ModalContent>
<ModalHeader>Rename client</ModalHeader>
<ModalCloseButton />
<ModalBody className={style.modalBody}>
<ModalBody>
<Input
placeholder='Connection must have a name'
defaultValue={newName}
onChange={(e) => setNewName(e.target.value)}
variant='ontime-filled-on-light'
variant='ontime-filled'
/>
<Button
isDisabled={newName === clientName || !newName}
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { HStack, IconButton, PinInput, PinInputField } from '@chakra-ui/react';
import { IconButton, PinInput, PinInputField } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import style from './ProtectRoute.module.scss';
@@ -39,8 +39,8 @@ export default function PinPage(props: PinPageProps) {
return (
<div className={style.container}>
{`Ontime ${permission || ''}`}
<HStack spacing='10px' className={failed ? style.pin__failed : style.pin}>
{`Ontime ${permission}`}
<div className={failed ? style.pin__failed : style.pin}>
<PinInput
type='alphanumeric'
size='lg'
@@ -57,8 +57,15 @@ export default function PinPage(props: PinPageProps) {
<PinInputField />
<PinInputField />
</PinInput>
<IconButton aria-label='Enter' size='lg' isRound icon={<IoCheckmark />} onClick={validate} />
</HStack>
<IconButton
variant='ontime-filled'
aria-label='Enter'
size='lg'
isRound
icon={<IoCheckmark />}
onClick={validate}
/>
</div>
</div>
);
}
@@ -1,27 +1,31 @@
.container {
display: grid;
place-content: center;
height: 100vh;
padding-bottom: 30vh;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 25vh;
background: $bg-container-l1;
color: $ontime-color;
font-family: $ontime-font-family;
font-weight: 200;
text-align: center;
font-size: 3vw;
font-size: 3rem;
}
.pin,
.pin__failed {
padding: 20px;
display: flex;
gap: 0.125em;
padding-block: 0.5em;
input {
border-radius: 50%;
border-radius: 99px;
border-color: $gray-500;
&:hover {
border-color: $blue-500;
}
}
button {
margin-left: 20px;
margin-left: 1em;
}
}
@@ -33,9 +37,9 @@
@keyframes colourFade {
from {
background: $action-blue;
background: $red-500;
}
to {
background: rgba($action-blue, 0);
background: rgba($red-500, 0);
}
}
}
@@ -1,24 +1,9 @@
.drawerContent {
background-color: $gray-1250;
}
.drawerHeader {
@extend .drawerContent;
color: $section-white;
}
.drawerFooter {
@extend .drawerContent;
display: flex;
justify-content: start;
justify-content: end;
gap: $section-spacing;
button[type='reset'] {
padding: 0 2em;
margin-right: auto;
}
button[type='submit'] {
button {
padding: 0 2em;
}
}
@@ -26,6 +11,9 @@
.label {
font-size: $inner-section-text-size;
color: $label-gray;
display: flex;
flex-direction: column;
gap: 0.25rem
}
.columnSection {
@@ -1,5 +1,5 @@
import { FormEvent, useEffect } from 'react';
import { useLocation, useSearchParams } from 'react-router-dom';
import { useSearchParams } from 'react-router-dom';
import {
Button,
Drawer,
@@ -12,30 +12,26 @@ import {
useDisclosure,
} from '@chakra-ui/react';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import ParamInput from './ParamInput';
import { ParamField } from './types';
import style from './ViewParamsEditor.module.scss';
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
type SavedViewParams = Record<string, ViewParamsObj>;
/**
* Makes a new URLSearchParams object from the given params object
*/
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ParamField[]) => {
const defaultValues = paramFields.reduce<Record<string, string>>((acc, { id, defaultValue }) => {
return { ...acc, [id]: String(defaultValue) };
acc[id] = String(defaultValue);
return acc;
}, {});
return Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => {
if (typeof value === 'string' && value.length) {
if (defaultValues[id] === value) {
return newSearchParams;
}
if (typeof value === 'string' && value.length && defaultValues[id] !== value) {
newSearchParams.set(id, value);
return newSearchParams;
}
return newSearchParams;
}, new URLSearchParams());
};
@@ -47,8 +43,6 @@ interface EditFormDrawerProps {
export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams();
const { isOpen, onClose, onOpen } = useDisclosure();
const { pathname } = useLocation();
const [storedViewParams, setStoredViewParams] = useLocalStorage<SavedViewParams>('ontime-views', {});
useEffect(() => {
const isEditing = searchParams.get('edit');
@@ -58,37 +52,16 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
}
}, [searchParams, onOpen]);
/**
* disabling this for now, this feature needs more testing
* - we seem to have a bug where this is conflicting with the aliases
* - I wonder if the logic below needs to be inside an effect,
* both localStorage and searchParams should trigger a component update when they change
useEffect(() => {
const viewParamsObjFromLocalStorage = storedViewParams[pathname];
if (viewParamsObjFromLocalStorage !== undefined) {
const defaultSearchParams = getURLSearchParamsFromObj(viewParamsObjFromLocalStorage);
setSearchParams(defaultSearchParams);
}
// linter is asking for `setSearchParams` & `storedViewParams` in the useEffect deps
// rule is disabled since adding `setSearchParams` & `storedViewParams` results in unnecessary re-renders
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
*/
const onCloseWithoutSaving = () => {
onClose();
const handleClose = () => {
searchParams.delete('edit');
setSearchParams(searchParams);
onClose();
};
const resetParams = () => {
setStoredViewParams({ ...storedViewParams, [pathname]: {} });
setSearchParams();
onClose();
};
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
@@ -96,21 +69,21 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, paramFields);
setStoredViewParams({ ...storedViewParams, [pathname]: newParamsObject });
setSearchParams(newSearchParams);
handleClose();
};
return (
<Drawer isOpen={isOpen} placement='right' onClose={onCloseWithoutSaving} size='lg'>
<Drawer isOpen={isOpen} placement='right' onClose={handleClose} variant='ontime' size='lg'>
<DrawerOverlay />
<DrawerContent>
<DrawerHeader className={style.drawerHeader}>
<DrawerCloseButton _hover={{ bg: '#ebedf0', color: '#333' }} size='lg' />
<DrawerHeader>
<DrawerCloseButton size='lg' />
Customise
</DrawerHeader>
<DrawerBody className={style.drawerContent}>
<DrawerBody>
<form id='edit-params-form' onSubmit={onParamsFormSubmit}>
{paramFields.map((field) => (
<div key={field.title} className={style.columnSection}>
@@ -126,10 +99,7 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
<DrawerFooter className={style.drawerFooter}>
<Button variant='ontime-ghosted' onClick={resetParams} type='reset'>
Reset
</Button>
<Button variant='ontime-subtle' onClick={onCloseWithoutSaving}>
Cancel
Reset to default
</Button>
<Button variant='ontime-filled' form='edit-params-form' type='submit'>
Save
@@ -26,7 +26,6 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
useEffect(() => {
if (status === 'pending') return;
if (!data) return;
const previousEditor = sessionStorage.getItem(storageKeys.editor);
if (previousEditor && previousEditor === data.editorKey) {
@@ -53,10 +52,6 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
return savedPin == null || savedPin === '' || pin === savedPin;
}
if (!data) {
return false;
}
if (permission === 'editor') {
const correct = isValid(pin, data.editorKey);
if (correct) {
@@ -11,7 +11,7 @@ export default function useCustomFields() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: CUSTOM_FIELDS,
queryFn: getCustomFields,
placeholderData: placeholder,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval,
@@ -11,14 +11,13 @@ export function useHttpSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: HTTP_SETTINGS,
queryFn: getHTTP,
placeholderData: httpPlaceholder,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
// we need to jump through some hoops because of the type op port
return { data: data ?? httpPlaceholder, status, isFetching, isError, refetch };
}
@@ -10,7 +10,7 @@ export default function useInfo() {
const { data, status, isError, refetch, isFetching } = useQuery<GetInfo>({
queryKey: APP_INFO,
queryFn: getInfo,
placeholderData: ontimePlaceholderInfo,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -11,7 +11,7 @@ export default function useOscSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: OSC_SETTINGS,
queryFn: getOSC,
placeholderData: oscPlaceholderSettings,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -9,7 +9,7 @@ export default function useProjectData() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: PROJECT_DATA,
queryFn: getProjectData,
placeholderData: projectDataPlaceholder,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -14,7 +14,7 @@ export function useProjectList() {
const { data, status, refetch } = useQuery({
queryKey: PROJECT_LIST,
queryFn: getProjects,
placeholderData: placeholderProjectList,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -13,7 +13,7 @@ export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
queryKey: RUNDOWN,
queryFn: fetchNormalisedRundown,
placeholderData: cachedRundownPlaceholder,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval,
@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { unobfuscate } from 'ontime-utils';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { APP_SETTINGS } from '../api/constants';
@@ -9,12 +10,22 @@ export default function useSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: APP_SETTINGS,
queryFn: getSettings,
placeholderData: ontimePlaceholderSettings,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
select: (data) => {
const unobfuscated = { ...data };
if (data.editorKey) {
unobfuscated.editorKey = unobfuscate(data.editorKey);
}
if (data.operatorKey) {
unobfuscated.operatorKey = unobfuscate(data.operatorKey);
}
return unobfuscated;
},
});
return { data, status, isFetching, isError, refetch };
return { data: data ?? ontimePlaceholderSettings, status, isFetching, isError, refetch };
}
@@ -8,7 +8,7 @@ export default function useUrlPresets() {
const { data, status, isError, refetch } = useQuery({
queryKey: URL_PRESETS,
queryFn: getUrlPresets,
placeholderData: [],
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -9,12 +9,12 @@ export default function useViewSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: VIEW_SETTINGS,
queryFn: getView,
placeholderData: viewsSettingsPlaceholder,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data, status, isError, refetch, isFetching };
return { data: data ?? viewsSettingsPlaceholder, status, isError, refetch, isFetching };
}
+21 -18
View File
@@ -25,9 +25,7 @@ import { forgivingStringToMillis } from '../utils/dateConfig';
*/
export const useEventAction = () => {
const queryClient = useQueryClient();
const eventSettings = useEditorSettings((state) => state.eventSettings);
const defaultPublic = eventSettings.defaultPublic;
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
const { defaultPublic, linkPrevious, defaultDuration } = useEditorSettings((state) => state.eventSettings);
/**
* Calls mutation to add new event
@@ -45,11 +43,12 @@ export const useEventAction = () => {
after?: string;
};
type EventOptions = BaseOptions & {
defaultPublic?: boolean;
lastEventId?: string;
startTimeIsLastEnd?: boolean;
};
type EventOptions = BaseOptions &
Partial<{
defaultPublic: boolean;
linkPrevious: boolean;
lastEventId: string;
}>;
/**
* Adds an event to rundown
@@ -61,27 +60,31 @@ export const useEventAction = () => {
// ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEvent)) {
const applicationOptions = {
defaultPublic: options?.defaultPublic ?? defaultPublic,
startTimeIsLastEnd: options?.startTimeIsLastEnd ?? startTimeIsLastEnd,
lastEventId: options?.lastEventId,
after: options?.after,
defaultPublic: options?.defaultPublic ?? defaultPublic,
lastEventId: options?.lastEventId,
linkPrevious: options?.linkPrevious ?? linkPrevious,
};
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this has a value
const rundownData = queryClient.getQueryData<RundownCached>(RUNDOWN)!;
const { rundown } = rundownData;
if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) {
if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) {
newEvent.linkStart = applicationOptions.lastEventId;
} else if (applicationOptions?.lastEventId) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
const rundownData = queryClient.getQueryData<RundownCached>(RUNDOWN)!;
const { rundown } = rundownData;
const previousEvent = rundown[applicationOptions.lastEventId];
if (isOntimeEvent(previousEvent)) {
newEvent.timeStart = previousEvent.timeEnd;
newEvent.timeEnd = previousEvent.timeEnd;
}
}
if (applicationOptions.defaultPublic) {
newEvent.isPublic = true;
}
if (newEvent.duration === undefined && newEvent.timeEnd === undefined) {
newEvent.duration = forgivingStringToMillis(defaultDuration);
}
}
// handle adding options that concern all event type
@@ -95,7 +98,7 @@ export const useEventAction = () => {
logAxiosError('Failed adding event', error);
}
},
[_addEventMutation, defaultPublic, queryClient, startTimeIsLastEnd],
[_addEventMutation, defaultDuration, defaultPublic, linkPrevious],
);
/**
@@ -1,68 +0,0 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-nocheck -- working on it
import { useCallback, useEffect, useState } from 'react';
interface WebkitDocument extends Document {
webkitFullscreenElement?: Element | null;
webkitIsFullScreen?: boolean;
webkitExitFullscreen?: () => Promise<void>;
webkitRequestFullscreen?: () => Promise<void>;
}
export default function useFullscreen() {
const [isFullScreen, setFullScreen] = useState(
document.fullscreenElement || (document as WebkitDocument).webkitFullscreenElement,
);
useEffect(() => {
const handleChange = () => {
if (typeof (document as WebkitDocument).webkitFullscreenElement !== 'undefined') {
setFullScreen((document as WebkitDocument).webkitFullscreenElement);
} else {
setFullScreen(document.fullscreenElement);
}
};
(document as WebkitDocument).addEventListener('webkitfullscreenchange', handleChange, { passive: true });
document.addEventListener('fullscreenchange', handleChange, { passive: true });
document.addEventListener('resize', handleChange, { passive: true });
return () => {
(document as WebkitDocument).removeEventListener('webkitfullscreenchange', handleChange);
document.removeEventListener('fullscreenchange', handleChange);
document.removeEventListener('resize', handleChange);
};
}, []);
const toggleFullScreen = useCallback(() => {
if (!document.fullscreenElement && !(document as WebkitDocument).webkitIsFullScreen) {
// Fullscreen mode is not active, so we can enter fullscreen mode
const element = document.documentElement;
if (element.requestFullscreen) {
// Standard fullscreen API is supported
element.requestFullscreen().catch(() => {
/* nothing to do */
});
} else if (element.webkitRequestFullscreen) {
// iOS Safari fullscreen API is supported
element.webkitRequestFullscreen?.().catch(() => {
/* nothing to do */
});
}
} else {
// Fullscreen mode is active, so we can exit fullscreen mode
if (document.exitFullscreen) {
// Standard fullscreen API is supported
document.exitFullscreen().catch((error) => {
console.error('Error while trying to exit fullscreen:', error);
});
} else if ((document as WebkitDocument).webkitExitFullscreen) {
// iOS Safari fullscreen API is supported
(document as WebkitDocument).webkitExitFullscreen?.().catch(() => {
/* nothing to do */
});
}
}
}, []);
return { isFullScreen, toggleFullScreen };
}
@@ -0,0 +1,15 @@
import { useEffect, useRef } from 'react';
export default function useScrollIntoView<T extends HTMLElement>(name: string, location?: string) {
const ref = useRef<T>(null);
useEffect(() => {
if (location && ref.current) {
if (location === name) {
ref.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}, [location, name]);
return ref;
}
@@ -0,0 +1,11 @@
import { useEffect } from 'react';
/**
* Sets tab title
* @param title
*/
export function useWindowTitle(title: string) {
useEffect(() => {
document.title = `ontime - ${title}`;
}, []);
}
@@ -1,9 +1,10 @@
import { ViewSettings } from 'ontime-types';
export const viewsSettingsPlaceholder: ViewSettings = {
overrideStyles: false,
normalColor: '#ffffffcc',
warningColor: '#FFAB33',
dangerColor: '#ED3333',
endMessage: '',
freezeEnd: false,
normalColor: '#ffffffcc',
overrideStyles: false,
warningColor: '#FFAB33',
};
+19 -20
View File
@@ -3,50 +3,43 @@ import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage';
type EditorSettings = {
showQuickEntry: boolean;
startTimeIsLastEnd: boolean;
linkPrevious: boolean;
defaultPublic: boolean;
defaultDuration: string;
};
type EditorSettingsStore = {
eventSettings: EditorSettings;
setLocalEventSettings: (newState: EditorSettings) => void;
setShowQuickEntry: (showQuickEntry: boolean) => void;
setStartTimeIsLastEnd: (startTimeIsLastEnd: boolean) => void;
setLinkPrevious: (linkPrevious: boolean) => void;
setDefaultPublic: (defaultPublic: boolean) => void;
setDefaultDuration: (defaultDuration: string) => void;
};
enum EditorSettingsKeys {
ShowQuickEntry = 'ontime-show-quick-entry',
StartTimeIsLastEnd = 'ontime-start-is-last-end',
LinkPrevious = 'ontime-link-previous',
DefaultPublic = 'ontime-default-public',
DefaultDuration = 'ontime-default-duration',
}
export const useEditorSettings = create<EditorSettingsStore>((set) => ({
eventSettings: {
showQuickEntry: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, false),
startTimeIsLastEnd: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true),
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true),
linkPrevious: booleanFromLocalStorage(EditorSettingsKeys.LinkPrevious, true),
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, true),
defaultDuration: localStorage.getItem(EditorSettingsKeys.DefaultDuration) ?? '00:10:00',
},
setLocalEventSettings: (value) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(value.showQuickEntry));
localStorage.setItem(EditorSettingsKeys.StartTimeIsLastEnd, String(value.startTimeIsLastEnd));
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(value.linkPrevious));
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(value.defaultPublic));
return { eventSettings: value };
}),
setShowQuickEntry: (showQuickEntry) =>
setLinkPrevious: (linkPrevious) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(showQuickEntry));
return { eventSettings: { ...state.eventSettings, showQuickEntry } };
}),
setStartTimeIsLastEnd: (startTimeIsLastEnd) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.StartTimeIsLastEnd, String(startTimeIsLastEnd));
return { eventSettings: { ...state.eventSettings, startTimeIsLastEnd } };
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(linkPrevious));
return { eventSettings: { ...state.eventSettings, linkPrevious } };
}),
setDefaultPublic: (defaultPublic) =>
@@ -54,4 +47,10 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => ({
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
return { eventSettings: { ...state.eventSettings, defaultPublic } };
}),
setDefaultDuration: (defaultDuration) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.DefaultDuration, String(defaultDuration));
return { eventSettings: { ...state.eventSettings, defaultDuration } };
}),
}));
+2 -2
View File
@@ -2,7 +2,7 @@
* Collection of rules for pre-validating a spreadsheet
* @param file
*/
export function validateSpreadsheetImport(file: File) {
export function validateExcelImport(file: File) {
if (!isExcelFile(file)) {
throw new Error('Unknown file type');
}
@@ -12,7 +12,7 @@ export function validateSpreadsheetImport(file: File) {
throw new Error('File is empty');
}
// Limit file size of an excel file to around 10MB
// Limit file size of an Excel file to around 10MB
if (file.size > 10_000_000) {
throw new Error('File size limit (10MB) exceeded');
}
@@ -9,35 +9,32 @@ import InterfacePanel from './panel/interface-panel/InterfacePanel';
import LogPanel from './panel/log-panel/LogPanel';
import ProjectPanel from './panel/project-panel/ProjectPanel';
import ProjectSettingsPanel from './panel/project-settings-panel/ProjectSettingsPanel';
import ShutdownPanel from './panel/shutdown-panel/ShutdownPanel';
import SourcesPanel from './panel/sources-panel/SourcesPanel';
import PanelContent from './panel-content/PanelContent';
import PanelList from './panel-list/PanelList';
import { useSettingsStore } from './settingsStore';
import useAppSettingsNavigation from './useAppSettingsNavigation';
import style from './AppSettings.module.scss';
export default function AppSettings() {
const setShowSettings = useSettingsStore((state) => state.setShowSettings);
const selectedPanel = useSettingsStore((state) => state.showSettings);
const closeSettings = () => {
setShowSettings(null);
};
useKeyDown(closeSettings, 'Escape');
const { close, panel, location } = useAppSettingsNavigation();
useKeyDown(close, 'Escape');
return (
<div className={style.container}>
<ErrorBoundary>
<PanelList />
<PanelContent onClose={closeSettings}>
{selectedPanel === 'project' && <ProjectPanel />}
{selectedPanel === 'general' && <GeneralPanel />}
{selectedPanel === 'project_settings' && <ProjectSettingsPanel />}
{selectedPanel === 'sources' && <SourcesPanel />}
{selectedPanel === 'interface' && <InterfacePanel />}
{selectedPanel === 'integrations' && <IntegrationsPanel />}
{selectedPanel === 'about' && <AboutPanel />}
{selectedPanel === 'log' && <LogPanel />}
<PanelList selectedPanel={panel} location={location} />
<PanelContent onClose={close}>
{panel === 'project' && <ProjectPanel location={location} />}
{panel === 'general' && <GeneralPanel location={location} />}
{panel === 'project_settings' && <ProjectSettingsPanel />}
{panel === 'sources' && <SourcesPanel />}
{panel === 'interface' && <InterfacePanel />}
{panel === 'integrations' && <IntegrationsPanel location={location} />}
{panel === 'about' && <AboutPanel />}
{panel === 'log' && <LogPanel />}
{panel === 'shutdown' && <ShutdownPanel />}
</PanelContent>
</ErrorBoundary>
</div>
@@ -1,7 +1,7 @@
.corner {
position: absolute;
top: 1rem;
right: 1rem;
right: 2rem;
z-index: 100;
}
@@ -58,4 +58,8 @@ ul {
color: $secondary-text-gray;
border-left: 1px solid $white-10;
font-size: $inner-section-text-size;
&.active {
color: $blue-400;
}
}
@@ -2,16 +2,18 @@ import { Fragment } from 'react';
import { isKeyEnter } from '../../../common/utils/keyEvent';
import { cx } from '../../../common/utils/styleUtils';
import { settingPanels, SettingsOption, useSettingsStore } from '../settingsStore';
import { PanelBaseProps, settingPanels, useSettingsStore } from '../settingsStore';
import useAppSettingsNavigation from '../useAppSettingsNavigation';
import style from './PanelList.module.scss';
export default function PanelList() {
const { showSettings, setShowSettings, hasUnsavedChanges } = useSettingsStore();
interface PanelListProps extends PanelBaseProps {
selectedPanel: string;
}
const handleSelect = (panel: SettingsOption) => {
setShowSettings(panel.id);
};
export default function PanelList({ selectedPanel, location }: PanelListProps) {
const { setLocation } = useAppSettingsNavigation();
const { hasUnsavedChanges } = useSettingsStore();
return (
<ul className={style.tabs}>
@@ -20,7 +22,7 @@ export default function PanelList() {
const classes = cx([
style.primary,
showSettings === panel.id ? style.active : null,
selectedPanel === panel.id ? style.active : null,
panel.split ? style.split : null,
unsaved ? style.unsaved : null,
]);
@@ -29,9 +31,9 @@ export default function PanelList() {
<Fragment key={panel.id}>
<li
key={panel.id}
onClick={() => handleSelect(panel)}
onClick={() => setLocation(panel.id)}
onKeyDown={(event) => {
isKeyEnter(event) && handleSelect(panel);
isKeyEnter(event) && setLocation(panel.id);
}}
className={classes}
tabIndex={0}
@@ -40,8 +42,18 @@ export default function PanelList() {
{panel.label}
</li>
{panel.secondary?.map((secondary) => {
const id = secondary.id.split('__')[1];
const secondaryClasses = cx([style.secondary, location === id ? style.active : null]);
return (
<li key={secondary.id} onClick={() => handleSelect(panel)} className={style.secondary} role='button'>
<li
key={secondary.id}
onClick={() => setLocation(secondary.id)}
onKeyDown={(event) => {
isKeyEnter(event) && setLocation(secondary.id);
}}
className={secondaryClasses}
role='button'
>
{secondary.label}
</li>
);
@@ -75,10 +75,6 @@ $inner-padding: 1rem;
box-shadow: 0 1px $white-10;
}
tr {
padding: 1rem 0;
}
th {
font-weight: 400;
color: $gray-400;
@@ -1,16 +1,28 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { PanelBaseProps } from '../../settingsStore';
import * as Panel from '../PanelUtils';
import GeneralPanelForm from './GeneralPanelForm';
import UrlPresetsForm from './UrlPresetsForm';
import ViewSettingsForm from './ViewSettingsForm';
export default function GeneralPanel() {
export default function GeneralPanel({ location }: PanelBaseProps) {
const manageRef = useScrollIntoView<HTMLDivElement>('manage', location);
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
const urlPresetsRef = useScrollIntoView<HTMLDivElement>('urlpresets', location);
return (
<>
<Panel.Header>Settings</Panel.Header>
<GeneralPanelForm />
<ViewSettingsForm />
<UrlPresetsForm />
<Panel.Header>App Settings</Panel.Header>
<div ref={manageRef}>
<GeneralPanelForm />
</div>
<div ref={viewRef}>
<ViewSettingsForm />
</div>
<div ref={urlPresetsRef}>
<UrlPresetsForm />
</div>
</>
);
}
@@ -118,7 +118,17 @@ export default function ViewSettingsForm() {
</Panel.ListGroup>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='End message' description='If no end message is provided, timer will continue' />
<Panel.Field
title='Freeze timer on end'
description='Timer in views will stop from going negative after reaching'
/>
<Switch {...register('freezeEnd')} variant='ontime' size='lg' />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='End message'
description='Message to show on negative timers if not frozen. If not provided, timer will continue'
/>
<Input
size='sm'
autoComplete='off'
@@ -1,6 +1,8 @@
import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { PanelBaseProps } from '../../settingsStore';
import * as Panel from '../PanelUtils';
import HttpIntegrations from './HttpIntegrations';
@@ -8,7 +10,10 @@ import OscIntegrations from './OscIntegrations';
const integrationDocsUrl = 'https://docs.getontime.no/api/integrations/';
export default function IntegrationsPanel() {
export default function IntegrationsPanel({ location }: PanelBaseProps) {
const oscRef = useScrollIntoView<HTMLDivElement>('osc', location);
const httpRef = useScrollIntoView<HTMLDivElement>('http', location);
return (
<>
<Panel.Header>Integration settings</Panel.Header>
@@ -25,8 +30,12 @@ export default function IntegrationsPanel() {
</Alert>
</Panel.Section>
<Panel.Section>
<OscIntegrations />
<HttpIntegrations />
<div ref={oscRef}>
<OscIntegrations />
</div>
<div ref={httpRef}>
<HttpIntegrations />
</div>
</Panel.Section>
</>
);
@@ -1,54 +1,99 @@
import { Switch } from '@chakra-ui/react';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { useEditorSettings } from '../../../../common/stores/editorSettings';
import { forgivingStringToMillis } from '../../../../common/utils/dateConfig';
import * as Panel from '../PanelUtils';
export default function EditorSettingsForm() {
const eventSettings = useEditorSettings((state) => state.eventSettings);
const setShowQuickEntry = useEditorSettings((state) => state.setShowQuickEntry);
const setStartTimeIsLastEnd = useEditorSettings((state) => state.setStartTimeIsLastEnd);
const setLinkPrevious = useEditorSettings((state) => state.setLinkPrevious);
const setDefaultPublic = useEditorSettings((state) => state.setDefaultPublic);
const setDefaultDuration = useEditorSettings((state) => state.setDefaultDuration);
const durationInMs = forgivingStringToMillis(eventSettings.defaultDuration);
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Editor settings</Panel.SubHeader>
<Panel.Divider />
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Show quick entry'
description='Whether the quick entry buttons show under selected event'
/>
<Switch
variant='ontime'
size='lg'
defaultChecked={eventSettings.showQuickEntry}
onChange={(event) => setShowQuickEntry(event.target.checked)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Start time is last end'
description='New events start time will be the previous event end'
/>
<Switch
variant='ontime'
size='lg'
defaultChecked={eventSettings.startTimeIsLastEnd}
onChange={(event) => setStartTimeIsLastEnd(event.target.checked)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Default public' description='New events will be public' />
<Switch
variant='ontime'
size='lg'
defaultChecked={eventSettings.defaultPublic}
onChange={(event) => setDefaultPublic(event.target.checked)}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.Section>
<Panel.Title>Rundown options</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Default duration'
description='When creating a new event, what is the default duration'
/>
<TimeInput<'defaultDuration'>
name='defaultDuration'
submitHandler={(_field, value) => setDefaultDuration(value)}
time={durationInMs}
placeholder='00:10:00'
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Link previous'
description='New events start time will be linked to the previous event'
/>
<Switch
variant='ontime'
size='lg'
defaultChecked={eventSettings.linkPrevious}
onChange={(event) => setLinkPrevious(event.target.checked)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Default public' description='New events will be public' />
<Switch
variant='ontime'
size='lg'
defaultChecked={eventSettings.defaultPublic}
onChange={(event) => setDefaultPublic(event.target.checked)}
/>
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
<Panel.Section>
<Panel.Title>Play mode</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Show quick entry'
description='Whether the quick entry buttons show above / under selected event'
/>
<Switch variant='ontime' size='lg' defaultChecked={false} isDisabled />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Follow playback'
description='Whether view automatically follows the event being played'
/>
<Switch variant='ontime' size='lg' defaultChecked isDisabled />
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
<Panel.Section>
<Panel.Title>Edit mode</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Show quick entry'
description='Whether the quick entry buttons show above / under selected event'
/>
<Switch variant='ontime' size='lg' defaultChecked isDisabled />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Follow playback'
description='Whether view automatically follows the event being played'
/>
<Switch variant='ontime' size='lg' defaultChecked={false} isDisabled />
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
@@ -43,8 +43,7 @@ export default function ProjectList() {
<thead>
<tr>
<th className={style.containCell}>Project Name</th>
<th>Date Created</th>
<th>Date Modified</th>
<th>Last Used</th>
<th />
</tr>
</thead>
@@ -53,7 +52,6 @@ export default function ProjectList() {
<ProjectListItem
key={project.filename}
filename={project.filename}
createdAt={project.createdAt}
updatedAt={project.updatedAt}
onToggleEditMode={handleToggleEditMode}
onSubmit={handleClear}
@@ -5,7 +5,7 @@ import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHoriz
import {
deleteProject,
downloadCSV,
downloadRundown,
downloadProject,
duplicateProject,
loadProject,
renameProject,
@@ -21,7 +21,6 @@ export type EditMode = 'rename' | 'duplicate' | null;
interface ProjectListItemProps {
current?: boolean;
filename: string;
createdAt: string;
updatedAt: string;
onToggleEditMode: (editMode: EditMode, filename: string | null) => void;
onSubmit: () => void;
@@ -32,14 +31,13 @@ interface ProjectListItemProps {
export default function ProjectListItem({
current,
createdAt,
updatedAt,
editingFilename,
editingMode,
filename,
onRefetch,
onSubmit,
onToggleEditMode,
updatedAt,
}: ProjectListItemProps) {
const [submitError, setSubmitError] = useState<string | null>(null);
@@ -102,7 +100,6 @@ export default function ProjectListItem({
) : (
<>
<td className={style.containCell}>{filename}</td>
<td>{new Date(createdAt).toLocaleString()}</td>
<td>{new Date(updatedAt).toLocaleString()}</td>
<td className={style.actionButton}>
<ActionMenu
@@ -148,7 +145,7 @@ function ActionMenu({
};
const handleDownload = async () => {
await downloadRundown(filename);
await downloadProject(filename);
};
const handleExportCSV = async () => {
@@ -1,5 +1,5 @@
.current {
background-color: $blue-900;
background-color: $blue-1100;
}
.actionButton {
@@ -1,14 +1,23 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { PanelBaseProps } from '../../settingsStore';
import * as Panel from '../PanelUtils';
import ManageProjects from './ManageProjects';
import ProjectData from './ProjectData';
export default function ProjectPanel() {
export default function ProjectPanel({ location }: PanelBaseProps) {
const projectRef = useScrollIntoView<HTMLDivElement>('project', location);
const manageRef = useScrollIntoView<HTMLDivElement>('manage', location);
return (
<>
<Panel.Header>Project</Panel.Header>
<ProjectData />
<ManageProjects />
<div ref={projectRef}>
<ProjectData />
</div>
<div ref={manageRef}>
<ManageProjects />
</div>
</>
);
}
@@ -11,6 +11,7 @@ import CustomFieldForm from './CustomFieldForm';
import style from './ProjectSettingsPanel.module.scss';
interface CustomFieldEntryProps {
field: string;
colour: string;
label: string;
onEdit: (label: CustomFieldLabel, patch: CustomField) => Promise<void>;
@@ -18,13 +19,11 @@ interface CustomFieldEntryProps {
}
export default function CustomFieldEntry(props: CustomFieldEntryProps) {
const { colour, label, onEdit, onDelete } = props;
const { colour, label, onEdit, onDelete, field } = props;
const [isEditing, setIsEditing] = useState(false);
const handleEdit = async (patch: CustomField) => {
const oldLabel = label;
await onEdit(oldLabel, patch);
await onEdit(field, patch);
setIsEditing(false);
};
@@ -64,7 +63,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={() => onDelete(label)}
onClick={() => onDelete(field)}
/>
</td>
</tr>
@@ -84,6 +84,7 @@ export default function ProjectSettingsPanel() {
return (
<CustomFieldEntry
key={key}
field={key}
colour={colour}
label={label}
onEdit={handleEditField}
@@ -0,0 +1,27 @@
import { Button } from '@chakra-ui/react';
import useElectronEvent from '../../../../common/hooks/useElectronEvent';
import * as Panel from '../PanelUtils';
export default function ShutdownPanel() {
const { isElectron, sendToElectron } = useElectronEvent();
const sendShutdown = () => {
sendToElectron('shutdown', 'now');
};
return (
<>
<Panel.Header>Shutdown Ontime</Panel.Header>
<Panel.Section>
<Panel.Paragraph>
This will shutdown the Ontime server. <br />
The runtime state will be lost, but your project is kept for next time.
</Panel.Paragraph>
<Button colorScheme='red' onClick={sendShutdown} isDisabled={!isElectron}>
Shutdown ontime
</Button>
</Panel.Section>
</>
);
}
@@ -1,8 +1,10 @@
import { ChangeEvent, useEffect, useState } from 'react';
import { Button, Input } from '@chakra-ui/react';
import { Button, Input, Spinner } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { IoShieldCheckmarkOutline } from '@react-icons/all-files/io5/IoShieldCheckmarkOutline';
import { getWorksheetNames } from '../../../../common/api/sheets';
import { maybeAxiosError } from '../../../../common/api/utils';
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import { openLink } from '../../../../common/utils/linkUtils';
import * as Panel from '../PanelUtils';
@@ -27,7 +29,8 @@ export default function GSheetSetup(props: GSheetSetupProps) {
const sheetId = useSheetStore((state) => state.sheetId);
const setSheetId = useSheetStore((state) => state.setSheetId);
const setWorksheets = useSheetStore((state) => state.setWorksheets);
const patchStepData = useSheetStore((state) => state.patchStepData);
const authenticationStatus = useSheetStore((state) => state.authenticationStatus);
const setAuthenticationStatus = useSheetStore((state) => state.setAuthenticationStatus);
@@ -53,7 +56,6 @@ export default function GSheetSetup(props: GSheetSetupProps) {
};
const handleCancelFlow = async () => {
await handleRevoke();
onCancel();
};
@@ -89,6 +91,15 @@ export default function GSheetSetup(props: GSheetSetupProps) {
if (result?.authenticated) {
setAuthenticationStatus(result.authenticated);
if (result.authenticated !== 'pending') {
if (result.authenticated == 'authenticated') {
try {
const names = await getWorksheetNames(result.sheetId);
setWorksheets(names);
} catch (error) {
const message = maybeAxiosError(error);
patchStepData({ worksheet: { available: false, error: message } });
}
}
setLoading('');
return;
}
@@ -179,6 +190,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
) : (
<Panel.ListGroup>
<div className={style.buttonRow}>
{isAuthenticating && <Spinner />}
<CopyTag label='Google Auth Key' disabled={!canAuthenticate} size='sm'>
{authKey ? authKey : 'Upload files to generate Auth Key'}
</CopyTag>
@@ -187,8 +199,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
size='sm'
leftIcon={<IoShieldCheckmarkOutline />}
onClick={handleAuthenticate}
isDisabled={!canAuthenticate || isLoading}
isLoading={loading === 'authenticate' || isAuthenticating}
isDisabled={!canAuthenticate}
>
Authenticate
</Button>
@@ -14,10 +14,11 @@ interface ImportReviewProps {
rundown: OntimeRundown;
customFields: CustomFields;
onFinished: () => void;
onCancel: () => void;
}
export default function ImportReview(props: ImportReviewProps) {
const { rundown, customFields, onFinished } = props;
const { rundown, customFields, onFinished, onCancel } = props;
const [loading, setLoading] = useState(false);
const { importRundown } = useGoogleSheet();
@@ -25,7 +26,7 @@ export default function ImportReview(props: ImportReviewProps) {
const handleCancel = () => {
resetPreview();
onFinished();
onCancel();
};
const applyImport = async () => {
@@ -26,6 +26,7 @@
.buttonRow {
display: flex;
gap: 1rem;
align-items: center;
justify-content: end;
}
@@ -2,11 +2,16 @@ import { ChangeEvent, useRef, useState } from 'react';
import { Button, Input } from '@chakra-ui/react';
import { IoCloudOutline } from '@react-icons/all-files/io5/IoCloudOutline';
import { IoDownloadOutline } from '@react-icons/all-files/io5/IoDownloadOutline';
import { ImportMap, unpackError } from 'ontime-utils';
import { getErrorMessage, ImportMap } from 'ontime-utils';
import { importSpreadsheetPreview } from '../../../../common/api/db';
import {
getWorksheetNames as getWorksheetNamesExcel,
importRundownPreview as importRundownPreviewExcel,
upload as uploadExcel,
} from '../../../../common/api/excel';
import { getWorksheetNames } from '../../../../common/api/sheets';
import { maybeAxiosError } from '../../../../common/api/utils';
import { validateSpreadsheetImport } from '../../../../common/utils/uploadUtils';
import { validateExcelImport } from '../../../../common/utils/uploadUtils';
import * as Panel from '../PanelUtils';
import ImportMapForm from './import-map/ImportMapForm';
@@ -21,36 +26,43 @@ import style from './SourcesPanel.module.scss';
export default function SourcesPanel() {
const [importFlow, setImportFlow] = useState<'none' | 'excel' | 'gsheet' | 'finished'>('none');
const [error, setError] = useState('');
const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none');
const { exportRundown, importRundownPreview, revoke, verifyAuth } = useGoogleSheet();
const { exportRundown, importRundownPreview, verifyAuth } = useGoogleSheet();
const spreadsheet = useSheetStore((state) => state.spreadsheet);
const setSpreadsheet = useSheetStore((state) => state.setSpreadsheet);
const setWorksheets = useSheetStore((state) => state.setWorksheets);
const authenticationStatus = useSheetStore((state) => state.authenticationStatus);
const setAuthenticationStatus = useSheetStore((state) => state.setAuthenticationStatus);
const rundown = useSheetStore((state) => state.rundown);
const setRundown = useSheetStore((state) => state.setRundown);
const customFields = useSheetStore((state) => state.customFields);
const setCustomFields = useSheetStore((state) => state.setCustomFields);
const setSheetId = useSheetStore((state) => state.setSheetId);
const sheetId = useSheetStore((state) => state.sheetId);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFile = (event: ChangeEvent<HTMLInputElement>) => {
const handleFile = async (event: ChangeEvent<HTMLInputElement>) => {
const fileToUpload = event.target.files?.[0];
if (!fileToUpload) {
setSpreadsheet(null);
setWorksheets(null);
setHasFile('none');
return;
}
try {
validateSpreadsheetImport(fileToUpload);
setSpreadsheet(fileToUpload);
setHasFile('loading');
validateExcelImport(fileToUpload);
await uploadExcel(fileToUpload);
const names = await getWorksheetNamesExcel();
setWorksheets(names);
setImportFlow('excel');
setHasFile('done');
} catch (error) {
const errorMessage = unpackError(error);
const errorMessage = getErrorMessage(error);
setError(`Error uploading file: ${errorMessage}`);
setSpreadsheet(null);
setWorksheets(null);
setHasFile('none');
}
};
@@ -58,7 +70,16 @@ export default function SourcesPanel() {
fileInputRef.current?.click();
};
const openGSheetFlow = () => {
const openGSheetFlow = async () => {
const result = await verifyAuth();
if (result) {
setAuthenticationStatus(result.authenticated);
setSheetId(result.sheetId);
if (result.authenticated === 'authenticated' && result.sheetId) {
const names = await getWorksheetNames(result.sheetId);
setWorksheets(names);
}
}
setImportFlow('gsheet');
};
@@ -68,9 +89,8 @@ export default function SourcesPanel() {
const handleSubmitImportPreview = async (importMap: ImportMap) => {
if (importFlow === 'excel') {
if (!spreadsheet) return;
try {
const previewData = await importSpreadsheetPreview(spreadsheet, importMap);
const previewData = await importRundownPreviewExcel(importMap);
setRundown(previewData.rundown);
setCustomFields(previewData.customFields);
} catch (error) {
@@ -86,12 +106,9 @@ export default function SourcesPanel() {
const cancelImportMap = async () => {
setImportFlow('none');
if (spreadsheet) {
setSpreadsheet(null);
}
setHasFile('none');
setWorksheets(null);
if (authenticationStatus === 'authenticated') {
await revoke();
const result = await verifyAuth();
if (result) {
setAuthenticationStatus(result.authenticated);
@@ -102,7 +119,8 @@ export default function SourcesPanel() {
const handleFinished = () => {
setImportFlow('finished');
setRundown(null);
setSpreadsheet(null);
setHasFile('none');
setWorksheets(null);
setCustomFields(null);
};
@@ -113,12 +131,11 @@ export default function SourcesPanel() {
const isExcelFlow = importFlow === 'excel';
const isGSheetFlow = importFlow === 'gsheet';
const hasFile = Boolean(spreadsheet);
const isAuthenticated = authenticationStatus === 'authenticated';
const showInput = importFlow === 'none';
const showSuccess = importFlow === 'finished';
const showAuth = isGSheetFlow && !isAuthenticated;
const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile);
const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile === 'done');
const showReview = rundown !== null && customFields !== null;
return (
@@ -141,13 +158,25 @@ export default function SourcesPanel() {
/>
<div className={style.uploadSection}>
<div>
<Button variant='ontime-filled' size='sm' leftIcon={<IoDownloadOutline />} onClick={handleUpload}>
<Button
variant='ontime-filled'
size='sm'
leftIcon={<IoDownloadOutline />}
onClick={handleUpload}
isLoading={hasFile === 'loading'}
>
Import from spreadsheet
</Button>
<Panel.Description>Accepts .xlsx files</Panel.Description>
</div>
<div>
<Button variant='ontime-filled' size='sm' leftIcon={<IoCloudOutline />} onClick={openGSheetFlow}>
<Button
variant='ontime-filled'
size='sm'
leftIcon={<IoCloudOutline />}
onClick={openGSheetFlow}
isDisabled={hasFile !== 'none'}
>
Synchronise with Google
</Button>
<Panel.Description>Start authentication process</Panel.Description>
@@ -172,7 +201,14 @@ export default function SourcesPanel() {
onSubmitImport={handleSubmitImportPreview}
/>
)}
{showReview && <ImportReview rundown={rundown} customFields={customFields} onFinished={handleFinished} />}
{showReview && (
<ImportReview
rundown={rundown}
customFields={customFields}
onFinished={handleFinished}
onCancel={cancelImportMap}
/>
)}
</Panel.Card>
</Panel.Section>
</>
@@ -1,12 +1,13 @@
import { useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { Button, IconButton, Input } from '@chakra-ui/react';
import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { ImportMap } from 'ontime-utils';
import { isAlphanumeric } from '../../../../../common/utils/regex';
import * as Panel from '../../PanelUtils';
import useGoogleSheet from '../useGoogleSheet';
import { useSheetStore } from '../useSheetStore';
import { convertToImportMap, getPersistedOptions, NamedImportMap, persistImportMap } from './importMapUtils';
@@ -23,7 +24,7 @@ interface ImportMapFormProps {
export default function ImportMapForm(props: ImportMapFormProps) {
const { isSpreadsheet, onCancel, onSubmitExport, onSubmitImport } = props;
const namedImportMap = getPersistedOptions();
const { revoke } = useGoogleSheet();
const {
control,
handleSubmit,
@@ -41,6 +42,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
});
const stepData = useSheetStore((state) => state.stepData);
const worksheetNames = useSheetStore((state) => state.worksheetNames);
const [loading, setLoading] = useState<'' | 'export' | 'import'>('');
@@ -52,6 +54,11 @@ export default function ImportMapForm(props: ImportMapFormProps) {
setLoading('');
};
const handleRevoke = async () => {
await revoke();
onCancel();
};
const handleImportPreview = async (values: NamedImportMap) => {
setLoading('import');
const importMap = convertToImportMap(values);
@@ -78,6 +85,13 @@ export default function ImportMapForm(props: ImportMapFormProps) {
<Panel.Title>
Import options
<div className={style.buttonRow}>
{!isSpreadsheet && (
<Tooltip label='Revoke the google authentication'>
<Button variant='ontime-subtle' size='sm' onClick={handleRevoke} isDisabled={isLoading}>
Revoke
</Button>
</Tooltip>
)}
<Button variant='ontime-subtle' size='sm' onClick={onCancel} isDisabled={isLoading}>
Cancel
</Button>
@@ -116,6 +130,30 @@ export default function ImportMapForm(props: ImportMapFormProps) {
if (label === 'custom') {
return null;
}
if (label === 'Worksheet') {
return (
<tr key={importName as string}>
<td>{label}</td>
<td>
<Select
variant='ontime'
id={importName as string}
size='sm'
{...register(label as keyof NamedImportMap)}
>
{worksheetNames?.map((name) => {
return (
<option key={name} value={name}>
{name}
</option>
);
})}
</Select>
</td>
<td className={style.singleActionCell} />
</tr>
);
}
return (
<tr key={importName as string}>
<td>{label}</td>
@@ -23,7 +23,7 @@ export default function useGoogleSheet() {
const setCustomFields = useSheetStore((state) => state.setCustomFields);
/** whether the current session has been authenticated */
const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus } | void> => {
const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus; sheetId: string } | void> => {
try {
return verifyAuthenticationStatus();
} catch (_error) {
@@ -5,13 +5,12 @@ import { create } from 'zustand';
type SheetStore = {
stepData: typeof initialStepData;
patchStepData: (patch: Partial<typeof initialStepData>) => void;
setWorksheets: (worksheetNames: string[] | null) => void;
worksheetNames: string[] | null;
spreadsheet: File | null;
setSpreadsheet: (spreadsheet: File | null) => void;
//gSheet
sheetId: string | null;
setSheetId: (sheetId: string | null) => void;
authenticationStatus: AuthenticationStatus;
setAuthenticationStatus: (status: AuthenticationStatus) => void;
@@ -39,7 +38,7 @@ const initialStepData = {
const initialState = {
stepData: initialStepData,
spreadsheet: null,
worksheetNames: null,
sheetId: null,
authenticationStatus: 'not_authenticated' as AuthenticationStatus,
rundown: null,
@@ -55,7 +54,7 @@ export const useSheetStore = create<SheetStore>((set, get) => ({
set({ stepData: { ...stepData, ...patch } });
},
setSpreadsheet: (spreadsheet: File | null) => set({ spreadsheet }),
setWorksheets: (worksheetNames: string[] | null) => set({ worksheetNames }),
setSheetId: (sheetId: string | null) => set({ sheetId }),
@@ -15,11 +15,11 @@ export const settingPanels: Readonly<SettingsOption[]> = [
},
{
id: 'general',
label: 'General',
label: 'App Settings',
secondary: [
{ id: 'general__manage', label: 'Manage Ontime settings' },
{ id: 'general__view', label: 'View settings' },
{ id: 'general__urlPresets', label: 'URL presets' },
{ id: 'general__urlpresets', label: 'URL presets' },
],
},
{
@@ -27,7 +27,7 @@ export const settingPanels: Readonly<SettingsOption[]> = [
label: 'Project Settings',
secondary: [{ id: 'project_settings__custom', label: 'Custom fields' }],
},
{ id: 'interface', label: 'Interface', secondary: [{ id: 'general__editor', label: 'Editor settings' }] },
{ id: 'interface', label: 'Interface', secondary: [{ id: 'interface__editor', label: 'Editor settings' }] },
{
id: 'sources',
label: 'Data Sources',
@@ -51,14 +51,20 @@ export const settingPanels: Readonly<SettingsOption[]> = [
label: 'About',
split: true,
},
{
id: 'shutdown',
label: 'Shutdown',
split: true,
},
] as const;
export type SettingsOptionId = (typeof settingPanels)[number]['id'];
const firstPanel = settingPanels[0].id;
export interface PanelBaseProps {
location?: string;
}
type SettingsStore = {
showSettings: SettingsOptionId | null;
setShowSettings: (panelId?: SettingsOptionId | null) => void;
unsavedChanges: Set<SettingsOptionId>;
hasUnsavedChanges: (panelId: SettingsOptionId) => boolean;
addUnsavedChanges: (panelId: SettingsOptionId) => void;
@@ -66,16 +72,6 @@ type SettingsStore = {
};
export const useSettingsStore = create<SettingsStore>((set, get) => ({
showSettings: null,
setShowSettings: (panelId?: SettingsOptionId | null) => {
const newPanel = panelId === undefined ? firstPanel : panelId;
set((state) => {
return {
...state,
showSettings: newPanel,
};
});
},
unsavedChanges: new Set(),
hasUnsavedChanges: (panelId: SettingsOptionId) => get().unsavedChanges.has(panelId),
addUnsavedChanges: (panelId: SettingsOptionId) =>
@@ -0,0 +1,32 @@
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { SettingsOptionId } from './settingsStore';
const settingsKey = 'settings';
export default function useAppSettingsNavigation() {
const [searchParams, setSearchParams] = useSearchParams();
const selectedPanel = useMemo(
() => (searchParams.get(settingsKey) as SettingsOptionId | null) ?? 'project',
[searchParams],
);
const isOpen = useMemo(() => Boolean(searchParams.get(settingsKey)), [searchParams]);
const [panel, location] = selectedPanel.split('__');
const close = useCallback(() => {
searchParams.delete(settingsKey);
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
const setLocation = useCallback(
(panelId: SettingsOptionId) => {
searchParams.set(settingsKey, panelId);
setSearchParams(searchParams);
},
[searchParams, setSearchParams],
);
return { isOpen, panel, location, setLocation, close };
}
@@ -11,6 +11,8 @@ import InputRow from './InputRow';
import style from './MessageControl.module.scss';
const noop = () => undefined;
export default function MessageControl() {
const message = useMessageControl();
const blink = message.timer.blink;
@@ -50,7 +52,7 @@ export default function MessageControl() {
onClick={() => setMessage.timerBlink(!blink)}
data-testid='toggle timer blink'
>
Blink message
Blink
</Button>
<Button
size='sm'
@@ -64,13 +66,13 @@ export default function MessageControl() {
</Button>
</div>
<InputRow
label='External Message'
label='External Message (read only)'
placeholder={enDash}
readonly
text={message.external.text || ''}
visible={message.external.visible || false}
changeHandler={() => undefined}
actionHandler={() => undefined}
changeHandler={noop}
actionHandler={noop}
/>
</div>
);
@@ -86,16 +86,17 @@
grid-area: 2 / 2 / 2 / 4 ;
}
.tag {
color: $label-gray;
font-size: calc(1rem - 2px);
margin-right: 0.25rem;
}
.time {
color: $section-white;
font-size: $text-body-size;
}
.tag {
color: $label-gray;
font-size: 13px;
}
.rolltag {
color: $ontime-roll;
font-size: $text-body-size;
@@ -1,6 +1,6 @@
import { Tooltip } from '@chakra-ui/react';
import { Playback } from 'ontime-types';
import { millisToMinutes, millisToSeconds, millisToString } from 'ontime-utils';
import { dayInMs, millisToMinutes, millisToSeconds, millisToString } from 'ontime-utils';
import { setPlayback, useTimer } from '../../../../common/hooks/useSocket';
import { tooltipDelayMid } from '../../../../ontimeConfig';
@@ -17,9 +17,10 @@ export default function PlaybackTimer(props: PlaybackTimerProps) {
const { playback } = props;
const timer = useTimer();
// TODO: checkout typescript in utilities
const started = millisToString(timer.startedAt);
const finish = millisToString(timer.expectedFinish);
const expectedFinish = timer.expectedFinish !== null ? timer.expectedFinish % dayInMs : null;
const finish = millisToString(expectedFinish);
const isRolling = playback === Playback.Roll;
const isStopped = playback === Playback.Stop;
const isWaiting = timer.secondaryTimer !== null && timer.secondaryTimer > 0 && timer.current === null;
@@ -72,11 +73,11 @@ export default function PlaybackTimer(props: PlaybackTimerProps) {
) : (
<>
<div className={style.start}>
<span className={style.tag}>Started at </span>
<span className={style.tag}>Started at</span>
<span className={style.time}>{started}</span>
</div>
<div className={style.finish}>
<span className={style.tag}>Finish at </span>
<span className={style.tag}>Expect end</span>
<span className={style.time}>{finish}</span>
</div>
</>
@@ -1,21 +1,14 @@
.tableWrapper {
width: 100%;
height: 100vh;
padding: 1rem;
padding: 1rem 0.5rem;
display: grid;
grid-template-rows: auto auto 1fr;
grid-template-rows: 3rem auto 1fr;
grid-template-areas:
'header'
'overview'
'settings'
'table';
gap: 1rem;
background-color: $gray-1300;
color: white;
& > * {
border: 1px solid $white-10;
border-radius: 3px;
}
color: $ui-white;
}
@@ -1,17 +1,22 @@
import { useCallback, useEffect, useMemo } from 'react';
import { CustomFieldLabel, isOntimeEvent, ProjectData } from 'ontime-types';
import { useCallback, useMemo } from 'react';
import { IconButton, useDisclosure } from '@chakra-ui/react';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { CustomFieldLabel, isOntimeEvent } from 'ontime-types';
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
import Empty from '../../common/components/state/Empty';
import { useEventAction } from '../../common/hooks/useEventAction';
import { useCuesheet } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useFlatRundown } from '../../common/hooks-query/useRundown';
import Overview from '../overview/Overview';
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
import CuesheetTableHeader from './cuesheet-table-header/CuesheetTableHeader';
import { useCuesheetSettings } from './store/CuesheetSettings';
import Cuesheet from './Cuesheet';
import { makeCuesheetColumns } from './cuesheetCols';
import { makeCSV, makeTable } from './cuesheetUtils';
import styles from './CuesheetWrapper.module.scss';
@@ -19,15 +24,14 @@ export default function CuesheetWrapper() {
// TODO: can we use the normalised rundown for the table?
const { data: flatRundown, status: rundownStatus } = useFlatRundown();
const { data: customFields } = useCustomFields();
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
const { updateCustomField } = useEventAction();
const featureData = useCuesheet();
const columns = useMemo(() => makeCuesheetColumns(customFields), [customFields]);
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
// Set window title
useEffect(() => {
document.title = 'ontime - Cuesheet';
}, []);
useWindowTitle('Cuesheet');
/**
* Handles updating a field
@@ -74,38 +78,29 @@ export default function CuesheetWrapper() {
[flatRundown, rundownStatus, updateCustomField],
);
const exportHandler = useCallback(
(headerData: ProjectData) => {
if (!flatRundown || rundownStatus !== 'success') {
return;
}
const sheetData = makeTable(headerData, flatRundown, customFields);
const csvContent = makeCSV(sheetData);
const fileName = 'ontime rundown.csv';
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
// Clean up the URL.createObjectURL to release resources
URL.revokeObjectURL(url);
return;
},
[flatRundown, rundownStatus, customFields],
);
if (!customFields || !flatRundown || rundownStatus !== 'success') {
return <Empty text='Loading...' />;
}
return (
<div className={styles.tableWrapper} data-testid='cuesheet'>
<CuesheetTableHeader handleExport={exportHandler} featureData={featureData} />
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
<Overview>
<IconButton
aria-label='Toggle settings'
variant='ontime-subtle-white'
size='lg'
icon={<IoApps />}
onClick={onOpen}
/>
<IconButton
aria-label='Toggle navigation'
variant='ontime-subtle-white'
size='lg'
icon={<IoSettingsOutline />}
onClick={() => toggleSettings()}
/>
</Overview>
<CuesheetProgress />
<Cuesheet
data={flatRundown}
@@ -1,4 +1,4 @@
import { memo } from 'react';
import { memo, useEffect } from 'react';
import { Tooltip } from '@chakra-ui/react';
import {
closestCenter,
@@ -31,6 +31,12 @@ function CuesheetHeader(props: CuesheetHeaderProps) {
const { headerGroups } = props;
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>('table-order', initialColumnOrder);
useEffect(() => {
if (!localStorage.getItem('table-order')) {
saveColumnOrder(initialColumnOrder);
}
}, [saveColumnOrder]);
const handleOnDragEnd = (event: DragEndEvent) => {
const { delta, active, over } = event;
@@ -1,12 +1,8 @@
import { Tooltip } from '@chakra-ui/react';
import { IoContract } from '@react-icons/all-files/io5/IoContract';
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { Playback, ProjectData } from 'ontime-types';
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
import useFullscreen from '../../../common/hooks/useFullscreen';
import useProjectData from '../../../common/hooks-query/useProjectData';
import { cx, enDash } from '../../../common/utils/styleUtils';
import { tooltipDelayFast } from '../../../ontimeConfig';
@@ -28,10 +24,7 @@ interface CuesheetTableHeaderProps {
export default function CuesheetTableHeader({ handleExport, featureData }: CuesheetTableHeaderProps) {
const followSelected = useCuesheetSettings((state) => state.followSelected);
const showSettings = useCuesheetSettings((state) => state.showSettings);
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
const toggleFollow = useCuesheetSettings((state) => state.toggleFollow);
const { isFullScreen, toggleFullScreen } = useFullscreen();
const { data: project } = useProjectData();
const exportProject = () => {
@@ -66,19 +59,6 @@ export default function CuesheetTableHeader({ handleExport, featureData }: Cuesh
<IoLocate />
</span>
</Tooltip>
<Tooltip openDelay={tooltipDelayFast} label='Toggle settings'>
<span
onClick={() => toggleSettings()}
className={cx([style.actionIcon, showSettings ? style.enabled : null])}
>
<IoSettingsOutline />
</span>
</Tooltip>
<Tooltip openDelay={tooltipDelayFast} label='Toggle Fullscreen'>
<span onClick={() => toggleFullScreen()} className={style.actionIcon}>
{isFullScreen ? <IoContract /> : <IoExpand />}
</span>
</Tooltip>
<Tooltip openDelay={tooltipDelayFast} label='Export rundown'>
<span className={style.actionText} onClick={exportProject}>
Export CSV
@@ -23,12 +23,14 @@ interface CuesheetTableSettingsProps {
function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
const {
followSelected,
toggleFollow,
showPrevious,
toggleDelayVisibility,
togglePreviousVisibility,
showDelayBlock,
showDelayedTimes,
toggleDelayedTimes,
togglePreviousVisibility,
toggleDelayVisibility,
} = useCuesheetSettings();
return (
@@ -53,6 +55,10 @@ function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
</div>
<div className={style.sectionTitle}>Table Options</div>
<div className={style.options}>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={followSelected} onChange={() => toggleFollow()} />
Follow selected event
</label>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={showPrevious} onChange={() => togglePreviousVisibility()} />
Show past events
@@ -1,6 +1,5 @@
@use './EditorMixin' as editor;
$menu-width: 2.75rem;
$min-playback-width: 27rem;
$max-playback-width: 35rem;
$panel-gap: 0.5rem;
@@ -17,11 +16,11 @@ $panel-gap: 0.5rem;
padding: 1rem 0.5rem;
display: grid;
grid-template-columns: $menu-width auto;
grid-template-columns: auto;
grid-template-rows: 3rem 1fr;
grid-template-areas:
'menu overview'
'menu main';
'overview'
'main';
gap: $panel-gap;
}
+66 -18
View File
@@ -1,9 +1,13 @@
import { lazy, useEffect } from 'react';
import { lazy, useCallback, useEffect } from 'react';
import { IconButton, useDisclosure } from '@chakra-ui/react';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
import useElectronEvent from '../../common/hooks/useElectronEvent';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import AppSettings from '../app-settings/AppSettings';
import { SettingsOptionId, useSettingsStore } from '../app-settings/settingsStore';
import MenuBar from '../menu/MenuBar';
import useAppSettingsNavigation from '../app-settings/useAppSettingsNavigation';
import Overview from '../overview/Overview';
import styles from './Editor.module.scss';
@@ -13,26 +17,71 @@ const TimerControl = lazy(() => import('../control/playback/TimerControlExport')
const MessageControl = lazy(() => import('../control/message/MessageControlExport'));
export default function Editor() {
const showSettings = useSettingsStore((state) => state.showSettings);
const setShowSettings = useSettingsStore((state) => state.setShowSettings);
const { isOpen: isSettingsOpen, setLocation, close } = useAppSettingsNavigation();
const { isElectron } = useElectronEvent();
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
const handleSettings = (newTab?: SettingsOptionId) => {
setShowSettings(newTab);
};
const toggleSettings = useCallback(() => {
if (isSettingsOpen) {
close();
} else {
setLocation('project');
}
}, [close, isSettingsOpen, setLocation]);
// Set window title
// Handle keyboard shortcuts
const handleKeyPress = useCallback(
(event: KeyboardEvent) => {
// handle held key
if (event.repeat) return;
// check if the ctrl key is pressed
if (event.ctrlKey || event.metaKey) {
// ctrl + , (settings)
if (event.key === ',') {
toggleSettings();
event.preventDefault();
event.stopPropagation();
}
}
},
[toggleSettings],
);
// register ctrl + , to open settings
useEffect(() => {
document.title = 'ontime - Editor';
}, []);
if (isElectron) {
document.addEventListener('keydown', handleKeyPress);
}
return () => {
if (isElectron) {
document.removeEventListener('keydown', handleKeyPress);
}
};
}, [handleKeyPress, isElectron]);
const isSettingsOpen = Boolean(showSettings);
useWindowTitle('Editor');
return (
<div className={styles.mainContainer} data-testid='event-editor'>
<ErrorBoundary>
<MenuBar openSettings={handleSettings} isSettingsOpen={isSettingsOpen} />
</ErrorBoundary>
{showSettings ? (
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
<Overview>
<IconButton
aria-label='Toggle navigation'
variant='ontime-subtle-white'
size='lg'
icon={<IoApps />}
onClick={onOpen}
/>
<IconButton
aria-label='Toggle settings'
variant='ontime-subtle-white'
size='lg'
icon={<IoSettingsOutline />}
onClick={toggleSettings}
/>
</Overview>
{isSettingsOpen ? (
<AppSettings />
) : (
<div id='panels' className={styles.panelContainer}>
@@ -43,7 +92,6 @@ export default function Editor() {
<Rundown />
</div>
)}
<Overview />
</div>
);
}
@@ -1,20 +0,0 @@
.menu {
grid-area: menu;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5em;
padding-bottom: 1rem
}
.gap {
height: 1em;
}
.open {
background: $blue-700;
}
.bottom {
margin-top: auto;
}
-83
View File
@@ -1,83 +0,0 @@
import { memo, useCallback, useEffect } from 'react';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import QuitIconBtn from '../../common/components/buttons/QuitIconBtn';
import TooltipActionBtn from '../../common/components/buttons/TooltipActionBtn';
import useElectronEvent from '../../common/hooks/useElectronEvent';
import { cx } from '../../common/utils/styleUtils';
import style from './MenuBar.module.scss';
interface MenuBarProps {
openSettings: (newTab?: string) => void;
isSettingsOpen: boolean;
}
const buttonStyle = {
fontSize: '1.25em',
size: 'md',
colorScheme: 'white',
_hover: {
background: 'rgba(255, 255, 255, 0.10)', // $white-10
},
_active: {
background: 'rgba(255, 255, 255, 0.13)', // $white-13
},
};
const MenuBar = (props: MenuBarProps) => {
const { openSettings, isSettingsOpen } = props;
const { isElectron, sendToElectron } = useElectronEvent();
const sendShutdown = () => {
if (isElectron) {
sendToElectron('shutdown', 'now');
}
};
// Handle keyboard shortcuts
const handleKeyPress = useCallback(
(event: KeyboardEvent) => {
// handle held key
if (event.repeat) return;
// check if the ctrl key is pressed
if (event.ctrlKey || event.metaKey) {
// ctrl + , (settings)
if (event.key === ',') {
openSettings();
event.preventDefault();
event.stopPropagation();
}
}
},
[openSettings],
);
useEffect(() => {
if (isElectron) {
document.addEventListener('keydown', handleKeyPress);
}
return () => {
if (isElectron) {
document.removeEventListener('keydown', handleKeyPress);
}
};
}, [handleKeyPress, isElectron]);
return (
<div className={style.menu}>
<QuitIconBtn disabled={!isElectron} clickHandler={sendShutdown} />
<TooltipActionBtn
{...buttonStyle}
className={cx([isSettingsOpen ? style.open : null, style.bottom])}
icon={<IoSettingsOutline />}
clickHandler={() => openSettings()}
tooltip='Application settings'
aria-label='Application settings'
/>
</div>
);
};
export default memo(MenuBar);
@@ -3,12 +3,12 @@ import { useSearchParams } from 'react-router-dom';
import { CustomField, CustomFields, isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types';
import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
import Empty from '../../common/components/state/Empty';
import { getOperatorOptions } from '../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useOperator } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import useProjectData from '../../common/hooks-query/useProjectData';
import useRundown from '../../common/hooks-query/useRundown';
@@ -57,10 +57,7 @@ export default function Operator() {
topOffset: selectedOffset,
});
// Set window title
useEffect(() => {
document.title = 'ontime - Operator';
}, []);
useWindowTitle('Operator');
// reset scroll if nothing is selected
useEffect(() => {
@@ -141,7 +138,6 @@ export default function Operator() {
return (
<div className={style.operatorContainer}>
<NavigationMenu />
<ViewParamsEditor paramFields={operatorOptions} />
{editEvent && <EditModal event={editEvent} onClose={() => setEditEvent(null)} />}
@@ -0,0 +1,29 @@
import { useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useDisclosure } from '@chakra-ui/react';
import FloatingNavigation from '../../common/components/navigation-menu/FloatingNavigation';
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import Operator from './Operator';
export default function OperatorExport() {
const [searchParams, setSearchParams] = useSearchParams();
const { isOpen, onOpen, onClose } = useDisclosure();
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
const toggleMenu = isOpen ? onClose : onOpen;
return (
<ProtectRoute permission='operator'>
<FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
<ProductionNavigationMenu isMenuOpen={isOpen} onMenuClose={onClose} />
<Operator />
</ProtectRoute>
);
}
@@ -1,10 +1,20 @@
.overview {
grid-area: overview;
font-size: $inner-section-text-size;
display: flex;
}
.nav {
display: flex;
gap: 0.5rem;
}
.info {
flex: 1;
padding-inline: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
font-size: $inner-section-text-size;
padding: 0 1rem;
}
.title {
+29 -34
View File
@@ -1,39 +1,46 @@
import { MaybeNumber } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { memo, useMemo } from 'react';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import { useRuntimeOverview, useRuntimePlaybackOverview } from '../../common/hooks/useSocket';
import useProjectData from '../../common/hooks-query/useProjectData';
import { enDash, timerPlaceholder } from '../../common/utils/styleUtils';
import { enDash } from '../../common/utils/styleUtils';
import { TimeColumn, TimeRow } from './composite/TimeLayout';
import { calculateEndAndDaySpan, formatedTime, getOffsetText } from './overviewUtils';
import style from './Overview.module.scss';
/**
* Encapsulates the logic for formatting time in overview
* @param time
* @returns
*/
function formatedTime(time: MaybeNumber) {
return millisToString(time, { fallback: timerPlaceholder });
}
export default memo(Overview);
export default function Overview() {
function Overview({ children }: { children: React.ReactNode }) {
const { plannedEnd, plannedStart, actualStart, expectedEnd } = useRuntimeOverview();
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
const plannedEndText = formatedTime(maybePlannedEnd);
const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]);
const expectedEndText = formatedTime(maybeExpectedEnd);
return (
<div className={style.overview}>
<ErrorBoundary>
<TitlesOverview />
<div className={style.column}>
<TimeRow label='Planned start' value={formatedTime(plannedStart)} className={style.start} />
<TimeRow label='Actual start' value={formatedTime(actualStart)} className={style.start} />
</div>
<RuntimeOverview />
<div className={style.column}>
<TimeRow label='Planned end' value={formatedTime(plannedEnd)} className={style.end} />
<TimeRow label='Expected end' value={formatedTime(expectedEnd)} className={style.end} />
<div className={style.nav}>{children}</div>
<div className={style.info}>
<TitlesOverview />
<div>
<TimeRow label='Planned start' value={formatedTime(plannedStart)} className={style.start} />
<TimeRow label='Actual start' value={formatedTime(actualStart)} className={style.start} />
</div>
<RuntimeOverview />
<div>
<TimeRow label='Planned end' value={plannedEndText} className={style.end} daySpan={maybePlannedDaySpan} />
<TimeRow
label='Expected end'
value={expectedEndText}
className={style.end}
daySpan={maybeExpectedDaySpan}
/>
</div>
</div>
</ErrorBoundary>
</div>
@@ -44,25 +51,13 @@ function TitlesOverview() {
const { data } = useProjectData();
return (
<div className={style.titles}>
<div>
<div className={style.title}>{data.title}</div>
<div className={style.description}>{data.description}</div>
</div>
);
}
function getOffsetText(offset: MaybeNumber): string {
if (offset === null) {
return enDash;
}
const isAhead = offset <= 0;
let offsetText = millisToString(Math.abs(offset), { fallback: enDash });
if (offsetText !== enDash) {
offsetText = isAhead ? `+${offsetText}` : `${enDash}${offsetText}`;
}
return offsetText;
}
function RuntimeOverview() {
const { clock, numEvents, selectedEventIndex, offset } = useRuntimePlaybackOverview();
@@ -20,8 +20,7 @@
flex-direction: column;
.label {
line-height: 0.9em;
line-height: 0.9em;
}
}
@@ -29,6 +28,7 @@
display: flex;
align-items: center;
gap: 0.5rem;
height: 2.25em;
.label {
text-align: right;
@@ -38,3 +38,12 @@
font-size: 1.25rem;
}
}
.daySpan {
&::after {
content: "*";
vertical-align: super;
font-size: 0.75em;
color: $blue-500;
}
}
@@ -1,3 +1,5 @@
import { Tooltip } from '@chakra-ui/react';
import { cx } from '../../../common/utils/styleUtils';
import style from './TimeLayout.module.scss';
@@ -5,6 +7,7 @@ import style from './TimeLayout.module.scss';
interface TimeLayoutProps {
label: string;
value: string;
daySpan?: number;
className?: string;
}
@@ -17,11 +20,17 @@ export function TimeColumn({ label, value, className }: TimeLayoutProps) {
);
}
export function TimeRow({ label, value, className }: TimeLayoutProps) {
export function TimeRow({ label, value, daySpan, className }: TimeLayoutProps) {
return (
<div className={style.row}>
<span className={style.label}>{label}</span>
<span className={cx([style.clock, className])}>{value}</span>
{daySpan ? (
<Tooltip label={`Event spans over ${daySpan + 1} days`}>
<span className={cx([style.clock, style.daySpan, className])}>{value}</span>
</Tooltip>
) : (
<span className={cx([style.clock, className])}>{value}</span>
)}
</div>
);
}
@@ -0,0 +1,47 @@
import { MaybeNumber } from 'ontime-types';
import { dayInMs, millisToString } from 'ontime-utils';
import { enDash, timerPlaceholder } from '../../common/utils/styleUtils';
/**
* Encapsulates the logic for formatting time in overview
* @param time
* @returns
*/
export function formatedTime(time: MaybeNumber) {
return millisToString(time, { fallback: timerPlaceholder });
}
/**
* Calculates a day span from a number range
* @param end
* @returns
*/
export function calculateEndAndDaySpan(end: MaybeNumber): [MaybeNumber, number] {
let maybeEnd = end;
let maybeDaySpan = 0;
if (end !== null) {
if (end > dayInMs) {
maybeEnd = end % dayInMs;
maybeDaySpan = Math.floor(end / dayInMs);
}
}
return [maybeEnd, maybeDaySpan];
}
/**
* Formats offset text
* @param offset
* @returns
*/
export function getOffsetText(offset: MaybeNumber): string {
if (offset === null) {
return enDash;
}
const isAhead = offset <= 0;
let offsetText = millisToString(Math.abs(offset), { fallback: enDash });
if (offsetText !== enDash) {
offsetText = isAhead ? `+${offsetText}` : `${enDash}${offsetText}`;
}
return offsetText;
}
+22 -13
View File
@@ -38,15 +38,13 @@ export default function Rundown({ data }: RundownProps) {
const { addEvent, reorderEvent } = useEventAction();
const eventSettings = useEditorSettings((state) => state.eventSettings);
const defaultPublic = eventSettings.defaultPublic;
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
const showQuickEntry = eventSettings.showQuickEntry;
const linkPrevious = eventSettings.linkPrevious;
// cursor
const { cursor, mode: appMode, setCursor } = useAppMode();
const viewFollowsCursor = appMode === AppMode.Run;
const cursorRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
useFollowComponent({ followRef: cursorRef, scrollRef, doFollow: true });
useFollowComponent({ followRef: cursorRef, scrollRef, doFollow: appMode === AppMode.Run });
// DND KIT
const sensors = useSensors(useSensor(PointerSensor));
@@ -74,17 +72,17 @@ export default function Rundown({ data }: RundownProps) {
type: SupportedEvent.Event,
};
const options = {
defaultPublic,
startTimeIsLastEnd,
lastEventId: cursor,
after: cursor,
defaultPublic,
lastEventId: cursor,
linkPrevious,
};
addEvent(newEvent, options);
} else {
addEvent({ type }, { after: cursor });
}
},
[addEvent, rundown, defaultPublic, startTimeIsLastEnd],
[addEvent, rundown, defaultPublic, linkPrevious],
);
// Handle keyboard shortcuts
@@ -183,11 +181,11 @@ export default function Rundown({ data }: RundownProps) {
useEffect(() => {
// in run mode, we follow selection
if (!viewFollowsCursor || !featureData?.selectedEventId) {
if (appMode !== AppMode.Run || !featureData?.selectedEventId) {
return;
}
// moveCursorTo(featureData.selectedEventId);
}, [featureData?.selectedEventId, viewFollowsCursor]);
setCursor(featureData.selectedEventId);
}, [appMode, featureData.selectedEventId, setCursor]);
const handleOnDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
@@ -220,6 +218,8 @@ export default function Rundown({ data }: RundownProps) {
// all events before the current selected are in the past
let isPast = Boolean(featureData?.selectedEventId);
const isEditMode = appMode === AppMode.Edit;
return (
<div className={style.eventContainer} ref={scrollRef} data-testid='rundown'>
<DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}>
@@ -249,6 +249,7 @@ export default function Rundown({ data }: RundownProps) {
thisId = eventId;
}
}
const isFirst = index === 0;
const isLast = index === order.length - 1;
const isLoaded = featureData?.selectedEventId === event.id;
const isNext = featureData?.nextEventId === event.id;
@@ -259,6 +260,14 @@ export default function Rundown({ data }: RundownProps) {
return (
<Fragment key={event.id}>
{isEditMode && (hasCursor || isFirst) && (
<QuickAddBlock
showKbd={hasCursor ? 'above' : 'none'}
previousEventId={previousEventId}
disableAddDelay={isOntimeDelay(event)}
disableAddBlock={isOntimeBlock(event)}
/>
)}
<div className={style.entryWrapper} data-testid={`entry-${eventIndex}`}>
{isOntimeEvent(event) && <div className={style.entryIndex}>{eventIndex}</div>}
<div className={style.entry} key={event.id} ref={hasCursor ? cursorRef : undefined}>
@@ -278,9 +287,9 @@ export default function Rundown({ data }: RundownProps) {
/>
</div>
</div>
{((showQuickEntry && hasCursor) || isLast) && (
{isEditMode && (hasCursor || isLast) && (
<QuickAddBlock
showKbd={hasCursor}
showKbd={hasCursor ? 'below' : 'none'}
previousEventId={event.id}
disableAddDelay={isOntimeDelay(event)}
disableAddBlock={isOntimeBlock(event)}
@@ -13,7 +13,18 @@ import DelayBlock from './delay-block/DelayBlock';
import EventBlock from './event-block/EventBlock';
import { useEventSelection } from './useEventSelection';
export type EventItemActions = 'set-cursor' | 'event' | 'delay' | 'block' | 'delete' | 'clone' | 'update' | 'swap';
export type EventItemActions =
| 'set-cursor'
| 'event'
| 'event-before'
| 'delay'
| 'delay-before'
| 'block'
| 'block-before'
| 'delete'
| 'clone'
| 'update'
| 'swap';
interface RundownEntryProps {
type: SupportedEvent;
@@ -46,22 +57,24 @@ export default function RundownEntry(props: RundownEntryProps) {
} = props;
const { emitError } = useEmitLog();
const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction();
const { cursor } = useAppMode();
const cursor = useAppMode((state) => state.cursor);
const setCursor = useAppMode((state) => state.setCursor);
const { selectedEvents, clearSelectedEvents } = useEventSelection();
const eventSettings = useEditorSettings((state) => state.eventSettings);
const defaultPublic = eventSettings.defaultPublic;
const linkPrevious = eventSettings.linkPrevious;
const removeOpenEvent = useCallback(() => {
if (selectedEvents.has(data.id)) {
clearSelectedEvents();
}
// clear cursor if we are deleting the event that is currently selected
if (cursor === data.id) {
// setCursor(null);
setCursor(null);
}
}, [cursor, data.id, selectedEvents, clearSelectedEvents]);
const eventSettings = useEditorSettings((state) => state.eventSettings);
const defaultPublic = eventSettings.defaultPublic;
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
}, [selectedEvents, data.id, cursor, clearSelectedEvents, setCursor]);
// Create / delete new events
type FieldValue = {
@@ -74,19 +87,34 @@ export default function RundownEntry(props: RundownEntryProps) {
case 'event': {
const newEvent = { type: SupportedEvent.Event };
const options = {
startTimeIsLastEnd,
after: data.id,
defaultPublic,
lastEventId: previousEventId,
after: data.id,
linkPrevious,
};
return addEvent(newEvent, options);
}
case 'event-before': {
const newEvent = { type: SupportedEvent.Event };
const options = {
after: previousEventId,
defaultPublic,
linkPrevious,
};
return addEvent(newEvent, options);
}
case 'delay': {
return addEvent({ type: SupportedEvent.Delay }, { after: data.id });
}
case 'delay-before': {
return addEvent({ type: SupportedEvent.Delay }, { after: previousEventId });
}
case 'block': {
return addEvent({ type: SupportedEvent.Block }, { after: data.id });
}
case 'block-before': {
return addEvent({ type: SupportedEvent.Block }, { after: previousEventId });
}
case 'swap': {
const { value } = payload as FieldValue;
return swapEvents({ from: value as string, to: data.id });
@@ -161,9 +189,9 @@ export default function RundownEntry(props: RundownEntryProps) {
/>
);
} else if (data.type === SupportedEvent.Block) {
return <BlockBlock data={data} hasCursor={hasCursor} actionHandler={actionHandler} />;
return <BlockBlock data={data} hasCursor={hasCursor} onDelete={() => actionHandler('delete')} />;
} else if (data.type === SupportedEvent.Delay) {
return <DelayBlock data={data} hasCursor={hasCursor} actionHandler={actionHandler} />;
return <DelayBlock data={data} hasCursor={hasCursor} />;
}
return null;
}
@@ -4,11 +4,10 @@ import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils';
import EventEditor from './event-editor/EventEditor';
import RundownWrapper from './RundownWrapper';
import style from './RundownExport.module.scss';
import EventEditorWrapper from './event-editor/EventEditorWrapper';
const RundownExport = () => {
const isExtracted = window.location.pathname.includes('/rundown');
@@ -26,7 +25,7 @@ const RundownExport = () => {
</div>
<div className={style.side}>
<ErrorBoundary>
<EventEditor />
<EventEditorWrapper />
</ErrorBoundary>
</div>
</div>
@@ -1,32 +1,24 @@
import { useEffect, useRef } from 'react';
import { useRef } from 'react';
import { IconButton } from '@chakra-ui/react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
import { OntimeBlock, OntimeEvent } from 'ontime-types';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { OntimeBlock } from 'ontime-types';
import { cx } from '../../../common/utils/styleUtils';
import EditableBlockTitle from '../common/EditableBlockTitle';
import BlockActionMenu from '../event-block/composite/BlockActionMenu';
import type { EventItemActions } from '../RundownEntry';
import style from './BlockBlock.module.scss';
interface BlockBlockProps {
data: OntimeBlock;
hasCursor: boolean;
actionHandler: (
action: EventItemActions,
payload?:
| number
| {
field: keyof Omit<OntimeEvent, 'duration'> | 'durationOverride';
value: unknown;
},
) => void;
onDelete: () => void;
}
export default function BlockBlock(props: BlockBlockProps) {
const { data, hasCursor, actionHandler } = props;
const { data, hasCursor, onDelete } = props;
const handleRef = useRef<null | HTMLSpanElement>(null);
const {
@@ -45,12 +37,6 @@ export default function BlockBlock(props: BlockBlockProps) {
transition,
};
useEffect(() => {
if (hasCursor) {
handleRef?.current?.focus();
}
}, [hasCursor]);
const blockClasses = cx([style.block, hasCursor ? style.hasCursor : null]);
return (
@@ -59,7 +45,14 @@ export default function BlockBlock(props: BlockBlockProps) {
<IoReorderTwo />
</span>
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' />
<BlockActionMenu className={style.actionMenu} enableDelete actionHandler={actionHandler} />
<IconButton
aria-label='Delete'
size='sm'
icon={<IoTrash />}
variant='ontime-subtle'
color='#FA5656'
onClick={onDelete}
/>
</div>
);
}
@@ -5,32 +5,21 @@ import { CSS } from '@dnd-kit/utilities';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
import { OntimeDelay, OntimeEvent } from 'ontime-types';
import { OntimeDelay } from 'ontime-types';
import DelayInput from '../../../common/components/input/delay-input/DelayInput';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { cx } from '../../../common/utils/styleUtils';
import BlockActionMenu from '../event-block/composite/BlockActionMenu';
import type { EventItemActions } from '../RundownEntry';
import style from './DelayBlock.module.scss';
interface DelayBlockProps {
data: OntimeDelay;
hasCursor: boolean;
actionHandler: (
action: EventItemActions,
payload?:
| number
| {
field: keyof Omit<OntimeEvent, 'duration'> | 'durationOverride';
value: unknown;
},
) => void;
}
export default function DelayBlock(props: DelayBlockProps) {
const { data, hasCursor, actionHandler } = props;
const { data, hasCursor } = props;
const { applyDelay, deleteEvent } = useEventAction();
const handleRef = useRef<null | HTMLSpanElement>(null);
@@ -79,7 +68,6 @@ export default function DelayBlock(props: DelayBlockProps) {
<Button onClick={cancelDelayHandler} size='sm' leftIcon={<IoClose />} variant='ontime-subtle-white'>
Cancel
</Button>
<BlockActionMenu enableDelete actionHandler={actionHandler} />
</div>
</div>
);
@@ -9,7 +9,7 @@ $skip-opacity: 0.2;
display: grid;
grid-template-areas:
'binder ... ... ...'
'binder pb-actions times actions'
'binder pb-actions times ...'
'binder pb-actions title title'
'binder pb-actions estatus estatus'
'binder ... ... ...';
@@ -55,7 +55,6 @@ $skip-opacity: 0.2;
outline: 1px solid $block-cursor-color;
}
/* we stop the eventActions from having opacity to fix issue with dropdown drawing order */
&.past:not(.skip) {
.timerNote,
.statusElements,
@@ -153,12 +152,6 @@ $skip-opacity: 0.2;
}
}
.eventActions {
grid-area: actions;
height: 100%;
text-align: end;
}
.progressBg {
grid-area: progb;
border-radius: 1px;
@@ -2,16 +2,18 @@ import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoCopyOutline } from '@react-icons/all-files/io5/IoCopyOutline';
import { IoDuplicateOutline } from '@react-icons/all-files/io5/IoDuplicateOutline';
import { IoPeople } from '@react-icons/all-files/io5/IoPeople';
import { IoPeopleOutline } from '@react-icons/all-files/io5/IoPeopleOutline';
import { IoRemoveCircleOutline } from '@react-icons/all-files/io5/IoRemoveCircleOutline';
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { IoTimerOutline } from '@react-icons/all-files/io5/IoTimerOutline';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { EndAction, MaybeNumber, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useAppMode } from '../../../common/stores/appModeStore';
import copyToClipboard from '../../../common/utils/copyToClipboard';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import type { EventItemActions } from '../RundownEntry';
import { useEventIdSwapping } from '../useEventIdSwapping';
@@ -96,31 +98,25 @@ export default function EventBlock(props: EventBlockProps) {
selectedEvents.size > 1
? [
{
label: 'Visiblity',
group: [
{
label: 'Make public',
icon: IoPeople,
onClick: () =>
actionHandler('update', {
field: 'isPublic',
value: true,
}),
},
{
label: 'Make private',
icon: IoPeopleOutline,
onClick: () =>
actionHandler('update', {
field: 'isPublic',
value: false,
}),
},
],
label: 'Make public',
icon: IoPeople,
onClick: () =>
actionHandler('update', {
field: 'isPublic',
value: true,
}),
},
{
label: 'Make private',
icon: IoPeopleOutline,
onClick: () =>
actionHandler('update', {
field: 'isPublic',
value: false,
}),
},
]
: [
{ label: `Copy ID: ${eventId}`, icon: IoCopyOutline, onClick: () => copyToClipboard(eventId) },
{
label: 'Toggle public',
icon: IoPeopleOutline,
@@ -145,6 +141,14 @@ export default function EventBlock(props: EventBlockProps) {
},
isDisabled: selectedEventId == null || selectedEventId === eventId,
},
{ withDivider: true, label: 'Clone', icon: IoDuplicateOutline, onClick: () => actionHandler('clone') },
{ withDivider: true, label: 'Event before', icon: IoAdd, onClick: () => actionHandler('event-before') },
{ label: 'Event after', icon: IoAdd, onClick: () => actionHandler('event') },
{ label: 'Block before', icon: IoRemoveCircleOutline, onClick: () => actionHandler('block-before') },
{ label: 'Block after', icon: IoRemoveCircleOutline, onClick: () => actionHandler('block') },
{ label: 'Delay before', icon: IoTimerOutline, onClick: () => actionHandler('delay-before') },
{ label: 'Delay after', icon: IoTimerOutline, onClick: () => actionHandler('delay') },
{ withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
],
);
@@ -281,7 +285,6 @@ export default function EventBlock(props: EventBlockProps) {
loaded={loaded}
playback={playback}
isRolling={isRolling}
actionHandler={actionHandler}
/>
)}
</div>
@@ -1,5 +1,5 @@
import { MaybeNumber } from 'ontime-types';
import { millisToString, removeLeadingZero, removeTrailingZero } from 'ontime-utils';
import { dayInMs, millisToString, removeLeadingZero, removeTrailingZero } from 'ontime-utils';
export function formatDelay(timeStart: number, delay: number): string | undefined {
if (!delay) return;
@@ -23,10 +23,13 @@ export function formatOverlap(
if (previousStart && timeStart < previousEnd) {
const overlap = timeEnd - previousStart;
if (overlap <= 0) return;
const overlapString = removeLeadingZero(millisToString(Math.abs(overlap)));
return `Overlap ${overlapString}`;
if (overlap > 0) {
const overlapString = removeLeadingZero(millisToString(Math.abs(overlap)));
return `Overlap ${overlapString}`;
}
const gap = timeStart + dayInMs - previousEnd;
const gapString = removeLeadingZero(millisToString(Math.abs(gap)));
return `Gap ${gapString} (next day)`;
}
const overlapString = removeLeadingZero(millisToString(Math.abs(overlap)));
@@ -14,10 +14,8 @@ import { EndAction, MaybeString, Playback, TimerType, TimeStrategy } from 'ontim
import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayMid } from '../../../ontimeConfig';
import EditableBlockTitle from '../common/EditableBlockTitle';
import { EventItemActions } from '../RundownEntry';
import TimeInputFlow from '../time-input-flow/TimeInputFlow';
import BlockActionMenu from './composite/BlockActionMenu';
import EventBlockPlayback from './composite/EventBlockPlayback';
import EventBlockProgressBar from './composite/EventBlockProgressBar';
@@ -46,7 +44,6 @@ interface EventBlockInnerProps {
loaded: boolean;
playback?: Playback;
isRolling: boolean;
actionHandler: (action: EventItemActions, payload?: any) => void;
}
const EventBlockInner = (props: EventBlockInnerProps) => {
@@ -68,7 +65,6 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
loaded,
playback,
isRolling,
actionHandler,
} = props;
const [renderInner, setRenderInner] = useState(false);
@@ -139,9 +135,6 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
</Tooltip>
</div>
</div>
<div className={style.eventActions}>
<BlockActionMenu showClone enableDelete={!loaded} actionHandler={actionHandler} />
</div>
</>
);
};
@@ -20,20 +20,29 @@ describe('formatOverlap()', () => {
});
it('handles events the day after, without overlap', () => {
const previousStart = new Date(0).setUTCHours(11).valueOf();
const previousEnd = new Date(0).setUTCHours(12).valueOf();
const timeStart = new Date(0).setUTCHours(6).valueOf();
const timeEnd = new Date(0).setUTCHours(10).valueOf();
const previousStart = new Date(0).setUTCHours(11);
const previousEnd = new Date(0).setUTCHours(12);
const timeStart = new Date(0).setUTCHours(6);
const timeEnd = new Date(0).setUTCHours(10);
const result = formatOverlap(previousStart, previousEnd, timeStart, timeEnd);
expect(result).toBeUndefined();
expect(result).toBe('Gap 18:00:00 (next day)');
});
it('handles events the day after, with overlap', () => {
const previousStart = new Date(0).setUTCHours(9).valueOf();
const previousEnd = new Date(0).setUTCHours(10).valueOf();
const timeStart = new Date(0).setUTCHours(6).valueOf();
const timeEnd = new Date(0).setUTCHours(11).valueOf();
const previousStart = new Date(0).setUTCHours(9);
const previousEnd = new Date(0).setUTCHours(10);
const timeStart = new Date(0).setUTCHours(6);
const timeEnd = new Date(0).setUTCHours(11);
const result = formatOverlap(previousStart, previousEnd, timeStart, timeEnd);
expect(result).toBe('Overlap 02:00:00');
});
it('handles events the day after, with gap', () => {
const previousStart = new Date(0).setUTCHours(17);
const previousEnd = new Date(0).setUTCHours(23);
const timeStart = new Date(0).setUTCHours(9);
const timeEnd = new Date(0).setUTCHours(11);
const result = formatOverlap(previousStart, previousEnd, timeStart, timeEnd);
expect(result).toBe('Gap 10:00:00 (next day)');
});
});
@@ -1,64 +0,0 @@
import { useCallback } from 'react';
import { IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuList, Tooltip } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoDuplicateOutline } from '@react-icons/all-files/io5/IoDuplicateOutline';
import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHorizontal';
import { IoRemoveCircleOutline } from '@react-icons/all-files/io5/IoRemoveCircleOutline';
import { IoTimerOutline } from '@react-icons/all-files/io5/IoTimerOutline';
import { IoTrashBinSharp } from '@react-icons/all-files/io5/IoTrashBinSharp';
import { tooltipDelayMid } from '../../../../ontimeConfig';
import { EventItemActions } from '../../RundownEntry';
interface BlockActionMenuProps {
enableDelete?: boolean;
showClone?: boolean;
actionHandler: (action: EventItemActions, payload?: any) => void;
className?: string;
}
export default function BlockActionMenu(props: BlockActionMenuProps) {
const { enableDelete, showClone, actionHandler, className } = props;
const handleAddEvent = useCallback(() => actionHandler('event'), [actionHandler]);
const handleAddDelay = useCallback(() => actionHandler('delay'), [actionHandler]);
const handleAddBlock = useCallback(() => actionHandler('block'), [actionHandler]);
const handleClone = useCallback(() => actionHandler('clone'), [actionHandler]);
const handleDelete = useCallback(() => actionHandler('delete'), [actionHandler]);
return (
<Menu isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
<Tooltip label='Add ...' openDelay={tooltipDelayMid}>
<MenuButton
as={IconButton}
aria-label='Event options'
icon={<IoEllipsisHorizontal />}
tabIndex={-1}
variant='ontime-ghosted-white'
size='sm'
className={className}
/>
</Tooltip>
<MenuList>
<MenuItem icon={<IoAdd />} onClick={handleAddEvent}>
Add Event after
</MenuItem>
<MenuItem icon={<IoTimerOutline />} onClick={handleAddDelay}>
Add Delay after
</MenuItem>
<MenuItem icon={<IoRemoveCircleOutline />} onClick={handleAddBlock}>
Add Block after
</MenuItem>
{showClone && (
<MenuItem icon={<IoDuplicateOutline />} onClick={handleClone}>
Clone event
</MenuItem>
)}
<MenuDivider />
<MenuItem icon={<IoTrashBinSharp />} onClick={handleDelete} isDisabled={!enableDelete} color='#D20300'>
Delete
</MenuItem>
</MenuList>
</Menu>
);
}
@@ -1,13 +1,11 @@
import { CSSProperties, useCallback, useEffect, useState } from 'react';
import { CSSProperties } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Button } from '@chakra-ui/react';
import { CustomFieldLabel, isOntimeEvent, OntimeEvent } from 'ontime-types';
import { CustomFieldLabel, OntimeEvent } from 'ontime-types';
import CopyTag from '../../../common/components/copy-tag/CopyTag';
import { useEventAction } from '../../../common/hooks/useEventAction';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import useRundown from '../../../common/hooks-query/useRundown';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import { useEventSelection } from '../useEventSelection';
import EventEditorTimes from './composite/EventEditorTimes';
import EventEditorTitles from './composite/EventEditorTitles';
@@ -19,46 +17,19 @@ export type EventEditorSubmitActions = keyof OntimeEvent;
export type EditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | CustomFieldLabel;
export default function EventEditor() {
const selectedEvents = useEventSelection((state) => state.selectedEvents);
const { data } = useRundown();
interface EventEditorProps {
event: OntimeEvent | null;
handleSubmit: (field: EditorUpdateFields, value: string) => void;
isMultiple: boolean;
}
export default function EventEditor({ event, handleSubmit, isMultiple }: EventEditorProps) {
const { data: customFields } = useCustomFields();
const { order, rundown } = data;
const { updateEvent } = useEventAction();
const [_searchParams, setSearchParams] = useSearchParams();
const [event, setEvent] = useState<OntimeEvent | null>(null);
useEffect(() => {
if (order.length === 0) {
setEvent(null);
return;
}
const selectedEventId = order.find((eventId) => selectedEvents.has(eventId));
if (!selectedEventId) {
setEvent(null);
return;
}
const event = rundown[selectedEventId];
if (event && isOntimeEvent(event)) {
setEvent(event);
} else {
setEvent(null);
}
}, [order, rundown, selectedEvents]);
const handleSubmit = useCallback(
(field: EditorUpdateFields, value: string) => {
if (field.startsWith('custom-')) {
const fieldLabel = field.split('custom-')[1];
updateEvent({ id: event?.id, custom: { [fieldLabel]: { value } } });
} else {
updateEvent({ id: event?.id, [field]: value });
}
},
[event?.id, updateEvent],
);
const handleOpenCustomManager = () => {
setSearchParams({ settings: 'project_settings__custom' });
};
if (!event) {
return (
@@ -94,11 +65,12 @@ export default function EventEditor() {
note={event.note}
colour={event.colour}
handleSubmit={handleSubmit}
isMultiple={isMultiple}
/>
<div className={style.column}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>Custom Fields</span>
<Button variant='ontime-subtle' size='sm' isDisabled>
<Button variant='ontime-subtle' size='sm' onClick={handleOpenCustomManager}>
Manage
</Button>
</div>
@@ -122,10 +94,12 @@ export default function EventEditor() {
})}
</div>
</div>
<div className={style.footer}>
<CopyTag label='OSC trigger by id'>{`/ontime/load/id "${event.id}"`}</CopyTag>
<CopyTag label='OSC trigger by cue'>{`/ontime/load/cue "${event.cue}"`}</CopyTag>
</div>
{!isMultiple ? (
<div className={style.footer}>
<CopyTag label='OSC trigger by id'>{`/ontime/load/id "${event.id}"`}</CopyTag>
<CopyTag label='OSC trigger by cue'>{`/ontime/load/cue "${event.cue}"`}</CopyTag>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,140 @@
import { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
CustomFieldLabel,
EndAction,
isOntimeEvent,
OntimeEvent,
SupportedEvent,
TimerType,
TimeStrategy,
} from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction';
import useRundown from '../../../common/hooks-query/useRundown';
import { useEventSelection } from '../useEventSelection';
import style from './EventEditor.module.scss';
import EventEditor from './EventEditor';
export type EventEditorSubmitActions = keyof OntimeEvent;
export type EditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | CustomFieldLabel;
export default function EventEditorWrapper() {
const selectedEvents = useEventSelection((state) => state.selectedEvents);
const { data } = useRundown();
const { order, rundown } = data;
const { updateEvent } = useEventAction();
const [_searchParams] = useSearchParams();
const [event, setEvent] = useState<OntimeEvent | null>(null);
useEffect(() => {
if (order.length === 0) {
setEvent(null);
return;
}
const selectedEventId = order.find((eventId) => selectedEvents.has(eventId));
if (!selectedEventId) {
setEvent(null);
return;
}
const event = rundown[selectedEventId];
if (event && isOntimeEvent(event)) {
setEvent(event);
} else {
setEvent(null);
}
}, [order, rundown, selectedEvents]);
const handleSingleSubmit = useCallback(
(field: EditorUpdateFields, value: string) => {
if (field.startsWith('custom-')) {
const fieldLabel = field.split('custom-')[1];
updateEvent({ id: event?.id, custom: { [fieldLabel]: { value } } });
} else {
updateEvent({ id: event?.id, [field]: value });
}
},
[event?.id, updateEvent],
);
const handleMultipleSubmits = useCallback(
(field: EditorUpdateFields, value: string) => {
if (field.startsWith('custom-')) {
// const fieldLabel = field.split('custom-')[1];
// updateEvent({ id: event?.id, custom: { [fieldLabel]: { value } } });
} else {
// updateEvent({ id: event?.id, [field]: value });
}
},
[event?.id, updateEvent],
);
if (!event) {
return (
<div className={style.eventEditor} data-testid='editor-container'>
Select an event to edit
</div>
);
}
const getMultipleEvent = (): OntimeEvent => {
const allHaveSameValue = (arr: any[], propertyName: string) => {
if (!arr || arr.length <= 0) return false;
return arr.every((obj) => JSON.stringify(obj[propertyName]) === JSON.stringify(arr[0][propertyName]));
};
const events: OntimeEvent[] = [];
let eventsIds: string = '';
for (const event of selectedEvents) {
const data = rundown[event];
if (data && isOntimeEvent(data)) {
events.push(data);
eventsIds += `${event} `;
}
}
const multipleEvents: Partial<OntimeEvent> = {
id: eventsIds.trim().split(' ').join(', '),
colour: allHaveSameValue(events, 'colour') ? events[0]['colour'] : 'unkown',
custom: allHaveSameValue(events, 'custom') ? events[0]['custom'] : undefined,
duration: allHaveSameValue(events, 'duration') ? events[0]['duration'] : undefined,
endAction: allHaveSameValue(events, 'endAction') ? events[0]['endAction'] : EndAction.Unkown,
delay: allHaveSameValue(events, 'delay') ? events[0]['delay'] : undefined,
isPublic: allHaveSameValue(events, 'isPublic') ? events[0]['isPublic'] : true, // ??
linkStart: allHaveSameValue(events, 'linkStart') ? events[0]['linkStart'] : null,
note: allHaveSameValue(events, 'note') ? events[0]['note'] : '', // ??
revision: allHaveSameValue(events, 'revision') ? events[0]['revision'] : undefined,
skip: allHaveSameValue(events, 'skip') ? events[0]['skip'] : undefined,
timeDanger: allHaveSameValue(events, 'timeDanger') ? events[0]['timeDanger'] : undefined,
timeEnd: allHaveSameValue(events, 'timeEnd') ? events[0]['timeEnd'] : undefined,
timeStart: allHaveSameValue(events, 'timeStart') ? events[0]['timeStart'] : undefined,
timeStrategy: allHaveSameValue(events, 'timeStrategy') ? events[0]['timeStrategy'] : TimeStrategy.Unkown,
timeWarning: allHaveSameValue(events, 'timeWarning') ? events[0]['timeWarning'] : undefined,
timerType: allHaveSameValue(events, 'timerType') ? events[0]['timerType'] : TimerType.Unkown,
title: allHaveSameValue(events, 'title') ? events[0]['title'] : '', // ??
type: allHaveSameValue(events, 'type') ? events[0]['type'] : SupportedEvent.Event,
cue: allHaveSameValue(events, 'cue') ? events[0]['cue'] : 'unkown',
};
return {
...event,
...multipleEvents,
};
};
return (
<div className={style.eventEditor} data-testid='editor-container'>
{selectedEvents.size <= 1 ? (
<EventEditor event={event} handleSubmit={handleSingleSubmit} isMultiple={false} />
) : (
<EventEditor event={getMultipleEvent()} handleSubmit={handleMultipleSubmits} isMultiple={true} />
)}
</div>
);
}

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