Compare commits

..

2 Commits

Author SHA1 Message Date
Carlos Valente 079be74c16 bump version to 3.6.2-beta1 2024-10-22 20:30:13 +02:00
Carlos Valente c3db240148 chore: add win32 target 2024-10-22 20:25:48 +02:00
362 changed files with 5509 additions and 8597 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.TEAMID }} APPLE_TEAM_ID: ${{ secrets.TEAMID }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
CSC_LINK: ${{ secrets.CSC_LINK }} CSC_LINK: ${{ secrets.CSC_LINK }}
run: pnpm dist-mac --env-mode=loose run: pnpm dist-mac
timeout-minutes: 60 timeout-minutes: 60
- name: Release - name: Release
-2
View File
@@ -28,7 +28,6 @@ test-results
playwright-report playwright-report
/playwright/.cache/ /playwright/.cache/
automated-screenshots automated-screenshots
e2e/tests/fixtures/tmp/*
# production # production
build/ build/
@@ -40,7 +39,6 @@ dist/
# docker utils # docker utils
ontime-db ontime-db
ontime-external/ ontime-external/
ontime-data/
# versioning file # versioning file
**/ONTIME_VERSION.js **/ONTIME_VERSION.js
+1 -1
View File
@@ -91,7 +91,7 @@ While it should allow for a generic setup, it might need to be modified to fit y
From the project root, run the following commands From the project root, run the following commands
- __Build docker image from__ by running `docker build -t getontime/ontime .` - __Build docker image from__ by running `docker build -t getontime/ontime`
- __Run docker image from compose__ by running `docker-compose up -d` - __Run docker image from compose__ by running `docker-compose up -d`
Other useful commands Other useful commands
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "3.10.3", "version": "3.6.2-beta1",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+35 -26
View File
@@ -1,28 +1,37 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang='en'>
<head> <head>
<meta charset="utf-8" /> <meta charset='utf-8' />
<base href="/" /> <link rel='icon' href='/favicon.ico' />
<link rel="icon" href="/favicon.ico" /> <meta name='viewport' content='width=device-width, initial-scale=1' />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name='theme-color' content='#121212' />
<meta name="theme-color" content="#101010" /> <meta name='ontime' content='ontime - event timer manager' />
<meta name="ontime" content="ontime - time keeping for live events" /> <link rel='apple-touch-icon' href='/logo192.png' />
<link rel="apple-touch-icon" href="/ontime-logo.png" /> <link
<link rel="icon" type="image/png" href="/ontime-logo.png" /> rel='icon'
<link rel="manifest" href="/site.webmanifest" /> type='image/png'
<link rel="manifest" href="/manifest.json" /> sizes='32x32'
<meta name="robots" content="noindex" /> href='/favicon-32x32.png'
<title>ontime</title> />
<style> <link
body, rel='icon'
html { type='image/png'
background-color: rgba(0, 0, 0, 0) !important; sizes='16x16'
} href='/favicon-16x16.png'
</style> />
</head> <link rel='manifest' href='/site.webmanifest' />
<body> <link rel='manifest' href='/manifest.json' />
<noscript>You need to enable JavaScript to run this app.</noscript> <title>ontime</title>
<div id="root"></div> <style>
<script type="module" src="/src/index.tsx"></script> body,
</body> html {
background-color: rgba(0, 0, 0, 0) !important;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id='root'></div>
<script type='module' src='/src/index.tsx'></script>
</body>
</html> </html>
+10 -10
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "3.10.3", "version": "3.6.2-beta1",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
@@ -11,12 +11,12 @@
"@emotion/react": "^11.10.6", "@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6", "@emotion/styled": "^11.10.6",
"@fontsource/open-sans": "^5.0.28", "@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^7.13.3", "@mantine/hooks": "^7.6.2",
"@react-icons/all-files": "^4.1.0", "@react-icons/all-files": "^4.1.0",
"@sentry/react": "^8.43.0", "@sentry/react": "^8.19.0",
"@tanstack/react-query": "^5.62.7", "@tanstack/react-query": "^5.17.9",
"@tanstack/react-query-devtools": "^5.62.7", "@tanstack/react-query-devtools": "^5.17.9",
"@tanstack/react-table": "^8.20.5", "@tanstack/react-table": "^8.11.3",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.2.0", "axios": "^1.2.0",
"color": "^4.2.3", "color": "^4.2.3",
@@ -26,7 +26,7 @@
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-fast-compare": "^3.2.2", "react-fast-compare": "^3.2.2",
"react-hook-form": "^7.53.1", "react-hook-form": "^7.49.2",
"react-qr-code": "^2.0.12", "react-qr-code": "^2.0.12",
"react-router-dom": "^6.3.0", "react-router-dom": "^6.3.0",
"web-vitals": "^3.1.1", "web-vitals": "^3.1.1",
@@ -39,7 +39,7 @@
"build": "vite build", "build": "vite build",
"build:local": "cross-env NODE_ENV=local vite build", "build:local": "cross-env NODE_ENV=local vite build",
"build:electron": "cross-env NODE_ENV=local vite build", "build:electron": "cross-env NODE_ENV=local vite build",
"build:docker": "cross-env VITE_IS_DOCKER=true vite build", "build:docker": "vite build",
"build:localdocker": "cross-env NODE_ENV=local vite build", "build:localdocker": "cross-env NODE_ENV=local vite build",
"lint": "eslint . --quiet", "lint": "eslint . --quiet",
"test": "vitest", "test": "vitest",
@@ -87,9 +87,9 @@
"sass": "^1.57.1", "sass": "^1.57.1",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "^5.2.11", "vite": "^5.2.11",
"vite-plugin-compression2": "^1.3.3", "vite-plugin-compression2": "^0.12.0",
"vite-plugin-svgr": "^4.2.0", "vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^4.3.1", "vite-tsconfig-paths": "^4.3.1",
"vitest": "catalog:" "vitest": "^1.6.0"
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+9 -2
View File
@@ -4,11 +4,18 @@
"icons": [ "icons": [
{ {
"src": "favicon.ico", "src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon" "type": "image/x-icon"
}, },
{ {
"src": "ontime-logo.png", "src": "logo192.png",
"type": "image/png" "type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
} }
], ],
"scope": "/", "scope": "/",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

+1 -10
View File
@@ -1,10 +1 @@
{ {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
"name": "",
"short_name": "",
"icons": [
{ "src": "/ontime-logo.png", "sizes": "295x295", "type": "image/png" }
],
"theme_color": "#2B5ABC",
"background_color": "#101010",
"display": "standalone"
}
+1 -2
View File
@@ -11,7 +11,6 @@ import { connectSocket } from './common/utils/socket';
import theme from './theme/theme'; import theme from './theme/theme';
import { TranslationProvider } from './translation/TranslationProvider'; import { TranslationProvider } from './translation/TranslationProvider';
import AppRouter from './AppRouter'; import AppRouter from './AppRouter';
import { baseURI } from './externals';
connectSocket(); connectSocket();
@@ -20,7 +19,7 @@ function App() {
<ChakraProvider disableGlobalStyle resetCSS theme={theme}> <ChakraProvider disableGlobalStyle resetCSS theme={theme}>
<QueryClientProvider client={ontimeQueryClient}> <QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider> <AppContextProvider>
<BrowserRouter basename={baseURI}> <BrowserRouter>
<div className='App'> <div className='App'>
<ErrorBoundary> <ErrorBoundary>
<TranslationProvider> <TranslationProvider>
+1 -11
View File
@@ -19,7 +19,7 @@ import { ONTIME_VERSION } from './ONTIME_VERSION';
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config'; import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
const Editor = React.lazy(() => import('./features/editors/ProtectedEditor')); const Editor = React.lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet')); const Cuesheet = React.lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
const Operator = React.lazy(() => import('./features/operator/OperatorExport')); const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
const TimerView = React.lazy(() => import('./features/viewers/timer/Timer')); const TimerView = React.lazy(() => import('./features/viewers/timer/Timer'));
@@ -32,14 +32,12 @@ const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
const Public = React.lazy(() => import('./features/viewers/public/Public')); const Public = React.lazy(() => import('./features/viewers/public/Public'));
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird')); const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock')); const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
const STimer = withPreset(withData(TimerView)); const STimer = withPreset(withData(TimerView));
const SMinimalTimer = withPreset(withData(MinimalTimerView)); const SMinimalTimer = withPreset(withData(MinimalTimerView));
const SClock = withPreset(withData(ClockView)); const SClock = withPreset(withData(ClockView));
const SCountdown = withPreset(withData(Countdown)); const SCountdown = withPreset(withData(Countdown));
const SBackstage = withPreset(withData(Backstage)); const SBackstage = withPreset(withData(Backstage));
const SProjectInfo = withPreset(withData(ProjectInfo));
const SPublic = withPreset(withData(Public)); const SPublic = withPreset(withData(Public));
const SLowerThird = withPreset(withData(Lower)); const SLowerThird = withPreset(withData(Lower));
const SStudio = withPreset(withData(StudioClock)); const SStudio = withPreset(withData(StudioClock));
@@ -144,14 +142,6 @@ export default function AppRouter() {
</ViewLoader> </ViewLoader>
} }
/> />
<Route
path='/info'
element={
<ViewLoader>
<SProjectInfo />
</ViewLoader>
}
/>
{/*/!* Protected Routes *!/*/} {/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} /> <Route path='/editor' element={<Editor />} />
+13 -5
View File
@@ -1,9 +1,6 @@
import { serverURL } from '../../externals';
// keys in tanstack store // keys in tanstack store
export const APP_INFO = ['appinfo']; export const APP_INFO = ['appinfo'];
export const APP_SETTINGS = ['appSettings']; export const APP_SETTINGS = ['appSettings'];
export const APP_VERSION = ['appVersion'];
export const CUSTOM_FIELDS = ['customFields']; export const CUSTOM_FIELDS = ['customFields'];
export const HTTP_SETTINGS = ['httpSettings']; export const HTTP_SETTINGS = ['httpSettings'];
export const OSC_SETTINGS = ['oscSettings']; export const OSC_SETTINGS = ['oscSettings'];
@@ -16,7 +13,19 @@ export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings']; export const VIEW_SETTINGS = ['viewSettings'];
export const CLIENT_LIST = ['clientList']; export const CLIENT_LIST = ['clientList'];
// API URLs // 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`;
export const apiEntryUrl = `${serverURL}/data`; export const apiEntryUrl = `${serverURL}/data`;
export const projectDataURL = `${serverURL}/project`; export const projectDataURL = `${serverURL}/project`;
@@ -26,4 +35,3 @@ export const ontimeURL = `${serverURL}/ontime`;
export const userAssetsPath = 'user'; export const userAssetsPath = 'user';
export const cssOverridePath = 'styles/override.css'; export const cssOverridePath = 'styles/override.css';
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`; export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
+3 -19
View File
@@ -1,7 +1,7 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types'; import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types';
import { makeCSV, makeTable } from '../../views/cuesheet/cuesheet.utils'; import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import { createBlob, downloadBlob } from './utils'; import { createBlob, downloadBlob } from './utils';
@@ -12,7 +12,7 @@ const dbPath = `${apiEntryUrl}/db`;
* HTTP request to the current DB * HTTP request to the current DB
*/ */
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> { export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
return axios.post(`${dbPath}/download`, { filename }); return axios.post(`${dbPath}/download/`, { filename });
} }
/** /**
@@ -86,14 +86,6 @@ export async function createProject(
return res.data; return res.data;
} }
/**
* HTTP request to create a project file
*/
export async function quickProject(data: QuickStartData): Promise<MessageResponse> {
const res = await axios.post(`${dbPath}/quick`, data);
return res.data;
}
/** /**
* HTTP request to get the list of available project files * HTTP request to get the list of available project files
*/ */
@@ -112,14 +104,6 @@ export async function loadProject(filename: string): Promise<MessageResponse> {
return res.data; return res.data;
} }
/**
* HTTP request to load the demo project file
*/
export async function loadDemo(): Promise<MessageResponse> {
const res = await axios.post(`${dbPath}/demo`);
return res.data;
}
/** /**
* HTTP request to duplicate a project file * HTTP request to duplicate a project file
*/ */
+1 -16
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { ProjectData, ProjectLogoResponse } from 'ontime-types'; import { ProjectData } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
@@ -19,18 +19,3 @@ export async function getProjectData(): Promise<ProjectData> {
export async function postProjectData(data: ProjectData): Promise<AxiosResponse<ProjectData>> { export async function postProjectData(data: ProjectData): Promise<AxiosResponse<ProjectData>> {
return axios.post(projectPath, data); 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;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached, TransientEventPayload } from 'ontime-types'; import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
@@ -16,7 +16,7 @@ export async function fetchNormalisedRundown(): Promise<RundownCached> {
/** /**
* HTTP request to post new event * HTTP request to post new event
*/ */
export async function requestPostEvent(data: TransientEventPayload): Promise<AxiosResponse<OntimeRundownEntry>> { export async function requestPostEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> {
return axios.post(rundownPath, data); return axios.post(rundownPath, data);
} }
-2
View File
@@ -23,8 +23,6 @@ export function maybeAxiosError(error: unknown) {
} }
} }
return `${statusText}: ${data}`; return `${statusText}: ${data}`;
} else if (error instanceof Error) {
return error.message;
} else { } else {
if (typeof error !== 'string') { if (typeof error !== 'string') {
return JSON.stringify(error); return JSON.stringify(error);
@@ -0,0 +1,6 @@
.buttonSection {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $element-spacing;
margin-top: -$element-spacing;
}
@@ -15,6 +15,8 @@ import {
import { setClientRemote } from '../../hooks/useSocket'; import { setClientRemote } from '../../hooks/useSocket';
import style from './ClientModal.module.scss';
interface RedirectClientModalProps { interface RedirectClientModalProps {
id: string; id: string;
name?: string; name?: string;
@@ -52,12 +54,14 @@ export function RedirectClientModal(props: RedirectClientModalProps) {
</InputGroup> </InputGroup>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button size='md' variant='ontime-subtle' onClick={onClose}> <div className={style.buttonSection}>
Cancel <Button size='md' variant='ontime-subtle' onClick={onClose}>
</Button> Cancel
<Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}> </Button>
Submit <Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}>
</Button> Submit
</Button>
</div>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>
</Modal> </Modal>
@@ -13,6 +13,8 @@ import {
import { setClientRemote } from '../../hooks/useSocket'; import { setClientRemote } from '../../hooks/useSocket';
import style from './ClientModal.module.scss';
interface RenameClientModalProps { interface RenameClientModalProps {
id: string; id: string;
name?: string; name?: string;
@@ -51,12 +53,14 @@ export function RenameClientModal(props: RenameClientModalProps) {
/> />
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button size='md' variant='ontime-subtle' onClick={onClose}> <div className={style.buttonSection}>
Cancel <Button size='md' variant='ontime-subtle' onClick={onClose}>
</Button> Cancel
<Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}> </Button>
Submit <Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}>
</Button> Submit
</Button>
</div>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>
</Modal> </Modal>
@@ -9,23 +9,32 @@ import style from './DelayIndicator.module.scss';
interface DelayIndicatorProps { interface DelayIndicatorProps {
delayValue?: number; delayValue?: number;
tooltipPrefix?: string;
} }
export default function DelayIndicator(props: DelayIndicatorProps) { export default function DelayIndicator(props: DelayIndicatorProps) {
const { delayValue, tooltipPrefix } = props; const { delayValue } = props;
if (typeof delayValue !== 'number' || delayValue === 0) { if (typeof delayValue === 'number') {
return null; if (delayValue < 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronDown />
</span>
</Tooltip>
);
}
if (delayValue > 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronUp />
</span>
</Tooltip>
);
}
} }
const delayString = tooltipPrefix return null;
? `${tooltipPrefix} ${millisToDelayString(delayValue)}`
: millisToDelayString(delayValue);
return (
<Tooltip openDelay={tooltipDelayFast} label={delayString}>
<span className={style.delaySymbol}>{delayValue < 0 ? <IoChevronDown /> : <IoChevronUp />}</span>
</Tooltip>
);
} }
@@ -16,11 +16,11 @@ export default function Swatch(props: SwatchProps) {
const handleClick = () => { const handleClick = () => {
onClick?.(color); onClick?.(color);
}; };
const classes = cx([style.swatch, isSelected && style.selected, onClick && style.selectable]); const classes = cx([style.swatch, isSelected ? style.selected : null, onClick ? style.selectable : null]);
if (!color) { if (!color) {
return ( return (
<div className={classes} onClick={handleClick}> <div className={`${classes} ${style.center}`} onClick={handleClick}>
<IoBan /> <IoBan />
</div> </div>
); );
@@ -1,60 +0,0 @@
import { useCallback } from 'react';
import { useController, UseControllerProps } from 'react-hook-form';
import { IoEyedrop } from '@react-icons/all-files/io5/IoEyedrop';
import { ViewSettings } from 'ontime-types';
import PopoverPicker from '../../../../common/components/input/popover-picker/PopoverPicker';
import { debounce } from '../../../../common/utils/debounce';
import { cx, getAccessibleColour } from '../../../utils/styleUtils';
import style from './SwatchSelect.module.scss';
interface SwatchPickerProps {
color: string;
isSelected?: boolean;
onChange: (name: string) => void;
alwaysDisplayColor?: boolean;
}
export default function SwatchPicker(props: SwatchPickerProps) {
const { color, onChange, isSelected, alwaysDisplayColor } = props;
const debouncedOnChange = useCallback(
debounce((newValue: string) => {
onChange(newValue);
}, 500),
[onChange],
);
const displayColor = alwaysDisplayColor || isSelected ? color : '';
const { color: iconColor } = getAccessibleColour(displayColor);
return (
<PopoverPicker color={displayColor} onChange={debouncedOnChange}>
<div
className={cx([style.swatch, isSelected && style.selected, style.selectable])}
style={{ backgroundColor: displayColor }}
>
<IoEyedrop color={iconColor} />
</div>
</PopoverPicker>
);
}
export function SwatchPickerRHF(props: UseControllerProps<ViewSettings>) {
const { name, control } = props;
const {
field: { onChange, value },
} = useController({ control, name });
const displayColor = typeof value === 'string' ? value : '';
const { color: iconColor } = getAccessibleColour(displayColor);
return (
<PopoverPicker color={value as string} onChange={onChange}>
<div className={cx([style.swatch, style.selectable])} style={{ backgroundColor: displayColor }}>
<IoEyedrop color={iconColor} />
</div>
</PopoverPicker>
);
}
@@ -1,18 +1,15 @@
.list { .list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem
} }
.swatch { .swatch {
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
aspect-ratio: 1;
border-radius: 99px; border-radius: 99px;
border: 2px solid $gray-1200; border: 2px solid $gray-1200;
color: $ui-white;
display: grid;
place-content: center;
&.selected { &.selected {
border: 2px solid $blue-500; border: 2px solid $blue-500;
@@ -20,9 +17,11 @@
&.selectable { &.selectable {
cursor: pointer; cursor: pointer;
&:hover {
border: 2px solid $blue-300;
}
} }
} }
.center {
display: grid;
place-content: center;
color: $ui-white;
}
@@ -1,7 +1,6 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import Swatch from './Swatch'; import Swatch from './Swatch';
import SwatchPicker from './SwatchPicker';
import style from './SwatchSelect.module.scss'; import style from './SwatchSelect.module.scss';
@@ -44,7 +43,6 @@ export default function SwatchSelect(props: ColourInputProps) {
{colours.map((colour) => ( {colours.map((colour) => (
<Swatch key={colour} color={colour} onClick={setColour} isSelected={value === colour} /> <Swatch key={colour} color={colour} onClick={setColour} isSelected={value === colour} />
))} ))}
<SwatchPicker color={value} onChange={setColour} isSelected={!colours.includes(value)} />
</div> </div>
); );
} }
@@ -1,20 +1,16 @@
.input { .swatch {
color: $gray-200; width: 24px;
border: 1px solid transparent; height: 24px;
border-radius: 0 0 8px 8px; border-radius: 12px;
background-color: $gray-1200; border: 1px solid white;
padding: 0 0.5rem; box-shadow: 0 0 0 1px $gray-300;
font-size: 1rem; cursor: pointer;
height: 2rem;
outline: none;
&:hover { transition-property: box-shadow;
background-color: $gray-1100; transition-duration: $transition-time-action;
}
&:focus {
background-color: $gray-1000;
color: $gray-50;
border: 1px solid $blue-500;
}
} }
.swatch:hover {
box-shadow: 0 0 0 1px $blue-300;
}
@@ -1,22 +1,34 @@
import { PropsWithChildren } from 'react'; import { HexAlphaColorPicker } from 'react-colorful';
import { HexAlphaColorPicker, HexColorInput } from 'react-colorful'; import { useController, UseControllerProps } from 'react-hook-form';
import { Popover, PopoverContent, PopoverTrigger } from '@chakra-ui/react'; import { Popover, PopoverContent, PopoverTrigger } from '@chakra-ui/react';
import { ViewSettings } from 'ontime-types';
import style from './PopoverPicker.module.scss'; import style from './PopoverPicker.module.scss';
export function PopoverPickerRHF(props: UseControllerProps<ViewSettings>) {
const { name, control } = props;
const {
field: { onChange, value },
} = useController({ control, name });
return <PopoverPicker color={value as string} onChange={onChange} />;
}
interface PopoverPickerProps { interface PopoverPickerProps {
color: string; color: string;
onChange: (color: string) => void; onChange: (color: string) => void;
} }
export default function PopoverPicker(props: PropsWithChildren<PopoverPickerProps>) { export default function PopoverPicker(props: PopoverPickerProps) {
const { color, onChange, children } = props; const { color, onChange } = props;
return ( return (
<Popover> <Popover>
<PopoverTrigger>{children}</PopoverTrigger> <PopoverTrigger>
<PopoverContent className={style.small} style={{ borderRadius: '9px', width: 'auto' }}> <div className={style.swatch} style={{ backgroundColor: color }} />
</PopoverTrigger>
<PopoverContent style={{ width: 'auto' }}>
<HexAlphaColorPicker color={color} onChange={onChange} /> <HexAlphaColorPicker color={color} onChange={onChange} />
<HexColorInput color={color} onChange={onChange} className={style.input} prefixed />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );
@@ -15,8 +15,6 @@ export default function useReactiveTextInput(
options?: { options?: {
submitOnEnter?: boolean; submitOnEnter?: boolean;
submitOnCtrlEnter?: boolean; submitOnCtrlEnter?: boolean;
onCancelUpdate?: () => void;
allowSubmitSameValue?: boolean;
}, },
): UseReactiveTextInputReturn { ): UseReactiveTextInputReturn {
const [text, setText] = useState<string>(initialText); const [text, setText] = useState<string>(initialText);
@@ -49,9 +47,7 @@ export default function useReactiveTextInput(
const handleSubmit = useCallback( const handleSubmit = useCallback(
(valueToSubmit: string) => { (valueToSubmit: string) => {
// No need to update if it hasn't changed // No need to update if it hasn't changed
if (valueToSubmit === initialText && !options?.allowSubmitSameValue) { if (valueToSubmit !== initialText) {
options?.onCancelUpdate?.();
} else {
const cleanVal = valueToSubmit.trim(); const cleanVal = valueToSubmit.trim();
submitCallback(cleanVal); submitCallback(cleanVal);
if (cleanVal !== valueToSubmit) { if (cleanVal !== valueToSubmit) {
@@ -60,7 +56,7 @@ export default function useReactiveTextInput(
} }
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
}, },
[initialText, options, ref, submitCallback], [initialText, ref, submitCallback],
); );
/** /**
@@ -70,9 +66,8 @@ export default function useReactiveTextInput(
const handleEscape = useCallback(() => { const handleEscape = useCallback(() => {
// No need to update if it hasn't changed // No need to update if it hasn't changed
setText(initialText); setText(initialText);
options?.onCancelUpdate?.();
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
}, [initialText, options, ref]); }, [initialText, ref]);
const keyHandler = useMemo(() => { const keyHandler = useMemo(() => {
const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]]; const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]];
@@ -5,7 +5,6 @@ import { millisToString, parseUserTime } from 'ontime-utils';
import { useEmitLog } from '../../../stores/logger'; import { useEmitLog } from '../../../stores/logger';
interface TimeInputProps<T extends string> { interface TimeInputProps<T extends string> {
id?: T;
name: T; name: T;
submitHandler: (field: T, value: string) => void; submitHandler: (field: T, value: string) => void;
time?: number; time?: number;
@@ -16,7 +15,7 @@ interface TimeInputProps<T extends string> {
} }
export default function TimeInput<T extends string>(props: TimeInputProps<T>) { export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
const { id, name, submitHandler, time = 0, placeholder, disabled, align = 'center', className } = props; const { name, submitHandler, time = 0, placeholder, disabled, align = 'center', className } = props;
const { emitError } = useEmitLog(); const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>(''); const [value, setValue] = useState<string>('');
@@ -55,7 +54,6 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
// we dont know the values in the rundown, escalate to handler // we dont know the values in the rundown, escalate to handler
if (newValue.startsWith('p') || newValue.startsWith('+')) { if (newValue.startsWith('p') || newValue.startsWith('+')) {
submitHandler(name, newValue); submitHandler(name, newValue);
return true;
} }
const valueInMillis = parseUserTime(newValue); const valueInMillis = parseUserTime(newValue);
@@ -119,7 +117,6 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
return ( return (
<Input <Input
id={id}
disabled={disabled} disabled={disabled}
size='sm' size='sm'
ref={inputRef} ref={inputRef}
@@ -2,8 +2,6 @@ $input-delayed-border-color: $ontime-delay-text;
.timeInput { .timeInput {
border: 1px solid transparent; border: 1px solid transparent;
color: $label-gray;
&.delayed { &.delayed {
border: 1px solid $input-delayed-border-color; border: 1px solid $input-delayed-border-color;
} }
@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react';
import { IoApps } from '@react-icons/all-files/io5/IoApps'; import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline'; import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity'; import { debounce } from '../../utils/debounce';
import style from './NavigationMenu.module.scss'; import style from './NavigationMenu.module.scss';
@@ -12,10 +13,32 @@ interface FloatingNavigationProps {
export default function FloatingNavigation(props: FloatingNavigationProps) { export default function FloatingNavigation(props: FloatingNavigationProps) {
const { toggleMenu, toggleSettings } = props; const { toggleMenu, toggleSettings } = props;
const isButtonShown = useFadeOutOnInactivity(); const [showButton, setShowButton] = useState(false);
// show on mouse move
useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null;
const setShowMenuTrue = () => {
setShowButton(true);
if (fadeOut) {
clearTimeout(fadeOut);
}
fadeOut = setTimeout(() => setShowButton(false), 3000);
};
const debouncedShowMenu = debounce(setShowMenuTrue, 1000);
document.addEventListener('mousemove', debouncedShowMenu);
return () => {
document.removeEventListener('mousemove', debouncedShowMenu);
if (fadeOut) {
clearTimeout(fadeOut);
}
};
}, []);
return ( return (
<div className={`${style.buttonContainer} ${!isButtonShown ? style.hidden : ''}`}> <div className={`${style.buttonContainer} ${!showButton ? style.hidden : ''}`}>
<button <button
onClick={toggleMenu} onClick={toggleMenu}
aria-label='toggle menu' aria-label='toggle menu'
@@ -38,16 +38,6 @@ $button-size: 3rem;
border-radius: 3px; border-radius: 3px;
} }
.lockIcon {
font-size: 1.5rem;
display: grid;
place-content: center;
border-radius: 3px;
background-color: transparent;
color: $active-indicator;
cursor: default;
}
.navButton { .navButton {
@extend .button; @extend .button;
z-index: 3; z-index: 3;
@@ -17,15 +17,15 @@ import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline'; import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical'; import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { isLocalhost } from '../../../externals';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import { isLocalhost, serverPort } from '../../api/constants';
import useClickOutside from '../../hooks/useClickOutside'; import useClickOutside from '../../hooks/useClickOutside';
import { useElectronEvent } from '../../hooks/useElectronEvent'; import useElectronEvent from '../../hooks/useElectronEvent';
import useInfo from '../../hooks-query/useInfo'; import useInfo from '../../hooks-query/useInfo';
import { useClientStore } from '../../stores/clientStore'; import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
import { isKeyEnter } from '../../utils/keyEvent'; import { isKeyEnter } from '../../utils/keyEvent';
import { handleLinks, linkToOtherHost, openLink } from '../../utils/linkUtils'; import { handleLinks, openLink } from '../../utils/linkUtils';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import { RenameClientModal } from '../client-modal/RenameClientModal'; import { RenameClientModal } from '../client-modal/RenameClientModal';
import CopyTag from '../copy-tag/CopyTag'; import CopyTag from '../copy-tag/CopyTag';
@@ -45,7 +45,7 @@ function NavigationMenu(props: NavigationMenuProps) {
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure(); const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
const { fullscreen, toggle } = useFullscreen(); const { fullscreen, toggle } = useFullscreen();
const { mirror, toggleMirror } = useViewOptionsStore(); const { toggleMirror } = useViewOptionsStore();
const location = useLocation(); const location = useLocation();
const menuRef = useRef<HTMLDivElement | null>(null); const menuRef = useRef<HTMLDivElement | null>(null);
@@ -57,7 +57,7 @@ function NavigationMenu(props: NavigationMenuProps) {
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} /> <RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'> <Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
<DrawerOverlay /> <DrawerOverlay />
<DrawerContent maxWidth='22rem'> <DrawerContent maxWidth='21rem'>
<DrawerHeader> <DrawerHeader>
<DrawerCloseButton size='lg' /> <DrawerCloseButton size='lg' />
Ontime Ontime
@@ -65,7 +65,7 @@ function NavigationMenu(props: NavigationMenuProps) {
<DrawerBody padding={0}> <DrawerBody padding={0}>
<div className={style.buttonsContainer}> <div className={style.buttonsContainer}>
<div <div
className={cx([style.link, fullscreen && style.current])} className={style.link}
tabIndex={0} tabIndex={0}
role='button' role='button'
onClick={toggle} onClick={toggle}
@@ -77,7 +77,7 @@ function NavigationMenu(props: NavigationMenuProps) {
{fullscreen ? <IoContract /> : <IoExpand />} {fullscreen ? <IoContract /> : <IoExpand />}
</div> </div>
<div <div
className={cx([style.link, mirror && style.current])} className={style.link}
tabIndex={0} tabIndex={0}
role='button' role='button'
onClick={() => toggleMirror()} onClick={() => toggleMirror()}
@@ -154,8 +154,7 @@ function OtherAddresses(props: OtherAddressesProps) {
return null; return null;
} }
const address = linkToOtherHost(nif.address, currentLocation); const address = `http://${nif.address}:${serverPort}${currentLocation}`;
return ( return (
<CopyTag <CopyTag
key={nif.name} key={nif.name}
@@ -1,15 +0,0 @@
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
import style from './NavigationMenu.module.scss';
export default function ViewLockedIcon() {
const isLockIconShown = useFadeOutOnInactivity();
return (
<div className={`${style.buttonContainer} ${!isLockIconShown ? style.hidden : ''}`}>
<IoLockClosedOutline className={style.lockIcon} />
</div>
);
}
@@ -2,18 +2,13 @@ import { memo, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { useDisclosure } from '@chakra-ui/react'; import { useDisclosure } from '@chakra-ui/react';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import FloatingNavigation from './FloatingNavigation'; import FloatingNavigation from './FloatingNavigation';
import NavigationMenu from './NavigationMenu'; import NavigationMenu from './NavigationMenu';
import ViewLockedIcon from './ViewLockedIcon';
function ViewNavigationMenu() { function ViewNavigationMenu() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure(); const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
const isViewLocked = isStringBoolean(searchParams.get('locked'));
const showEditFormDrawer = useCallback(() => { const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true'); searchParams.set('edit', 'true');
setSearchParams(searchParams); setSearchParams(searchParams);
@@ -21,10 +16,6 @@ function ViewNavigationMenu() {
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen()); const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
if (isViewLocked) {
return <ViewLockedIcon />;
}
return ( return (
<> <>
<FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} /> <FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
@@ -1,13 +1,9 @@
.container { .container {
width: 100%;
height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding-top: 25vh; padding-top: 25vh;
background: $ui-black;
color: $ontime-color; color: $ontime-color;
font-weight: 200; font-weight: 200;
font-size: 3rem; font-size: 3rem;
@@ -46,4 +42,4 @@
to { to {
background: rgba($red-500, 0); background: rgba($red-500, 0);
} }
} }
@@ -1,6 +1,5 @@
import { PropsWithChildren, useCallback, useContext } from 'react'; import { PropsWithChildren, useCallback, useContext } from 'react';
import { isLocalhost } from '../../../externals';
import { AppContext } from '../../context/AppContext'; import { AppContext } from '../../context/AppContext';
import PinPage from './PinPage'; import PinPage from './PinPage';
@@ -10,6 +9,7 @@ interface ProtectRouteProps {
} }
export default function ProtectRoute({ permission, children }: PropsWithChildren<ProtectRouteProps>) { export default function ProtectRoute({ permission, children }: PropsWithChildren<ProtectRouteProps>) {
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
const { editorAuth, operatorAuth, validate } = useContext(AppContext); const { editorAuth, operatorAuth, validate } = useContext(AppContext);
const handleValidation = useCallback( const handleValidation = useCallback(
@@ -29,7 +29,7 @@ export default function ProtectRoute({ permission, children }: PropsWithChildren
return false; return false;
}; };
if (isLocalhost || hasRelevantAuth()) { if (isLocal || hasRelevantAuth()) {
// eslint-disable-next-line react/jsx-no-useless-fragment -- trying to make typescript happy // eslint-disable-next-line react/jsx-no-useless-fragment -- trying to make typescript happy
return <>{children}</>; return <>{children}</>;
} }
@@ -1,20 +0,0 @@
@use '../../../theme/viewerDefs' as *;
/* share the same style as a page layout */
.page {
margin: 0;
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100vh;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color);
color: var(--color-override, $viewer-color);
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
display: flex;
flex-direction: column;
align-items: center;
padding-top: 5rem;
}
@@ -1,20 +0,0 @@
import { CSSProperties } from 'react';
import Empty from './Empty';
import style from './EmptyPage.module.scss';
interface EmptyPageProps {
text?: string;
style?: CSSProperties;
}
export default function EmptyPage(props: EmptyPageProps) {
const { text, ...rest } = props;
return (
<div className={style.page}>
<Empty text={text} {...rest} />
</div>
);
}
@@ -1,11 +0,0 @@
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} />;
}
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import SwatchPicker from '../input/colour-input/SwatchPicker'; import PopoverPicker from '../input/popover-picker/PopoverPicker';
import style from './InlineColourPicker.module.scss'; import style from './InlineColourPicker.module.scss';
@@ -20,10 +20,13 @@ export default function InlineColourPicker(props: InlineColourPickerProps) {
const { name, value } = props; const { name, value } = props;
const [colour, setColour] = useState(() => ensureHex(value)); const [colour, setColour] = useState(() => ensureHex(value));
const debouncedChange = (value: string) => {
setColour(value);
};
return ( return (
<div className={style.inline}> <div className={style.inline}>
<SwatchPicker color={colour} onChange={setColour} alwaysDisplayColor /> <PopoverPicker color={colour} onChange={debouncedChange} />
<span>{colour}</span>
<input type='hidden' name={name} value={colour} /> <input type='hidden' name={name} value={colour} />
</div> </div>
); );
@@ -13,7 +13,6 @@ import {
Select, Select,
Switch, Switch,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils'; import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
@@ -109,21 +108,25 @@ function MultiOption(props: EditFormMultiOptionProps) {
const { paramField } = props; const { paramField } = props;
const { id, defaultValue } = paramField; const { id, defaultValue } = paramField;
const optionFromParams = searchParams.getAll(id); const optionFromParams = (searchParams.get(id) ?? '').toLocaleLowerCase();
const [paramState, setParamState] = useState<string[]>(optionFromParams || defaultValue || ['']); const defaultOptionValue = optionFromParams || defaultValue?.toLocaleLowerCase() || '';
const [paramState, setParamState] = useState<string>(defaultOptionValue);
return ( return (
<> <>
<input name={id} hidden readOnly value={paramState} /> <input name={id} hidden readOnly value={paramState} />
<Menu isLazy closeOnSelect={false} variant='ontime-on-dark'> <Menu isLazy closeOnSelect={false} variant='ontime-on-dark'>
<MenuButton as={Button} variant='ontime-subtle-white' position='relative' width='fit-content' fontWeight={400}> <MenuButton as={Button} variant='ontime-subtle-white' position='relative' width='fit-content' fontWeight={400}>
{paramField.title} <IoChevronDown style={{ display: 'inline' }} /> {paramField.title}
</MenuButton> </MenuButton>
<MenuList> <MenuList>
<MenuOptionGroup <MenuOptionGroup
type='checkbox' type='checkbox'
value={paramState} value={paramState.split('_')}
onChange={(value) => setParamState(Array.isArray(value) ? value : [value])} onChange={(value) => {
setParamState(typeof value === 'object' ? value.filter((v) => v !== '').join('_') : value);
}}
> >
{Object.values(paramField.values).map((option) => { {Object.values(paramField.values).map((option) => {
const { value, label } = option; const { value, label } = option;
@@ -1,6 +1,6 @@
.drawerFooter { .drawerFooter {
display: flex; display: flex;
justify-content: end; justify-content: end;
gap: $section-spacing; gap: $section-spacing;
button { button {
@@ -8,23 +8,6 @@
} }
} }
.infoLabel {
display: flex;
align-items: center;
gap: $element-spacing;
padding: 1rem;
margin-bottom: 1rem;
background-color: $gray-1100;
border-radius: 2px;
font-size: $inner-section-text-size;
svg {
font-size: 1.5rem;
color: $info-blue;
}
}
.label { .label {
font-size: $inner-section-text-size; font-size: $inner-section-text-size;
color: $label-gray; color: $label-gray;
@@ -11,9 +11,6 @@ import {
DrawerOverlay, DrawerOverlay,
useDisclosure, useDisclosure,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { IoAlertCircle } from '@react-icons/all-files/io5/IoAlertCircle';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
import ParamInput from './ParamInput'; import ParamInput from './ParamInput';
import { isSection, ViewOption } from './types'; import { isSection, ViewOption } from './types';
@@ -58,28 +55,10 @@ const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOp
// unfortunately this means we run all the strings through the sanitation // unfortunately this means we run all the strings through the sanitation
const valueWithoutHash = sanitiseColour(value); const valueWithoutHash = sanitiseColour(value);
if (defaultValues[id] !== valueWithoutHash) { if (defaultValues[id] !== valueWithoutHash) {
handleValueString(id, valueWithoutHash); newSearchParams.set(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; return newSearchParams;
}; };
@@ -90,8 +69,6 @@ interface EditFormDrawerProps {
// TODO: this is a good candidate for memoisation, but needs the paramFields to be stable // TODO: this is a good candidate for memoisation, but needs the paramFields to be stable
export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) { export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings();
const { isOpen, onClose, onOpen } = useDisclosure(); const { isOpen, onClose, onOpen } = useDisclosure();
useEffect(() => { useEffect(() => {
@@ -120,6 +97,8 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget)); const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions); const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
setSearchParams(newSearchParams); setSearchParams(newSearchParams);
onClose();
}; };
return ( return (
@@ -132,12 +111,6 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
</DrawerHeader> </DrawerHeader>
<DrawerBody> <DrawerBody>
{viewSettings.overrideStyles && (
<div className={style.infoLabel}>
<IoAlertCircle />
This view style is being modified by a custom CSS file. <br />
</div>
)}
<form id='edit-params-form' onSubmit={onParamsFormSubmit}> <form id='edit-params-form' onSubmit={onParamsFormSubmit}>
{viewOptions.map((option) => { {viewOptions.map((option) => {
if (isSection(option)) { if (isSection(option)) {
@@ -1,34 +0,0 @@
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,3 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { ProjectFileListResponse } from 'ontime-types'; import { ProjectFileListResponse } from 'ontime-types';
@@ -23,23 +22,3 @@ export function useProjectList() {
}); });
return { data: data ?? placeholderProjectList, status, refetch }; return { data: data ?? placeholderProjectList, status, refetch };
} }
export function useOrderedProjectList() {
const response = useProjectList();
const { files, lastLoadedProject } = response.data;
const reorderedProjectFiles = useMemo(() => {
if (!files.length) return [];
const currentlyLoadedIndex = files.findIndex((project) => project.filename === lastLoadedProject);
if (currentlyLoadedIndex === -1) return files;
const projectFiles = [...files];
const current = projectFiles.splice(currentlyLoadedIndex, 1)[0];
return [current, ...projectFiles];
}, [files, lastLoadedProject]);
return { ...response, data: { reorderedProjectFiles, lastLoadedProject: response.data.lastLoadedProject } };
}
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types'; import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
@@ -6,8 +6,6 @@ import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { RUNDOWN } from '../api/constants'; import { RUNDOWN } from '../api/constants';
import { fetchNormalisedRundown } from '../api/rundown'; import { fetchNormalisedRundown } from '../api/rundown';
import useProjectData from './useProjectData';
// revision is -1 so that the remote revision is higher // revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 }; const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 };
@@ -26,9 +24,7 @@ export default function useRundown() {
export function useFlatRundown() { export function useFlatRundown() {
const { data, status } = useRundown(); const { data, status } = useRundown();
const { data: projectData } = useProjectData();
const loadedProject = useRef<string>('');
const [prevRevision, setPrevRevision] = useState<number>(-1); const [prevRevision, setPrevRevision] = useState<number>(-1);
const [flatRunDown, setFlatRunDown] = useState<OntimeRundown>([]); const [flatRunDown, setFlatRunDown] = useState<OntimeRundown>([]);
@@ -41,14 +37,5 @@ export function useFlatRundown() {
} }
}, [data.order, data.revision, data.rundown, prevRevision]); }, [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 }; return { data: flatRunDown, status };
} }
+4 -11
View File
@@ -4,23 +4,16 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { useClientStore } from '../stores/clientStore'; import { useClientStore } from '../stores/clientStore';
import { socketSendJson } from '../utils/socket'; import { socketSendJson } from '../utils/socket';
import { useIsOnline } from './useSocket';
export const useClientPath = () => { export const useClientPath = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { pathname, search } = useLocation(); const { pathname, search } = useLocation();
const { redirect, setRedirect } = useClientStore((store) => ({ const redirect = useClientStore((store) => store.redirect);
redirect: store.redirect, const setRedirect = useClientStore((store) => store.setRedirect);
setRedirect: store.setRedirect,
}));
const isOnline = useIsOnline();
// notify of client path changes // notify of client path changes
useEffect(() => { useEffect(() => {
if (!isOnline) return;
socketSendJson('set-client-path', pathname + search); socketSendJson('set-client-path', pathname + search);
}, [pathname, search, isOnline]); }, [pathname, search]);
// navigate to new path when received from server // navigate to new path when received from server
useEffect(() => { useEffect(() => {
@@ -33,7 +26,7 @@ export const useClientPath = () => {
// navigate if there is a path change // navigate if there is a path change
if (redirect !== pathname + search) { if (redirect !== pathname + search) {
navigate(redirect, { replace: true }); navigate(redirect);
} }
}, [navigate, pathname, redirect, search, setRedirect]); }, [navigate, pathname, redirect, search, setRedirect]);
}; };
@@ -1,42 +1,31 @@
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { addDialog } from '../stores/dialogStore';
const isElectron = window.process?.type === 'renderer'; const isElectron = window.process?.type === 'renderer';
const ipcRenderer = isElectron ? window.require('electron').ipcRenderer : null; const ipcRenderer = isElectron ? window.require('electron').ipcRenderer : null;
export function useElectronEvent() { export default function useElectronEvent() {
const navigate = useNavigate();
const sendToElectron = useCallback((channel: string, args?: string | Record<string, unknown>) => { const sendToElectron = useCallback((channel: string, args?: string | Record<string, unknown>) => {
if (isElectron && ipcRenderer) { if (isElectron && ipcRenderer) {
ipcRenderer.send(channel, args); ipcRenderer.send(channel, args);
} }
}, []); }, []);
return { isElectron, sendToElectron };
}
export function useElectronListener() {
const navigate = useNavigate();
const { isElectron } = useElectronEvent();
// listen to requests to change the editor location // listen to requests to change the editor location
useEffect(() => { useEffect(() => {
if (isElectron) { if (isElectron) {
ipcRenderer.on('request-editor-location', (_event: unknown, location: string) => { ipcRenderer.on('request-editor-location', (_event: unknown, location: string) => {
navigate(location, { relative: 'route' }); navigate(location, { relative: 'route' });
}); });
ipcRenderer.on('dialog', (_event: unknown, dialog: string) => {
if (dialog === 'welcome') {
addDialog('welcome');
}
});
} }
// Clean the listener after the component is dismounted // Clean the listener after the component is dismounted
return () => { return () => {
ipcRenderer?.removeAllListeners(); ipcRenderer?.removeAllListeners();
}; };
}, [isElectron, navigate]); }, [navigate]);
return { isElectron, sendToElectron };
} }
+95 -95
View File
@@ -1,17 +1,15 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { import {
isOntimeEvent, dayInMs,
OntimeBlock, getLinkedTimes,
OntimeDelay, getPreviousEventNormal,
OntimeEvent, MILLIS_PER_SECOND,
OntimeRundownEntry, parseUserTime,
RundownCached, reorderArray,
TimeField, swapEventData,
TimeStrategy, } from 'ontime-utils';
TransientEventPayload,
} from 'ontime-types';
import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils';
import { RUNDOWN } from '../api/constants'; import { RUNDOWN } from '../api/constants';
import { import {
@@ -29,16 +27,6 @@ import {
import { logAxiosError } from '../api/utils'; import { logAxiosError } from '../api/utils';
import { useEditorSettings } from '../stores/editorSettings'; import { useEditorSettings } from '../stores/editorSettings';
export type EventOptions = Partial<{
// options to any new block (event / delay / block)
after: string;
before: string;
// options to blocks of type OntimeEvent
defaultPublic: boolean;
linkPrevious: boolean;
lastEventId: string;
}>;
/** /**
* @description Set of utilities for events //TODO: should this be called useEntryAction and so on * @description Set of utilities for events //TODO: should this be called useEntryAction and so on
*/ */
@@ -67,19 +55,31 @@ export const useEventAction = () => {
networkMode: 'always', networkMode: 'always',
}); });
// options to any new block (event / delay / block)
type BaseOptions = {
after?: string;
};
// options to blocks of type OntimeEvent
type EventOptions = BaseOptions &
Partial<{
defaultPublic: boolean;
linkPrevious: boolean;
lastEventId: string;
}>;
/** /**
* Adds an event to rundown * Adds an event to rundown
*/ */
const addEvent = useCallback( const addEvent = useCallback(
async (event: Partial<OntimeEvent | OntimeDelay | OntimeBlock>, options?: EventOptions) => { async (event: Partial<OntimeRundownEntry>, options?: EventOptions) => {
const newEvent: TransientEventPayload = { ...event }; const newEvent: Partial<OntimeRundownEntry> = { ...event };
// ************* CHECK OPTIONS specific to events // ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEvent)) { if (isOntimeEvent(newEvent)) {
// merge creation time options with event settings // merge creation time options with event settings
const applicationOptions = { const applicationOptions = {
after: options?.after, after: options?.after,
before: options?.before,
defaultPublic: options?.defaultPublic ?? defaultPublic, defaultPublic: options?.defaultPublic ?? defaultPublic,
lastEventId: options?.lastEventId, lastEventId: options?.lastEventId,
linkPrevious: options?.linkPrevious ?? linkPrevious, linkPrevious: options?.linkPrevious ?? linkPrevious,
@@ -129,16 +129,11 @@ export const useEventAction = () => {
// handle adding options that concern all event type // handle adding options that concern all event type
if (options?.after) { if (options?.after) {
// @ts-expect-error -- not sure how to type this, <after> is a transient property
newEvent.after = options.after; newEvent.after = options.after;
} }
if (options?.before) {
// @ts-expect-error -- not sure how to type this, <before> is a transient property
newEvent.before = options.before;
}
try { try {
await _addEventMutation.mutateAsync(newEvent as TransientEventPayload); await _addEventMutation.mutateAsync(newEvent);
} catch (error) { } catch (error) {
logAxiosError('Failed adding event', error); logAxiosError('Failed adding event', error);
} }
@@ -216,75 +211,13 @@ export const useEventAction = () => {
[updateEvent], [updateEvent],
); );
type TimeField = 'timeStart' | 'timeEnd' | 'duration';
/** /**
* Updates time of existing event * Updates time of existing event
* @param eventId {string} - id of the event
* @param field {TimeField} - field to update
* @param value {string} - new value string to be parsed
* @param lockOnUpdate {boolean} - whether we will apply the lock / release on update
*/ */
const updateTimer = useCallback( const updateTimer = useCallback(
async (eventId: string, field: TimeField, value: string, lockOnUpdate?: boolean) => { async (eventId: string, field: TimeField, value: string) => {
// an empty value with no lock has no domain validity const getPreviousEnd = (): number => {
if (!lockOnUpdate && value === '') {
return;
}
const newEvent: Partial<OntimeEvent> = {
id: eventId,
};
// check if we should lock the field
if (lockOnUpdate) {
if (field === 'timeEnd') {
// an empty value indicates that we should unlock the field
newEvent.timeStrategy = value === '' ? TimeStrategy.LockDuration : TimeStrategy.LockEnd;
newEvent.timeEnd = value === '' ? undefined : calculateNewValue();
} else if (field === 'duration') {
// an empty value indicates that we should unlock the field
newEvent.timeStrategy = value === '' ? TimeStrategy.LockEnd : TimeStrategy.LockDuration;
newEvent.duration = value === '' ? undefined : calculateNewValue();
} else if (field === 'timeStart') {
// an empty values means we should link to the previous
newEvent.linkStart = value === '' ? 'true' : null;
newEvent.timeStart = value === '' ? undefined : calculateNewValue();
}
} else {
newEvent[field] = calculateNewValue();
}
try {
await _updateEventMutation.mutateAsync(newEvent);
} catch (error) {
logAxiosError('Error updating event', error);
}
/**
* Utility function to calculate the new time value
*/
function calculateNewValue(): number {
let newValMillis = 0;
// check for previous keyword
if (value === 'p' || value === 'prev' || value === 'previous') {
newValMillis = getPreviousEnd();
// check for adding time keyword
} else if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) {
// TODO: is this logic solid?
const remainingString = value.substring(1);
newValMillis = getPreviousEnd() + parseUserTime(remainingString);
} else {
newValMillis = parseUserTime(value);
}
// dont allow timer values over 23:59:59
return Math.min(newValMillis, dayInMs - MILLIS_PER_SECOND);
}
/**
* Utility function to get the previous event end time
*/
function getPreviousEnd(): number {
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN); const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (!cachedRundown?.order || !cachedRundown?.rundown) { if (!cachedRundown?.order || !cachedRundown?.rundown) {
@@ -304,6 +237,72 @@ export const useEventAction = () => {
} }
} }
return previousEnd; return previousEnd;
};
let newValMillis = 0;
// check for previous keyword
if (value === 'p' || value === 'prev' || value === 'previous') {
newValMillis = getPreviousEnd();
// check for adding time keyword
} else if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) {
// TODO: is this logic solid?
const remainingString = value.substring(1);
newValMillis = getPreviousEnd() + parseUserTime(remainingString);
} else {
newValMillis = parseUserTime(value);
}
// dont allow timer values over 23:59:59
const cappedMillis = Math.min(newValMillis, dayInMs - MILLIS_PER_SECOND);
const newEvent = {
id: eventId,
[field]: cappedMillis,
};
try {
await _updateEventMutation.mutateAsync(newEvent);
} catch (error) {
logAxiosError('Error updating event', error);
}
},
[_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], [_updateEventMutation, queryClient],
@@ -615,6 +614,7 @@ export const useEventAction = () => {
batchUpdateEvents, batchUpdateEvents,
deleteEvent, deleteEvent,
deleteAllEvents, deleteAllEvents,
linkTimer,
reorderEvent, reorderEvent,
swapEvents, swapEvents,
updateEvent, updateEvent,
@@ -1,31 +0,0 @@
import { useEffect, useState } from 'react';
import { throttle } from '../../common/utils/throttle';
export const useFadeOutOnInactivity = () => {
const [isMouseMoved, setIsMouseMoved] = useState(false);
// show on mouse move
useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null;
const setShowMenuTrue = () => {
setIsMouseMoved(true);
if (fadeOut) {
clearTimeout(fadeOut);
}
fadeOut = setTimeout(() => setIsMouseMoved(false), 3000);
};
const throttledShowMenu = throttle(setShowMenuTrue, 1000);
document.addEventListener('mousemove', throttledShowMenu);
return () => {
document.removeEventListener('mousemove', throttledShowMenu);
if (fadeOut) {
clearTimeout(fadeOut);
}
};
}, []);
return isMouseMoved;
};
@@ -6,7 +6,7 @@
import { useMemo, useRef } from 'react'; import { useMemo, useRef } from 'react';
import { isDev } from '../../externals'; import { isDev } from '../api/constants';
type noop = (this: any, ...args: any[]) => any; type noop = (this: any, ...args: any[]) => any;
+7 -27
View File
@@ -65,7 +65,6 @@ export const useMessagePreview = () => {
showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external), showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external),
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text), showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
timerType: state.eventNow?.timerType ?? null, timerType: state.eventNow?.timerType ?? null,
countToEnd: state.eventNow?.countToEnd ?? false,
}); });
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
@@ -149,18 +148,16 @@ export const setAuxTimer = {
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }), setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
}; };
export const useSelectedEventId = () => { export const useCuesheet = () => {
const featureSelector = (state: RuntimeStore) => ({
selectedEventId: state.eventNow?.id ?? null,
});
return useRuntimeStore(featureSelector);
};
export const useCurrentBlockId = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
currentBlockId: state.currentBlock.block?.id ?? null, currentBlockId: state.currentBlock.block?.id ?? null,
selectedEventId: state.eventNow?.id ?? null,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
titleNow: state.eventNow?.title || '',
}); });
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
}; };
@@ -233,20 +230,3 @@ export const useTimelineStatus = () => {
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
}; };
export const usePing = () => {
const featureSelector = (state: RuntimeStore) => ({
ping: state.ping,
});
return useRuntimeStore(featureSelector);
};
/** convert ping into a derived value which changes less often */
export const useIsOnline = () => {
const featureSelector = (state: RuntimeStore) => ({
isOnline: state.ping > 0,
});
return useRuntimeStore(featureSelector);
};
@@ -7,5 +7,4 @@ export const projectDataPlaceholder: ProjectData = {
publicInfo: '', publicInfo: '',
backstageUrl: '', backstageUrl: '',
backstageInfo: '', backstageInfo: '',
projectLogo: null,
}; };
@@ -15,5 +15,4 @@ export type ViewExtendedTimer = {
clock: number; clock: number;
timerType: TimerType; timerType: TimerType;
countToEnd: boolean;
}; };
@@ -15,5 +15,4 @@ export type OverridableOptions = {
language?: string; language?: string;
showProgressBar?: boolean; showProgressBar?: boolean;
hideTimerSeconds?: boolean; hideTimerSeconds?: boolean;
removeLeadingZeros?: boolean;
}; };
@@ -1,24 +0,0 @@
import { MaybeString } from 'ontime-types';
import { create } from 'zustand';
interface DialogStore {
showDialog: MaybeString;
setDialog: (name: string) => void;
clearDialog: () => void;
}
/**
* Used to allow the server to show a dialog in the client
*/
export const useDialogStore = create<DialogStore>((set) => ({
showDialog: null,
clearDialog: () => set({ showDialog: null }),
setDialog: (name) => set({ showDialog: name }),
}));
/**
* Allows setting a dialog to show (outside react)
*/
export function addDialog(name: string): void {
useDialogStore.getState().setDialog(name);
}
+3 -9
View File
@@ -16,17 +16,11 @@ export const useRuntimeStore = <T>(selector: (state: RuntimeStore) => T) =>
/** /**
* Allows patching a property of the runtime store * Allows patching a property of the runtime store
* @param key
* @param value
*/ */
export function patchRuntimeProperty<K extends keyof RuntimeStore>(key: K, value: RuntimeStore[K]) { export function patchRuntime<K extends keyof RuntimeStore>(key: K, value: RuntimeStore[K]): void {
const state = runtimeStore.getState(); const state = runtimeStore.getState();
state[key] = value; state[key] = value;
runtimeStore.setState({ ...state }); runtimeStore.setState({ ...state });
} }
/**
* Allows patching the entire runtime store
*/
export function patchRuntime(patch: Partial<RuntimeStore>) {
const state = runtimeStore.getState();
runtimeStore.setState({ ...state, ...patch });
}
+2 -2
View File
@@ -3,8 +3,8 @@ import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-uti
/** /**
* Parses a value in millis to a string which encodes a delay * Parses a value in millis to a string which encodes a delay
* @param millis * @param millis
* @param format * @param format
*/ */
export function millisToDelayString(millis: MaybeNumber, format: 'compact' | 'expanded' = 'compact'): string { export function millisToDelayString(millis: MaybeNumber, format: 'compact' | 'expanded' = 'compact'): string {
if (millis == null || millis === 0) { if (millis == null || millis === 0) {
+4 -4
View File
@@ -1,12 +1,12 @@
export function debounce<T extends any[]>(callback: (...args: T) => void, wait: number) { export function debounce(callback: () => void, wait: number) {
let timeout: NodeJS.Timeout | null; let timeout: NodeJS.Timeout | null;
return (...args: T) => { return () => {
if (timeout) { if (timeout) {
clearTimeout(timeout); return;
} }
timeout = setTimeout(() => { timeout = setTimeout(() => {
timeout = null; timeout = null;
callback(...args); callback();
}, wait); }, wait);
}; };
} }
@@ -7,7 +7,7 @@ import { OntimeEvent, SupportedEvent } from 'ontime-types';
* @return {OntimeEvent} clean event * @return {OntimeEvent} clean event
*/ */
type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>; type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>;
export const cloneEvent = (event: OntimeEvent): ClonedEvent => { export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
return { return {
type: SupportedEvent.Event, type: SupportedEvent.Event,
title: event.title, title: event.title,
@@ -17,16 +17,13 @@ export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
timeEnd: event.timeEnd, timeEnd: event.timeEnd,
timerType: event.timerType, timerType: event.timerType,
timeStrategy: event.timeStrategy, timeStrategy: event.timeStrategy,
countToEnd: event.countToEnd,
linkStart: event.linkStart, linkStart: event.linkStart,
endAction: event.endAction, endAction: event.endAction,
isPublic: event.isPublic, isPublic: event.isPublic,
skip: event.skip, skip: event.skip,
colour: event.colour, colour: event.colour,
after,
revision: 0, revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: event.timeWarning, timeWarning: event.timeWarning,
timeDanger: event.timeDanger, timeDanger: event.timeDanger,
custom: {}, custom: {},
-8
View File
@@ -7,11 +7,3 @@ export function isKeyEnter<T>(event: KeyboardEvent<T>): boolean {
export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean { export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean {
return event.key === 'Escape'; return event.key === 'Escape';
} }
export function preventEscape<T>(event: KeyboardEvent<T>, callback?: () => void): void {
if (isKeyEscape(event)) {
event.preventDefault();
event.stopPropagation();
callback?.();
}
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Returns hostname
* @type {string}
*/
export const host = window?.location?.host;
/**
* Open an external URLs: specifically for a electron / browser case
* If electron: ask main process to call a new browser window
* If browser: open in new tab
* @param url
*/
export function openLink(url) {
if (window.process?.type === 'renderer') {
window.ipcRenderer.send('send-to-link', url);
} else {
window.open(url);
}
}
/**
* Handles opening external links
* @param event
* @param location
*/
export function handleLinks(event, location) {
// we handle the link manually
event.preventDefault();
openLink(`http://${host}/${location}`);
}
-40
View File
@@ -1,40 +0,0 @@
import type { MouseEvent } from 'react';
import { baseURI, serverURL } from '../../externals';
/**
* Open an external URLs: specifically for a electron / browser case
* If electron: ask main process to call a new browser window
* If browser: open in new tab
* @param url
*/
export function openLink(url: string) {
if (window.process?.type === 'renderer') {
window.ipcRenderer.send('send-to-link', url);
} else {
window.open(url);
}
}
/**
* Handles opening external links
* @param event
* @param location
*/
export function handleLinks(event: MouseEvent, location: string) {
// we handle the link manually
event.preventDefault();
const destination = new URL(serverURL);
destination.pathname = baseURI ? `${baseURI}/${location}` : location;
openLink(destination.toString());
}
export function linkToOtherHost(host: string, path?: string) {
const destination = new URL(serverURL);
destination.hostname = host;
if (path) {
destination.pathname = baseURI ? `${baseURI}/${path}` : path;
}
return destination.toString();
}
+21 -53
View File
@@ -1,8 +1,6 @@
import { Log, RundownCached, RuntimeStore } from 'ontime-types'; import { Log, RundownCached, RuntimeStore } from 'ontime-types';
import { isProduction, websocketUrl } from '../../externals'; import { CLIENT_LIST, CUSTOM_FIELDS, isProduction, RUNDOWN, RUNTIME, websocketUrl } from '../api/constants';
import { CLIENT_LIST, CUSTOM_FIELDS, RUNDOWN, RUNTIME } from '../api/constants';
import { invalidateAllCaches } from '../api/utils';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
import { import {
getClientId, getClientId,
@@ -12,9 +10,8 @@ import {
setClientRedirect, setClientRedirect,
setClients, setClients,
} from '../stores/clientStore'; } from '../stores/clientStore';
import { addDialog } from '../stores/dialogStore';
import { addLog } from '../stores/logger'; import { addLog } from '../stores/logger';
import { patchRuntime, patchRuntimeProperty } from '../stores/runtime'; import { patchRuntime, runtimeStore } from '../stores/runtime';
export let websocket: WebSocket | null = null; export let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null; let reconnectTimeout: NodeJS.Timeout | null = null;
@@ -39,13 +36,12 @@ export const connectSocket = () => {
} }
socketSendJson('set-client-type', 'ontime'); socketSendJson('set-client-type', 'ontime');
setOnlineStatus(true);
socketSendJson('set-client-path', location.pathname + location.search);
}; };
websocket.onclose = () => { websocket.onclose = () => {
console.warn('WebSocket disconnected'); console.warn('WebSocket disconnected');
setOnlineStatus(false);
if (shouldReconnect) { if (shouldReconnect) {
reconnectTimeout = setTimeout(() => { reconnectTimeout = setTimeout(() => {
console.warn('WebSocket: attempting reconnect'); console.warn('WebSocket: attempting reconnect');
@@ -72,12 +68,6 @@ export const connectSocket = () => {
} }
switch (type) { switch (type) {
case 'pong': {
const offset = (new Date().getTime() - new Date(payload).getTime()) * 0.5;
patchRuntimeProperty('ping', offset);
updateDevTools({ ping: offset });
break;
}
case 'client-id': { case 'client-id': {
if (typeof payload === 'string') { if (typeof payload === 'string') {
setClientId(payload); setClientId(payload);
@@ -120,88 +110,78 @@ export const connectSocket = () => {
break; break;
} }
case 'dialog': {
if (payload.dialog === 'welcome') {
addDialog('welcome');
}
break;
}
case 'ontime-log': { case 'ontime-log': {
addLog(payload as Log); addLog(payload as Log);
break; break;
} }
case 'ontime': { case 'ontime': {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- removing the key from the payload runtimeStore.setState(payload as RuntimeStore);
const { ping, ...serverPayload } = payload as Partial<RuntimeStore>; if (!isProduction) {
ontimeQueryClient.setQueryData(RUNTIME, data.payload);
patchRuntime(serverPayload); }
updateDevTools(serverPayload);
break; break;
} }
case 'ontime-clock': { case 'ontime-clock': {
patchRuntimeProperty('clock', payload); patchRuntime('clock', payload);
updateDevTools({ clock: payload }); updateDevTools({ clock: payload });
break; break;
} }
case 'ontime-timer': { case 'ontime-timer': {
patchRuntimeProperty('timer', payload); patchRuntime('timer', payload);
updateDevTools({ timer: payload }); updateDevTools({ timer: payload });
break; break;
} }
case 'ontime-onAir': { case 'ontime-onAir': {
patchRuntimeProperty('onAir', payload); patchRuntime('onAir', payload);
updateDevTools({ onAir: payload }); updateDevTools({ onAir: payload });
break; break;
} }
case 'ontime-message': { case 'ontime-message': {
patchRuntimeProperty('message', payload); patchRuntime('message', payload);
updateDevTools({ message: payload }); updateDevTools({ message: payload });
break; break;
} }
case 'ontime-runtime': { case 'ontime-runtime': {
patchRuntimeProperty('runtime', payload); patchRuntime('runtime', payload);
updateDevTools({ runtime: payload }); updateDevTools({ runtime: payload });
break; break;
} }
case 'ontime-eventNow': { case 'ontime-eventNow': {
patchRuntimeProperty('eventNow', payload); patchRuntime('eventNow', payload);
updateDevTools({ eventNow: payload }); updateDevTools({ eventNow: payload });
break; break;
} }
case 'ontime-currentBlock': { case 'ontime-currentBlock': {
patchRuntimeProperty('currentBlock', payload); patchRuntime('currentBlock', payload);
updateDevTools({ currentBlock: payload }); updateDevTools({ currentBlock: payload });
break; break;
} }
case 'ontime-publicEventNow': { case 'ontime-publicEventNow': {
patchRuntimeProperty('publicEventNow', payload); patchRuntime('publicEventNow', payload);
updateDevTools({ publicEventNow: payload }); updateDevTools({ publicEventNow: payload });
break; break;
} }
case 'ontime-eventNext': { case 'ontime-eventNext': {
patchRuntimeProperty('eventNext', payload); patchRuntime('eventNext', payload);
updateDevTools({ eventNext: payload }); updateDevTools({ eventNext: payload });
break; break;
} }
case 'ontime-publicEventNext': { case 'ontime-publicEventNext': {
patchRuntimeProperty('publicEventNext', payload); patchRuntime('publicEventNext', payload);
updateDevTools({ publicEventNext: payload }); updateDevTools({ publicEventNext: payload });
break; break;
} }
case 'ontime-auxtimer1': { case 'ontime-auxtimer1': {
patchRuntimeProperty('auxtimer1', payload); patchRuntime('auxtimer1', payload);
updateDevTools({ auxtimer1: payload }); updateDevTools({ auxtimer1: payload });
break; break;
} }
case 'ontime-refetch': { case 'ontime-refetch': {
// the refetch message signals that the rundown has changed in the server side // the refetch message signals that the rundown has changed in the server side
const { revision, reload } = payload; const { revision } = payload;
const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1; const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1;
if (reload) { if (revision > currentRevision) {
invalidateAllCaches();
} else if (revision > currentRevision) {
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN }); ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS }); ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
} }
@@ -242,15 +222,3 @@ function updateDevTools(newData: Partial<RuntimeStore>) {
})); }));
} }
} }
/**
* Allows setting the status of the client
* We leverage the ping as an indication of the client's online status
* @example ping < 0 - client is offline
* @example ping > 0 -> client is online
*/
function setOnlineStatus(status: boolean) {
const derivedPing = status ? 1 : -1;
patchRuntimeProperty('ping', derivedPing);
updateDevTools({ ping: derivedPing });
}
-12
View File
@@ -1,12 +0,0 @@
export function throttle<T extends any[]>(callback: (...args: T) => void, wait: number) {
let timeout: NodeJS.Timeout | null;
return (...args: T) => {
if (timeout) {
return;
}
timeout = setTimeout(() => {
timeout = null;
callback(...args);
}, wait);
};
}
+1 -17
View File
@@ -34,7 +34,7 @@ export function validateProjectFile(file: File) {
// Limit file size of a project file to around 1MB // Limit file size of a project file to around 1MB
if (file.size > 1_000_000) { if (file.size > 1_000_000) {
throw new Error('File size limit (1MB) exceeded'); throw new Error('File size limit (10MB) exceeded');
} }
} }
@@ -45,19 +45,3 @@ export function isExcelFile(file: File | null) {
export function isOntimeFile(file: File | null) { export function isOntimeFile(file: File | null) {
return file?.name.endsWith('.json'); 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');
}
}
-21
View File
@@ -6,9 +6,6 @@ declare module '*.scss' {
type ListenerType = (event: 'string', args: unknown[]) => void; type ListenerType = (event: 'string', args: unknown[]) => void;
declare global { declare global {
/**
* Register the electron properties
*/
interface Window { interface Window {
ipcRenderer: { ipcRenderer: {
send: (channel: string, args?: string | object) => void; send: (channel: string, args?: string | object) => void;
@@ -20,24 +17,6 @@ declare global {
} }
} }
/**
* We pass a custom property to the table meta to allow field update
*/
declare module '@tanstack/react-table' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface TableMeta<TData extends RowData> {
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom: boolean) => void;
handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => void;
options: {
showDelayedTimes: boolean;
hideTableSeconds: boolean;
};
}
}
/**
* Allow passing CSS Properties
*/
declare module 'react' { declare module 'react' {
interface CSSProperties { interface CSSProperties {
[key: `--${string}`]: string | number; [key: `--${string}`]: string | number;
-69
View File
@@ -1,75 +1,6 @@
/**
* This file contains a list of constants that may need to be resolved at runtime
*/
import { version } from '../../../package.json';
export const githubUrl = 'https://www.github.com/cpvalente/ontime'; export const githubUrl = 'https://www.github.com/cpvalente/ontime';
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest'; export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
export const websiteUrl = 'https://www.getontime.no'; export const websiteUrl = 'https://www.getontime.no';
export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
export const documentationUrl = 'https://docs.getontime.no'; export const documentationUrl = 'https://docs.getontime.no';
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/'; export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
// resolve environment
export const appVersion = version;
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 isDockerImage = Boolean(import.meta.env.VITE_IS_DOCKER);
export const isOntimeCloud = window.location.hostname.includes('cloud.getontime.no');
// resolve entrypoint URLs
/**
* Resolve base
* @example '' for electron
* @example '/client-hash' for cloud
*/
export const baseURI = resolveBaseURI();
export const serverURL = resolveUrl('http', '');
export const websocketUrl = resolveUrl('ws', 'ws');
function resolveUrl(protocol: 'http' | 'ws', path: string) {
const url = new URL(window.location.origin);
// generate ws url
if (protocol === 'ws') {
// ensure we remain in a secure context
const isSecure = window.location.protocol === 'https:';
url.protocol = isSecure ? 'wss' : 'ws';
}
// make path name relative to the base URI
url.pathname = baseURI ? `${baseURI}/${path}` : path;
// in development mode, we use the React port for UI, but need the requests to target the server
if (isDev) {
// this is used as a fallback port for development
url.port = '4001';
}
const result = url.toString();
// prevent trailing slash
return result.endsWith('/') ? result.slice(0, -1) : result;
}
/**
* Resolves a base URI for a client that is not at the root segment
* ie: https://cloud.getontime.com/client-hash/timer
* This is necessary for ontime cloud and should otherwise not affect the client
*/
function resolveBaseURI(): string {
// in ontime cloud, the base tag is set by the server
const baseHref = document.querySelector('base')?.getAttribute('href');
const base = baseHref ?? '';
// prevent a trailing slash from either an empty base or a base with a trailing slash
if (base.endsWith('/')) {
return base.slice(0, -1);
}
return base;
}
@@ -18,7 +18,7 @@ import useAppSettingsNavigation from './useAppSettingsNavigation';
import style from './AppSettings.module.scss'; import style from './AppSettings.module.scss';
export default function AppSettings() { export default function AppSettings() {
const { close, panel, location, setLocation } = useAppSettingsNavigation(); const { close, panel, location } = useAppSettingsNavigation();
useKeyDown(close, 'Escape'); useKeyDown(close, 'Escape');
return ( return (
@@ -26,7 +26,7 @@ export default function AppSettings() {
<ErrorBoundary> <ErrorBoundary>
<PanelList selectedPanel={panel} location={location} /> <PanelList selectedPanel={panel} location={location} />
<PanelContent onClose={close}> <PanelContent onClose={close}>
{panel === 'project' && <ProjectPanel location={location} setLocation={setLocation} />} {panel === 'project' && <ProjectPanel location={location} />}
{panel === 'general' && <GeneralPanel location={location} />} {panel === 'general' && <GeneralPanel location={location} />}
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />} {panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
{panel === 'sources' && <SourcesPanel />} {panel === 'sources' && <SourcesPanel />}
@@ -40,16 +40,12 @@ ul {
background-color: $gray-1100; background-color: $gray-1100;
} }
&.highlight { &.unsaved::before {
color: $red-400; content: '';
width: 6px;
&::before { height: 6px;
content: ''; border-radius: 3px;
width: 0.5em; background-color: $blue-400;
height: 0.5em;
border-radius: 99px;
background-color: $red-400;
}
} }
&.split { &.split {
@@ -1,90 +1,66 @@
import { Fragment } from 'react'; import { Fragment } from 'react';
import { Tooltip } from '@chakra-ui/react';
import { isKeyEnter } from '../../../common/utils/keyEvent'; import { isKeyEnter } from '../../../common/utils/keyEvent';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayFast } from '../../../ontimeConfig'; import { PanelBaseProps, settingPanels, useSettingsStore } from '../settingsStore';
import { SettingsOption, SettingsOptionId, useAppSettingsMenu } from '../useAppSettingsMenu';
import useAppSettingsNavigation from '../useAppSettingsNavigation'; import useAppSettingsNavigation from '../useAppSettingsNavigation';
import style from './PanelList.module.scss'; import style from './PanelList.module.scss';
export interface PanelBaseProps {
location?: string;
}
interface PanelListProps extends PanelBaseProps { interface PanelListProps extends PanelBaseProps {
selectedPanel: string; selectedPanel: string;
} }
export default function PanelList({ selectedPanel, location }: PanelListProps) { export default function PanelList({ selectedPanel, location }: PanelListProps) {
const { options } = useAppSettingsMenu(); const { setLocation } = useAppSettingsNavigation();
const { hasUnsavedChanges } = useSettingsStore();
return ( return (
<ul className={style.tabs}> <ul className={style.tabs}>
{options.map((panel) => { {settingPanels.map((panel) => {
const isSelected = selectedPanel === panel.id; const unsaved = hasUnsavedChanges(panel.id);
if (panel.highlight) {
return ( const classes = cx([
<Tooltip key={panel.id} label={panel.highlight} openDelay={tooltipDelayFast} shouldWrapChildren> style.primary,
<PanelListItem panel={panel} location={location} isSelected={isSelected} /> selectedPanel === panel.id ? style.active : null,
</Tooltip> panel.split ? style.split : null,
); unsaved ? style.unsaved : null,
} ]);
return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />;
return (
<Fragment key={panel.id}>
<li
key={panel.id}
onClick={() => setLocation(panel.id)}
onKeyDown={(event) => {
isKeyEnter(event) && setLocation(panel.id);
}}
className={classes}
tabIndex={0}
role='button'
>
{panel.label}
</li>
{panel.secondary?.map((secondary) => {
const id = secondary.id.split('__')[1];
const secondaryClasses = cx([style.secondary, location === id ? style.active : null]);
return (
<li
key={secondary.id}
onClick={() => setLocation(secondary.id)}
onKeyDown={(event) => {
isKeyEnter(event) && setLocation(secondary.id);
}}
className={secondaryClasses}
role='button'
>
{secondary.label}
</li>
);
})}
</Fragment>
);
})} })}
</ul> </ul>
); );
} }
interface PanelListItemProps {
panel: SettingsOption;
isSelected: boolean;
location?: string;
}
function PanelListItem(props: PanelListItemProps) {
const { panel, isSelected, location } = props;
const { setLocation } = useAppSettingsNavigation();
const classes = cx([
style.primary,
isSelected && style.active,
panel.split && style.split,
panel.highlight && style.highlight,
]);
return (
<Fragment key={panel.id}>
<li
key={panel.id}
onClick={() => setLocation(panel.id as SettingsOptionId)}
onKeyDown={(event) => {
isKeyEnter(event) && setLocation(panel.id as SettingsOptionId);
}}
className={classes}
tabIndex={0}
role='button'
>
{panel.label}
</li>
{panel.secondary?.map((secondary) => {
const id = secondary.id.split('__')[1];
const secondaryClasses = cx([style.secondary, isSelected && 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>
);
}
@@ -26,24 +26,15 @@ $inner-padding: 1rem;
color: $gray-300; color: $gray-300;
} }
.section, .indent { .section {
position: relative;
margin-top: 2rem;
font-size: calc(1rem - 1px); font-size: calc(1rem - 1px);
max-width: 800px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
color: $ui-white; color: $ui-white;
}
.section {
position: relative;
margin-top: 2rem;
max-width: 800px;
}
.indent {
padding: 1rem;
background-color: $gray-1350;
} }
.paragraph { .paragraph {
@@ -58,18 +49,6 @@ $inner-padding: 1rem;
border-radius: 3px; 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 { .error {
font-size: $inner-section-text-size; font-size: $inner-section-text-size;
display: block; display: block;
@@ -93,6 +72,7 @@ $inner-padding: 1rem;
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 3; z-index: 3;
box-shadow: 0 1px $white-10;
background-color: $gray-1350; background-color: $gray-1350;
} }
@@ -171,40 +151,6 @@ $inner-padding: 1rem;
animation: animloader 1s ease-in infinite; animation: animloader 1s ease-in infinite;
} }
.empty {
background-color: $black-10;
text-align: center;
color: $muted-gray;
td {
padding: 2rem;
}
button {
margin-top: 1rem;
}
}
.inlineSiblings {
display: flex;
align-items: center;
gap: 1rem;
}
.empty {
background-color: $black-10;
text-align: center;
color: $muted-gray;
td {
padding: 2rem;
}
button {
margin-top: 1rem;
}
}
@keyframes animloader { @keyframes animloader {
0% { 0% {
transform: scale(0); transform: scale(0);
@@ -1,10 +1,8 @@
import { HTMLAttributes, ReactNode } from 'react'; import { HTMLAttributes, ReactNode } from 'react';
import { Button } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import style from './PanelUtils.module.scss'; import style from './Panel.module.scss';
export function Header({ children }: { children: ReactNode }) { export function Header({ children }: { children: ReactNode }) {
return <h2 className={style.header}>{children}</h2>; return <h2 className={style.header}>{children}</h2>;
@@ -24,19 +22,10 @@ type SectionProps<C extends AllowedTags> = {
children: ReactNode; children: ReactNode;
} & JSX.IntrinsicElements[C]; } & JSX.IntrinsicElements[C];
export function Section<C extends AllowedTags = 'div'>({ as, className, children, ...props }: SectionProps<C>) { export function Section<C extends AllowedTags = 'div'>({ as, children, ...props }: SectionProps<C>) {
const Element = as ?? 'div'; const Element = as ?? 'div';
return ( return (
<Element className={cx([style.section, className])} {...(props as HTMLAttributes<HTMLElement>)}> <Element className={style.section} {...(props as HTMLAttributes<HTMLElement>)}>
{children}
</Element>
);
}
export function Indent<C extends AllowedTags = 'div'>({ as, className, children, ...props }: SectionProps<C>) {
const Element = as ?? 'div';
return (
<Element className={cx([style.indent, className])} {...(props as HTMLAttributes<HTMLElement>)}>
{children} {children}
</Element> </Element>
); );
@@ -46,9 +35,9 @@ export function Paragraph({ children }: { children: ReactNode }) {
return <p className={style.paragraph}>{children}</p>; return <p className={style.paragraph}>{children}</p>;
} }
export function Card({ children, className, ...props }: { children: ReactNode } & JSX.IntrinsicElements['div']) { export function Card({ children, ...props }: { children: ReactNode } & JSX.IntrinsicElements['div']) {
return ( return (
<div className={cx([style.card, className])} {...props}> <div className={style.card} {...props}>
{children} {children}
</div> </div>
); );
@@ -63,21 +52,8 @@ export function Table({ className, children }: { className?: string; children: R
); );
} }
export function TableEmpty({ handleClick }: { handleClick: () => void }) { export function ListGroup({ children }: { children: ReactNode }) {
return ( return <ul className={style.listGroup}>{children}</ul>;
<tr className={style.empty}>
<td colSpan={99}>
<div>No data yet</div>
<Button onClick={handleClick} variant='ontime-subtle' rightIcon={<IoAdd />} size='sm'>
New
</Button>
</td>
</tr>
);
}
export function ListGroup({ className, children }: { className?: string; children: ReactNode }) {
return <ul className={cx([style.listGroup, className])}>{children}</ul>;
} }
export function ListItem({ children }: { children: ReactNode }) { export function ListItem({ children }: { children: ReactNode }) {
@@ -98,14 +74,6 @@ export function Description({ children }: { children: ReactNode }) {
return <div className={style.fieldDescription}>{children}</div>; 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 }) { export function Error({ children }: { children: ReactNode }) {
return <div className={style.fieldError}>{children}</div>; return <div className={style.fieldError}>{children}</div>;
} }
@@ -1,8 +1,9 @@
import { version } from '../../../../../package.json';
import ExternalLink from '../../../../common/components/external-link/ExternalLink'; import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import { discordUrl, documentationUrl, githubUrl, websiteUrl } from '../../../../externals'; import { documentationUrl, githubUrl, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import AppVersion from './AppVersion'; import CheckUpdatesButton from './CheckUpdatesButton';
export default function AboutPanel() { export default function AboutPanel() {
return ( return (
@@ -19,11 +20,11 @@ export default function AboutPanel() {
<Panel.SubHeader>Links</Panel.SubHeader> <Panel.SubHeader>Links</Panel.SubHeader>
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink> <ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink> <ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
</Panel.Section> </Panel.Section>
<Panel.Section> <Panel.Section>
<Panel.SubHeader>Current version</Panel.SubHeader> <Panel.SubHeader>Current version</Panel.SubHeader>
<AppVersion /> <Panel.Paragraph>{`You are currently using Ontime ${version}`}</Panel.Paragraph>
<CheckUpdatesButton version={version} />
</Panel.Section> </Panel.Section>
</> </>
); );
@@ -1,31 +0,0 @@
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import useAppVersion from '../../../../common/hooks-query/useAppVersion';
import { appVersion } from '../../../../externals';
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 ${appVersion}`}
<Panel.Error>{`Could not fetch version information: ${isError}`}</Panel.Error>
</Panel.Paragraph>
);
}
if (data.hasUpdates) {
return (
<>
<Panel.Paragraph>{`You are currently using Ontime version ${appVersion}.`}</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: ${appVersion}`}</Panel.Paragraph>;
}
@@ -0,0 +1,80 @@
import { useState } from 'react';
import { Button } from '@chakra-ui/react';
import { getLatestVersion, HasUpdate } from '../../../../common/api/external';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import style from '../Panel.module.scss';
type CheckFail = {
error: string;
};
type CheckIsLatest = {
latest: true;
};
type CheckRemote = CheckFail | CheckIsLatest | HasUpdate;
interface CheckUpdatesButtonProps {
version: string;
}
export default function CheckUpdatesButton(props: CheckUpdatesButtonProps) {
const { version } = props;
const [updateMessage, setUpdateMessage] = useState<CheckRemote | null>(null);
const [isFetching, setIsFetching] = useState(false);
/**
* Handles version comparison and returns component with message
*/
const versionCheck = async () => {
setIsFetching(true);
try {
const latest = await getLatestVersion();
if (!latest.version.includes(version)) {
// new version, pass data to component
setUpdateMessage(latest);
} else {
setUpdateMessage({ latest: true });
}
} catch {
setUpdateMessage({ error: 'Error reaching server' });
} finally {
setIsFetching(false);
}
};
const disableButton = Boolean(updateMessage && 'version' in updateMessage);
return (
<>
<Button
onClick={versionCheck}
variant='ontime-filled'
isLoading={isFetching}
isDisabled={disableButton}
size='sm'
maxWidth='max-content'
>
Check for updates
</Button>
<ResolveUpdateMessage updateMessage={updateMessage} />
</>
);
}
function ResolveUpdateMessage(props: { updateMessage: CheckRemote | null }) {
const { updateMessage } = props;
if (updateMessage && 'error' in updateMessage) {
return <span className={style.error}>{updateMessage.error}</span>;
}
if (updateMessage && 'url' in updateMessage) {
return <ExternalLink href={updateMessage?.url}>{`New version available: ${updateMessage.version}`}</ExternalLink>;
}
return null;
}
@@ -1,4 +1,4 @@
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import ClientList from './ClientList'; import ClientList from './ClientList';
@@ -5,7 +5,7 @@ import { RedirectClientModal } from '../../../../common/components/client-modal/
import { RenameClientModal } from '../../../../common/components/client-modal/RenameClientModal'; import { RenameClientModal } from '../../../../common/components/client-modal/RenameClientModal';
import { setClientRemote } from '../../../../common/hooks/useSocket'; import { setClientRemote } from '../../../../common/hooks/useSocket';
import { useClientStore } from '../../../../common/stores/clientStore'; import { useClientStore } from '../../../../common/stores/clientStore';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import style from './ClientControlPanel.module.scss'; import style from './ClientControlPanel.module.scss';
@@ -74,7 +74,7 @@ export default function ClientList() {
)} )}
{name} {name}
</td> </td>
<td className={style.pathList}>{path}</td> {isCurrent ? <td /> : <td className={style.pathList}>{path}</td>}
<td className={style.actionButtons}> <td className={style.actionButtons}>
<Button <Button
size='xs' size='xs'
@@ -1,6 +1,6 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import type { PanelBaseProps } from '../../panel-list/PanelList'; import { PanelBaseProps } from '../../settingsStore';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import CustomFields from './custom-fields/CustomFields'; import CustomFields from './custom-fields/CustomFields';
import UrlPresetsForm from './UrlPresetsForm'; import UrlPresetsForm from './UrlPresetsForm';
@@ -13,7 +13,7 @@ import ExternalLink from '../../../../common/components/external-link/ExternalLi
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets'; import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
import { handleLinks } from '../../../../common/utils/linkUtils'; import { handleLinks } from '../../../../common/utils/linkUtils';
import { validateUrlPresetPath } from '../../../../common/utils/urlPresets'; import { validateUrlPresetPath } from '../../../../common/utils/urlPresets';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import style from './FeatureSettings.module.scss'; import style from './FeatureSettings.module.scss';
@@ -33,7 +33,7 @@ export default function UrlPresetsForm() {
setError, setError,
formState: { isSubmitting, isDirty, isValid, errors }, formState: { isSubmitting, isDirty, isValid, errors },
} = useForm<FormData>({ } = useForm<FormData>({
mode: 'onChange', mode: 'onBlur',
defaultValues: { data }, defaultValues: { data },
values: { data }, values: { data },
resetOptions: { resetOptions: {
@@ -105,25 +105,12 @@ export default function UrlPresetsForm() {
<Alert status='info' variant='ontime-on-dark-info'> <Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon /> <AlertIcon />
<AlertDescription> <AlertDescription>
URL presets are user defined aliases to Ontime URLs URL Presets
<br /> <br />
<br /> <br />
<b>Preset Name</b> <br /> Custom presets allow providing a short name for any ontime URL. <br />
The alias for the URL. This will be the URL you will be calling. eg: <br /> - Providing dynamic URLs for automation or unattended screens <br />- Simplifying complex URLs
<Panel.BlockQuote>
Preset name <Panel.Highlight>cam3</Panel.Highlight> called as{' '}
<Panel.Highlight>http://localhost:4001/cam3</Panel.Highlight>
</Panel.BlockQuote>
<br /> <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 /> <br />
<ExternalLink href={urlPresetsDocs}>See the docs</ExternalLink> <ExternalLink href={urlPresetsDocs}>See the docs</ExternalLink>
</AlertDescription> </AlertDescription>
@@ -143,20 +130,15 @@ export default function UrlPresetsForm() {
<thead> <thead>
<tr> <tr>
<th className={style.fit}>Active</th> <th className={style.fit}>Active</th>
<th className={style.aliasConstrain}>Preset name</th> <th className={style.aliasConstrain}>Preset</th>
<th className={style.fullWidth}>URL segment</th> <th className={style.fullWidth}>URL</th>
<th /> <th />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{fields.length === 0 && <Panel.TableEmpty handleClick={addNew} />}
{fields.map((preset, index) => { {fields.map((preset, index) => {
const maybeAliasError = errors.data?.[index]?.alias?.message; const maybeAliasError = errors.data?.[index]?.alias?.message;
const maybeUrlError = errors.data?.[index]?.pathAndParams?.message; const maybeUrlError = errors.data?.[index]?.pathAndParams?.message;
// only saved and enabled URLs can be tested
const canTest =
preset.alias && preset.enabled && preset.pathAndParams && !maybeAliasError && !maybeUrlError;
return ( return (
<tr key={preset.id}> <tr key={preset.id}>
<td className={style.fit}> <td className={style.fit}>
@@ -195,7 +177,6 @@ export default function UrlPresetsForm() {
<td className={style.flex}> <td className={style.flex}>
<TooltipActionBtn <TooltipActionBtn
size='sm' size='sm'
isDisabled={!canTest}
clickHandler={(event) => handleLinks(event, preset.alias)} clickHandler={(event) => handleLinks(event, preset.alias)}
tooltip='Test preset' tooltip='Test preset'
aria-label='Test preset' aria-label='Test preset'
@@ -7,7 +7,7 @@ import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
import { maybeAxiosError } from '../../../../../common/api/utils'; import { maybeAxiosError } from '../../../../../common/api/utils';
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect'; import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../PanelUtils';
import style from '../FeatureSettings.module.scss'; import style from '../FeatureSettings.module.scss';
@@ -67,8 +67,6 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
const colour = getValues('colour'); const colour = getValues('colour');
const canSubmit = isDirty && isValid; const canSubmit = isDirty && isValid;
// if initial values are given, we can assume we are in edit mode
const isEditMode = initialKey !== undefined;
return ( return (
<form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}> <form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}>
@@ -83,9 +81,7 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
validate: (value) => { validate: (value) => {
if (value.trim().length === 0) return 'Required field'; if (value.trim().length === 0) return 'Required field';
if (!isAlphanumericWithSpace(value)) return 'Only alphanumeric characters and space are allowed'; if (!isAlphanumericWithSpace(value)) return 'Only alphanumeric characters and space are allowed';
if (!isEditMode) { if (Object.keys(data).includes(value)) return 'Custom fields must be unique';
if (isEditMode && Object.keys(data).includes(value)) return 'Custom fields must be unique';
}
return true; return true;
}, },
})} })}
@@ -96,7 +92,7 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
</div> </div>
<div> <div>
<Panel.Description>Key (use in Integrations and API)</Panel.Description> <Panel.Description>Key (auto-generated value for use in Integrations and API)</Panel.Description>
<Input {...register('key')} disabled size='sm' variant='ontime-filled' autoComplete='off' /> <Input {...register('key')} disabled size='sm' variant='ontime-filled' autoComplete='off' />
</div> </div>
</div> </div>
@@ -7,7 +7,7 @@ import { deleteCustomField, editCustomField, postCustomField } from '../../../..
import ExternalLink from '../../../../../common/components/external-link/ExternalLink'; import ExternalLink from '../../../../../common/components/external-link/ExternalLink';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { customFieldsDocsUrl } from '../../../../../externals'; import { customFieldsDocsUrl } from '../../../../../externals';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../PanelUtils';
import CustomFieldEntry from './CustomFieldEntry'; import CustomFieldEntry from './CustomFieldEntry';
import CustomFieldForm from './CustomFieldForm'; import CustomFieldForm from './CustomFieldForm';
@@ -1,7 +1,7 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import type { PanelBaseProps } from '../../panel-list/PanelList'; import { PanelBaseProps } from '../../settingsStore';
import * as Panel from '../../panel-utils/PanelUtils';
import EditorSettingsForm from '../interface-panel/EditorSettingsForm'; import EditorSettingsForm from '../interface-panel/EditorSettingsForm';
import * as Panel from '../PanelUtils';
import GeneralPanelForm from './GeneralPanelForm'; import GeneralPanelForm from './GeneralPanelForm';
import ViewSettingsForm from './ViewSettingsForm'; import ViewSettingsForm from './ViewSettingsForm';
@@ -7,7 +7,7 @@ import { postSettings } from '../../../../common/api/settings';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import useSettings from '../../../../common/hooks-query/useSettings'; import useSettings from '../../../../common/hooks-query/useSettings';
import { isOnlyNumbers } from '../../../../common/utils/regex'; import { isOnlyNumbers } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import GeneralPinInput from './GeneralPinInput'; import GeneralPinInput from './GeneralPinInput';
@@ -154,8 +154,7 @@ export default function GeneralPanelForm() {
<option value='pt'>Portuguese</option> <option value='pt'>Portuguese</option>
<option value='es'>Spanish</option> <option value='es'>Spanish</option>
<option value='sv'>Swedish</option> <option value='sv'>Swedish</option>
<option value='pl'>Polish</option> <option value='pl'>Polish</option>
<option value='zh'>Chinese (Simplified)</option>
</Select> </Select>
</Panel.ListItem> </Panel.ListItem>
</Panel.ListGroup> </Panel.ListGroup>
@@ -6,10 +6,10 @@ import { ViewSettings } from 'ontime-types';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import { postViewSettings } from '../../../../common/api/viewSettings'; import { postViewSettings } from '../../../../common/api/viewSettings';
import ExternalLink from '../../../../common/components/external-link/ExternalLink'; import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import { SwatchPickerRHF } from '../../../../common/components/input/colour-input/SwatchPicker'; import { PopoverPickerRHF } from '../../../../common/components/input/popover-picker/PopoverPicker';
import useInfo from '../../../../common/hooks-query/useInfo'; import useInfo from '../../../../common/hooks-query/useInfo';
import useViewSettings from '../../../../common/hooks-query/useViewSettings'; import useViewSettings from '../../../../common/hooks-query/useViewSettings';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import style from './GeneralPanel.module.scss'; import style from './GeneralPanel.module.scss';
@@ -111,22 +111,22 @@ export default function ViewSettingsForm() {
<Panel.ListGroup> <Panel.ListGroup>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field title='Timer colour' description='Default colour of a running timer' /> <Panel.Field title='Timer colour' description='Default colour of a running timer' />
<SwatchPickerRHF name='normalColor' control={control} /> <PopoverPickerRHF name='normalColor' control={control} />
</Panel.ListItem> </Panel.ListItem>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field title='Warning colour' description='Colour of a running timer in warning mode' /> <Panel.Field title='Warning colour' description='Colour of a running timer in warning mode' />
<SwatchPickerRHF name='warningColor' control={control} /> <PopoverPickerRHF name='warningColor' control={control} />
</Panel.ListItem> </Panel.ListItem>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field title='Danger colour' description='Colour of a running timer in danger mode' /> <Panel.Field title='Danger colour' description='Colour of a running timer in danger mode' />
<SwatchPickerRHF name='dangerColor' control={control} /> <PopoverPickerRHF name='dangerColor' control={control} />
</Panel.ListItem> </Panel.ListItem>
</Panel.ListGroup> </Panel.ListGroup>
<Panel.ListGroup> <Panel.ListGroup>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title='Freeze timer on end' title='Freeze timer on end'
description='When a timer hits 00:00:00, it freezes instead of going negative. It invalidates the End Message.' description='Timer in views will stop from going negative and instead stop at 00:00:00'
/> />
<Controller <Controller
control={control} control={control}
@@ -139,7 +139,7 @@ export default function ViewSettingsForm() {
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title='End message' title='End message'
description='Message for negative timers; applies only if the timer isn`t frozen on End. If no message is provided, it continues into negative time' description='Message to show on negative timers if not frozen. If not provided, timer will continue'
/> />
<Input <Input
size='sm' size='sm'
@@ -9,7 +9,7 @@ import { maybeAxiosError } from '../../../../common/api/utils';
import { useHttpSettings, usePostHttpSettings } from '../../../../common/hooks-query/useHttpSettings'; import { useHttpSettings, usePostHttpSettings } from '../../../../common/hooks-query/useHttpSettings';
import { isKeyEscape } from '../../../../common/utils/keyEvent'; import { isKeyEscape } from '../../../../common/utils/keyEvent';
import { startsWithHttp } from '../../../../common/utils/regex'; import { startsWithHttp } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import { cycles } from './integrationUtils'; import { cycles } from './integrationUtils';
@@ -27,7 +27,7 @@ export default function HttpIntegrations() {
setError, setError,
formState: { errors, isSubmitting, isDirty, isValid }, formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<HttpSettings>({ } = useForm<HttpSettings>({
mode: 'onChange', mode: 'onBlur',
defaultValues: data, defaultValues: data,
values: data, values: data,
resetOptions: { resetOptions: {
@@ -2,8 +2,8 @@ import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
import ExternalLink from '../../../../common/components/external-link/ExternalLink'; import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import type { PanelBaseProps } from '../../panel-list/PanelList'; import { PanelBaseProps } from '../../settingsStore';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import HttpIntegrations from './HttpIntegrations'; import HttpIntegrations from './HttpIntegrations';
import OscIntegrations from './OscIntegrations'; import OscIntegrations from './OscIntegrations';
@@ -8,10 +8,9 @@ import { generateId } from 'ontime-utils';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings'; import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
import { preventEscape } from '../../../../common/utils/keyEvent'; import { isKeyEscape } from '../../../../common/utils/keyEvent';
import { isASCII, isASCIIorEmpty, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex'; import { isASCII, isASCIIorEmpty, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
import { isOntimeCloud } from '../../../../externals'; import * as Panel from '../PanelUtils';
import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './integrationUtils'; import { cycles } from './integrationUtils';
@@ -29,7 +28,7 @@ export default function OscIntegrations() {
setError, setError,
formState: { errors, isSubmitting, isDirty, isValid }, formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<OSCSettings>({ } = useForm<OSCSettings>({
mode: 'onChange', mode: 'onBlur',
defaultValues: data, defaultValues: data,
values: data, values: data,
resetOptions: { resetOptions: {
@@ -63,6 +62,13 @@ export default function OscIntegrations() {
} }
}; };
const preventEscape = (event: React.KeyboardEvent) => {
if (isKeyEscape(event)) {
event.preventDefault();
event.stopPropagation();
}
};
const handleAddNewSubscription = () => { const handleAddNewSubscription = () => {
prepend({ prepend({
id: generateId(), id: generateId(),
@@ -100,9 +106,6 @@ export default function OscIntegrations() {
</Button> </Button>
</div> </div>
</Panel.SubHeader> </Panel.SubHeader>
{isOntimeCloud && (
<Panel.Highlight>For security reasons OSC integrations are not available in the cloud service.</Panel.Highlight>
)}
<Panel.Divider /> <Panel.Divider />
@@ -4,7 +4,7 @@ import { parseUserTime } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput'; import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { editorSettingsDefaults, useEditorSettings } from '../../../../common/stores/editorSettings'; import { editorSettingsDefaults, useEditorSettings } from '../../../../common/stores/editorSettings';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
export default function EditorSettingsForm() { export default function EditorSettingsForm() {
const { const {
@@ -88,8 +88,8 @@ export default function EditorSettingsForm() {
> >
<option value={TimerType.CountDown}>Count down</option> <option value={TimerType.CountDown}>Count down</option>
<option value={TimerType.CountUp}>Count up</option> <option value={TimerType.CountUp}>Count up</option>
<option value={TimerType.TimeToEnd}>Time to end</option>
<option value={TimerType.Clock}>Clock</option> <option value={TimerType.Clock}>Clock</option>
<option value={TimerType.None}>None</option>
</Select> </Select>
</Panel.ListItem> </Panel.ListItem>
<Panel.ListItem> <Panel.ListItem>
@@ -1,6 +1,6 @@
import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react'; import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import EditorSettingsForm from './EditorSettingsForm'; import EditorSettingsForm from './EditorSettingsForm';
@@ -1,9 +1,9 @@
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp'; import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { serverPort } from '../../../../common/api/constants';
import CopyTag from '../../../../common/components/copy-tag/CopyTag'; import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import useInfo from '../../../../common/hooks-query/useInfo'; import useInfo from '../../../../common/hooks-query/useInfo';
import { linkToOtherHost, openLink } from '../../../../common/utils/linkUtils'; import { openLink } from '../../../../common/utils/linkUtils';
import { isLocalhost } from '../../../../externals';
import style from './NetworkInterfaces.module.scss'; import style from './NetworkInterfaces.module.scss';
@@ -14,10 +14,8 @@ export default function InfoNif() {
return ( return (
<div className={style.interfaces}> <div className={style.interfaces}>
{data.networkInterfaces.map((nif) => { {data?.networkInterfaces?.map((nif) => {
// interfaces outside localhost wont have access const address = `http://${nif.address}:${serverPort}`;
if (nif.name === 'localhost' && !isLocalhost) return null;
const address = linkToOtherHost(nif.address);
return ( return (
<CopyTag <CopyTag
@@ -4,7 +4,7 @@ import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { handleLinks } from '../../../../common/utils/linkUtils'; import { handleLinks } from '../../../../common/utils/linkUtils';
import Log from '../../../log/Log'; import Log from '../../../log/Log';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import style from './NetworkLogExport.module.scss'; import style from './NetworkLogExport.module.scss';

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