mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 238eb49b5c | |||
| 7aec3e0d73 | |||
| 312b294a61 | |||
| 9bfa3e9f8e | |||
| 9a2cfb59e7 | |||
| a6e1cbbbce | |||
| 8dd448db44 | |||
| d87542dcf7 | |||
| 18575fc558 | |||
| 04e2593939 | |||
| 73d6b97afa | |||
| b55724ad0e | |||
| 3a7c2b8a55 | |||
| 420dfae652 | |||
| fb6896de76 | |||
| f574fdbc02 | |||
| 597be4e710 | |||
| 6e8734c73f | |||
| 9440ad17d2 | |||
| 9ba94288a1 | |||
| 6c78d25af1 | |||
| 0d8c186c77 | |||
| 7ccd41eeee | |||
| 43b390a079 | |||
| 80ff3251ea | |||
| 3591b93c2d | |||
| b637621871 | |||
| ed2efe183b | |||
| 27f99b4c87 | |||
| ed4d0f060b | |||
| 7c19c52332 | |||
| b1bff22b84 | |||
| 83b9fde45e | |||
| 37974df5c6 | |||
| 161ec383fd | |||
| de38f9d467 | |||
| 8ad1f2cb38 | |||
| 143917f11a | |||
| a8756c8afe | |||
| 5501f57b45 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "3.6.2",
|
||||
"version": "3.8.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "3.6.2",
|
||||
"version": "3.8.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -11,7 +11,7 @@
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@emotion/styled": "^11.10.6",
|
||||
"@fontsource/open-sans": "^5.0.28",
|
||||
"@mantine/hooks": "^7.6.2",
|
||||
"@mantine/hooks": "^7.13.3",
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
"@sentry/react": "^8.19.0",
|
||||
"@tanstack/react-query": "^5.17.9",
|
||||
@@ -26,7 +26,7 @@
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"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-router-dom": "^6.3.0",
|
||||
"web-vitals": "^3.1.1",
|
||||
@@ -39,7 +39,7 @@
|
||||
"build": "vite build",
|
||||
"build:local": "cross-env NODE_ENV=local vite build",
|
||||
"build:electron": "cross-env NODE_ENV=local vite build",
|
||||
"build:docker": "vite build",
|
||||
"build:docker": "cross-env VITE_IS_CLOUD=true vite build",
|
||||
"build:localdocker": "cross-env NODE_ENV=local vite build",
|
||||
"lint": "eslint . --quiet",
|
||||
"test": "vitest",
|
||||
|
||||
@@ -32,12 +32,14 @@ const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
|
||||
const Public = React.lazy(() => import('./features/viewers/public/Public'));
|
||||
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
||||
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
|
||||
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
|
||||
|
||||
const STimer = withPreset(withData(TimerView));
|
||||
const SMinimalTimer = withPreset(withData(MinimalTimerView));
|
||||
const SClock = withPreset(withData(ClockView));
|
||||
const SCountdown = withPreset(withData(Countdown));
|
||||
const SBackstage = withPreset(withData(Backstage));
|
||||
const SProjectInfo = withPreset(withData(ProjectInfo));
|
||||
const SPublic = withPreset(withData(Public));
|
||||
const SLowerThird = withPreset(withData(Lower));
|
||||
const SStudio = withPreset(withData(StudioClock));
|
||||
@@ -142,6 +144,14 @@ export default function AppRouter() {
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/info'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SProjectInfo />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
|
||||
{/*/!* Protected Routes *!/*/}
|
||||
<Route path='/editor' element={<Editor />} />
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { serverURL } from '../../externals';
|
||||
|
||||
// keys in tanstack store
|
||||
export const APP_INFO = ['appinfo'];
|
||||
export const APP_SETTINGS = ['appSettings'];
|
||||
export const APP_VERSION = ['appVersion'];
|
||||
export const CUSTOM_FIELDS = ['customFields'];
|
||||
export const HTTP_SETTINGS = ['httpSettings'];
|
||||
export const OSC_SETTINGS = ['oscSettings'];
|
||||
@@ -13,19 +16,7 @@ export const URL_PRESETS = ['urlpresets'];
|
||||
export const VIEW_SETTINGS = ['viewSettings'];
|
||||
export const CLIENT_LIST = ['clientList'];
|
||||
|
||||
// resolve location
|
||||
const location = window.location;
|
||||
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
export const isProduction = import.meta.env.MODE === 'production';
|
||||
export const isDev = !isProduction;
|
||||
export const isLocalhost = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
|
||||
|
||||
// resolve port
|
||||
const STATIC_PORT = 4001;
|
||||
export const serverPort = isProduction ? location.port : STATIC_PORT;
|
||||
export const serverURL = `${location.protocol}//${location.hostname}:${serverPort}`;
|
||||
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
|
||||
|
||||
// API URLs
|
||||
export const apiEntryUrl = `${serverURL}/data`;
|
||||
|
||||
export const projectDataURL = `${serverURL}/project`;
|
||||
@@ -35,3 +26,4 @@ export const ontimeURL = `${serverURL}/ontime`;
|
||||
export const userAssetsPath = 'user';
|
||||
export const cssOverridePath = 'styles/override.css';
|
||||
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
|
||||
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
@@ -86,6 +86,14 @@ export async function createProject(
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { ProjectData } from 'ontime-types';
|
||||
import { ProjectData, ProjectLogoResponse } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
@@ -19,3 +19,18 @@ export async function getProjectData(): Promise<ProjectData> {
|
||||
export async function postProjectData(data: ProjectData): Promise<AxiosResponse<ProjectData>> {
|
||||
return axios.post(projectPath, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to upload a project logo
|
||||
*/
|
||||
export async function uploadProjectLogo(file: File): Promise<AxiosResponse<ProjectLogoResponse>> {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
const response = await axios.post(`${projectPath}/upload`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ export function maybeAxiosError(error: unknown) {
|
||||
}
|
||||
}
|
||||
return `${statusText}: ${data}`;
|
||||
} else if (error instanceof Error) {
|
||||
return error.message;
|
||||
} else {
|
||||
if (typeof error !== 'string') {
|
||||
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 style from './ClientModal.module.scss';
|
||||
|
||||
interface RedirectClientModalProps {
|
||||
id: string;
|
||||
name?: string;
|
||||
@@ -54,14 +52,12 @@ export function RedirectClientModal(props: RedirectClientModalProps) {
|
||||
</InputGroup>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<div className={style.buttonSection}>
|
||||
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
@@ -13,8 +13,6 @@ import {
|
||||
|
||||
import { setClientRemote } from '../../hooks/useSocket';
|
||||
|
||||
import style from './ClientModal.module.scss';
|
||||
|
||||
interface RenameClientModalProps {
|
||||
id: string;
|
||||
name?: string;
|
||||
@@ -53,14 +51,12 @@ export function RenameClientModal(props: RenameClientModalProps) {
|
||||
/>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<div className={style.buttonSection}>
|
||||
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
@@ -17,10 +17,10 @@ import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
|
||||
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
|
||||
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
||||
|
||||
import { isLocalhost, serverPort } from '../../../externals';
|
||||
import { navigatorConstants } from '../../../viewerConfig';
|
||||
import { isLocalhost, serverPort } from '../../api/constants';
|
||||
import useClickOutside from '../../hooks/useClickOutside';
|
||||
import useElectronEvent from '../../hooks/useElectronEvent';
|
||||
import { useElectronEvent } from '../../hooks/useElectronEvent';
|
||||
import useInfo from '../../hooks-query/useInfo';
|
||||
import { useClientStore } from '../../stores/clientStore';
|
||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||
@@ -57,7 +57,7 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
|
||||
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent maxWidth='21rem'>
|
||||
<DrawerContent maxWidth='22rem'>
|
||||
<DrawerHeader>
|
||||
<DrawerCloseButton size='lg' />
|
||||
Ontime
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PropsWithChildren, useCallback, useContext } from 'react';
|
||||
|
||||
import { isLocalhost } from '../../../externals';
|
||||
import { AppContext } from '../../context/AppContext';
|
||||
|
||||
import PinPage from './PinPage';
|
||||
@@ -9,7 +10,6 @@ interface 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 handleValidation = useCallback(
|
||||
@@ -29,7 +29,7 @@ export default function ProtectRoute({ permission, children }: PropsWithChildren
|
||||
return false;
|
||||
};
|
||||
|
||||
if (isLocal || hasRelevantAuth()) {
|
||||
if (isLocalhost || hasRelevantAuth()) {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment -- trying to make typescript happy
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { projectLogoPath } from '../../api/constants';
|
||||
|
||||
interface ViewLogoProps {
|
||||
name: string;
|
||||
className: string;
|
||||
}
|
||||
|
||||
export default function ViewLogo(props: ViewLogoProps) {
|
||||
const { name, className } = props;
|
||||
return <img src={`${projectLogoPath}/${name}`} className={className} />;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Select,
|
||||
Switch,
|
||||
} from '@chakra-ui/react';
|
||||
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
|
||||
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
|
||||
@@ -108,25 +109,21 @@ function MultiOption(props: EditFormMultiOptionProps) {
|
||||
const { paramField } = props;
|
||||
const { id, defaultValue } = paramField;
|
||||
|
||||
const optionFromParams = (searchParams.get(id) ?? '').toLocaleLowerCase();
|
||||
const defaultOptionValue = optionFromParams || defaultValue?.toLocaleLowerCase() || '';
|
||||
|
||||
const [paramState, setParamState] = useState<string>(defaultOptionValue);
|
||||
const optionFromParams = searchParams.getAll(id);
|
||||
const [paramState, setParamState] = useState<string[]>(optionFromParams || defaultValue || ['']);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input name={id} hidden readOnly value={paramState} />
|
||||
<Menu isLazy closeOnSelect={false} variant='ontime-on-dark'>
|
||||
<MenuButton as={Button} variant='ontime-subtle-white' position='relative' width='fit-content' fontWeight={400}>
|
||||
{paramField.title}
|
||||
{paramField.title} <IoChevronDown style={{ display: 'inline' }} />
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
<MenuOptionGroup
|
||||
type='checkbox'
|
||||
value={paramState.split('_')}
|
||||
onChange={(value) => {
|
||||
setParamState(typeof value === 'object' ? value.filter((v) => v !== '').join('_') : value);
|
||||
}}
|
||||
value={paramState}
|
||||
onChange={(value) => setParamState(Array.isArray(value) ? value : [value])}
|
||||
>
|
||||
{Object.values(paramField.values).map((option) => {
|
||||
const { value, label } = option;
|
||||
|
||||
@@ -55,10 +55,28 @@ const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOp
|
||||
// unfortunately this means we run all the strings through the sanitation
|
||||
const valueWithoutHash = sanitiseColour(value);
|
||||
if (defaultValues[id] !== valueWithoutHash) {
|
||||
newSearchParams.set(id, valueWithoutHash);
|
||||
handleValueString(id, valueWithoutHash);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/** Utility function contains logic to add a value into the searchParams object */
|
||||
function handleValueString(id: string, value: string) {
|
||||
const maybeMultipleValues = value.split(',');
|
||||
|
||||
// we need to check if the value contains comma separated list, for the case of the multi-select data
|
||||
if (Array.isArray(maybeMultipleValues) && maybeMultipleValues.length > 1) {
|
||||
const added = new Set();
|
||||
maybeMultipleValues.forEach((v) => {
|
||||
if (!added.has(v)) {
|
||||
added.add(v);
|
||||
newSearchParams.append(id, v);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
newSearchParams.set(id, value);
|
||||
}
|
||||
}
|
||||
return newSearchParams;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
|
||||
import { version } from '../../../../../package.json';
|
||||
import { isLocalhost } from '../../externals';
|
||||
import { APP_VERSION } 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 };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
|
||||
|
||||
@@ -6,6 +6,8 @@ import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { RUNDOWN } from '../api/constants';
|
||||
import { fetchNormalisedRundown } from '../api/rundown';
|
||||
|
||||
import useProjectData from './useProjectData';
|
||||
|
||||
// revision is -1 so that the remote revision is higher
|
||||
const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 };
|
||||
|
||||
@@ -24,7 +26,9 @@ export default function useRundown() {
|
||||
|
||||
export function useFlatRundown() {
|
||||
const { data, status } = useRundown();
|
||||
const { data: projectData } = useProjectData();
|
||||
|
||||
const loadedProject = useRef<string>('');
|
||||
const [prevRevision, setPrevRevision] = useState<number>(-1);
|
||||
const [flatRunDown, setFlatRunDown] = useState<OntimeRundown>([]);
|
||||
|
||||
@@ -37,5 +41,14 @@ export function useFlatRundown() {
|
||||
}
|
||||
}, [data.order, data.revision, data.rundown, prevRevision]);
|
||||
|
||||
// TODO: should we have a project id field?
|
||||
// invalidate current version if project changes
|
||||
useEffect(() => {
|
||||
if (projectData?.title !== loadedProject.current) {
|
||||
setPrevRevision(-1);
|
||||
loadedProject.current = projectData?.title ?? '';
|
||||
}
|
||||
}, [projectData]);
|
||||
|
||||
return { data: flatRunDown, status };
|
||||
}
|
||||
|
||||
@@ -4,15 +4,20 @@ import { useNavigate } from 'react-router-dom';
|
||||
const isElectron = window.process?.type === 'renderer';
|
||||
const ipcRenderer = isElectron ? window.require('electron').ipcRenderer : null;
|
||||
|
||||
export default function useElectronEvent() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
export function useElectronEvent() {
|
||||
const sendToElectron = useCallback((channel: string, args?: string | Record<string, unknown>) => {
|
||||
if (isElectron && ipcRenderer) {
|
||||
ipcRenderer.send(channel, args);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { isElectron, sendToElectron };
|
||||
}
|
||||
|
||||
export function useElectronListener() {
|
||||
const navigate = useNavigate();
|
||||
const { isElectron } = useElectronEvent();
|
||||
|
||||
// listen to requests to change the editor location
|
||||
useEffect(() => {
|
||||
if (isElectron) {
|
||||
@@ -25,7 +30,5 @@ export default function useElectronEvent() {
|
||||
return () => {
|
||||
ipcRenderer?.removeAllListeners();
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
return { isElectron, sendToElectron };
|
||||
}, [isElectron, navigate]);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
|
||||
import {
|
||||
dayInMs,
|
||||
getLinkedTimes,
|
||||
getPreviousEventNormal,
|
||||
MILLIS_PER_SECOND,
|
||||
parseUserTime,
|
||||
reorderArray,
|
||||
swapEventData,
|
||||
} from 'ontime-utils';
|
||||
import { isOntimeEvent, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
|
||||
import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils';
|
||||
|
||||
import { RUNDOWN } from '../api/constants';
|
||||
import {
|
||||
@@ -270,44 +262,6 @@ export const useEventAction = () => {
|
||||
[_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
|
||||
* @private
|
||||
@@ -614,7 +568,6 @@ export const useEventAction = () => {
|
||||
batchUpdateEvents,
|
||||
deleteEvent,
|
||||
deleteAllEvents,
|
||||
linkTimer,
|
||||
reorderEvent,
|
||||
swapEvents,
|
||||
updateEvent,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { useMemo, useRef } from 'react';
|
||||
|
||||
import { isDev } from '../api/constants';
|
||||
import { isDev } from '../../externals';
|
||||
|
||||
type noop = (this: any, ...args: any[]) => any;
|
||||
|
||||
|
||||
@@ -230,3 +230,11 @@ export const useTimelineStatus = () => {
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
|
||||
export const usePing = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
ping: state.ping,
|
||||
});
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
|
||||
@@ -7,4 +7,5 @@ export const projectDataPlaceholder: ProjectData = {
|
||||
publicInfo: '',
|
||||
backstageUrl: '',
|
||||
backstageInfo: '',
|
||||
projectLogo: null,
|
||||
};
|
||||
|
||||
@@ -15,4 +15,5 @@ export type OverridableOptions = {
|
||||
language?: string;
|
||||
showProgressBar?: boolean;
|
||||
hideTimerSeconds?: boolean;
|
||||
removeLeadingZeros?: boolean;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Log, RundownCached, RuntimeStore } from 'ontime-types';
|
||||
|
||||
import { CLIENT_LIST, CUSTOM_FIELDS, isProduction, RUNDOWN, RUNTIME, websocketUrl } from '../api/constants';
|
||||
import { isProduction, websocketUrl } from '../../externals';
|
||||
import { CLIENT_LIST, CUSTOM_FIELDS, RUNDOWN, RUNTIME } from '../api/constants';
|
||||
import { invalidateAllCaches } from '../api/utils';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
import {
|
||||
getClientId,
|
||||
@@ -68,6 +70,12 @@ export const connectSocket = () => {
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'pong': {
|
||||
const offset = (new Date().getTime() - new Date(payload).getTime()) * 0.5;
|
||||
patchRuntime('ping', offset);
|
||||
updateDevTools({ ping: offset }, ['PING']);
|
||||
break;
|
||||
}
|
||||
case 'client-id': {
|
||||
if (typeof payload === 'string') {
|
||||
setClientId(payload);
|
||||
@@ -178,10 +186,12 @@ export const connectSocket = () => {
|
||||
}
|
||||
case 'ontime-refetch': {
|
||||
// the refetch message signals that the rundown has changed in the server side
|
||||
const { revision } = payload;
|
||||
const { revision, reload } = payload;
|
||||
const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1;
|
||||
|
||||
if (revision > currentRevision) {
|
||||
if (reload) {
|
||||
invalidateAllCaches();
|
||||
} else if (revision > currentRevision) {
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
|
||||
}
|
||||
@@ -214,9 +224,9 @@ export const socketSendJson = (type: string, payload?: unknown) => {
|
||||
);
|
||||
};
|
||||
|
||||
function updateDevTools(newData: Partial<RuntimeStore>) {
|
||||
function updateDevTools(newData: Partial<RuntimeStore>, store = RUNTIME) {
|
||||
if (!isProduction) {
|
||||
ontimeQueryClient.setQueryData(RUNTIME, (oldData: RuntimeStore) => ({
|
||||
ontimeQueryClient.setQueryData(store, (oldData: RuntimeStore) => ({
|
||||
...oldData,
|
||||
...newData,
|
||||
}));
|
||||
|
||||
@@ -34,7 +34,7 @@ export function validateProjectFile(file: File) {
|
||||
|
||||
// Limit file size of a project file to around 1MB
|
||||
if (file.size > 1_000_000) {
|
||||
throw new Error('File size limit (10MB) exceeded');
|
||||
throw new Error('File size limit (1MB) exceeded');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,3 +45,19 @@ export function isExcelFile(file: File | null) {
|
||||
export function isOntimeFile(file: File | null) {
|
||||
return file?.name.endsWith('.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection of rules for pre-validating a project file
|
||||
* @param file
|
||||
*/
|
||||
export function validateLogo(file: File) {
|
||||
// Check if file is empty
|
||||
if (file.size === 0) {
|
||||
throw new Error('File is empty');
|
||||
}
|
||||
|
||||
// Limit file size of a project file to around 1MB
|
||||
if (file.size > 1_000_000) {
|
||||
throw new Error('File size limit (1MB) exceeded');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
/**
|
||||
* This file contains a list of constants that may need to be resolved at runtime
|
||||
*/
|
||||
|
||||
export const githubUrl = 'https://www.github.com/cpvalente/ontime';
|
||||
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
|
||||
export const websiteUrl = 'https://www.getontime.no';
|
||||
|
||||
export const documentationUrl = 'https://docs.getontime.no';
|
||||
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
|
||||
|
||||
// resolve environment
|
||||
export const isProduction = import.meta.env.MODE === 'production';
|
||||
export const isDev = !isProduction;
|
||||
export const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||
export const isOntimeCloud = Boolean(import.meta.env.VITE_IS_CLOUD);
|
||||
|
||||
// resolve protocol
|
||||
const socketProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
|
||||
// resolve port
|
||||
const STATIC_PORT = 4001;
|
||||
export const serverPort = isProduction ? window.location.port : STATIC_PORT;
|
||||
export const serverURL = `${window.location.protocol}//${location.hostname}:${serverPort}`;
|
||||
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
|
||||
|
||||
@@ -18,7 +18,7 @@ import useAppSettingsNavigation from './useAppSettingsNavigation';
|
||||
import style from './AppSettings.module.scss';
|
||||
|
||||
export default function AppSettings() {
|
||||
const { close, panel, location } = useAppSettingsNavigation();
|
||||
const { close, panel, location, setLocation } = useAppSettingsNavigation();
|
||||
useKeyDown(close, 'Escape');
|
||||
|
||||
return (
|
||||
@@ -26,7 +26,7 @@ export default function AppSettings() {
|
||||
<ErrorBoundary>
|
||||
<PanelList selectedPanel={panel} location={location} />
|
||||
<PanelContent onClose={close}>
|
||||
{panel === 'project' && <ProjectPanel location={location} />}
|
||||
{panel === 'project' && <ProjectPanel location={location} setLocation={setLocation} />}
|
||||
{panel === 'general' && <GeneralPanel location={location} />}
|
||||
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
|
||||
{panel === 'sources' && <SourcesPanel />}
|
||||
|
||||
@@ -40,12 +40,16 @@ ul {
|
||||
background-color: $gray-1100;
|
||||
}
|
||||
|
||||
&.unsaved::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background-color: $blue-400;
|
||||
&.highlight {
|
||||
color: $red-400;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 0.5em;
|
||||
height: 0.5em;
|
||||
border-radius: 99px;
|
||||
background-color: $red-400;
|
||||
}
|
||||
}
|
||||
|
||||
&.split {
|
||||
|
||||
@@ -1,66 +1,90 @@
|
||||
import { Fragment } from 'react';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
|
||||
import { isKeyEnter } from '../../../common/utils/keyEvent';
|
||||
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 style from './PanelList.module.scss';
|
||||
|
||||
export interface PanelBaseProps {
|
||||
location?: string;
|
||||
}
|
||||
|
||||
interface PanelListProps extends PanelBaseProps {
|
||||
selectedPanel: string;
|
||||
}
|
||||
|
||||
export default function PanelList({ selectedPanel, location }: PanelListProps) {
|
||||
const { setLocation } = useAppSettingsNavigation();
|
||||
const { hasUnsavedChanges } = useSettingsStore();
|
||||
const { options } = useAppSettingsMenu();
|
||||
|
||||
return (
|
||||
<ul className={style.tabs}>
|
||||
{settingPanels.map((panel) => {
|
||||
const unsaved = hasUnsavedChanges(panel.id);
|
||||
|
||||
const classes = cx([
|
||||
style.primary,
|
||||
selectedPanel === panel.id ? style.active : null,
|
||||
panel.split ? style.split : null,
|
||||
unsaved ? style.unsaved : null,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Fragment key={panel.id}>
|
||||
<li
|
||||
key={panel.id}
|
||||
onClick={() => setLocation(panel.id)}
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && setLocation(panel.id);
|
||||
}}
|
||||
className={classes}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
>
|
||||
{panel.label}
|
||||
</li>
|
||||
{panel.secondary?.map((secondary) => {
|
||||
const id = secondary.id.split('__')[1];
|
||||
const secondaryClasses = cx([style.secondary, location === id ? style.active : null]);
|
||||
return (
|
||||
<li
|
||||
key={secondary.id}
|
||||
onClick={() => setLocation(secondary.id)}
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && setLocation(secondary.id);
|
||||
}}
|
||||
className={secondaryClasses}
|
||||
role='button'
|
||||
>
|
||||
{secondary.label}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
{options.map((panel) => {
|
||||
const isSelected = selectedPanel === panel.id;
|
||||
if (panel.highlight) {
|
||||
return (
|
||||
<Tooltip key={panel.id} label={panel.highlight} openDelay={tooltipDelayFast} shouldWrapChildren>
|
||||
<PanelListItem panel={panel} location={location} isSelected={isSelected} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />;
|
||||
})}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
+12
@@ -49,6 +49,18 @@ $inner-padding: 1rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
font-size: calc(1rem - 2px);
|
||||
color: $orange-500;
|
||||
padding-inline: 0.25rem;
|
||||
}
|
||||
|
||||
.blockquote {
|
||||
padding-left: 0.5rem;
|
||||
border-left: 2px solid $gray-100;
|
||||
background-color: $white-3;
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: $inner-section-text-size;
|
||||
display: block;
|
||||
+11
-3
@@ -2,7 +2,7 @@ import { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
|
||||
import style from './Panel.module.scss';
|
||||
import style from './PanelUtils.module.scss';
|
||||
|
||||
export function Header({ children }: { children: ReactNode }) {
|
||||
return <h2 className={style.header}>{children}</h2>;
|
||||
@@ -35,9 +35,9 @@ export function Paragraph({ children }: { children: ReactNode }) {
|
||||
return <p className={style.paragraph}>{children}</p>;
|
||||
}
|
||||
|
||||
export function Card({ children, ...props }: { children: ReactNode } & JSX.IntrinsicElements['div']) {
|
||||
export function Card({ children, className, ...props }: { children: ReactNode } & JSX.IntrinsicElements['div']) {
|
||||
return (
|
||||
<div className={style.card} {...props}>
|
||||
<div className={cx([style.card, className])} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -74,6 +74,14 @@ export function Description({ children }: { children: ReactNode }) {
|
||||
return <div className={style.fieldDescription}>{children}</div>;
|
||||
}
|
||||
|
||||
export function Highlight({ children }: { children: ReactNode }) {
|
||||
return <code className={style.highlight}>{children}</code>;
|
||||
}
|
||||
|
||||
export function BlockQuote({ children }: { children: ReactNode }) {
|
||||
return <blockquote className={style.blockquote}>{children}</blockquote>;
|
||||
}
|
||||
|
||||
export function Error({ children }: { children: ReactNode }) {
|
||||
return <div className={style.fieldError}>{children}</div>;
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { version } from '../../../../../package.json';
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import { documentationUrl, githubUrl, websiteUrl } from '../../../../externals';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import CheckUpdatesButton from './CheckUpdatesButton';
|
||||
import AppVersion from './AppVersion';
|
||||
|
||||
export default function AboutPanel() {
|
||||
return (
|
||||
@@ -23,8 +22,7 @@ export default function AboutPanel() {
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
<Panel.SubHeader>Current version</Panel.SubHeader>
|
||||
<Panel.Paragraph>{`You are currently using Ontime ${version}`}</Panel.Paragraph>
|
||||
<CheckUpdatesButton version={version} />
|
||||
<AppVersion />
|
||||
</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
-1
@@ -1,4 +1,4 @@
|
||||
import * as Panel from '../PanelUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import ClientList from './ClientList';
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { RedirectClientModal } from '../../../../common/components/client-modal/
|
||||
import { RenameClientModal } from '../../../../common/components/client-modal/RenameClientModal';
|
||||
import { setClientRemote } from '../../../../common/hooks/useSocket';
|
||||
import { useClientStore } from '../../../../common/stores/clientStore';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './ClientControlPanel.module.scss';
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { PanelBaseProps } from '../../settingsStore';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import CustomFields from './custom-fields/CustomFields';
|
||||
import UrlPresetsForm from './UrlPresetsForm';
|
||||
|
||||
+20
-7
@@ -13,7 +13,7 @@ import ExternalLink from '../../../../common/components/external-link/ExternalLi
|
||||
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
|
||||
import { handleLinks } from '../../../../common/utils/linkUtils';
|
||||
import { validateUrlPresetPath } from '../../../../common/utils/urlPresets';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './FeatureSettings.module.scss';
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function UrlPresetsForm() {
|
||||
setError,
|
||||
formState: { isSubmitting, isDirty, isValid, errors },
|
||||
} = useForm<FormData>({
|
||||
mode: 'onBlur',
|
||||
mode: 'onChange',
|
||||
defaultValues: { data },
|
||||
values: { data },
|
||||
resetOptions: {
|
||||
@@ -105,12 +105,25 @@ export default function UrlPresetsForm() {
|
||||
<Alert status='info' variant='ontime-on-dark-info'>
|
||||
<AlertIcon />
|
||||
<AlertDescription>
|
||||
URL Presets
|
||||
URL presets are user defined aliases to Ontime URLs
|
||||
<br />
|
||||
<br />
|
||||
Custom presets allow providing a short name for any ontime URL. <br />
|
||||
- Providing dynamic URLs for automation or unattended screens <br />- Simplifying complex URLs
|
||||
<b>Preset Name</b> <br />
|
||||
The alias for the URL. This will be the URL you will be calling. eg: <br />
|
||||
<Panel.BlockQuote>
|
||||
Preset name <Panel.Highlight>cam3</Panel.Highlight> called as{' '}
|
||||
<Panel.Highlight>http://localhost:4001/cam3</Panel.Highlight>
|
||||
</Panel.BlockQuote>
|
||||
<br />
|
||||
<b>URL Segment</b> <br />
|
||||
The corresponding alias path and configuration parameters. eg: <br />
|
||||
<Panel.BlockQuote>
|
||||
URL segment <Panel.Highlight>backstage?hidePast=true&stopCycle=true</Panel.Highlight> corresponds to
|
||||
complete URL
|
||||
<Panel.Highlight>http://localhost:4001/backstage?hidePast=true&stopCycle=true</Panel.Highlight>
|
||||
</Panel.BlockQuote>
|
||||
<br />
|
||||
You will need to save the changes before the presets are functional.
|
||||
<br />
|
||||
<ExternalLink href={urlPresetsDocs}>See the docs</ExternalLink>
|
||||
</AlertDescription>
|
||||
@@ -130,8 +143,8 @@ export default function UrlPresetsForm() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={style.fit}>Active</th>
|
||||
<th className={style.aliasConstrain}>Preset</th>
|
||||
<th className={style.fullWidth}>URL</th>
|
||||
<th className={style.aliasConstrain}>Preset name</th>
|
||||
<th className={style.fullWidth}>URL segment</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
+7
-3
@@ -7,7 +7,7 @@ import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
|
||||
import { maybeAxiosError } from '../../../../../common/api/utils';
|
||||
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
|
||||
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||
import * as Panel from '../../PanelUtils';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import style from '../FeatureSettings.module.scss';
|
||||
|
||||
@@ -67,6 +67,8 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
||||
|
||||
const colour = getValues('colour');
|
||||
const canSubmit = isDirty && isValid;
|
||||
// if initial values are given, we can assume we are in edit mode
|
||||
const isEditMode = initialKey !== undefined;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}>
|
||||
@@ -81,7 +83,9 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
||||
validate: (value) => {
|
||||
if (value.trim().length === 0) return 'Required field';
|
||||
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;
|
||||
},
|
||||
})}
|
||||
@@ -92,7 +96,7 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
||||
</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' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import { deleteCustomField, editCustomField, postCustomField } from '../../../..
|
||||
import ExternalLink from '../../../../../common/components/external-link/ExternalLink';
|
||||
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||
import { customFieldsDocsUrl } from '../../../../../externals';
|
||||
import * as Panel from '../../PanelUtils';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import CustomFieldEntry from './CustomFieldEntry';
|
||||
import CustomFieldForm from './CustomFieldForm';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 * as Panel from '../PanelUtils';
|
||||
|
||||
import GeneralPanelForm from './GeneralPanelForm';
|
||||
import ViewSettingsForm from './ViewSettingsForm';
|
||||
|
||||
@@ -7,7 +7,7 @@ import { postSettings } from '../../../../common/api/settings';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import useSettings from '../../../../common/hooks-query/useSettings';
|
||||
import { isOnlyNumbers } from '../../../../common/utils/regex';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import GeneralPinInput from './GeneralPinInput';
|
||||
|
||||
@@ -154,7 +154,7 @@ export default function GeneralPanelForm() {
|
||||
<option value='pt'>Portuguese</option>
|
||||
<option value='es'>Spanish</option>
|
||||
<option value='sv'>Swedish</option>
|
||||
<option value='pl'>Polish</option>
|
||||
<option value='pl'>Polish</option>
|
||||
</Select>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
|
||||
@@ -9,7 +9,7 @@ import ExternalLink from '../../../../common/components/external-link/ExternalLi
|
||||
import { PopoverPickerRHF } from '../../../../common/components/input/popover-picker/PopoverPicker';
|
||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||
import useViewSettings from '../../../../common/hooks-query/useViewSettings';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './GeneralPanel.module.scss';
|
||||
|
||||
@@ -126,7 +126,7 @@ export default function ViewSettingsForm() {
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
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
|
||||
control={control}
|
||||
@@ -139,7 +139,7 @@ export default function ViewSettingsForm() {
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
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
|
||||
size='sm'
|
||||
|
||||
@@ -9,7 +9,7 @@ import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import { useHttpSettings, usePostHttpSettings } from '../../../../common/hooks-query/useHttpSettings';
|
||||
import { isKeyEscape } from '../../../../common/utils/keyEvent';
|
||||
import { startsWithHttp } from '../../../../common/utils/regex';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import { cycles } from './integrationUtils';
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function HttpIntegrations() {
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isDirty, isValid },
|
||||
} = useForm<HttpSettings>({
|
||||
mode: 'onBlur',
|
||||
mode: 'onChange',
|
||||
defaultValues: data,
|
||||
values: data,
|
||||
resetOptions: {
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@ import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
|
||||
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { PanelBaseProps } from '../../settingsStore';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import HttpIntegrations from './HttpIntegrations';
|
||||
import OscIntegrations from './OscIntegrations';
|
||||
|
||||
@@ -10,7 +10,7 @@ import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
|
||||
import { isKeyEscape } from '../../../../common/utils/keyEvent';
|
||||
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';
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function OscIntegrations() {
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isDirty, isValid },
|
||||
} = useForm<OSCSettings>({
|
||||
mode: 'onBlur',
|
||||
mode: 'onChange',
|
||||
defaultValues: data,
|
||||
values: data,
|
||||
resetOptions: {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { parseUserTime } from 'ontime-utils';
|
||||
|
||||
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
||||
import { editorSettingsDefaults, useEditorSettings } from '../../../../common/stores/editorSettings';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
export default function EditorSettingsForm() {
|
||||
const {
|
||||
@@ -90,6 +90,7 @@ export default function EditorSettingsForm() {
|
||||
<option value={TimerType.CountUp}>Count up</option>
|
||||
<option value={TimerType.TimeToEnd}>Time to end</option>
|
||||
<option value={TimerType.Clock}>Clock</option>
|
||||
<option value={TimerType.None}>None</option>
|
||||
</Select>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
|
||||
|
||||
import * as Panel from '../PanelUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import EditorSettingsForm from './EditorSettingsForm';
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
|
||||
import { serverPort } from '../../../../common/api/constants';
|
||||
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||
import { openLink } from '../../../../common/utils/linkUtils';
|
||||
import { isLocalhost, serverPort } from '../../../../externals';
|
||||
|
||||
import style from './NetworkInterfaces.module.scss';
|
||||
|
||||
@@ -14,9 +14,11 @@ export default function InfoNif() {
|
||||
|
||||
return (
|
||||
<div className={style.interfaces}>
|
||||
{data?.networkInterfaces?.map((nif) => {
|
||||
const address = `http://${nif.address}:${serverPort}`;
|
||||
{data.networkInterfaces?.map((nif) => {
|
||||
// interfaces outside localhost wont have access
|
||||
if (nif.name === 'localhost' && !isLocalhost) return null;
|
||||
|
||||
const address = `http://${nif.address}:${serverPort}`;
|
||||
return (
|
||||
<CopyTag
|
||||
key={nif.name}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
|
||||
import { handleLinks } from '../../../../common/utils/linkUtils';
|
||||
import Log from '../../../log/Log';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './NetworkLogExport.module.scss';
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { PanelBaseProps } from '../../settingsStore';
|
||||
import { usePing } from '../../../../common/hooks/useSocket';
|
||||
import { socketSendJson } from '../../../../common/utils/socket';
|
||||
import { isOntimeCloud } from '../../../../externals';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import ClientControlPanel from '../client-control-panel/ClientControlPanel';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import InfoNif from './NetworkInterfaces';
|
||||
import LogExport from './NetworkLogExport';
|
||||
@@ -14,6 +19,7 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
||||
<>
|
||||
<Panel.Header>Network</Panel.Header>
|
||||
<Panel.Section>
|
||||
{isOntimeCloud && <OntimeCloudStats />}
|
||||
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
|
||||
</Panel.Section>
|
||||
<InfoNif />
|
||||
@@ -26,3 +32,29 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function OntimeCloudStats() {
|
||||
const { ping } = usePing();
|
||||
|
||||
/**
|
||||
* Send immediate ping request, and keep sending on an interval
|
||||
*/
|
||||
useEffect(() => {
|
||||
socketSendJson('ping', new Date());
|
||||
|
||||
const doPing = setInterval(() => {
|
||||
socketSendJson('ping', new Date());
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
clearInterval(doPing);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Panel.SubHeader>
|
||||
Ontime cloud
|
||||
<Panel.Description>Current ping: {ping}ms</Panel.Description>
|
||||
</Panel.SubHeader>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { uploadProjectFile } from '../../../../common/api/db';
|
||||
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
|
||||
import { validateProjectFile } from '../../../../common/utils/uploadUtils';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import ProjectCreateForm from './ProjectCreateForm';
|
||||
import ProjectList from './ProjectList';
|
||||
@@ -20,10 +20,10 @@ export default function ManageProjects() {
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isCreatingProject = searchParams.get('create') === 'true';
|
||||
const isCreatingProject = searchParams.get('new') === 'true';
|
||||
|
||||
const handleToggleCreate = () => {
|
||||
searchParams.set('create', isCreatingProject ? 'false' : 'true');
|
||||
searchParams.set('new', isCreatingProject ? 'false' : 'true');
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function ManageProjects() {
|
||||
};
|
||||
|
||||
const handleCloseForm = () => {
|
||||
searchParams.delete('create');
|
||||
searchParams.delete('new');
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import { PROJECT_LIST } from '../../../../common/api/constants';
|
||||
import { createProject } from '../../../../common/api/db';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './ProjectPanel.module.scss';
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { useEffect } from 'react';
|
||||
import { ChangeEvent, useEffect, useRef } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Button, Input, Textarea } from '@chakra-ui/react';
|
||||
import { IoDownloadOutline } from '@react-icons/all-files/io5/IoDownloadOutline';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
import { type ProjectData } from 'ontime-types';
|
||||
|
||||
import { postProjectData } from '../../../../common/api/project';
|
||||
import { projectLogoPath } from '../../../../common/api/constants';
|
||||
import { postProjectData, uploadProjectLogo } from '../../../../common/api/project';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import useProjectData from '../../../../common/hooks-query/useProjectData';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import { validateLogo } from '../../../../common/utils/uploadUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './ProjectPanel.module.scss';
|
||||
|
||||
@@ -17,8 +21,10 @@ export default function ProjectData() {
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
formState: { isSubmitting, isValid, isDirty },
|
||||
formState: { isSubmitting, isValid, isDirty, errors },
|
||||
setError,
|
||||
watch,
|
||||
setValue,
|
||||
} = useForm({
|
||||
defaultValues: data,
|
||||
values: data,
|
||||
@@ -34,6 +40,40 @@ export default function ProjectData() {
|
||||
}
|
||||
}, [data, reset]);
|
||||
|
||||
const handleUploadProjectLogo = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
validateLogo(file);
|
||||
const response = await uploadProjectLogo(file);
|
||||
|
||||
setValue('projectLogo', response.data.logoFilename, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
setError('projectLogo', { message });
|
||||
}
|
||||
};
|
||||
|
||||
const { ref, ...projectLogoRest } = register('projectLogo');
|
||||
|
||||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const handleClickUpload = () => {
|
||||
uploadInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleDeleteLogo = () => {
|
||||
setValue('projectLogo', null, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async (formData: ProjectData) => {
|
||||
try {
|
||||
await postProjectData(formData);
|
||||
@@ -81,11 +121,58 @@ export default function ProjectData() {
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
maxLength={50}
|
||||
placeholder='Your project name'
|
||||
placeholder='Project title is shown in production views'
|
||||
autoComplete='off'
|
||||
{...register('title')}
|
||||
/>
|
||||
</label>
|
||||
<Panel.Section style={{ marginTop: 0 }}>
|
||||
<label>
|
||||
Project logo
|
||||
<Input
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
type='file'
|
||||
style={{ display: 'none' }}
|
||||
accept='image/*'
|
||||
{...projectLogoRest}
|
||||
ref={(e) => {
|
||||
ref(e);
|
||||
uploadInputRef.current = e;
|
||||
}}
|
||||
onChange={handleUploadProjectLogo}
|
||||
/>
|
||||
<Panel.Card className={style.uploadLogoCard}>
|
||||
{watch('projectLogo') ? (
|
||||
<>
|
||||
<img src={`${projectLogoPath}/${watch('projectLogo')}`} />
|
||||
<Button
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
isDisabled={isSubmitting || !watch('projectLogo')}
|
||||
leftIcon={<IoTrash />}
|
||||
onClick={handleDeleteLogo}
|
||||
type='button'
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
isDisabled={isSubmitting}
|
||||
leftIcon={<IoDownloadOutline />}
|
||||
onClick={handleClickUpload}
|
||||
type='button'
|
||||
>
|
||||
Upload logo
|
||||
</Button>
|
||||
)}
|
||||
{errors?.projectLogo?.message && <Panel.Error>{errors.projectLogo.message}</Panel.Error>}
|
||||
</Panel.Card>
|
||||
</label>
|
||||
</Panel.Section>
|
||||
<label>
|
||||
Project description
|
||||
<Input
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useProjectList } from '../../../../common/hooks-query/useProjectList';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import ProjectListItem, { EditMode } from './ProjectListItem';
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '../../../../common/api/db';
|
||||
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import ProjectForm, { ProjectFormValues } from './ProjectForm';
|
||||
import ProjectMergeForm from './ProjectMergeForm';
|
||||
|
||||
@@ -7,7 +7,7 @@ import { PROJECT_DATA } from '../../../../common/api/constants';
|
||||
import { getDb, patchData } from '../../../../common/api/db';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import { makeProjectPatch } from './project.utils';
|
||||
|
||||
|
||||
@@ -61,3 +61,26 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.uploadLogoCard {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
|
||||
background-color: $gray-1350;
|
||||
border: 1px solid $white-10;
|
||||
border-radius: 3px;
|
||||
|
||||
img {
|
||||
max-width: 250px;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { PanelBaseProps } from '../../settingsStore';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import QuickStart from '../../quick-start/QuickStart';
|
||||
import type { SettingsOptionId } from '../../useAppSettingsMenu';
|
||||
|
||||
import ManageProjects from './ManageProjects';
|
||||
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 manageRef = useScrollIntoView<HTMLDivElement>('manage', location);
|
||||
|
||||
const handleQuickClose = () => {
|
||||
setLocation('project');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Project</Panel.Header>
|
||||
<QuickStart isOpen={location === 'create'} onClose={handleQuickClose} />
|
||||
<div ref={projectRef}>
|
||||
<ProjectData />
|
||||
</div>
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { isLocalhost } from '../../../../common/api/constants';
|
||||
import useElectronEvent from '../../../../common/hooks/useElectronEvent';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import { useElectronEvent } from '../../../../common/hooks/useElectronEvent';
|
||||
import { isLocalhost } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
export default function ShutdownPanel() {
|
||||
const { isElectron, sendToElectron } = useElectronEvent();
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getWorksheetNames } from '../../../../common/api/sheets';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
||||
import { openLink } from '../../../../common/utils/linkUtils';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import useGoogleSheet from './useGoogleSheet';
|
||||
import { useSheetStore } from './useSheetStore';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { CustomFields, OntimeRundown } from 'ontime-types';
|
||||
|
||||
import * as Panel from '../PanelUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import PreviewSpreadsheet from './preview/PreviewRundown';
|
||||
import useGoogleSheet from './useGoogleSheet';
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { getWorksheetNames } from '../../../../common/api/sheets';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
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 GSheetInfo from './GSheetInfo';
|
||||
@@ -70,6 +70,17 @@ export default function SourcesPanel() {
|
||||
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 result = await verifyAuth();
|
||||
if (result) {
|
||||
@@ -89,7 +100,7 @@ export default function SourcesPanel() {
|
||||
};
|
||||
|
||||
const cancelGSheetFlow = () => {
|
||||
setImportFlow('none');
|
||||
resetFlow();
|
||||
};
|
||||
|
||||
const handleSubmitImportPreview = async (importMap: ImportMap) => {
|
||||
@@ -110,9 +121,7 @@ export default function SourcesPanel() {
|
||||
};
|
||||
|
||||
const cancelImportMap = async () => {
|
||||
setImportFlow('none');
|
||||
setHasFile('none');
|
||||
setWorksheets(null);
|
||||
resetFlow();
|
||||
if (authenticationStatus === 'authenticated') {
|
||||
const result = await verifyAuth();
|
||||
if (result) {
|
||||
@@ -127,6 +136,7 @@ export default function SourcesPanel() {
|
||||
setHasFile('none');
|
||||
setWorksheets(null);
|
||||
setCustomFields(null);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleSubmitExport = async (importMap: ImportMap) => {
|
||||
@@ -134,15 +144,6 @@ export default function SourcesPanel() {
|
||||
await exportRundown(sheetId, importMap);
|
||||
};
|
||||
|
||||
const resetFlow = () => {
|
||||
setImportFlow('none');
|
||||
setRundown(null);
|
||||
setHasFile('none');
|
||||
setWorksheets(null);
|
||||
setCustomFields(null);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const isExcelFlow = importFlow === 'excel';
|
||||
const isGSheetFlow = importFlow === 'gsheet';
|
||||
const isAuthenticated = authenticationStatus === 'authenticated';
|
||||
|
||||
+3
-3
@@ -5,7 +5,7 @@ import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
import { ImportMap, isAlphanumericWithSpace } from 'ontime-utils';
|
||||
|
||||
import * as Panel from '../../PanelUtils';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
import useGoogleSheet from '../useGoogleSheet';
|
||||
import { useSheetStore } from '../useSheetStore';
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
|
||||
register,
|
||||
formState: { errors, isValid },
|
||||
} = useForm<NamedImportMap>({
|
||||
mode: 'onBlur',
|
||||
mode: 'onChange',
|
||||
defaultValues: namedImportMap,
|
||||
values: namedImportMap,
|
||||
});
|
||||
@@ -121,7 +121,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ontime field</th>
|
||||
<th>From spreadsheet name</th>
|
||||
<th>Column name in spreadsheet</th>
|
||||
<th className={style.singleActionCell} />
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { millisToString } from 'ontime-utils';
|
||||
|
||||
import Tag from '../../../../../common/components/tag/Tag';
|
||||
import { getAccessibleColour } from '../../../../../common/utils/styleUtils';
|
||||
import * as Panel from '../../PanelUtils';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
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: '',
|
||||
},
|
||||
};
|
||||
+20
-27
@@ -1,17 +1,21 @@
|
||||
import { create } from 'zustand';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import useAppVersion from '../../common/hooks-query/useAppVersion';
|
||||
|
||||
export type SettingsOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
secondary?: Readonly<SettingsOption[]>;
|
||||
split?: boolean;
|
||||
highlight?: string;
|
||||
};
|
||||
|
||||
export const settingPanels: Readonly<SettingsOption[]> = [
|
||||
const staticOptions = [
|
||||
{
|
||||
id: 'project',
|
||||
label: 'Project',
|
||||
secondary: [
|
||||
{ id: 'project__create', label: 'Create...' },
|
||||
{ id: 'project__data', label: 'Project data' },
|
||||
{ id: 'project__manage', label: 'Manage projects' },
|
||||
],
|
||||
@@ -77,30 +81,19 @@ export const settingPanels: Readonly<SettingsOption[]> = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type SettingsOptionId = (typeof settingPanels)[number]['id'];
|
||||
export type SettingsOptionId = (typeof staticOptions)[number]['id'];
|
||||
|
||||
export interface PanelBaseProps {
|
||||
location?: string;
|
||||
export function useAppSettingsMenu() {
|
||||
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 { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { SettingsOptionId } from './settingsStore';
|
||||
import { SettingsOptionId } from './useAppSettingsMenu';
|
||||
|
||||
const settingsKey = 'settings';
|
||||
|
||||
|
||||
@@ -21,22 +21,6 @@
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { memo } from 'react';
|
||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
|
||||
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
|
||||
import { handleLinks } from '../../../common/utils/linkUtils';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { Corner } from '../../editors/editor-utils/EditorUtils';
|
||||
|
||||
import MessageControl from './MessageControl';
|
||||
|
||||
import style from '../../editors/Editor.module.scss';
|
||||
|
||||
const MessageControlExport = () => {
|
||||
const isExtracted = window.location.pathname.includes('/messagecontrol');
|
||||
const classes = cx([style.content, style.contentColumnLayout]);
|
||||
|
||||
return (
|
||||
<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}>
|
||||
<ErrorBoundary>
|
||||
<MessageControl />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown';
|
||||
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 { IoTime } from '@react-icons/all-files/io5/IoTime';
|
||||
import { TimerPhase, TimerType } from 'ontime-types';
|
||||
@@ -8,8 +9,9 @@ import { TimerPhase, TimerType } from 'ontime-types';
|
||||
import { useMessagePreview } from '../../../common/hooks/useSocket';
|
||||
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
||||
import { handleLinks } from '../../../common/utils/linkUtils';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { cx, timerPlaceholder } from '../../../common/utils/styleUtils';
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
import { Corner } from '../../editors/editor-utils/EditorUtils';
|
||||
|
||||
import style from './MessageControl.module.scss';
|
||||
|
||||
@@ -24,6 +26,9 @@ export default function TimerPreview() {
|
||||
if (showTimerMessage) return 'Message';
|
||||
if (phase === TimerPhase.Pending) return 'Standby to start';
|
||||
if (phase === TimerPhase.Overtime && data.endMessage) return 'Custom end message';
|
||||
if (timerType === TimerType.TimeToEnd) return 'Time to end';
|
||||
if (timerType === TimerType.Clock) return 'Clock';
|
||||
if (timerType === TimerType.None) return timerPlaceholder;
|
||||
return 'Timer';
|
||||
})();
|
||||
|
||||
@@ -48,7 +53,7 @@ export default function TimerPreview() {
|
||||
|
||||
return (
|
||||
<div className={style.preview}>
|
||||
<IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'timer')} />
|
||||
<Corner onClick={(event) => handleLinks(event, 'timer')} />
|
||||
<div className={contentClasses}>
|
||||
<div
|
||||
className={style.mainContent}
|
||||
@@ -72,6 +77,9 @@ export default function TimerPreview() {
|
||||
<Tooltip label='Time type: Time to end' openDelay={tooltipDelayMid} shouldWrapChildren>
|
||||
<IoFlag className={style.statusIcon} data-active={timerType === TimerType.TimeToEnd} />
|
||||
</Tooltip>
|
||||
<Tooltip label='Time type: None' openDelay={tooltipDelayMid} shouldWrapChildren>
|
||||
<IoBan className={style.statusIcon} data-active={timerType === TimerType.None} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { memo } from 'react';
|
||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
|
||||
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
|
||||
import { handleLinks } from '../../../common/utils/linkUtils';
|
||||
import { Corner } from '../../editors/editor-utils/EditorUtils';
|
||||
|
||||
import PlaybackControl from './PlaybackControl';
|
||||
|
||||
import style from '../../editors/Editor.module.scss';
|
||||
|
||||
const TimerControlExport = () => {
|
||||
const isExtracted = window.location.pathname.includes('/timercontrol');
|
||||
return (
|
||||
<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}>
|
||||
<ErrorBoundary>
|
||||
<PlaybackControl />
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
max-width: 18.75rem;
|
||||
min-width: 5em;
|
||||
|
||||
font-family: var(--font-family-override, $viewer-font-family);
|
||||
color: var(--timer-color-override, $timer-color);
|
||||
color: $timer-color;
|
||||
line-height: 0.9em;
|
||||
text-align: center;
|
||||
letter-spacing: 0.1em;
|
||||
|
||||
@@ -140,7 +140,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId, curr
|
||||
|
||||
let rowBgColour: string | undefined;
|
||||
if (isSelected) {
|
||||
rowBgColour = 'var(--cuesheet-running-bg-override, #D20300)'; // $red-700
|
||||
rowBgColour = '#D20300'; // $red-700
|
||||
} else if (row.original.colour) {
|
||||
try {
|
||||
// the colour is user defined and might be invalid
|
||||
|
||||
@@ -4,10 +4,6 @@ $min-playback-width: 27rem;
|
||||
$max-playback-width: 30rem;
|
||||
$panel-gap: 0.5rem;
|
||||
|
||||
.corner {
|
||||
@include editor.corner;
|
||||
}
|
||||
|
||||
.mainContainer {
|
||||
background-color: $ui-black;
|
||||
width: 100%;
|
||||
@@ -31,11 +27,6 @@ $panel-gap: 0.5rem;
|
||||
gap: $panel-gap;
|
||||
overflow: hidden;
|
||||
|
||||
.corner {
|
||||
/* we show this if the component hasnt been extracted */
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.rundown,
|
||||
.playback,
|
||||
.messages {
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { lazy, useCallback, useEffect } from 'react';
|
||||
import { IconButton, useDisclosure } from '@chakra-ui/react';
|
||||
import { useHotkeys } from '@mantine/hooks';
|
||||
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
||||
import { IoClose } from '@react-icons/all-files/io5/IoClose';
|
||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||
|
||||
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 AppSettings from '../app-settings/AppSettings';
|
||||
import useAppSettingsNavigation from '../app-settings/useAppSettingsNavigation';
|
||||
import { EditorOverview } from '../overview/Overview';
|
||||
|
||||
import Finder from './finder/Finder';
|
||||
|
||||
import styles from './Editor.module.scss';
|
||||
|
||||
const Rundown = lazy(() => import('../rundown/RundownExport'));
|
||||
@@ -19,50 +22,14 @@ const MessageControl = lazy(() => import('../control/message/MessageControlExpor
|
||||
|
||||
export default function Editor() {
|
||||
const { isOpen: isSettingsOpen, setLocation, close } = useAppSettingsNavigation();
|
||||
const { isElectron } = useElectronEvent();
|
||||
const { isOpen: isMenuOpen, onOpen, onClose } = 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]);
|
||||
const { isOpen: isFinderOpen, onToggle: onFinderToggle, onClose: onFinderClose } = useDisclosure();
|
||||
|
||||
useWindowTitle('Editor');
|
||||
|
||||
// we need to register the listener to change the editor location
|
||||
useElectronListener();
|
||||
|
||||
// listen to shutdown request from electron process
|
||||
useEffect(() => {
|
||||
if (window.process?.type === 'renderer') {
|
||||
@@ -72,8 +39,23 @@ export default function Editor() {
|
||||
}
|
||||
}, [setLocation]);
|
||||
|
||||
const toggleSettings = useCallback(() => {
|
||||
if (isSettingsOpen) {
|
||||
close();
|
||||
} else {
|
||||
setLocation('project');
|
||||
}
|
||||
}, [close, isSettingsOpen, setLocation]);
|
||||
|
||||
useHotkeys([
|
||||
['mod + ,', toggleSettings],
|
||||
['mod + f', onFinderToggle],
|
||||
['Escape', onFinderClose],
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className={styles.mainContainer} data-testid='event-editor'>
|
||||
<Finder isOpen={isFinderOpen} onClose={onFinderClose} />
|
||||
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
|
||||
<EditorOverview>
|
||||
<IconButton
|
||||
|
||||
@@ -6,23 +6,6 @@
|
||||
--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() {
|
||||
display: flex;
|
||||
|
||||
@@ -31,9 +14,4 @@
|
||||
height: 100%;
|
||||
background-color: $bg-container-l2;
|
||||
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 };
|
||||
}
|
||||
@@ -114,11 +114,8 @@ export default function Operator() {
|
||||
// get fields which the user subscribed to
|
||||
const shouldEdit = searchParams.get('shouldEdit');
|
||||
|
||||
const subscriptions = (searchParams.get('subscribe') ?? '')
|
||||
.toLocaleLowerCase()
|
||||
.split('_')
|
||||
.filter((value) => Object.hasOwn(customFields, value));
|
||||
|
||||
// subscriptions is a MultiSelect and may have multiple values
|
||||
const subscriptions = searchParams.getAll('subscribe').filter((value) => Object.hasOwn(customFields, value));
|
||||
const canEdit = shouldEdit && subscriptions;
|
||||
|
||||
const main = searchParams.get('main') as keyof TitleFields | null;
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
&.running {
|
||||
border-top: 1px solid $gray-1300;
|
||||
background-color: var(--operator-running-bg-override, $active-red);
|
||||
background-color: $active-red;
|
||||
}
|
||||
|
||||
&.past {
|
||||
@@ -105,7 +105,7 @@
|
||||
}
|
||||
|
||||
.noColour {
|
||||
outline: 0.15rem solid var(--operator-highlight-override, $ui-white);
|
||||
outline: 0.15rem solid $ui-white;
|
||||
outline-offset: -0.15rem;
|
||||
padding-right: 0.3rem;
|
||||
color: $ui-white;
|
||||
@@ -114,7 +114,7 @@
|
||||
.value {
|
||||
padding-inline: 0.25rem;
|
||||
background-color: $gray-1250;
|
||||
color: var(--operator-highlight-override, $ui-white);
|
||||
color: $ui-white;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,12 +22,6 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.corner {
|
||||
@include editor.corner;
|
||||
display: inline;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.list {
|
||||
@include editor.panel;
|
||||
height: inherit;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { memo } from 'react';
|
||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
|
||||
import { ContextMenu } from '../../common/components/context-menu/ContextMenu';
|
||||
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
|
||||
import { useAppMode } from '../../common/stores/appModeStore';
|
||||
import { handleLinks } from '../../common/utils/linkUtils';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
import { Corner } from '../editors/editor-utils/EditorUtils';
|
||||
|
||||
import EventEditor from './event-editor/EventEditor';
|
||||
import RundownWrapper from './RundownWrapper';
|
||||
@@ -24,7 +24,7 @@ const RundownExport = () => {
|
||||
<div className={style.rundown}>
|
||||
<div className={style.list}>
|
||||
<ErrorBoundary>
|
||||
{!isExtracted && <IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'rundown')} />}
|
||||
{!isExtracted && <Corner onClick={(event) => handleLinks(event, 'rundown')} />}
|
||||
<ContextMenu>
|
||||
<RundownWrapper />
|
||||
</ContextMenu>
|
||||
|
||||
@@ -3,11 +3,13 @@ import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
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 { IoPeopleOutline } from '@react-icons/all-files/io5/IoPeopleOutline';
|
||||
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
|
||||
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
||||
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 { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
@@ -93,6 +95,24 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
const [onContextMenu] = useContextMenu<HTMLDivElement>(
|
||||
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',
|
||||
icon: IoPeople,
|
||||
@@ -101,6 +121,7 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
field: 'isPublic',
|
||||
value: true,
|
||||
}),
|
||||
withDivider: true,
|
||||
},
|
||||
{
|
||||
label: 'Make private',
|
||||
@@ -114,6 +135,15 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
{ 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',
|
||||
icon: IoPeopleOutline,
|
||||
@@ -122,6 +152,7 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
field: 'isPublic',
|
||||
value: !isPublic,
|
||||
}),
|
||||
withDivider: true,
|
||||
},
|
||||
{
|
||||
label: 'Add to swap',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { memo, useEffect, useState } from 'react';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown';
|
||||
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 { IoPeople } from '@react-icons/all-files/io5/IoPeople';
|
||||
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
|
||||
@@ -90,6 +91,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
|
||||
delay={delay}
|
||||
timeStrategy={timeStrategy}
|
||||
linkStart={linkStart}
|
||||
timerType={timerType}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.titleSection}>
|
||||
@@ -157,8 +159,12 @@ function TimerIcon(props: { type: TimerType; className: string }) {
|
||||
if (type === TimerType.Clock) {
|
||||
return <IoTime className={className} />;
|
||||
}
|
||||
if (type === TimerType.None) {
|
||||
return <IoBan className={className} />;
|
||||
}
|
||||
if (type === TimerType.TimeToEnd) {
|
||||
return <IoFlag className={className} />;
|
||||
const classes = cx([style.active, className]);
|
||||
return <IoFlag className={classes} />;
|
||||
}
|
||||
return <IoArrowDown className={className} />;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,23 @@ function EventEditorEmpty() {
|
||||
<div className={style.prompt}>Rundown shortcuts:</div>
|
||||
<table className={style.shortcuts}>
|
||||
<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>
|
||||
<td>Select entry</td>
|
||||
<td>
|
||||
|
||||
@@ -82,6 +82,7 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
|
||||
timeStrategy={timeStrategy}
|
||||
linkStart={linkStart}
|
||||
delay={delay}
|
||||
timerType={timerType}
|
||||
/>
|
||||
</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.TimeToEnd}>Time to end</option>
|
||||
<option value={TimerType.Clock}>Clock</option>
|
||||
<option value={TimerType.None}>None</option>
|
||||
</Select>
|
||||
</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 { IoLockOpenOutline } from '@react-icons/all-files/io5/IoLockOpenOutline';
|
||||
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 { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
@@ -22,13 +22,14 @@ interface EventBlockTimerProps {
|
||||
timeStrategy: TimeStrategy;
|
||||
linkStart: MaybeString;
|
||||
delay: number;
|
||||
timerType: TimerType;
|
||||
}
|
||||
|
||||
type TimeActions = 'timeStart' | 'timeEnd' | 'duration';
|
||||
|
||||
const TimeInputFlow = (props: EventBlockTimerProps) => {
|
||||
const { eventId, timeStart, timeEnd, duration, timeStrategy, linkStart, delay } = props;
|
||||
const { updateEvent, updateTimer, linkTimer } = useEventAction();
|
||||
const { eventId, timeStart, timeEnd, duration, timeStrategy, linkStart, delay, timerType } = props;
|
||||
const { updateEvent, updateTimer } = useEventAction();
|
||||
|
||||
// In sync with EventEditorTimes
|
||||
const handleSubmit = (field: TimeActions, value: string) => {
|
||||
@@ -36,17 +37,22 @@ const TimeInputFlow = (props: EventBlockTimerProps) => {
|
||||
};
|
||||
|
||||
const handleChangeStrategy = (timeStrategy: TimeStrategy) => {
|
||||
const newEvent: Partial<OntimeEvent> = { id: eventId, timeStrategy };
|
||||
updateEvent(newEvent);
|
||||
updateEvent({ id: eventId, timeStrategy });
|
||||
};
|
||||
|
||||
const handleLink = (doLink: boolean) => {
|
||||
// the string doesnt mean much for now, not more than an intent to link
|
||||
// we imagine that we can leverage this to create offsets p+10
|
||||
linkTimer(eventId, doLink ? 'p' : null);
|
||||
updateEvent({ id: eventId, linkStart: doLink ? 'true' : 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 isLockedEnd = timeStrategy === TimeStrategy.LockEnd;
|
||||
@@ -113,9 +119,9 @@ const TimeInputFlow = (props: EventBlockTimerProps) => {
|
||||
</Tooltip>
|
||||
</TimeInputWithButton>
|
||||
|
||||
{overMidnight && (
|
||||
{warnings.length > 0 && (
|
||||
<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 />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,12 @@
|
||||
font-size: clamp(32px, 4.5vw, 64px);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
max-width: min(200px, 20vw);
|
||||
max-height: min(100px, 20vh);
|
||||
}
|
||||
|
||||
.clock-container {
|
||||
|
||||
@@ -10,6 +10,7 @@ import Schedule from '../../../common/components/schedule/Schedule';
|
||||
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
||||
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||
import ViewLogo from '../../../common/components/view-logo/ViewLogo';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||
@@ -27,19 +28,19 @@ import './Backstage.scss';
|
||||
export const MotionTitleCard = motion(TitleCard);
|
||||
|
||||
interface BackstageProps {
|
||||
customFields: CustomFields;
|
||||
isMirrored: boolean;
|
||||
eventNow: OntimeEvent | null;
|
||||
eventNext: OntimeEvent | null;
|
||||
time: ViewExtendedTimer;
|
||||
backstageEvents: OntimeEvent[];
|
||||
selectedId: string | null;
|
||||
customFields: CustomFields;
|
||||
eventNext: OntimeEvent | null;
|
||||
eventNow: OntimeEvent | null;
|
||||
general: ProjectData;
|
||||
isMirrored: boolean;
|
||||
time: ViewExtendedTimer;
|
||||
selectedId: string | null;
|
||||
settings: Settings | undefined;
|
||||
}
|
||||
|
||||
export default function Backstage(props: BackstageProps) {
|
||||
const { customFields, isMirrored, eventNow, eventNext, time, backstageEvents, selectedId, general, settings } = props;
|
||||
const { backstageEvents, customFields, eventNext, eventNow, general, time, isMirrored, selectedId, settings } = props;
|
||||
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const [blinkClass, setBlinkClass] = useState(false);
|
||||
@@ -81,6 +82,7 @@ export default function Backstage(props: BackstageProps) {
|
||||
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
|
||||
<ViewParamsEditor viewOptions={backstageOptions} />
|
||||
<div className='project-header'>
|
||||
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
|
||||
{general.title}
|
||||
<div className='clock-container'>
|
||||
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
||||
|
||||
@@ -13,10 +13,18 @@
|
||||
place-content: center;
|
||||
|
||||
.clock {
|
||||
font-family: var(--font-family-bold-override, $timer-bold-font-family) ;
|
||||
font-family: var(--font-family-bold-override, $timer-bold-font-family);
|
||||
font-size: 20vw;
|
||||
position: relative;
|
||||
color: var(--timer-color-override, $timer-color);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.logo {
|
||||
position: absolute;
|
||||
top: 2vw;
|
||||
left: 2vw;
|
||||
max-width: min(200px, 20vw);
|
||||
max-height: min(100px, 20vh);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Settings } from 'ontime-types';
|
||||
import { ProjectData, Settings } from 'ontime-types';
|
||||
|
||||
import ViewLogo from '../../../common/components/view-logo/ViewLogo';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||
@@ -13,13 +14,14 @@ import { getClockOptions } from './clock.options';
|
||||
import './Clock.scss';
|
||||
|
||||
interface ClockProps {
|
||||
general: ProjectData;
|
||||
isMirrored: boolean;
|
||||
time: ViewExtendedTimer;
|
||||
settings: Settings | undefined;
|
||||
}
|
||||
|
||||
export default function Clock(props: ClockProps) {
|
||||
const { isMirrored, time, settings } = props;
|
||||
const { general, isMirrored, time, settings } = props;
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
useWindowTitle('Clock');
|
||||
@@ -122,6 +124,7 @@ export default function Clock(props: ClockProps) {
|
||||
}}
|
||||
data-testid='clock-view'
|
||||
>
|
||||
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
|
||||
<ViewParamsEditor viewOptions={clockOptions} />
|
||||
<SuperscriptTime
|
||||
time={clock}
|
||||
|
||||
@@ -23,6 +23,8 @@ export function getTimerByType(freezeEnd: boolean, timerObject?: TimerTypeParams
|
||||
return Math.abs(timerObject.elapsed ?? 0);
|
||||
case TimerType.Clock:
|
||||
return timerObject.clock;
|
||||
case TimerType.None:
|
||||
return null;
|
||||
default: {
|
||||
const exhaustiveCheck: never = timerObject.timerType;
|
||||
return exhaustiveCheck;
|
||||
|
||||
@@ -33,6 +33,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
position: absolute;
|
||||
top: 2vw;
|
||||
left: 2vw;
|
||||
max-width: min(200px, 20vw);
|
||||
max-height: min(100px, 20vh);
|
||||
}
|
||||
|
||||
/* =================== MAIN - EVENT CONTAINER ===================*/
|
||||
|
||||
.countdown-container {
|
||||
@@ -75,7 +83,7 @@
|
||||
.status {
|
||||
grid-area: status;
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
font-size: clamp(2rem, 3.5vw, 3.5rem);
|
||||
font-size: clamp(2rem, 3.5vw, 3.5rem);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -127,7 +135,7 @@
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
row-gap: clamp(1rem, 5vh, 4rem);
|
||||
|
||||
|
||||
align-self: flex-end;
|
||||
height: fit-content;
|
||||
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { OntimeEvent, OntimeRundownEntry, Playback, Runtime, Settings, SupportedEvent, TimerPhase } from 'ontime-types';
|
||||
import {
|
||||
OntimeEvent,
|
||||
OntimeRundownEntry,
|
||||
Playback,
|
||||
ProjectData,
|
||||
Runtime,
|
||||
Settings,
|
||||
SupportedEvent,
|
||||
TimerPhase,
|
||||
} from 'ontime-types';
|
||||
|
||||
import ViewLogo from '../../../common/components/view-logo/ViewLogo';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||
@@ -17,8 +27,9 @@ import CountdownSelect from './CountdownSelect';
|
||||
import './Countdown.scss';
|
||||
|
||||
interface CountdownProps {
|
||||
isMirrored: boolean;
|
||||
backstageEvents: OntimeEvent[];
|
||||
general: ProjectData;
|
||||
isMirrored: boolean;
|
||||
runtime: Runtime;
|
||||
selectedId: string | null;
|
||||
settings: Settings | undefined;
|
||||
@@ -26,7 +37,7 @@ interface CountdownProps {
|
||||
}
|
||||
|
||||
export default function Countdown(props: CountdownProps) {
|
||||
const { isMirrored, backstageEvents, runtime, selectedId, settings, time } = props;
|
||||
const { backstageEvents, general, isMirrored, runtime, selectedId, settings, time } = props;
|
||||
const [searchParams] = useSearchParams();
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
@@ -105,6 +116,7 @@ export default function Countdown(props: CountdownProps) {
|
||||
|
||||
return (
|
||||
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
|
||||
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
|
||||
<ViewParamsEditor viewOptions={viewOptions} />
|
||||
{follow === null ? (
|
||||
<CountdownSelect events={backstageEvents} />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user