Compare commits

...

21 Commits

Author SHA1 Message Date
Carlos Valente 9ba94288a1 refactor: remove style override 2024-11-01 15:42:32 +01:00
Carlos Valente 6c78d25af1 fix: issue with rem 2024-11-01 15:42:32 +01:00
Carlos Valente 0d8c186c77 feat: add time type none 2024-11-01 15:40:42 +01:00
Carlos Valente 7ccd41eeee fix: validation cycle 2024-10-29 20:23:12 +01:00
Carlos Valente 43b390a079 refactor: register modal in electron 2024-10-29 20:23:12 +01:00
Carlos Valente 80ff3251ea refactor: allow two NIF links in macbook screens 2024-10-29 20:23:12 +01:00
Carlos Valente 3591b93c2d refactor: handle thrown errors 2024-10-29 20:23:12 +01:00
Carlos Valente b637621871 refactor: guarantee minimum width 2024-10-29 20:23:12 +01:00
Carlos Valente ed2efe183b feat: quick project wizard 2024-10-29 20:23:12 +01:00
Carlos Valente 27f99b4c87 refactor: generalise modal footer style 2024-10-29 20:23:12 +01:00
Carlos Valente ed4d0f060b fix: reset rem value 2024-10-29 20:23:12 +01:00
Carlos Valente 7c19c52332 chore: directory restructure
merge with directory
2024-10-29 20:23:12 +01:00
Carlos Valente b1bff22b84 refactor: improve visibility of time-to-end 2024-10-29 20:02:23 +01:00
Carlos Valente 83b9fde45e refactor: improve visibility of extract element 2024-10-29 20:02:23 +01:00
Carlos Valente 37974df5c6 fix: allow changing custom field colour 2024-10-29 09:42:11 +01:00
Carlos Valente 161ec383fd refactor: unify localhost logic 2024-10-29 09:42:01 +01:00
Carlos Valente de38f9d467 refactor: show new app version 2024-10-29 09:42:01 +01:00
Carlos Valente 8ad1f2cb38 refactor: no style overrides in production views 2024-10-29 08:23:44 +01:00
Carlos Valente 143917f11a feat: event finder 2024-10-26 11:57:20 +02:00
Carlos Valente a8756c8afe refactor: clear state on sheet flows 2024-10-25 21:58:37 +02:00
Carlos Valente 5501f57b45 feat: add link to context menu 2024-10-25 19:44:55 +02:00
96 changed files with 1121 additions and 492 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "3.6.2", "version": "3.7.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "3.6.2", "version": "3.7.0",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
@@ -11,7 +11,7 @@
"@emotion/react": "^11.10.6", "@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6", "@emotion/styled": "^11.10.6",
"@fontsource/open-sans": "^5.0.28", "@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^7.6.2", "@mantine/hooks": "^7.13.3",
"@react-icons/all-files": "^4.1.0", "@react-icons/all-files": "^4.1.0",
"@sentry/react": "^8.19.0", "@sentry/react": "^8.19.0",
"@tanstack/react-query": "^5.17.9", "@tanstack/react-query": "^5.17.9",
@@ -26,7 +26,7 @@
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-fast-compare": "^3.2.2", "react-fast-compare": "^3.2.2",
"react-hook-form": "^7.49.2", "react-hook-form": "^7.53.1",
"react-qr-code": "^2.0.12", "react-qr-code": "^2.0.12",
"react-router-dom": "^6.3.0", "react-router-dom": "^6.3.0",
"web-vitals": "^3.1.1", "web-vitals": "^3.1.1",
+1
View File
@@ -1,6 +1,7 @@
// keys in tanstack store // keys in tanstack store
export const APP_INFO = ['appinfo']; export const APP_INFO = ['appinfo'];
export const APP_SETTINGS = ['appSettings']; export const APP_SETTINGS = ['appSettings'];
export const APP_VERSION = ['appVersion'];
export const CUSTOM_FIELDS = ['customFields']; export const CUSTOM_FIELDS = ['customFields'];
export const HTTP_SETTINGS = ['httpSettings']; export const HTTP_SETTINGS = ['httpSettings'];
export const OSC_SETTINGS = ['oscSettings']; export const OSC_SETTINGS = ['oscSettings'];
+9 -1
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types'; import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils'; import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
@@ -86,6 +86,14 @@ export async function createProject(
return res.data; return res.data;
} }
/**
* HTTP request to create a project file
*/
export async function quickProject(data: QuickStartData): Promise<MessageResponse> {
const res = await axios.post(`${dbPath}/quick`, data);
return res.data;
}
/** /**
* HTTP request to get the list of available project files * HTTP request to get the list of available project files
*/ */
+2
View File
@@ -23,6 +23,8 @@ export function maybeAxiosError(error: unknown) {
} }
} }
return `${statusText}: ${data}`; return `${statusText}: ${data}`;
} else if (error instanceof Error) {
return error.message;
} else { } else {
if (typeof error !== 'string') { if (typeof error !== 'string') {
return JSON.stringify(error); return JSON.stringify(error);
@@ -1,6 +0,0 @@
.buttonSection {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $element-spacing;
margin-top: -$element-spacing;
}
@@ -15,8 +15,6 @@ import {
import { setClientRemote } from '../../hooks/useSocket'; import { setClientRemote } from '../../hooks/useSocket';
import style from './ClientModal.module.scss';
interface RedirectClientModalProps { interface RedirectClientModalProps {
id: string; id: string;
name?: string; name?: string;
@@ -54,14 +52,12 @@ export function RedirectClientModal(props: RedirectClientModalProps) {
</InputGroup> </InputGroup>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<div className={style.buttonSection}> <Button size='md' variant='ontime-subtle' onClick={onClose}>
<Button size='md' variant='ontime-subtle' onClick={onClose}> Cancel
Cancel </Button>
</Button> <Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}>
<Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}> Submit
Submit </Button>
</Button>
</div>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>
</Modal> </Modal>
@@ -13,8 +13,6 @@ import {
import { setClientRemote } from '../../hooks/useSocket'; import { setClientRemote } from '../../hooks/useSocket';
import style from './ClientModal.module.scss';
interface RenameClientModalProps { interface RenameClientModalProps {
id: string; id: string;
name?: string; name?: string;
@@ -53,14 +51,12 @@ export function RenameClientModal(props: RenameClientModalProps) {
/> />
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<div className={style.buttonSection}> <Button size='md' variant='ontime-subtle' onClick={onClose}>
<Button size='md' variant='ontime-subtle' onClick={onClose}> Cancel
Cancel </Button>
</Button> <Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}>
<Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}> Submit
Submit </Button>
</Button>
</div>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>
</Modal> </Modal>
@@ -20,7 +20,7 @@ import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import { isLocalhost, serverPort } from '../../api/constants'; import { isLocalhost, serverPort } from '../../api/constants';
import useClickOutside from '../../hooks/useClickOutside'; import useClickOutside from '../../hooks/useClickOutside';
import useElectronEvent from '../../hooks/useElectronEvent'; import { useElectronEvent } from '../../hooks/useElectronEvent';
import useInfo from '../../hooks-query/useInfo'; import useInfo from '../../hooks-query/useInfo';
import { useClientStore } from '../../stores/clientStore'; import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
@@ -57,7 +57,7 @@ function NavigationMenu(props: NavigationMenuProps) {
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} /> <RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'> <Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
<DrawerOverlay /> <DrawerOverlay />
<DrawerContent maxWidth='21rem'> <DrawerContent maxWidth='22rem'>
<DrawerHeader> <DrawerHeader>
<DrawerCloseButton size='lg' /> <DrawerCloseButton size='lg' />
Ontime Ontime
@@ -1,5 +1,6 @@
import { PropsWithChildren, useCallback, useContext } from 'react'; import { PropsWithChildren, useCallback, useContext } from 'react';
import { isLocalhost } from '../../api/constants';
import { AppContext } from '../../context/AppContext'; import { AppContext } from '../../context/AppContext';
import PinPage from './PinPage'; import PinPage from './PinPage';
@@ -9,7 +10,6 @@ interface ProtectRouteProps {
} }
export default function ProtectRoute({ permission, children }: PropsWithChildren<ProtectRouteProps>) { export default function ProtectRoute({ permission, children }: PropsWithChildren<ProtectRouteProps>) {
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
const { editorAuth, operatorAuth, validate } = useContext(AppContext); const { editorAuth, operatorAuth, validate } = useContext(AppContext);
const handleValidation = useCallback( const handleValidation = useCallback(
@@ -29,7 +29,7 @@ export default function ProtectRoute({ permission, children }: PropsWithChildren
return false; return false;
}; };
if (isLocal || hasRelevantAuth()) { if (isLocalhost || hasRelevantAuth()) {
// eslint-disable-next-line react/jsx-no-useless-fragment -- trying to make typescript happy // eslint-disable-next-line react/jsx-no-useless-fragment -- trying to make typescript happy
return <>{children}</>; return <>{children}</>;
} }
@@ -0,0 +1,33 @@
import { useQuery } from '@tanstack/react-query';
import { dayInMs } from 'ontime-utils';
import { version } from '../../../../../package.json';
import { APP_VERSION, isLocalhost } from '../api/constants';
import { getLatestVersion, HasUpdate } from '../api/external';
const placeholder: HasUpdate & { hasUpdates: boolean } = { url: '', version: '', hasUpdates: false };
export default function useAppVersion() {
const {
data: fetchData,
status,
isFetching,
isError,
refetch,
} = useQuery({
queryKey: APP_VERSION,
queryFn: getLatestVersion,
placeholderData: (previousData, _previousQuery) => previousData,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
staleTime: dayInMs,
enabled: isLocalhost,
});
const hasUpdates = fetchData?.version && !fetchData.version.includes(version);
const data = fetchData ? { ...fetchData, hasUpdates } : placeholder;
return { data, placeholder, status, isFetching, isError, refetch };
}
@@ -4,15 +4,20 @@ import { useNavigate } from 'react-router-dom';
const isElectron = window.process?.type === 'renderer'; const isElectron = window.process?.type === 'renderer';
const ipcRenderer = isElectron ? window.require('electron').ipcRenderer : null; const ipcRenderer = isElectron ? window.require('electron').ipcRenderer : null;
export default function useElectronEvent() { export function useElectronEvent() {
const navigate = useNavigate();
const sendToElectron = useCallback((channel: string, args?: string | Record<string, unknown>) => { const sendToElectron = useCallback((channel: string, args?: string | Record<string, unknown>) => {
if (isElectron && ipcRenderer) { if (isElectron && ipcRenderer) {
ipcRenderer.send(channel, args); ipcRenderer.send(channel, args);
} }
}, []); }, []);
return { isElectron, sendToElectron };
}
export function useElectronListener() {
const navigate = useNavigate();
const { isElectron } = useElectronEvent();
// listen to requests to change the editor location // listen to requests to change the editor location
useEffect(() => { useEffect(() => {
if (isElectron) { if (isElectron) {
@@ -25,7 +30,5 @@ export default function useElectronEvent() {
return () => { return () => {
ipcRenderer?.removeAllListeners(); ipcRenderer?.removeAllListeners();
}; };
}, [navigate]); }, [isElectron, navigate]);
return { isElectron, sendToElectron };
} }
+2 -49
View File
@@ -1,15 +1,7 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types'; import { isOntimeEvent, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils';
dayInMs,
getLinkedTimes,
getPreviousEventNormal,
MILLIS_PER_SECOND,
parseUserTime,
reorderArray,
swapEventData,
} from 'ontime-utils';
import { RUNDOWN } from '../api/constants'; import { RUNDOWN } from '../api/constants';
import { import {
@@ -270,44 +262,6 @@ export const useEventAction = () => {
[_updateEventMutation, queryClient], [_updateEventMutation, queryClient],
); );
/**
* Toggles link of an event to the previous
*/
const linkTimer = useCallback(
async (eventId: string, linkStart: MaybeString) => {
let newEvent: Partial<OntimeEvent> = { id: eventId };
if (!linkStart) {
newEvent.linkStart = null;
} else {
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (!cachedRundown) {
return;
}
const currentEvent = cachedRundown.rundown[eventId] as OntimeEvent;
if (!isOntimeEvent(currentEvent)) {
return;
}
const { previousEvent } = getPreviousEventNormal(cachedRundown.rundown, cachedRundown.order, eventId);
if (!previousEvent) {
newEvent.linkStart = null;
} else {
newEvent.linkStart = previousEvent.id;
const timePatch = getLinkedTimes(currentEvent, previousEvent);
newEvent = { ...newEvent, ...timePatch };
}
}
try {
await _updateEventMutation.mutateAsync(newEvent);
} catch (error) {
logAxiosError('Error updating event', error);
}
},
[_updateEventMutation, queryClient],
);
/** /**
* Calls mutation to edit multiple events * Calls mutation to edit multiple events
* @private * @private
@@ -614,7 +568,6 @@ export const useEventAction = () => {
batchUpdateEvents, batchUpdateEvents,
deleteEvent, deleteEvent,
deleteAllEvents, deleteAllEvents,
linkTimer,
reorderEvent, reorderEvent,
swapEvents, swapEvents,
updateEvent, updateEvent,
@@ -18,7 +18,7 @@ import useAppSettingsNavigation from './useAppSettingsNavigation';
import style from './AppSettings.module.scss'; import style from './AppSettings.module.scss';
export default function AppSettings() { export default function AppSettings() {
const { close, panel, location } = useAppSettingsNavigation(); const { close, panel, location, setLocation } = useAppSettingsNavigation();
useKeyDown(close, 'Escape'); useKeyDown(close, 'Escape');
return ( return (
@@ -26,7 +26,7 @@ export default function AppSettings() {
<ErrorBoundary> <ErrorBoundary>
<PanelList selectedPanel={panel} location={location} /> <PanelList selectedPanel={panel} location={location} />
<PanelContent onClose={close}> <PanelContent onClose={close}>
{panel === 'project' && <ProjectPanel location={location} />} {panel === 'project' && <ProjectPanel location={location} setLocation={setLocation} />}
{panel === 'general' && <GeneralPanel location={location} />} {panel === 'general' && <GeneralPanel location={location} />}
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />} {panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
{panel === 'sources' && <SourcesPanel />} {panel === 'sources' && <SourcesPanel />}
@@ -40,12 +40,16 @@ ul {
background-color: $gray-1100; background-color: $gray-1100;
} }
&.unsaved::before { &.highlight {
content: ''; color: $red-400;
width: 6px;
height: 6px; &::before {
border-radius: 3px; content: '';
background-color: $blue-400; width: 0.5em;
height: 0.5em;
border-radius: 99px;
background-color: $red-400;
}
} }
&.split { &.split {
@@ -1,66 +1,90 @@
import { Fragment } from 'react'; import { Fragment } from 'react';
import { Tooltip } from '@chakra-ui/react';
import { isKeyEnter } from '../../../common/utils/keyEvent'; import { isKeyEnter } from '../../../common/utils/keyEvent';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import { PanelBaseProps, settingPanels, useSettingsStore } from '../settingsStore'; import { tooltipDelayFast } from '../../../ontimeConfig';
import { SettingsOption, SettingsOptionId, useAppSettingsMenu } from '../useAppSettingsMenu';
import useAppSettingsNavigation from '../useAppSettingsNavigation'; import useAppSettingsNavigation from '../useAppSettingsNavigation';
import style from './PanelList.module.scss'; import style from './PanelList.module.scss';
export interface PanelBaseProps {
location?: string;
}
interface PanelListProps extends PanelBaseProps { interface PanelListProps extends PanelBaseProps {
selectedPanel: string; selectedPanel: string;
} }
export default function PanelList({ selectedPanel, location }: PanelListProps) { export default function PanelList({ selectedPanel, location }: PanelListProps) {
const { setLocation } = useAppSettingsNavigation(); const { options } = useAppSettingsMenu();
const { hasUnsavedChanges } = useSettingsStore();
return ( return (
<ul className={style.tabs}> <ul className={style.tabs}>
{settingPanels.map((panel) => { {options.map((panel) => {
const unsaved = hasUnsavedChanges(panel.id); const isSelected = selectedPanel === panel.id;
if (panel.highlight) {
const classes = cx([ return (
style.primary, <Tooltip key={panel.id} label={panel.highlight} openDelay={tooltipDelayFast} shouldWrapChildren>
selectedPanel === panel.id ? style.active : null, <PanelListItem panel={panel} location={location} isSelected={isSelected} />
panel.split ? style.split : null, </Tooltip>
unsaved ? style.unsaved : null, );
]); }
return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />;
return (
<Fragment key={panel.id}>
<li
key={panel.id}
onClick={() => setLocation(panel.id)}
onKeyDown={(event) => {
isKeyEnter(event) && setLocation(panel.id);
}}
className={classes}
tabIndex={0}
role='button'
>
{panel.label}
</li>
{panel.secondary?.map((secondary) => {
const id = secondary.id.split('__')[1];
const secondaryClasses = cx([style.secondary, location === id ? style.active : null]);
return (
<li
key={secondary.id}
onClick={() => setLocation(secondary.id)}
onKeyDown={(event) => {
isKeyEnter(event) && setLocation(secondary.id);
}}
className={secondaryClasses}
role='button'
>
{secondary.label}
</li>
);
})}
</Fragment>
);
})} })}
</ul> </ul>
); );
} }
interface PanelListItemProps {
panel: SettingsOption;
isSelected: boolean;
location?: string;
}
function PanelListItem(props: PanelListItemProps) {
const { panel, isSelected, location } = props;
const { setLocation } = useAppSettingsNavigation();
const classes = cx([
style.primary,
isSelected && style.active,
panel.split && style.split,
panel.highlight && style.highlight,
]);
return (
<Fragment key={panel.id}>
<li
key={panel.id}
onClick={() => setLocation(panel.id as SettingsOptionId)}
onKeyDown={(event) => {
isKeyEnter(event) && setLocation(panel.id as SettingsOptionId);
}}
className={classes}
tabIndex={0}
role='button'
>
{panel.label}
</li>
{panel.secondary?.map((secondary) => {
const id = secondary.id.split('__')[1];
const secondaryClasses = cx([style.secondary, location === id ? style.active : null]);
return (
<li
key={secondary.id}
onClick={() => setLocation(secondary.id as SettingsOptionId)}
onKeyDown={(event) => {
isKeyEnter(event) && setLocation(secondary.id as SettingsOptionId);
}}
className={secondaryClasses}
role='button'
>
{secondary.label}
</li>
);
})}
</Fragment>
);
}
@@ -2,7 +2,7 @@ import { HTMLAttributes, ReactNode } from 'react';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import style from './Panel.module.scss'; import style from './PanelUtils.module.scss';
export function Header({ children }: { children: ReactNode }) { export function Header({ children }: { children: ReactNode }) {
return <h2 className={style.header}>{children}</h2>; return <h2 className={style.header}>{children}</h2>;
@@ -1,9 +1,8 @@
import { version } from '../../../../../package.json';
import ExternalLink from '../../../../common/components/external-link/ExternalLink'; import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import { documentationUrl, githubUrl, websiteUrl } from '../../../../externals'; import { documentationUrl, githubUrl, websiteUrl } from '../../../../externals';
import * as Panel from '../PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import CheckUpdatesButton from './CheckUpdatesButton'; import AppVersion from './AppVersion';
export default function AboutPanel() { export default function AboutPanel() {
return ( return (
@@ -23,8 +22,7 @@ export default function AboutPanel() {
</Panel.Section> </Panel.Section>
<Panel.Section> <Panel.Section>
<Panel.SubHeader>Current version</Panel.SubHeader> <Panel.SubHeader>Current version</Panel.SubHeader>
<Panel.Paragraph>{`You are currently using Ontime ${version}`}</Panel.Paragraph> <AppVersion />
<CheckUpdatesButton version={version} />
</Panel.Section> </Panel.Section>
</> </>
); );
@@ -0,0 +1,31 @@
import { version } from '../../../../../package.json';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import useAppVersion from '../../../../common/hooks-query/useAppVersion';
import * as Panel from '../../panel-utils/PanelUtils';
export default function AppVersion() {
const { data, isError } = useAppVersion();
if (isError) {
return (
<Panel.Paragraph>
{`You are currently using Ontime version ${version}`}
<Panel.Error>{`Could not fetch version information: ${isError}`}</Panel.Error>
</Panel.Paragraph>
);
}
if (data.hasUpdates) {
return (
<>
<Panel.Paragraph>{`You are currently using Ontime version ${version}.`}</Panel.Paragraph>
<Panel.Paragraph>
{`A new version ${data.version} is available.`}
<ExternalLink href={data.url}> Please visit the release page to download</ExternalLink>
</Panel.Paragraph>
</>
);
}
return <Panel.Paragraph>{`You are currently using the latest version of Ontime: ${version}`}</Panel.Paragraph>;
}
@@ -1,80 +0,0 @@
import { useState } from 'react';
import { Button } from '@chakra-ui/react';
import { getLatestVersion, HasUpdate } from '../../../../common/api/external';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import style from '../Panel.module.scss';
type CheckFail = {
error: string;
};
type CheckIsLatest = {
latest: true;
};
type CheckRemote = CheckFail | CheckIsLatest | HasUpdate;
interface CheckUpdatesButtonProps {
version: string;
}
export default function CheckUpdatesButton(props: CheckUpdatesButtonProps) {
const { version } = props;
const [updateMessage, setUpdateMessage] = useState<CheckRemote | null>(null);
const [isFetching, setIsFetching] = useState(false);
/**
* Handles version comparison and returns component with message
*/
const versionCheck = async () => {
setIsFetching(true);
try {
const latest = await getLatestVersion();
if (!latest.version.includes(version)) {
// new version, pass data to component
setUpdateMessage(latest);
} else {
setUpdateMessage({ latest: true });
}
} catch {
setUpdateMessage({ error: 'Error reaching server' });
} finally {
setIsFetching(false);
}
};
const disableButton = Boolean(updateMessage && 'version' in updateMessage);
return (
<>
<Button
onClick={versionCheck}
variant='ontime-filled'
isLoading={isFetching}
isDisabled={disableButton}
size='sm'
maxWidth='max-content'
>
Check for updates
</Button>
<ResolveUpdateMessage updateMessage={updateMessage} />
</>
);
}
function ResolveUpdateMessage(props: { updateMessage: CheckRemote | null }) {
const { updateMessage } = props;
if (updateMessage && 'error' in updateMessage) {
return <span className={style.error}>{updateMessage.error}</span>;
}
if (updateMessage && 'url' in updateMessage) {
return <ExternalLink href={updateMessage?.url}>{`New version available: ${updateMessage.version}`}</ExternalLink>;
}
return null;
}
@@ -1,4 +1,4 @@
import * as Panel from '../PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import ClientList from './ClientList'; import ClientList from './ClientList';
@@ -5,7 +5,7 @@ import { RedirectClientModal } from '../../../../common/components/client-modal/
import { RenameClientModal } from '../../../../common/components/client-modal/RenameClientModal'; import { RenameClientModal } from '../../../../common/components/client-modal/RenameClientModal';
import { setClientRemote } from '../../../../common/hooks/useSocket'; import { setClientRemote } from '../../../../common/hooks/useSocket';
import { useClientStore } from '../../../../common/stores/clientStore'; import { useClientStore } from '../../../../common/stores/clientStore';
import * as Panel from '../PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import style from './ClientControlPanel.module.scss'; import style from './ClientControlPanel.module.scss';
@@ -1,6 +1,6 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { PanelBaseProps } from '../../settingsStore'; import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import CustomFields from './custom-fields/CustomFields'; import CustomFields from './custom-fields/CustomFields';
import UrlPresetsForm from './UrlPresetsForm'; import UrlPresetsForm from './UrlPresetsForm';
@@ -13,7 +13,7 @@ import ExternalLink from '../../../../common/components/external-link/ExternalLi
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets'; import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
import { handleLinks } from '../../../../common/utils/linkUtils'; import { handleLinks } from '../../../../common/utils/linkUtils';
import { validateUrlPresetPath } from '../../../../common/utils/urlPresets'; import { validateUrlPresetPath } from '../../../../common/utils/urlPresets';
import * as Panel from '../PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import style from './FeatureSettings.module.scss'; import style from './FeatureSettings.module.scss';
@@ -33,7 +33,7 @@ export default function UrlPresetsForm() {
setError, setError,
formState: { isSubmitting, isDirty, isValid, errors }, formState: { isSubmitting, isDirty, isValid, errors },
} = useForm<FormData>({ } = useForm<FormData>({
mode: 'onBlur', mode: 'onChange',
defaultValues: { data }, defaultValues: { data },
values: { data }, values: { data },
resetOptions: { resetOptions: {
@@ -7,7 +7,7 @@ import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
import { maybeAxiosError } from '../../../../../common/api/utils'; import { maybeAxiosError } from '../../../../../common/api/utils';
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect'; import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import * as Panel from '../../PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import style from '../FeatureSettings.module.scss'; import style from '../FeatureSettings.module.scss';
@@ -67,6 +67,8 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
const colour = getValues('colour'); const colour = getValues('colour');
const canSubmit = isDirty && isValid; const canSubmit = isDirty && isValid;
// if initial values are given, we can assume we are in edit mode
const isEditMode = initialKey !== undefined;
return ( return (
<form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}> <form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}>
@@ -81,7 +83,9 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
validate: (value) => { validate: (value) => {
if (value.trim().length === 0) return 'Required field'; if (value.trim().length === 0) return 'Required field';
if (!isAlphanumericWithSpace(value)) return 'Only alphanumeric characters and space are allowed'; if (!isAlphanumericWithSpace(value)) return 'Only alphanumeric characters and space are allowed';
if (Object.keys(data).includes(value)) return 'Custom fields must be unique'; if (!isEditMode) {
if (isEditMode && Object.keys(data).includes(value)) return 'Custom fields must be unique';
}
return true; return true;
}, },
})} })}
@@ -92,7 +96,7 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
</div> </div>
<div> <div>
<Panel.Description>Key (auto-generated value for use in Integrations and API)</Panel.Description> <Panel.Description>Key (use in Integrations and API)</Panel.Description>
<Input {...register('key')} disabled size='sm' variant='ontime-filled' autoComplete='off' /> <Input {...register('key')} disabled size='sm' variant='ontime-filled' autoComplete='off' />
</div> </div>
</div> </div>
@@ -7,7 +7,7 @@ import { deleteCustomField, editCustomField, postCustomField } from '../../../..
import ExternalLink from '../../../../../common/components/external-link/ExternalLink'; import ExternalLink from '../../../../../common/components/external-link/ExternalLink';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { customFieldsDocsUrl } from '../../../../../externals'; import { customFieldsDocsUrl } from '../../../../../externals';
import * as Panel from '../../PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import CustomFieldEntry from './CustomFieldEntry'; import CustomFieldEntry from './CustomFieldEntry';
import CustomFieldForm from './CustomFieldForm'; import CustomFieldForm from './CustomFieldForm';
@@ -1,7 +1,7 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { PanelBaseProps } from '../../settingsStore'; import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import EditorSettingsForm from '../interface-panel/EditorSettingsForm'; import EditorSettingsForm from '../interface-panel/EditorSettingsForm';
import * as Panel from '../PanelUtils';
import GeneralPanelForm from './GeneralPanelForm'; import GeneralPanelForm from './GeneralPanelForm';
import ViewSettingsForm from './ViewSettingsForm'; import ViewSettingsForm from './ViewSettingsForm';
@@ -7,7 +7,7 @@ import { postSettings } from '../../../../common/api/settings';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import useSettings from '../../../../common/hooks-query/useSettings'; import useSettings from '../../../../common/hooks-query/useSettings';
import { isOnlyNumbers } from '../../../../common/utils/regex'; import { isOnlyNumbers } from '../../../../common/utils/regex';
import * as Panel from '../PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import GeneralPinInput from './GeneralPinInput'; import GeneralPinInput from './GeneralPinInput';
@@ -154,7 +154,7 @@ export default function GeneralPanelForm() {
<option value='pt'>Portuguese</option> <option value='pt'>Portuguese</option>
<option value='es'>Spanish</option> <option value='es'>Spanish</option>
<option value='sv'>Swedish</option> <option value='sv'>Swedish</option>
<option value='pl'>Polish</option> <option value='pl'>Polish</option>
</Select> </Select>
</Panel.ListItem> </Panel.ListItem>
</Panel.ListGroup> </Panel.ListGroup>
@@ -9,7 +9,7 @@ import ExternalLink from '../../../../common/components/external-link/ExternalLi
import { PopoverPickerRHF } from '../../../../common/components/input/popover-picker/PopoverPicker'; import { PopoverPickerRHF } from '../../../../common/components/input/popover-picker/PopoverPicker';
import useInfo from '../../../../common/hooks-query/useInfo'; import useInfo from '../../../../common/hooks-query/useInfo';
import useViewSettings from '../../../../common/hooks-query/useViewSettings'; import useViewSettings from '../../../../common/hooks-query/useViewSettings';
import * as Panel from '../PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import style from './GeneralPanel.module.scss'; import style from './GeneralPanel.module.scss';
@@ -126,7 +126,7 @@ export default function ViewSettingsForm() {
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title='Freeze timer on end' title='Freeze timer on end'
description='Timer in views will stop from going negative and instead stop at 00:00:00' description='When a timer hits 00:00:00, it freezes instead of going negative. It invalidates the End Message.'
/> />
<Controller <Controller
control={control} control={control}
@@ -139,7 +139,7 @@ export default function ViewSettingsForm() {
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title='End message' title='End message'
description='Message to show on negative timers if not frozen. If not provided, timer will continue' description='Message for negative timers; applies only if the timer isn`t frozen on End. If no message is provided, it continues into negative time'
/> />
<Input <Input
size='sm' size='sm'
@@ -9,7 +9,7 @@ import { maybeAxiosError } from '../../../../common/api/utils';
import { useHttpSettings, usePostHttpSettings } from '../../../../common/hooks-query/useHttpSettings'; import { useHttpSettings, usePostHttpSettings } from '../../../../common/hooks-query/useHttpSettings';
import { isKeyEscape } from '../../../../common/utils/keyEvent'; import { isKeyEscape } from '../../../../common/utils/keyEvent';
import { startsWithHttp } from '../../../../common/utils/regex'; import { startsWithHttp } from '../../../../common/utils/regex';
import * as Panel from '../PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './integrationUtils'; import { cycles } from './integrationUtils';
@@ -27,7 +27,7 @@ export default function HttpIntegrations() {
setError, setError,
formState: { errors, isSubmitting, isDirty, isValid }, formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<HttpSettings>({ } = useForm<HttpSettings>({
mode: 'onBlur', mode: 'onChange',
defaultValues: data, defaultValues: data,
values: data, values: data,
resetOptions: { resetOptions: {
@@ -2,8 +2,8 @@ import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
import ExternalLink from '../../../../common/components/external-link/ExternalLink'; import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { PanelBaseProps } from '../../settingsStore'; import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import HttpIntegrations from './HttpIntegrations'; import HttpIntegrations from './HttpIntegrations';
import OscIntegrations from './OscIntegrations'; import OscIntegrations from './OscIntegrations';
@@ -10,7 +10,7 @@ import { maybeAxiosError } from '../../../../common/api/utils';
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings'; import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
import { isKeyEscape } from '../../../../common/utils/keyEvent'; import { isKeyEscape } from '../../../../common/utils/keyEvent';
import { isASCII, isASCIIorEmpty, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex'; import { isASCII, isASCIIorEmpty, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
import * as Panel from '../PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './integrationUtils'; import { cycles } from './integrationUtils';
@@ -28,7 +28,7 @@ export default function OscIntegrations() {
setError, setError,
formState: { errors, isSubmitting, isDirty, isValid }, formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<OSCSettings>({ } = useForm<OSCSettings>({
mode: 'onBlur', mode: 'onChange',
defaultValues: data, defaultValues: data,
values: data, values: data,
resetOptions: { resetOptions: {
@@ -4,7 +4,7 @@ import { parseUserTime } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput'; import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { editorSettingsDefaults, useEditorSettings } from '../../../../common/stores/editorSettings'; import { editorSettingsDefaults, useEditorSettings } from '../../../../common/stores/editorSettings';
import * as Panel from '../PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
export default function EditorSettingsForm() { export default function EditorSettingsForm() {
const { const {
@@ -90,6 +90,7 @@ export default function EditorSettingsForm() {
<option value={TimerType.CountUp}>Count up</option> <option value={TimerType.CountUp}>Count up</option>
<option value={TimerType.TimeToEnd}>Time to end</option> <option value={TimerType.TimeToEnd}>Time to end</option>
<option value={TimerType.Clock}>Clock</option> <option value={TimerType.Clock}>Clock</option>
<option value={TimerType.None}>None</option>
</Select> </Select>
</Panel.ListItem> </Panel.ListItem>
<Panel.ListItem> <Panel.ListItem>
@@ -1,6 +1,6 @@
import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react'; import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
import * as Panel from '../PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import EditorSettingsForm from './EditorSettingsForm'; import EditorSettingsForm from './EditorSettingsForm';
@@ -4,7 +4,7 @@ import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { handleLinks } from '../../../../common/utils/linkUtils'; import { handleLinks } from '../../../../common/utils/linkUtils';
import Log from '../../../log/Log'; import Log from '../../../log/Log';
import * as Panel from '../PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import style from './NetworkLogExport.module.scss'; import style from './NetworkLogExport.module.scss';
@@ -1,7 +1,7 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { PanelBaseProps } from '../../settingsStore'; import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import ClientControlPanel from '../client-control-panel/ClientControlPanel'; import ClientControlPanel from '../client-control-panel/ClientControlPanel';
import * as Panel from '../PanelUtils';
import InfoNif from './NetworkInterfaces'; import InfoNif from './NetworkInterfaces';
import LogExport from './NetworkLogExport'; import LogExport from './NetworkLogExport';
@@ -6,7 +6,7 @@ import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { uploadProjectFile } from '../../../../common/api/db'; import { uploadProjectFile } from '../../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils'; import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
import { validateProjectFile } from '../../../../common/utils/uploadUtils'; import { validateProjectFile } from '../../../../common/utils/uploadUtils';
import * as Panel from '../PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import ProjectCreateForm from './ProjectCreateForm'; import ProjectCreateForm from './ProjectCreateForm';
import ProjectList from './ProjectList'; import ProjectList from './ProjectList';
@@ -20,10 +20,10 @@ export default function ManageProjects() {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const isCreatingProject = searchParams.get('create') === 'true'; const isCreatingProject = searchParams.get('new') === 'true';
const handleToggleCreate = () => { const handleToggleCreate = () => {
searchParams.set('create', isCreatingProject ? 'false' : 'true'); searchParams.set('new', isCreatingProject ? 'false' : 'true');
setSearchParams(searchParams); setSearchParams(searchParams);
}; };
@@ -53,7 +53,7 @@ export default function ManageProjects() {
}; };
const handleCloseForm = () => { const handleCloseForm = () => {
searchParams.delete('create'); searchParams.delete('new');
setSearchParams(searchParams); setSearchParams(searchParams);
}; };
@@ -6,7 +6,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { PROJECT_LIST } from '../../../../common/api/constants'; import { PROJECT_LIST } from '../../../../common/api/constants';
import { createProject } from '../../../../common/api/db'; import { createProject } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import * as Panel from '../PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import style from './ProjectPanel.module.scss'; import style from './ProjectPanel.module.scss';
@@ -6,7 +6,7 @@ import { type ProjectData } from 'ontime-types';
import { postProjectData } from '../../../../common/api/project'; import { postProjectData } from '../../../../common/api/project';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import useProjectData from '../../../../common/hooks-query/useProjectData'; import useProjectData from '../../../../common/hooks-query/useProjectData';
import * as Panel from '../PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import style from './ProjectPanel.module.scss'; import style from './ProjectPanel.module.scss';
@@ -81,7 +81,7 @@ export default function ProjectData() {
variant='ontime-filled' variant='ontime-filled'
size='sm' size='sm'
maxLength={50} maxLength={50}
placeholder='Your project name' placeholder='Project title is shown in production views'
autoComplete='off' autoComplete='off'
{...register('title')} {...register('title')}
/> />
@@ -1,7 +1,7 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useProjectList } from '../../../../common/hooks-query/useProjectList'; import { useProjectList } from '../../../../common/hooks-query/useProjectList';
import * as Panel from '../PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import ProjectListItem, { EditMode } from './ProjectListItem'; import ProjectListItem, { EditMode } from './ProjectListItem';
@@ -12,7 +12,7 @@ import {
} from '../../../../common/api/db'; } from '../../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils'; import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import ProjectForm, { ProjectFormValues } from './ProjectForm'; import ProjectForm, { ProjectFormValues } from './ProjectForm';
import ProjectMergeForm from './ProjectMergeForm'; import ProjectMergeForm from './ProjectMergeForm';
@@ -7,7 +7,7 @@ import { PROJECT_DATA } from '../../../../common/api/constants';
import { getDb, patchData } from '../../../../common/api/db'; import { getDb, patchData } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { makeProjectPatch } from './project.utils'; import { makeProjectPatch } from './project.utils';
@@ -1,17 +1,28 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { PanelBaseProps } from '../../settingsStore'; import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import QuickStart from '../../quick-start/QuickStart';
import type { SettingsOptionId } from '../../useAppSettingsMenu';
import ManageProjects from './ManageProjects'; import ManageProjects from './ManageProjects';
import ProjectData from './ProjectData'; import ProjectData from './ProjectData';
export default function ProjectPanel({ location }: PanelBaseProps) { interface ProjectPanelProps extends PanelBaseProps {
setLocation: (location: SettingsOptionId) => void;
}
export default function ProjectPanel({ location, setLocation }: ProjectPanelProps) {
const projectRef = useScrollIntoView<HTMLDivElement>('data', location); const projectRef = useScrollIntoView<HTMLDivElement>('data', location);
const manageRef = useScrollIntoView<HTMLDivElement>('manage', location); const manageRef = useScrollIntoView<HTMLDivElement>('manage', location);
const handleQuickClose = () => {
setLocation('project');
};
return ( return (
<> <>
<Panel.Header>Project</Panel.Header> <Panel.Header>Project</Panel.Header>
<QuickStart isOpen={location === 'create'} onClose={handleQuickClose} />
<div ref={projectRef}> <div ref={projectRef}>
<ProjectData /> <ProjectData />
</div> </div>
@@ -11,8 +11,8 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { isLocalhost } from '../../../../common/api/constants'; import { isLocalhost } from '../../../../common/api/constants';
import useElectronEvent from '../../../../common/hooks/useElectronEvent'; import { useElectronEvent } from '../../../../common/hooks/useElectronEvent';
import * as Panel from '../PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
export default function ShutdownPanel() { export default function ShutdownPanel() {
const { isElectron, sendToElectron } = useElectronEvent(); const { isElectron, sendToElectron } = useElectronEvent();
@@ -7,7 +7,7 @@ import { getWorksheetNames } from '../../../../common/api/sheets';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import CopyTag from '../../../../common/components/copy-tag/CopyTag'; import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import { openLink } from '../../../../common/utils/linkUtils'; import { openLink } from '../../../../common/utils/linkUtils';
import * as Panel from '../PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import useGoogleSheet from './useGoogleSheet'; import useGoogleSheet from './useGoogleSheet';
import { useSheetStore } from './useSheetStore'; import { useSheetStore } from './useSheetStore';
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { CustomFields, OntimeRundown } from 'ontime-types'; import { CustomFields, OntimeRundown } from 'ontime-types';
import * as Panel from '../PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import PreviewSpreadsheet from './preview/PreviewRundown'; import PreviewSpreadsheet from './preview/PreviewRundown';
import useGoogleSheet from './useGoogleSheet'; import useGoogleSheet from './useGoogleSheet';
@@ -12,7 +12,7 @@ import {
import { getWorksheetNames } from '../../../../common/api/sheets'; import { getWorksheetNames } from '../../../../common/api/sheets';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import { validateExcelImport } from '../../../../common/utils/uploadUtils'; import { validateExcelImport } from '../../../../common/utils/uploadUtils';
import * as Panel from '../PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import ImportMapForm from './import-map/ImportMapForm'; import ImportMapForm from './import-map/ImportMapForm';
import GSheetInfo from './GSheetInfo'; import GSheetInfo from './GSheetInfo';
@@ -70,6 +70,17 @@ export default function SourcesPanel() {
fileInputRef.current?.click(); fileInputRef.current?.click();
}; };
const resetFlow = () => {
// we purposely omit clearing the authentication status
setImportFlow('none');
setRundown(null);
setHasFile('none');
setWorksheets(null);
setCustomFields(null);
setError('');
setSheetId(null);
};
const openGSheetFlow = async () => { const openGSheetFlow = async () => {
const result = await verifyAuth(); const result = await verifyAuth();
if (result) { if (result) {
@@ -89,7 +100,7 @@ export default function SourcesPanel() {
}; };
const cancelGSheetFlow = () => { const cancelGSheetFlow = () => {
setImportFlow('none'); resetFlow();
}; };
const handleSubmitImportPreview = async (importMap: ImportMap) => { const handleSubmitImportPreview = async (importMap: ImportMap) => {
@@ -110,9 +121,7 @@ export default function SourcesPanel() {
}; };
const cancelImportMap = async () => { const cancelImportMap = async () => {
setImportFlow('none'); resetFlow();
setHasFile('none');
setWorksheets(null);
if (authenticationStatus === 'authenticated') { if (authenticationStatus === 'authenticated') {
const result = await verifyAuth(); const result = await verifyAuth();
if (result) { if (result) {
@@ -127,6 +136,7 @@ export default function SourcesPanel() {
setHasFile('none'); setHasFile('none');
setWorksheets(null); setWorksheets(null);
setCustomFields(null); setCustomFields(null);
setError('');
}; };
const handleSubmitExport = async (importMap: ImportMap) => { const handleSubmitExport = async (importMap: ImportMap) => {
@@ -134,15 +144,6 @@ export default function SourcesPanel() {
await exportRundown(sheetId, importMap); await exportRundown(sheetId, importMap);
}; };
const resetFlow = () => {
setImportFlow('none');
setRundown(null);
setHasFile('none');
setWorksheets(null);
setCustomFields(null);
setError('');
};
const isExcelFlow = importFlow === 'excel'; const isExcelFlow = importFlow === 'excel';
const isGSheetFlow = importFlow === 'gsheet'; const isGSheetFlow = importFlow === 'gsheet';
const isAuthenticated = authenticationStatus === 'authenticated'; const isAuthenticated = authenticationStatus === 'authenticated';
@@ -5,7 +5,7 @@ import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash'; import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { ImportMap, isAlphanumericWithSpace } from 'ontime-utils'; import { ImportMap, isAlphanumericWithSpace } from 'ontime-utils';
import * as Panel from '../../PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import useGoogleSheet from '../useGoogleSheet'; import useGoogleSheet from '../useGoogleSheet';
import { useSheetStore } from '../useSheetStore'; import { useSheetStore } from '../useSheetStore';
@@ -31,7 +31,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
register, register,
formState: { errors, isValid }, formState: { errors, isValid },
} = useForm<NamedImportMap>({ } = useForm<NamedImportMap>({
mode: 'onBlur', mode: 'onChange',
defaultValues: namedImportMap, defaultValues: namedImportMap,
values: namedImportMap, values: namedImportMap,
}); });
@@ -121,7 +121,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
<thead> <thead>
<tr> <tr>
<th>Ontime field</th> <th>Ontime field</th>
<th>From spreadsheet name</th> <th>Column name in spreadsheet</th>
<th className={style.singleActionCell} /> <th className={style.singleActionCell} />
</tr> </tr>
</thead> </thead>
@@ -5,7 +5,7 @@ import { millisToString } from 'ontime-utils';
import Tag from '../../../../../common/components/tag/Tag'; import Tag from '../../../../../common/components/tag/Tag';
import { getAccessibleColour } from '../../../../../common/utils/styleUtils'; import { getAccessibleColour } from '../../../../../common/utils/styleUtils';
import * as Panel from '../../PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import style from './PreviewRundown.module.scss'; import style from './PreviewRundown.module.scss';
@@ -0,0 +1,8 @@
.scrollContainer {
max-height: 70vh;
overflow: auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
@@ -0,0 +1,186 @@
import { Controller, useForm } from 'react-hook-form';
import {
Button,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Select,
Switch,
} from '@chakra-ui/react';
import { QuickStartData } from 'ontime-types';
import { parseUserTime } from 'ontime-utils';
import { quickProject } from '../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../common/api/utils';
import TimeInput from '../../../common/components/input/time-input/TimeInput';
import { editorSettingsDefaults, useEditorSettings } from '../../../common/stores/editorSettings';
import * as Panel from '../panel-utils/PanelUtils';
import { quickStartDefaults } from './quickStart.utils';
import style from './QuickStart.module.scss';
interface QuickStartProps {
isOpen: boolean;
onClose: () => void;
}
export default function QuickStart(props: QuickStartProps) {
const { isOpen, onClose } = props;
const { defaultWarnTime, defaultDangerTime, setDangerTime, setWarnTime } = useEditorSettings();
const {
control,
handleSubmit,
register,
formState: { errors, isSubmitting, isValid },
setError,
} = useForm<QuickStartData>({
defaultValues: quickStartDefaults,
values: quickStartDefaults,
resetOptions: {
keepDirtyValues: true,
},
});
const onSubmit = async (formData: QuickStartData) => {
try {
if (formData.project.title === '') {
formData.project.title = 'untitled';
}
await quickProject(formData);
await invalidateAllCaches();
onClose();
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
}
};
const warnTimeInMs = parseUserTime(defaultWarnTime);
const dangerTimeInMs = parseUserTime(defaultDangerTime);
return (
<Modal isOpen={isOpen} onClose={onClose} closeOnOverlayClick={false} variant='ontime'>
<ModalOverlay />
<ModalCloseButton />
<ModalContent maxWidth='max(640px, 40vw)'>
<form onSubmit={handleSubmit(onSubmit)} id='quick-start'>
<ModalHeader>Create new project...</ModalHeader>
<ModalBody className={style.scrollContainer}>
<ModalCloseButton />
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='Project title' description='Shown as the title in some views' />
<Input
variant='ontime-filled'
size='sm'
maxLength={150}
placeholder='Project title'
autoComplete='off'
width='20rem'
{...register('project.title')}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Time format'
description='Default time format to show in views 12 /24 hours'
error={errors.settings?.timeFormat?.message}
/>
<Select variant='ontime' size='sm' width='auto' isDisabled={false} {...register('settings.timeFormat')}>
<option value='12'>12 hours 11:00:10 PM</option>
<option value='24'>24 hours 23:00:10</option>
</Select>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Views language'
description='Language to be displayed in views'
error={errors.settings?.language?.message}
/>
<Select variant='ontime' size='sm' width='auto' isDisabled={false} {...register('settings.language')}>
<option value='en'>English</option>
<option value='fr'>French</option>
<option value='de'>German</option>
<option value='hu'>Hungarian</option>
<option value='it'>Italian</option>
<option value='no'>Norwegian</option>
<option value='pt'>Portuguese</option>
<option value='es'>Spanish</option>
<option value='sv'>Swedish</option>
<option value='pl'>Polish</option>
</Select>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='Warning time' description='Default threshold for warning time in an event' />
<TimeInput<'warnTime'>
name='warnTime'
submitHandler={(_field, value) => setWarnTime(value)}
time={warnTimeInMs}
placeholder={editorSettingsDefaults.warnTime}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Danger time' description='Default threshold for danger time in an event' />
<TimeInput<'dangerTime'>
name='dangerTime'
submitHandler={(_field, value) => setDangerTime(value)}
time={dangerTimeInMs}
placeholder={editorSettingsDefaults.dangerTime}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Freeze timer on end'
description='When a timer hits 00:00:00, it freezes instead of going negative. It invalidates the End Message.'
/>
<Controller
control={control}
name='viewSettings.freezeEnd'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='End message'
description='Message for negative timers; applies only if the timer isn`t frozen on End. If no message is provided, it continues into negative time'
/>
<Input
size='sm'
autoComplete='off'
variant='ontime-filled'
maxLength={150}
width='20rem'
placeholder='Shown when timer reaches end'
{...register('viewSettings.endMessage')}
/>
</Panel.ListItem>
</Panel.ListGroup>
</ModalBody>
<ModalFooter>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button variant='ontime-ghosted' size='md' onClick={onClose} isDisabled={false}>
Cancel
</Button>
<Button variant='ontime-filled' size='md' type='submit' isDisabled={!isValid} isLoading={isSubmitting}>
Create project
</Button>
</ModalFooter>
</form>
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,15 @@
import type { QuickStartData } from 'ontime-types';
export const quickStartDefaults: QuickStartData = {
project: {
title: '',
},
settings: {
timeFormat: '24',
language: 'en',
},
viewSettings: {
freezeEnd: false,
endMessage: '',
},
};
@@ -1,17 +1,21 @@
import { create } from 'zustand'; import { useMemo } from 'react';
import useAppVersion from '../../common/hooks-query/useAppVersion';
export type SettingsOption = { export type SettingsOption = {
id: string; id: string;
label: string; label: string;
secondary?: Readonly<SettingsOption[]>; secondary?: Readonly<SettingsOption[]>;
split?: boolean; split?: boolean;
highlight?: string;
}; };
export const settingPanels: Readonly<SettingsOption[]> = [ const staticOptions = [
{ {
id: 'project', id: 'project',
label: 'Project', label: 'Project',
secondary: [ secondary: [
{ id: 'project__create', label: 'Create...' },
{ id: 'project__data', label: 'Project data' }, { id: 'project__data', label: 'Project data' },
{ id: 'project__manage', label: 'Manage projects' }, { id: 'project__manage', label: 'Manage projects' },
], ],
@@ -77,30 +81,19 @@ export const settingPanels: Readonly<SettingsOption[]> = [
}, },
] as const; ] as const;
export type SettingsOptionId = (typeof settingPanels)[number]['id']; export type SettingsOptionId = (typeof staticOptions)[number]['id'];
export interface PanelBaseProps { export function useAppSettingsMenu() {
location?: string; const { data } = useAppVersion();
const options: Readonly<SettingsOption[]> = useMemo(
() =>
staticOptions.map((option) => ({
...option,
highlight: option.id === 'about' && data.hasUpdates ? 'New version available' : undefined,
})),
[data],
);
return { options };
} }
type SettingsStore = {
unsavedChanges: Set<SettingsOptionId>;
hasUnsavedChanges: (panelId: SettingsOptionId) => boolean;
addUnsavedChanges: (panelId: SettingsOptionId) => void;
removeUnsavedChanges: (panelId: SettingsOptionId) => void;
};
export const useSettingsStore = create<SettingsStore>((set, get) => ({
unsavedChanges: new Set(),
hasUnsavedChanges: (panelId: SettingsOptionId) => get().unsavedChanges.has(panelId),
addUnsavedChanges: (panelId: SettingsOptionId) =>
set((state) => {
state.unsavedChanges.add(panelId);
return { unsavedChanges: new Set(state.unsavedChanges) };
}),
removeUnsavedChanges: (panelId: SettingsOptionId) =>
set((state) => {
state.unsavedChanges.delete(panelId);
return { unsavedChanges: new Set(state.unsavedChanges) };
}),
}));
@@ -1,7 +1,7 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { SettingsOptionId } from './settingsStore'; import { SettingsOptionId } from './useAppSettingsMenu';
const settingsKey = 'settings'; const settingsKey = 'settings';
@@ -21,22 +21,6 @@
position: relative; position: relative;
} }
.corner {
@include rotate-fourty-five;
position: absolute;
top: 0.5rem;
right: 0.5rem;
cursor: pointer;
color: $ui-white;
transition-property: color;
transition-duration: $transition-time-action;
&:hover {
color: $ontime-color;
}
}
.options { .options {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1,20 +1,21 @@
import { memo } from 'react'; import { memo } from 'react';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
import { handleLinks } from '../../../common/utils/linkUtils'; import { handleLinks } from '../../../common/utils/linkUtils';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import { Corner } from '../../editors/editor-utils/EditorUtils';
import MessageControl from './MessageControl'; import MessageControl from './MessageControl';
import style from '../../editors/Editor.module.scss'; import style from '../../editors/Editor.module.scss';
const MessageControlExport = () => { const MessageControlExport = () => {
const isExtracted = window.location.pathname.includes('/messagecontrol');
const classes = cx([style.content, style.contentColumnLayout]); const classes = cx([style.content, style.contentColumnLayout]);
return ( return (
<div className={style.messages} data-testid='panel-messages-control'> <div className={style.messages} data-testid='panel-messages-control'>
<IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'messagecontrol')} /> {!isExtracted && <Corner onClick={(event) => handleLinks(event, 'messagecontrol')} />}
<div className={classes}> <div className={classes}>
<ErrorBoundary> <ErrorBoundary>
<MessageControl /> <MessageControl />
@@ -1,6 +1,7 @@
import { Tooltip } from '@chakra-ui/react'; import { Tooltip } from '@chakra-ui/react';
import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown'; import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp'; import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoBan } from '@react-icons/all-files/io5/IoBan';
import { IoFlag } from '@react-icons/all-files/io5/IoFlag'; import { IoFlag } from '@react-icons/all-files/io5/IoFlag';
import { IoTime } from '@react-icons/all-files/io5/IoTime'; import { IoTime } from '@react-icons/all-files/io5/IoTime';
import { TimerPhase, TimerType } from 'ontime-types'; import { TimerPhase, TimerType } from 'ontime-types';
@@ -8,8 +9,9 @@ import { TimerPhase, TimerType } from 'ontime-types';
import { useMessagePreview } from '../../../common/hooks/useSocket'; import { useMessagePreview } from '../../../common/hooks/useSocket';
import useViewSettings from '../../../common/hooks-query/useViewSettings'; import useViewSettings from '../../../common/hooks-query/useViewSettings';
import { handleLinks } from '../../../common/utils/linkUtils'; import { handleLinks } from '../../../common/utils/linkUtils';
import { cx } from '../../../common/utils/styleUtils'; import { cx, timerPlaceholder } from '../../../common/utils/styleUtils';
import { tooltipDelayMid } from '../../../ontimeConfig'; import { tooltipDelayMid } from '../../../ontimeConfig';
import { Corner } from '../../editors/editor-utils/EditorUtils';
import style from './MessageControl.module.scss'; import style from './MessageControl.module.scss';
@@ -24,6 +26,8 @@ export default function TimerPreview() {
if (showTimerMessage) return 'Message'; if (showTimerMessage) return 'Message';
if (phase === TimerPhase.Pending) return 'Standby to start'; if (phase === TimerPhase.Pending) return 'Standby to start';
if (phase === TimerPhase.Overtime && data.endMessage) return 'Custom end message'; if (phase === TimerPhase.Overtime && data.endMessage) return 'Custom end message';
if (timerType === TimerType.TimeToEnd) return 'Time to end';
if (timerType === TimerType.None) return timerPlaceholder;
return 'Timer'; return 'Timer';
})(); })();
@@ -48,7 +52,7 @@ export default function TimerPreview() {
return ( return (
<div className={style.preview}> <div className={style.preview}>
<IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'timer')} /> <Corner onClick={(event) => handleLinks(event, 'timer')} />
<div className={contentClasses}> <div className={contentClasses}>
<div <div
className={style.mainContent} className={style.mainContent}
@@ -72,6 +76,9 @@ export default function TimerPreview() {
<Tooltip label='Time type: Time to end' openDelay={tooltipDelayMid} shouldWrapChildren> <Tooltip label='Time type: Time to end' openDelay={tooltipDelayMid} shouldWrapChildren>
<IoFlag className={style.statusIcon} data-active={timerType === TimerType.TimeToEnd} /> <IoFlag className={style.statusIcon} data-active={timerType === TimerType.TimeToEnd} />
</Tooltip> </Tooltip>
<Tooltip label='Time type: None' openDelay={tooltipDelayMid} shouldWrapChildren>
<IoBan className={style.statusIcon} data-active={timerType === TimerType.None} />
</Tooltip>
</div> </div>
</div> </div>
); );
@@ -1,17 +1,18 @@
import { memo } from 'react'; import { memo } from 'react';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
import { handleLinks } from '../../../common/utils/linkUtils'; import { handleLinks } from '../../../common/utils/linkUtils';
import { Corner } from '../../editors/editor-utils/EditorUtils';
import PlaybackControl from './PlaybackControl'; import PlaybackControl from './PlaybackControl';
import style from '../../editors/Editor.module.scss'; import style from '../../editors/Editor.module.scss';
const TimerControlExport = () => { const TimerControlExport = () => {
const isExtracted = window.location.pathname.includes('/timercontrol');
return ( return (
<div className={style.playback} data-testid='panel-timer-control'> <div className={style.playback} data-testid='panel-timer-control'>
<IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'timercontrol')} /> {!isExtracted && <Corner onClick={(event) => handleLinks(event, 'timercontrol')} />}
<div className={style.content}> <div className={style.content}>
<ErrorBoundary> <ErrorBoundary>
<PlaybackControl /> <PlaybackControl />
@@ -6,8 +6,7 @@
max-width: 18.75rem; max-width: 18.75rem;
min-width: 5em; min-width: 5em;
font-family: var(--font-family-override, $viewer-font-family); color: $timer-color;
color: var(--timer-color-override, $timer-color);
line-height: 0.9em; line-height: 0.9em;
text-align: center; text-align: center;
letter-spacing: 0.1em; letter-spacing: 0.1em;
@@ -140,7 +140,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId, curr
let rowBgColour: string | undefined; let rowBgColour: string | undefined;
if (isSelected) { if (isSelected) {
rowBgColour = 'var(--cuesheet-running-bg-override, #D20300)'; // $red-700 rowBgColour = '#D20300'; // $red-700
} else if (row.original.colour) { } else if (row.original.colour) {
try { try {
// the colour is user defined and might be invalid // the colour is user defined and might be invalid
@@ -4,10 +4,6 @@ $min-playback-width: 27rem;
$max-playback-width: 30rem; $max-playback-width: 30rem;
$panel-gap: 0.5rem; $panel-gap: 0.5rem;
.corner {
@include editor.corner;
}
.mainContainer { .mainContainer {
background-color: $ui-black; background-color: $ui-black;
width: 100%; width: 100%;
@@ -31,11 +27,6 @@ $panel-gap: 0.5rem;
gap: $panel-gap; gap: $panel-gap;
overflow: hidden; overflow: hidden;
.corner {
/* we show this if the component hasnt been extracted */
display: inline;
}
.rundown, .rundown,
.playback, .playback,
.messages { .messages {
+23 -41
View File
@@ -1,16 +1,19 @@
import { lazy, useCallback, useEffect } from 'react'; import { lazy, useCallback, useEffect } from 'react';
import { IconButton, useDisclosure } from '@chakra-ui/react'; import { IconButton, useDisclosure } from '@chakra-ui/react';
import { useHotkeys } from '@mantine/hooks';
import { IoApps } from '@react-icons/all-files/io5/IoApps'; import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoClose } from '@react-icons/all-files/io5/IoClose'; import { IoClose } from '@react-icons/all-files/io5/IoClose';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline'; import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu'; import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
import useElectronEvent from '../../common/hooks/useElectronEvent'; import { useElectronListener } from '../../common/hooks/useElectronEvent';
import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import AppSettings from '../app-settings/AppSettings'; import AppSettings from '../app-settings/AppSettings';
import useAppSettingsNavigation from '../app-settings/useAppSettingsNavigation'; import useAppSettingsNavigation from '../app-settings/useAppSettingsNavigation';
import { EditorOverview } from '../overview/Overview'; import { EditorOverview } from '../overview/Overview';
import Finder from './finder/Finder';
import styles from './Editor.module.scss'; import styles from './Editor.module.scss';
const Rundown = lazy(() => import('../rundown/RundownExport')); const Rundown = lazy(() => import('../rundown/RundownExport'));
@@ -19,50 +22,14 @@ const MessageControl = lazy(() => import('../control/message/MessageControlExpor
export default function Editor() { export default function Editor() {
const { isOpen: isSettingsOpen, setLocation, close } = useAppSettingsNavigation(); const { isOpen: isSettingsOpen, setLocation, close } = useAppSettingsNavigation();
const { isElectron } = useElectronEvent();
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure(); const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
const { isOpen: isFinderOpen, onToggle: onFinderToggle, onClose: onFinderClose } = useDisclosure();
const toggleSettings = useCallback(() => {
if (isSettingsOpen) {
close();
} else {
setLocation('project');
}
}, [close, isSettingsOpen, setLocation]);
// Handle keyboard shortcuts
const handleKeyPress = useCallback(
(event: KeyboardEvent) => {
// handle held key
if (event.repeat) return;
// check if the ctrl key is pressed
if (event.ctrlKey || event.metaKey) {
// ctrl + , (settings)
if (event.key === ',') {
toggleSettings();
event.preventDefault();
event.stopPropagation();
}
}
},
[toggleSettings],
);
// register ctrl + , to open settings
useEffect(() => {
if (isElectron) {
document.addEventListener('keydown', handleKeyPress);
}
return () => {
if (isElectron) {
document.removeEventListener('keydown', handleKeyPress);
}
};
}, [handleKeyPress, isElectron]);
useWindowTitle('Editor'); useWindowTitle('Editor');
// we need to register the listener to change the editor location
useElectronListener();
// listen to shutdown request from electron process // listen to shutdown request from electron process
useEffect(() => { useEffect(() => {
if (window.process?.type === 'renderer') { if (window.process?.type === 'renderer') {
@@ -72,8 +39,23 @@ export default function Editor() {
} }
}, [setLocation]); }, [setLocation]);
const toggleSettings = useCallback(() => {
if (isSettingsOpen) {
close();
} else {
setLocation('project');
}
}, [close, isSettingsOpen, setLocation]);
useHotkeys([
['mod + ,', toggleSettings],
['mod + f', onFinderToggle],
['Escape', onFinderClose],
]);
return ( return (
<div className={styles.mainContainer} data-testid='event-editor'> <div className={styles.mainContainer} data-testid='event-editor'>
<Finder isOpen={isFinderOpen} onClose={onFinderClose} />
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} /> <ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
<EditorOverview> <EditorOverview>
<IconButton <IconButton
@@ -6,23 +6,6 @@
--editor--panel__br: 8px; --editor--panel__br: 8px;
} }
@mixin corner() {
display: none;
transform: rotate(45deg);
position: absolute;
top: 0.5rem;
right: 0.5rem;
cursor: pointer;
color: $ui-white;
transition-property: color;
transition-duration: $transition-time-action;
&:hover {
color: $ontime-color;
}
}
@mixin panel() { @mixin panel() {
display: flex; display: flex;
@@ -31,9 +14,4 @@
height: 100%; height: 100%;
background-color: $bg-container-l2; background-color: $bg-container-l2;
padding: 1rem; padding: 1rem;
.corner {
/* we show this if the component hasnt been extracted */
display: inline;
}
} }
@@ -0,0 +1,22 @@
.corner {
transform: rotate(45deg);
position: absolute;
top: 0.5rem;
right: 0.5rem;
cursor: pointer;
color: $ui-white;
transition-property: color;
transition-duration: $transition-time-action;
border-radius: 99px;
// similar styles to ontime-button-subtle
background-color: $gray-1050;
outline: 2px solid $gray-1050;
&:hover {
color: $ontime-color;
background-color: $gray-1000;
outline: 2px solid $gray-1000;
}
}
@@ -0,0 +1,10 @@
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { type IconBaseProps } from '@react-icons/all-files/lib';
import { cx } from '../../../common/utils/styleUtils';
import style from './EditorUtils.module.scss';
export function Corner({ className, ...elementProps }: IconBaseProps) {
return <IoArrowUp className={cx([style.corner, className])} {...elementProps} />;
}
@@ -0,0 +1,69 @@
.entry,
.empty,
.error {
padding-inline: 0.5rem;
font-size: 1rem;
height: 3rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.entry[data-selected='true'] {
background-color: $blue-700;
}
.empty {
color: $label-gray;
}
.error {
color: $error-red;
}
.data {
display: grid;
grid-template-areas:
'index cue'
'index title';
column-gap: 1rem;
grid-template-rows: min-content 1fr;
.index {
grid-area: index;
background-color: var(--color, $gray-1000);
border-radius: 2px;
padding-block: 0.25rem;
width: 3.5rem;
align-self: center;
text-align: center;
}
.title {
grid-area: title;
}
.cue {
grid-area: cue;
font-size: calc(1rem - 2px);
color: $label-gray;
max-height: 1em;
min-height: 0;
}
}
.footer {
font-size: calc(1rem - 2px);
color: $label-gray;
}
.em {
color: $ui-white;
margin-inline: 0.25rem;
}
.scrollContainer {
max-height: 70vh;
overflow: auto;
}
@@ -0,0 +1,101 @@
import { KeyboardEvent, useState } from 'react';
import { Input, Modal, ModalBody, ModalContent, ModalFooter, ModalOverlay } from '@chakra-ui/react';
import { useDebouncedCallback } from '@mantine/hooks';
import { isOntimeEvent, SupportedEvent } from 'ontime-types';
import { useEventSelection } from '../../rundown/useEventSelection';
import useFinder from './useFinder';
import style from './Finder.module.scss';
interface FinderProps {
isOpen: boolean;
onClose: () => void;
}
export default function Finder(props: FinderProps) {
const { isOpen, onClose } = props;
const { find, results, error } = useFinder();
const [selected, setSelected] = useState(0);
const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents);
const debouncedFind = useDebouncedCallback(find, 100);
const navigate = (event: KeyboardEvent<HTMLDivElement>) => {
// all operations need results
if (results.length === 0) {
return;
}
if (event.key === 'ArrowDown') {
setSelected((prev) => (prev + 1) % results.length);
}
if (event.key === 'ArrowUp') {
setSelected((prev) => (prev - 1 + results.length) % results.length);
}
if (event.key === 'Enter') {
submit();
}
};
const submit = () => {
const selectedEvent = results[selected];
setSelectedEvents({ id: selectedEvent.id, index: selectedEvent.index, selectMode: 'click' });
onClose();
};
const handleMouseMoveEvent = (event: React.MouseEvent<HTMLUListElement>) => {
const target = event.target as HTMLElement;
const li = target.closest('li');
if (li) {
const index = Number(li.dataset.index);
if (!isNaN(index)) {
setSelected(index);
}
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
<ModalOverlay />
<ModalContent maxWidth='max(640px, 40vw)'>
<ModalBody onKeyDown={navigate}>
<Input size='lg' onChange={debouncedFind} variant='ontime-filled' placeholder='Search...' />
<ul className={style.scrollContainer} onMouseMove={handleMouseMoveEvent}>
{error && <li className={style.error}>{error}</li>}
{results.length === 0 && <li className={style.empty}>No results</li>}
{results.length > 0 &&
results.map((event, index) => {
const isSelected = selected === index;
const displayIndex = event.type === SupportedEvent.Block ? '-' : event.index;
const colour = event.type === SupportedEvent.Event ? event.colour : '';
return (
<li
key={event.id}
className={style.entry}
data-selected={isSelected}
data-index={index}
onClick={submit}
>
<div className={style.data}>
<div className={style.index} style={{ '--color': colour }}>
{displayIndex}
</div>
{isOntimeEvent(event) && <div className={style.cue}>{event.cue}</div>}
<div className={style.title}>{event.title}</div>
</div>
{isSelected && <span>Go </span>}
</li>
);
})}
</ul>
</ModalBody>
<ModalFooter className={style.footer}>
Use the keywords <span className={style.em}>cue</span>, <span className={style.em}>index</span> or
<span className={style.em}>title</span> to filter search
</ModalFooter>
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,182 @@
import { ChangeEvent, useState } from 'react';
import { isOntimeBlock, isOntimeEvent, MaybeString, SupportedEvent } from 'ontime-types';
import { useFlatRundown } from '../../../common/hooks-query/useRundown';
const maxResults = 12;
type FilterableBlock = {
type: SupportedEvent.Block;
id: string;
index: number;
title: string;
};
type FilterableEvent = {
type: SupportedEvent.Event;
id: string;
index: number;
eventIndex: number;
title: string;
cue: string;
colour: string;
};
type FilterableEntry = FilterableBlock | FilterableEvent;
export default function useFinder() {
const { data } = useFlatRundown();
const [results, setResults] = useState<FilterableEntry[]>([]);
const [error, setError] = useState<MaybeString>(null);
/** Returns a single item with a matching index */
const searchByIndex = (searchString: string) => {
const searchIndex = Number(searchString);
if (isNaN(searchIndex) || searchIndex < 1) {
return { results: [], error: 'Invalid index' };
}
if (searchIndex > data.length) {
return { results: [], error: null };
}
// indexes exposed to the UI are 1-based
let eventIndex = 1;
const results: FilterableEvent[] = [];
for (let i = 0; i < data.length; i++) {
const event = data[i];
if (isOntimeEvent(event)) {
if (eventIndex === searchIndex) {
results.push({
type: SupportedEvent.Event,
id: event.id,
index: i,
eventIndex,
title: event.title,
cue: event.cue,
colour: event.colour,
} satisfies FilterableEvent);
break;
}
eventIndex++;
}
}
return { results, error: null };
};
/** Returns maxResults of OntimeEvents that match the cue field */
const searchByCue = (searchString: string) => {
// indexes exposed to the UI are 1-based
let eventIndex = 1;
// limit amount of results we show
let remaining = maxResults;
const results: FilterableEvent[] = [];
for (let i = 0; i < data.length; i++) {
if (remaining <= 0) {
break;
}
const event = data[i];
if (isOntimeEvent(event)) {
if (event.cue.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEvent.Event,
id: event.id,
index: i,
eventIndex,
title: event.title,
cue: event.cue,
colour: event.colour,
} satisfies FilterableEvent);
}
eventIndex++;
}
}
return { results, error: null };
};
/** Returns maxResults of OntimeEvents that match the title field*/
const searchByTitle = (searchString: string) => {
// indexes exposed to the UI are 1-based
let eventIndex = 1;
// limit amount of results we show
let remaining = maxResults;
const results: FilterableEntry[] = [];
for (let i = 0; i < data.length; i++) {
if (remaining <= 0) {
break;
}
const event = data[i];
if (isOntimeEvent(event)) {
if (event.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEvent.Event,
id: event.id,
index: i,
eventIndex,
title: event.title,
cue: event.cue,
colour: event.colour,
} satisfies FilterableEvent);
}
eventIndex++;
}
if (isOntimeBlock(event)) {
if (event.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEvent.Block,
id: event.id,
index: i,
title: event.title,
} satisfies FilterableBlock);
}
}
}
return { results, error: null };
};
/** Filters the rundown to a given evaluation */
const find = (event: ChangeEvent<HTMLInputElement>) => {
if (!data || data.length === 0) {
setError('No data');
return;
}
setError(null);
if (event.target.value === '') {
setResults([]);
return;
}
const searchValue = event.target.value.toLowerCase();
if (searchValue.startsWith('index ')) {
const searchString = searchValue.replace('index ', '').trim();
const { results, error } = searchByIndex(searchString);
setResults(results);
setError(error);
return;
}
if (searchValue.startsWith('cue ')) {
const searchString = searchValue.replace('cue ', '').trim();
const { results, error } = searchByCue(searchString);
setResults(results);
setError(error);
return;
}
const searchString = searchValue.replace('title ', '').trim();
const { results, error } = searchByTitle(searchString);
setResults(results);
setError(error);
};
return { find, results, error };
}
@@ -30,7 +30,7 @@
&.running { &.running {
border-top: 1px solid $gray-1300; border-top: 1px solid $gray-1300;
background-color: var(--operator-running-bg-override, $active-red); background-color: $active-red;
} }
&.past { &.past {
@@ -105,7 +105,7 @@
} }
.noColour { .noColour {
outline: 0.15rem solid var(--operator-highlight-override, $ui-white); outline: 0.15rem solid $ui-white;
outline-offset: -0.15rem; outline-offset: -0.15rem;
padding-right: 0.3rem; padding-right: 0.3rem;
color: $ui-white; color: $ui-white;
@@ -114,7 +114,7 @@
.value { .value {
padding-inline: 0.25rem; padding-inline: 0.25rem;
background-color: $gray-1250; background-color: $gray-1250;
color: var(--operator-highlight-override, $ui-white); color: $ui-white;
margin-right: 1rem; margin-right: 1rem;
} }
} }
@@ -22,12 +22,6 @@
height: 100%; height: 100%;
} }
.corner {
@include editor.corner;
display: inline;
z-index: 2;
}
.list { .list {
@include editor.panel; @include editor.panel;
height: inherit; height: inherit;
@@ -1,11 +1,11 @@
import { memo } from 'react'; import { memo } from 'react';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { ContextMenu } from '../../common/components/context-menu/ContextMenu'; import { ContextMenu } from '../../common/components/context-menu/ContextMenu';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import { useAppMode } from '../../common/stores/appModeStore'; import { useAppMode } from '../../common/stores/appModeStore';
import { handleLinks } from '../../common/utils/linkUtils'; import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import { Corner } from '../editors/editor-utils/EditorUtils';
import EventEditor from './event-editor/EventEditor'; import EventEditor from './event-editor/EventEditor';
import RundownWrapper from './RundownWrapper'; import RundownWrapper from './RundownWrapper';
@@ -24,7 +24,7 @@ const RundownExport = () => {
<div className={style.rundown}> <div className={style.rundown}>
<div className={style.list}> <div className={style.list}>
<ErrorBoundary> <ErrorBoundary>
{!isExtracted && <IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'rundown')} />} {!isExtracted && <Corner onClick={(event) => handleLinks(event, 'rundown')} />}
<ContextMenu> <ContextMenu>
<RundownWrapper /> <RundownWrapper />
</ContextMenu> </ContextMenu>
@@ -3,11 +3,13 @@ import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoDuplicateOutline } from '@react-icons/all-files/io5/IoDuplicateOutline'; import { IoDuplicateOutline } from '@react-icons/all-files/io5/IoDuplicateOutline';
import { IoLink } from '@react-icons/all-files/io5/IoLink';
import { IoPeople } from '@react-icons/all-files/io5/IoPeople'; import { IoPeople } from '@react-icons/all-files/io5/IoPeople';
import { IoPeopleOutline } from '@react-icons/all-files/io5/IoPeopleOutline'; import { IoPeopleOutline } from '@react-icons/all-files/io5/IoPeopleOutline';
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo'; import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical'; import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash'; import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { IoUnlink } from '@react-icons/all-files/io5/IoUnlink';
import { EndAction, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types'; import { EndAction, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
@@ -93,6 +95,24 @@ export default function EventBlock(props: EventBlockProps) {
const [onContextMenu] = useContextMenu<HTMLDivElement>( const [onContextMenu] = useContextMenu<HTMLDivElement>(
selectedEvents.size > 1 selectedEvents.size > 1
? [ ? [
{
label: 'Link to previous',
icon: IoLink,
onClick: () =>
actionHandler('update', {
field: 'linkStart',
value: 'true',
}),
},
{
label: 'Unlink from previous',
icon: IoUnlink,
onClick: () =>
actionHandler('update', {
field: 'linkStart',
value: null,
}),
},
{ {
label: 'Make public', label: 'Make public',
icon: IoPeople, icon: IoPeople,
@@ -101,6 +121,7 @@ export default function EventBlock(props: EventBlockProps) {
field: 'isPublic', field: 'isPublic',
value: true, value: true,
}), }),
withDivider: true,
}, },
{ {
label: 'Make private', label: 'Make private',
@@ -114,6 +135,15 @@ export default function EventBlock(props: EventBlockProps) {
{ withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') }, { withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
] ]
: [ : [
{
label: 'Toggle link to previous',
icon: IoLink,
onClick: () =>
actionHandler('update', {
field: 'linkStart',
value: linkStart ? null : 'true',
}),
},
{ {
label: 'Toggle public', label: 'Toggle public',
icon: IoPeopleOutline, icon: IoPeopleOutline,
@@ -122,6 +152,7 @@ export default function EventBlock(props: EventBlockProps) {
field: 'isPublic', field: 'isPublic',
value: !isPublic, value: !isPublic,
}), }),
withDivider: true,
}, },
{ {
label: 'Add to swap', label: 'Add to swap',
@@ -2,6 +2,7 @@ import { memo, useEffect, useState } from 'react';
import { Tooltip } from '@chakra-ui/react'; import { Tooltip } from '@chakra-ui/react';
import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown'; import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp'; import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoBan } from '@react-icons/all-files/io5/IoBan';
import { IoFlag } from '@react-icons/all-files/io5/IoFlag'; import { IoFlag } from '@react-icons/all-files/io5/IoFlag';
import { IoPeople } from '@react-icons/all-files/io5/IoPeople'; import { IoPeople } from '@react-icons/all-files/io5/IoPeople';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay'; import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
@@ -90,6 +91,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
delay={delay} delay={delay}
timeStrategy={timeStrategy} timeStrategy={timeStrategy}
linkStart={linkStart} linkStart={linkStart}
timerType={timerType}
/> />
</div> </div>
<div className={style.titleSection}> <div className={style.titleSection}>
@@ -157,8 +159,12 @@ function TimerIcon(props: { type: TimerType; className: string }) {
if (type === TimerType.Clock) { if (type === TimerType.Clock) {
return <IoTime className={className} />; return <IoTime className={className} />;
} }
if (type === TimerType.None) {
return <IoBan className={className} />;
}
if (type === TimerType.TimeToEnd) { if (type === TimerType.TimeToEnd) {
return <IoFlag className={className} />; const classes = cx([style.active, className]);
return <IoFlag className={classes} />;
} }
return <IoArrowDown className={className} />; return <IoArrowDown className={className} />;
} }
@@ -14,6 +14,23 @@ function EventEditorEmpty() {
<div className={style.prompt}>Rundown shortcuts:</div> <div className={style.prompt}>Rundown shortcuts:</div>
<table className={style.shortcuts}> <table className={style.shortcuts}>
<tbody> <tbody>
<tr>
<td>Find in rundown</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>F</Kbd>
</td>
</tr>
<tr>
<td>Open Settings</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>,</Kbd>
</td>
</tr>
<tr className={style.spacer} />
<tr> <tr>
<td>Select entry</td> <td>Select entry</td>
<td> <td>
@@ -82,6 +82,7 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
timeStrategy={timeStrategy} timeStrategy={timeStrategy}
linkStart={linkStart} linkStart={linkStart}
delay={delay} delay={delay}
timerType={timerType}
/> />
</div> </div>
<div className={style.delayLabel}>{delayLabel}</div> <div className={style.delayLabel}>{delayLabel}</div>
@@ -107,6 +108,7 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
<option value={TimerType.CountUp}>Count up</option> <option value={TimerType.CountUp}>Count up</option>
<option value={TimerType.TimeToEnd}>Time to end</option> <option value={TimerType.TimeToEnd}>Time to end</option>
<option value={TimerType.Clock}>Clock</option> <option value={TimerType.Clock}>Clock</option>
<option value={TimerType.None}>None</option>
</Select> </Select>
</div> </div>
<div> <div>
@@ -5,7 +5,7 @@ import { IoLink } from '@react-icons/all-files/io5/IoLink';
import { IoLockClosed } from '@react-icons/all-files/io5/IoLockClosed'; import { IoLockClosed } from '@react-icons/all-files/io5/IoLockClosed';
import { IoLockOpenOutline } from '@react-icons/all-files/io5/IoLockOpenOutline'; import { IoLockOpenOutline } from '@react-icons/all-files/io5/IoLockOpenOutline';
import { IoUnlink } from '@react-icons/all-files/io5/IoUnlink'; import { IoUnlink } from '@react-icons/all-files/io5/IoUnlink';
import { MaybeString, OntimeEvent, TimeStrategy } from 'ontime-types'; import { MaybeString, TimerType, TimeStrategy } from 'ontime-types';
import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton'; import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEventAction } from '../../../common/hooks/useEventAction';
@@ -22,13 +22,14 @@ interface EventBlockTimerProps {
timeStrategy: TimeStrategy; timeStrategy: TimeStrategy;
linkStart: MaybeString; linkStart: MaybeString;
delay: number; delay: number;
timerType: TimerType;
} }
type TimeActions = 'timeStart' | 'timeEnd' | 'duration'; type TimeActions = 'timeStart' | 'timeEnd' | 'duration';
const TimeInputFlow = (props: EventBlockTimerProps) => { const TimeInputFlow = (props: EventBlockTimerProps) => {
const { eventId, timeStart, timeEnd, duration, timeStrategy, linkStart, delay } = props; const { eventId, timeStart, timeEnd, duration, timeStrategy, linkStart, delay, timerType } = props;
const { updateEvent, updateTimer, linkTimer } = useEventAction(); const { updateEvent, updateTimer } = useEventAction();
// In sync with EventEditorTimes // In sync with EventEditorTimes
const handleSubmit = (field: TimeActions, value: string) => { const handleSubmit = (field: TimeActions, value: string) => {
@@ -36,17 +37,22 @@ const TimeInputFlow = (props: EventBlockTimerProps) => {
}; };
const handleChangeStrategy = (timeStrategy: TimeStrategy) => { const handleChangeStrategy = (timeStrategy: TimeStrategy) => {
const newEvent: Partial<OntimeEvent> = { id: eventId, timeStrategy }; updateEvent({ id: eventId, timeStrategy });
updateEvent(newEvent);
}; };
const handleLink = (doLink: boolean) => { const handleLink = (doLink: boolean) => {
// the string doesnt mean much for now, not more than an intent to link updateEvent({ id: eventId, linkStart: doLink ? 'true' : null });
// we imagine that we can leverage this to create offsets p+10
linkTimer(eventId, doLink ? 'p' : null);
}; };
const overMidnight = timeStart > timeEnd; const warnings = [];
if (timeStart > timeEnd) {
warnings.push('Over midnight');
}
if (timerType === TimerType.TimeToEnd) {
warnings.push('Time to end');
}
const hasDelay = delay !== 0; const hasDelay = delay !== 0;
const isLockedEnd = timeStrategy === TimeStrategy.LockEnd; const isLockedEnd = timeStrategy === TimeStrategy.LockEnd;
@@ -113,9 +119,9 @@ const TimeInputFlow = (props: EventBlockTimerProps) => {
</Tooltip> </Tooltip>
</TimeInputWithButton> </TimeInputWithButton>
{overMidnight && ( {warnings.length > 0 && (
<div className={style.timerNote}> <div className={style.timerNote}>
<Tooltip label='Over midnight' openDelay={tooltipDelayFast} variant='ontime-ondark' shouldWrapChildren> <Tooltip label={warnings.join(' - ')} openDelay={tooltipDelayFast} variant='ontime-ondark' shouldWrapChildren>
<IoAlertCircleOutline /> <IoAlertCircleOutline />
</Tooltip> </Tooltip>
</div> </div>
@@ -23,6 +23,8 @@ export function getTimerByType(freezeEnd: boolean, timerObject?: TimerTypeParams
return Math.abs(timerObject.elapsed ?? 0); return Math.abs(timerObject.elapsed ?? 0);
case TimerType.Clock: case TimerType.Clock:
return timerObject.clock; return timerObject.clock;
case TimerType.None:
return null;
default: { default: {
const exhaustiveCheck: never = timerObject.timerType; const exhaustiveCheck: never = timerObject.timerType;
return exhaustiveCheck; return exhaustiveCheck;
@@ -160,6 +160,7 @@ export default function Timer(props: TimerProps) {
const defaultFormat = getDefaultFormat(settings?.timeFormat); const defaultFormat = getDefaultFormat(settings?.timeFormat);
const timerOptions = getTimerOptions(defaultFormat, customFields); const timerOptions = getTimerOptions(defaultFormat, customFields);
const disableProgress = timerIsTimeOfDay || time.timerType === TimerType.None;
return ( return (
<div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'> <div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'>
@@ -205,7 +206,7 @@ export default function Timer(props: TimerProps) {
{!userOptions.hideProgress && ( {!userOptions.hideProgress && (
<MultiPartProgressBar <MultiPartProgressBar
className={isPlaying ? 'progress-container' : 'progress-container progress-container--paused'} className={isPlaying ? 'progress-container' : 'progress-container progress-container--paused'}
now={timerIsTimeOfDay ? null : time.current} now={disableProgress ? null : time.current}
complete={totalTime} complete={totalTime}
normalColor={viewSettings.normalColor} normalColor={viewSettings.normalColor}
warning={eventNow?.timeWarning} warning={eventNow?.timeWarning}
+1 -1
View File
@@ -57,7 +57,7 @@ $thumb-color-hover: $gray-900;
box-sizing: inherit; box-sizing: inherit;
} }
body { body, html {
font-size: 15px; font-size: 15px;
font-family: $ontime-font-family; font-family: $ontime-font-family;
background-color: var(--background-color-override, $ui-black); background-color: var(--background-color-override, $ui-black);
+4 -1
View File
@@ -12,6 +12,7 @@ export const ontimeModal = {
minHeight: 'min(200px, 10vh)', minHeight: 'min(200px, 10vh)',
backgroundColor: '#202020', // $gray-1250 backgroundColor: '#202020', // $gray-1250
color: '#fefefe', // $gray-50 color: '#fefefe', // $gray-50
border: '1px solid #2d2d2d', // $gray-1100
}, },
body: { body: {
padding: '1rem', padding: '1rem',
@@ -24,6 +25,8 @@ export const ontimeModal = {
color: '#fefefe', // $gray-50 color: '#fefefe', // $gray-50
}, },
footer: { footer: {
padding: '0.5rem', padding: '1rem',
display: 'flex',
gap: '0.5rem',
}, },
}; };
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime", "name": "ontime",
"version": "3.6.2", "version": "3.7.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+7 -3
View File
@@ -81,14 +81,18 @@ function makeFileMenu(serverUrl, redirectWindow, download) {
submenu: [ submenu: [
{ {
label: 'New project...', label: 'New project...',
click: () => redirectWindow('/editor?settings=project__manage&create=true'), click: () => redirectWindow('/editor?settings=project__manage&new=true'),
}, },
{ {
label: 'Edit project info', label: 'Quick start...',
click: () => redirectWindow('/editor?settings=project__create'),
},
{
label: 'Edit project info...',
click: () => redirectWindow('/editor?settings=project__data'), click: () => redirectWindow('/editor?settings=project__data'),
}, },
{ {
label: 'Manage projects...', label: 'Manage projects',
click: () => redirectWindow('/editor?settings=project__manage'), click: () => redirectWindow('/editor?settings=project__manage'),
}, },
{ {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server", "name": "ontime-server",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"version": "3.6.2", "version": "3.7.0",
"exports": "./src/index.js", "exports": "./src/index.js",
"dependencies": { "dependencies": {
"@googleapis/sheets": "^5.0.5", "@googleapis/sheets": "^5.0.5",
+26 -11
View File
@@ -1,4 +1,4 @@
import { DatabaseModel, ErrorResponse, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types'; import { DatabaseModel, ErrorResponse, MessageResponse, ProjectFileListResponse } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils'; import { getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
@@ -30,17 +30,17 @@ export async function patchPartialProjectFile(req: Request, res: Response<Databa
* or a 500 status with an error message in case of an exception. * or a 500 status with an error message in case of an exception.
*/ */
export async function createProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) { export async function createProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
const newProjectData: ProjectData = {
title: req.body?.title ?? '',
description: req.body?.description ?? '',
publicUrl: req.body?.publicUrl ?? '',
publicInfo: req.body?.publicInfo ?? '',
backstageUrl: req.body?.backstageUrl ?? '',
backstageInfo: req.body?.backstageInfo ?? '',
};
try { try {
const newFileName = await projectService.createProject(req.body.filename, newProjectData); const newFileName = await projectService.createProject(req.body.filename, {
project: {
title: req.body?.title ?? '',
description: req.body?.description ?? '',
publicUrl: req.body?.publicUrl ?? '',
publicInfo: req.body?.publicInfo ?? '',
backstageUrl: req.body?.backstageUrl ?? '',
backstageInfo: req.body?.backstageInfo ?? '',
},
});
res.status(200).send({ res.status(200).send({
filename: newFileName, filename: newFileName,
@@ -51,6 +51,21 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
} }
} }
/**
* Creates and loads a new project with partial DataBase data
*/
export async function quickProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
try {
const filename = await projectService.createProject(req.body.project.title, req.body);
res.status(200).send({
filename,
});
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
}
/** /**
* Allows downloading of current project file * Allows downloading of current project file
*/ */
+3
View File
@@ -10,6 +10,7 @@ import {
patchPartialProjectFile, patchPartialProjectFile,
postProjectFile, postProjectFile,
projectDownload, projectDownload,
quickProjectFile,
renameProjectFile, renameProjectFile,
} from './db.controller.js'; } from './db.controller.js';
import { uploadProjectFile } from './db.middleware.js'; import { uploadProjectFile } from './db.middleware.js';
@@ -19,6 +20,7 @@ import {
validateFilenameBody, validateFilenameBody,
validateFilenameParam, validateFilenameParam,
validateNewFilenameBody, validateNewFilenameBody,
validateQuickProject,
} from './db.validation.js'; } from './db.validation.js';
export const router = express.Router(); export const router = express.Router();
@@ -29,6 +31,7 @@ router.post('/upload', uploadProjectFile, postProjectFile);
router.patch('/', validatePatchProject, patchPartialProjectFile); router.patch('/', validatePatchProject, patchPartialProjectFile);
router.post('/new', validateFilenameBody, validateNewProject, createProjectFile); router.post('/new', validateFilenameBody, validateNewProject, createProjectFile);
router.post('/quick', validateQuickProject, quickProjectFile);
router.get('/all', listProjects); router.get('/all', listProjects);
@@ -22,6 +22,28 @@ export const validateNewProject = [
}, },
]; ];
/**
* @description Validates request for a quick project.
*/
export const validateQuickProject = [
// Project fields
body('project.title').isString().trim(),
// Settings fields
body('settings.timeFormat').optional().isIn(['12', '24']),
body('settings.language').optional().isString().trim(),
// ViewSettings fields
body('viewSettings.freezeEnd').optional().isBoolean(),
body('viewSettings.endMessage').optional().isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
/** /**
* @description Validates request for pathing data in the project. * @description Validates request for pathing data in the project.
*/ */
@@ -4,11 +4,20 @@ import { DatabaseModel } from 'ontime-types';
* Merges a partial ontime project into a given ontime project * Merges a partial ontime project into a given ontime project
*/ */
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>): DatabaseModel { export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>): DatabaseModel {
const { rundown, project, settings, viewSettings, urlPresets, customFields, osc, http } = newData || {}; const {
rundown = existing.rundown,
project = {},
settings = {},
viewSettings = {},
urlPresets = existing.urlPresets,
customFields = existing.customFields,
osc = {},
http = {},
} = newData;
return { return {
...existing, ...existing,
rundown: rundown ?? existing.rundown, rundown,
project: { ...existing.project, ...project }, project: { ...existing.project, ...project },
settings: { ...existing.settings, ...settings }, settings: { ...existing.settings, ...settings },
viewSettings: { ...existing.viewSettings, ...viewSettings }, viewSettings: { ...existing.viewSettings, ...viewSettings },
@@ -1,4 +1,4 @@
import { DatabaseModel, LogOrigin, ProjectData, ProjectFileListResponse } from 'ontime-types'; import { DatabaseModel, LogOrigin, ProjectFileListResponse } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils'; import { getErrorMessage } from 'ontime-utils';
import { copyFile, rename } from 'fs/promises'; import { copyFile, rename } from 'fs/promises';
@@ -38,6 +38,7 @@ import {
moveCorruptFile, moveCorruptFile,
parseJsonFile, parseJsonFile,
} from './projectServiceUtils.js'; } from './projectServiceUtils.js';
import { safeMerge } from '../../classes/data-provider/DataProvider.utils.js';
// init dependencies // init dependencies
init(); init();
@@ -263,14 +264,8 @@ export async function renameProjectFile(originalFile: string, newFilename: strin
/** /**
* Creates a new project file and applies its result * Creates a new project file and applies its result
*/ */
export async function createProject(filename: string, projectData: ProjectData) { export async function createProject(filename: string, initialData: Partial<DatabaseModel>) {
const data: DatabaseModel = { const data = safeMerge(dbModel, initialData);
...dbModel,
project: {
...dbModel.project,
...projectData,
},
};
const uniqueFileName = generateUniqueFileName(publicDir.projectsDir, filename); const uniqueFileName = generateUniqueFileName(publicDir.projectsDir, filename);
const newFile = getPathToProject(uniqueFileName); const newFile = getPathToProject(uniqueFileName);
@@ -44,6 +44,11 @@ export function handleLink(
return; return;
} }
// sometimes the client cannot set the previous event
if (mutableEvent.linkStart === 'true') {
mutableEvent.linkStart = linkedEvent.id;
}
links[linkedEvent.id] = mutableEvent.id; links[linkedEvent.id] = mutableEvent.id;
const timePatch = getLinkedTimes(mutableEvent, linkedEvent); const timePatch = getLinkedTimes(mutableEvent, linkedEvent);
-7
View File
@@ -44,13 +44,6 @@
/** Colour used for external message and aux timer in /timer */ /** Colour used for external message and aux timer in /timer */
--external-color-override: #161616; --external-color-override: #161616;
/** View specific features: /cuesheet */
--cuesheet-running-bg-override: #D20300;
/** View specific features: /op */
--operator-running-bg-override: #D20300;
--operator-highlight-override: #FFAB33;
/** View specific features: /studio */ /** View specific features: /studio */
--studio-active: #101010; --studio-active: #101010;
--studio-idle: #cfcfcf; --studio-idle: #cfcfcf;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime", "name": "ontime",
"version": "3.6.2", "version": "3.7.0",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"keywords": [ "keywords": [
"ontime", "ontime",
+7
View File
@@ -0,0 +1,7 @@
import { type DatabaseModel } from '../../definitions/DataModel.type.js';
export interface QuickStartData {
project: Pick<DatabaseModel['project'], 'title'>;
settings: Pick<DatabaseModel['settings'], 'timeFormat' | 'language'>;
viewSettings: Pick<DatabaseModel['viewSettings'], 'freezeEnd' | 'endMessage'>;
}
@@ -3,4 +3,5 @@ export enum TimerType {
CountUp = 'count-up', CountUp = 'count-up',
TimeToEnd = 'time-to-end', TimeToEnd = 'time-to-end',
Clock = 'clock', Clock = 'clock',
None = 'none',
} }
+1
View File
@@ -53,6 +53,7 @@ export type {
RundownPaginated, RundownPaginated,
SessionStats, SessionStats,
} from './api/ontime-controller/BackendResponse.type.js'; } from './api/ontime-controller/BackendResponse.type.js';
export type { QuickStartData } from './api/db/db.type.js';
export type { RundownCached, NormalisedRundown } from './api/rundown-controller/BackendResponse.type.js'; export type { RundownCached, NormalisedRundown } from './api/rundown-controller/BackendResponse.type.js';
// SERVER RUNTIME // SERVER RUNTIME
+15 -12
View File
@@ -18,6 +18,9 @@ catalogs:
eslint: eslint:
specifier: 8.56.0 specifier: 8.56.0
version: 8.56.0 version: 8.56.0
eslint-config-prettier:
specifier: 9.1.0
version: 9.1.0
eslint-plugin-prettier: eslint-plugin-prettier:
specifier: 5.1.3 specifier: 5.1.3
version: 5.1.3 version: 5.1.3
@@ -102,8 +105,8 @@ importers:
specifier: ^5.0.28 specifier: ^5.0.28
version: 5.0.28 version: 5.0.28
'@mantine/hooks': '@mantine/hooks':
specifier: ^7.6.2 specifier: ^7.13.3
version: 7.6.2(react@18.3.1) version: 7.13.3(react@18.3.1)
'@react-icons/all-files': '@react-icons/all-files':
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0(react@18.3.1) version: 4.1.0(react@18.3.1)
@@ -147,8 +150,8 @@ importers:
specifier: ^3.2.2 specifier: ^3.2.2
version: 3.2.2 version: 3.2.2
react-hook-form: react-hook-form:
specifier: ^7.49.2 specifier: ^7.53.1
version: 7.49.2(react@18.3.1) version: 7.53.1(react@18.3.1)
react-qr-code: react-qr-code:
specifier: ^2.0.12 specifier: ^2.0.12
version: 2.0.12(react@18.3.1) version: 2.0.12(react@18.3.1)
@@ -1673,8 +1676,8 @@ packages:
resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
'@mantine/hooks@7.6.2': '@mantine/hooks@7.13.3':
resolution: {integrity: sha512-ZrOgrZHoIGCDKrr2/9njDgK0al+jjusYQFlmR0YyEFyRtgY6eNSI4zuYLcAPx1haHmUm5RsLBrqY6Iy/TLdGXA==} resolution: {integrity: sha512-r2c+Z8CdvPKFeOwg6mSJmxOp9K/ave5ZFR7eJbgv4wQU8K1CAS5f5ven9K5uUX8Vf9B5dFnSaSgYp9UY3vOWTw==}
peerDependencies: peerDependencies:
react: ^18.2.0 react: ^18.2.0
@@ -4508,11 +4511,11 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
react-hook-form@7.49.2: react-hook-form@7.53.1:
resolution: {integrity: sha512-TZcnSc17+LPPVpMRIDNVITY6w20deMdNi6iehTFLV1x8SqThXGwu93HjlUVU09pzFgZH7qZOvLMM7UYf2ShAHA==} resolution: {integrity: sha512-6aiQeBda4zjcuaugWvim9WsGqisoUk+etmFEsSUMm451/Ic8L/UAb7sRtMj3V+Hdzm6mMjU1VhiSzYUZeBm0Vg==}
engines: {node: '>=18', pnpm: '8'} engines: {node: '>=18.0.0'}
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17 || ^18 react: ^16.8.0 || ^17 || ^18 || ^19
react-is@16.13.1: react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -6796,7 +6799,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@mantine/hooks@7.6.2(react@18.3.1)': '@mantine/hooks@7.13.3(react@18.3.1)':
dependencies: dependencies:
react: 18.3.1 react: 18.3.1
@@ -10094,7 +10097,7 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 18.0.26 '@types/react': 18.0.26
react-hook-form@7.49.2(react@18.3.1): react-hook-form@7.53.1(react@18.3.1):
dependencies: dependencies:
react: 18.3.1 react: 18.3.1