Compare commits
167 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d9c2a9cfd | |||
| e9a7cd0400 | |||
| 9ba40c35a1 | |||
| f94db06812 | |||
| 35424cfff2 | |||
| 13eadeb0af | |||
| 9cfed5a150 | |||
| a22c2d380d | |||
| cd913fc144 | |||
| 743d7fa782 | |||
| 891c43ba77 | |||
| fba20b889e | |||
| bc4d913ba7 | |||
| 533f9798e8 | |||
| 1b62ed5f2d | |||
| bd0c951da8 | |||
| 2d5b86808f | |||
| c69bce3e89 | |||
| d62e314d19 | |||
| dbd56522f6 | |||
| 1ba48c6a6f | |||
| f4f24da47b | |||
| c3609b2aed | |||
| 260d326989 | |||
| a21e32a097 | |||
| 617e4b579b | |||
| 44f65b2fcc | |||
| 760de608e3 | |||
| 0dfef732bf | |||
| f65ec0da3c | |||
| 6bbf8ce795 | |||
| 7f39d035fd | |||
| bdd2a62cee | |||
| b38a7e5c92 | |||
| b173b5a8e8 | |||
| 29243e3f6c | |||
| 1af394cd49 | |||
| 1c85265623 | |||
| 6199d0ad10 | |||
| 2f2c26ed00 | |||
| c9ef2465f8 | |||
| 430b72c1d0 | |||
| 88b114020b | |||
| c445c42f8a | |||
| 2fc4e6da16 | |||
| 4cbe25a845 | |||
| ab6765b332 | |||
| fb38d82855 | |||
| 1061ed2a0e | |||
| 3d37f42fe0 | |||
| 1bdea8e436 | |||
| 2c25910863 | |||
| 8f30c0a7df | |||
| 67fa747aae | |||
| 8116b169b2 | |||
| ec823e0095 | |||
| c7faab00a3 | |||
| cfcf6a5684 | |||
| df1cf7a96b | |||
| 0f3feca7ab | |||
| 41fe213ebb | |||
| e91d5f5cd9 | |||
| bedab221d9 | |||
| 97208c052a | |||
| b048d0f88d | |||
| d8f7d4bba6 | |||
| ed65a4db67 | |||
| 4a70bcfe64 | |||
| 89e68c3b0e | |||
| 976dc9cbde | |||
| 3701ba5174 | |||
| acc6ba63ba | |||
| 6c5afcb8d9 | |||
| d46dfcf82e | |||
| 4c6acc4013 | |||
| 26a24449de | |||
| a1fb642441 | |||
| b64b154330 | |||
| 5032cbf65a | |||
| 466360f9d1 | |||
| ef2ea9a1da | |||
| fb83f48752 | |||
| c1d53b0e55 | |||
| 7915cc822d | |||
| 7dbf64d100 | |||
| 6ffbf2af9d | |||
| fa7ec623f1 | |||
| bfb9b51073 | |||
| 8bc3f5a56c | |||
| acf1afb5e9 | |||
| d8e2a8d092 | |||
| 8e587128a1 | |||
| 607bc40673 | |||
| e6aa7404f7 | |||
| d622a7738f | |||
| b9ba416366 | |||
| b3ce247f54 | |||
| 82a2660fbc | |||
| 1e8706aa73 | |||
| 7dec32aea7 | |||
| 7e4cf6ffce | |||
| e6dfe9c363 | |||
| 29e682ee06 | |||
| 55d349877b | |||
| 8f2a93d6a3 | |||
| 6529a05e2a | |||
| 0f5c47bc82 | |||
| f561e52bb5 | |||
| 35f4472daa | |||
| 272f788c15 | |||
| 85b71627a8 | |||
| 6e51e5083b | |||
| 1f998fda74 | |||
| e7b5baa49a | |||
| f3f4cdb19f | |||
| b489214004 | |||
| cc55414b57 | |||
| e694cb7556 | |||
| 988100c1d9 | |||
| b4eabfae72 | |||
| 22fe65c655 | |||
| 7bf90d669a | |||
| a518dc4882 | |||
| dd5ffb2f47 | |||
| c5a5eab225 | |||
| 5d6345717f | |||
| 4c88f90a15 | |||
| 238eb49b5c | |||
| 7aec3e0d73 | |||
| 312b294a61 | |||
| 9bfa3e9f8e | |||
| 9a2cfb59e7 | |||
| a6e1cbbbce | |||
| 8dd448db44 | |||
| d87542dcf7 | |||
| 18575fc558 | |||
| 04e2593939 | |||
| 73d6b97afa | |||
| b55724ad0e | |||
| 3a7c2b8a55 | |||
| 420dfae652 | |||
| fb6896de76 | |||
| f574fdbc02 | |||
| 597be4e710 | |||
| 6e8734c73f | |||
| 9440ad17d2 | |||
| 9ba94288a1 | |||
| 6c78d25af1 | |||
| 0d8c186c77 | |||
| 7ccd41eeee | |||
| 43b390a079 | |||
| 80ff3251ea | |||
| 3591b93c2d | |||
| b637621871 | |||
| ed2efe183b | |||
| 27f99b4c87 | |||
| ed4d0f060b | |||
| 7c19c52332 | |||
| b1bff22b84 | |||
| 83b9fde45e | |||
| 37974df5c6 | |||
| 161ec383fd | |||
| de38f9d467 | |||
| 8ad1f2cb38 | |||
| 143917f11a | |||
| a8756c8afe | |||
| 5501f57b45 |
@@ -36,7 +36,7 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.TEAMID }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
run: pnpm dist-mac
|
||||
run: pnpm dist-mac --env-mode=loose
|
||||
timeout-minutes: 60
|
||||
|
||||
- name: Release
|
||||
|
||||
@@ -28,6 +28,7 @@ test-results
|
||||
playwright-report
|
||||
/playwright/.cache/
|
||||
automated-screenshots
|
||||
e2e/tests/fixtures/tmp/*
|
||||
|
||||
# production
|
||||
build/
|
||||
@@ -39,6 +40,7 @@ dist/
|
||||
# docker utils
|
||||
ontime-db
|
||||
ontime-external/
|
||||
ontime-data/
|
||||
|
||||
# versioning file
|
||||
**/ONTIME_VERSION.js
|
||||
|
||||
@@ -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
|
||||
|
||||
- __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`
|
||||
|
||||
Other useful commands
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "3.6.2",
|
||||
"version": "3.10.3",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -1,37 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang='en'>
|
||||
<head>
|
||||
<meta charset='utf-8' />
|
||||
<link rel='icon' href='/favicon.ico' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1' />
|
||||
<meta name='theme-color' content='#121212' />
|
||||
<meta name='ontime' content='ontime - event timer manager' />
|
||||
<link rel='apple-touch-icon' href='/logo192.png' />
|
||||
<link
|
||||
rel='icon'
|
||||
type='image/png'
|
||||
sizes='32x32'
|
||||
href='/favicon-32x32.png'
|
||||
/>
|
||||
<link
|
||||
rel='icon'
|
||||
type='image/png'
|
||||
sizes='16x16'
|
||||
href='/favicon-16x16.png'
|
||||
/>
|
||||
<link rel='manifest' href='/site.webmanifest' />
|
||||
<link rel='manifest' href='/manifest.json' />
|
||||
<title>ontime</title>
|
||||
<style>
|
||||
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 lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<base href="/" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#101010" />
|
||||
<meta name="ontime" content="ontime - time keeping for live events" />
|
||||
<link rel="apple-touch-icon" href="/ontime-logo.png" />
|
||||
<link rel="icon" type="image/png" href="/ontime-logo.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>ontime</title>
|
||||
<style>
|
||||
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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "3.6.2",
|
||||
"version": "3.10.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -11,12 +11,12 @@
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@emotion/styled": "^11.10.6",
|
||||
"@fontsource/open-sans": "^5.0.28",
|
||||
"@mantine/hooks": "^7.6.2",
|
||||
"@mantine/hooks": "^7.13.3",
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
"@sentry/react": "^8.19.0",
|
||||
"@tanstack/react-query": "^5.17.9",
|
||||
"@tanstack/react-query-devtools": "^5.17.9",
|
||||
"@tanstack/react-table": "^8.11.3",
|
||||
"@sentry/react": "^8.43.0",
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"@tanstack/react-query-devtools": "^5.62.7",
|
||||
"@tanstack/react-table": "^8.20.5",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.2.0",
|
||||
"color": "^4.2.3",
|
||||
@@ -26,7 +26,7 @@
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-fast-compare": "^3.2.2",
|
||||
"react-hook-form": "^7.49.2",
|
||||
"react-hook-form": "^7.53.1",
|
||||
"react-qr-code": "^2.0.12",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"web-vitals": "^3.1.1",
|
||||
@@ -39,7 +39,7 @@
|
||||
"build": "vite build",
|
||||
"build:local": "cross-env NODE_ENV=local vite build",
|
||||
"build:electron": "cross-env NODE_ENV=local vite build",
|
||||
"build:docker": "vite build",
|
||||
"build:docker": "cross-env VITE_IS_DOCKER=true vite build",
|
||||
"build:localdocker": "cross-env NODE_ENV=local vite build",
|
||||
"lint": "eslint . --quiet",
|
||||
"test": "vitest",
|
||||
@@ -87,9 +87,9 @@
|
||||
"sass": "^1.57.1",
|
||||
"typescript": "catalog:",
|
||||
"vite": "^5.2.11",
|
||||
"vite-plugin-compression2": "^0.12.0",
|
||||
"vite-plugin-compression2": "^1.3.3",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vite-tsconfig-paths": "^4.3.1",
|
||||
"vitest": "^1.6.0"
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 600 B |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 47 KiB |
@@ -4,18 +4,11 @@
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
"src": "ontime-logo.png",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"scope": "/",
|
||||
|
||||
|
After Width: | Height: | Size: 19 KiB |
@@ -1 +1,10 @@
|
||||
{"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"
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { connectSocket } from './common/utils/socket';
|
||||
import theme from './theme/theme';
|
||||
import { TranslationProvider } from './translation/TranslationProvider';
|
||||
import AppRouter from './AppRouter';
|
||||
import { baseURI } from './externals';
|
||||
|
||||
connectSocket();
|
||||
|
||||
@@ -19,7 +20,7 @@ function App() {
|
||||
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
|
||||
<QueryClientProvider client={ontimeQueryClient}>
|
||||
<AppContextProvider>
|
||||
<BrowserRouter>
|
||||
<BrowserRouter basename={baseURI}>
|
||||
<div className='App'>
|
||||
<ErrorBoundary>
|
||||
<TranslationProvider>
|
||||
|
||||
@@ -19,7 +19,7 @@ import { ONTIME_VERSION } from './ONTIME_VERSION';
|
||||
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
|
||||
|
||||
const Editor = React.lazy(() => import('./features/editors/ProtectedEditor'));
|
||||
const Cuesheet = React.lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
|
||||
const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
|
||||
const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
|
||||
|
||||
const TimerView = React.lazy(() => import('./features/viewers/timer/Timer'));
|
||||
@@ -32,12 +32,14 @@ const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
|
||||
const Public = React.lazy(() => import('./features/viewers/public/Public'));
|
||||
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
||||
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
|
||||
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
|
||||
|
||||
const STimer = withPreset(withData(TimerView));
|
||||
const SMinimalTimer = withPreset(withData(MinimalTimerView));
|
||||
const SClock = withPreset(withData(ClockView));
|
||||
const SCountdown = withPreset(withData(Countdown));
|
||||
const SBackstage = withPreset(withData(Backstage));
|
||||
const SProjectInfo = withPreset(withData(ProjectInfo));
|
||||
const SPublic = withPreset(withData(Public));
|
||||
const SLowerThird = withPreset(withData(Lower));
|
||||
const SStudio = withPreset(withData(StudioClock));
|
||||
@@ -142,6 +144,14 @@ export default function AppRouter() {
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/info'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SProjectInfo />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
|
||||
{/*/!* Protected Routes *!/*/}
|
||||
<Route path='/editor' element={<Editor />} />
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { serverURL } from '../../externals';
|
||||
|
||||
// keys in tanstack store
|
||||
export const APP_INFO = ['appinfo'];
|
||||
export const APP_SETTINGS = ['appSettings'];
|
||||
export const APP_VERSION = ['appVersion'];
|
||||
export const CUSTOM_FIELDS = ['customFields'];
|
||||
export const HTTP_SETTINGS = ['httpSettings'];
|
||||
export const OSC_SETTINGS = ['oscSettings'];
|
||||
@@ -13,19 +16,7 @@ export const URL_PRESETS = ['urlpresets'];
|
||||
export const VIEW_SETTINGS = ['viewSettings'];
|
||||
export const CLIENT_LIST = ['clientList'];
|
||||
|
||||
// resolve location
|
||||
const location = window.location;
|
||||
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
export const isProduction = import.meta.env.MODE === 'production';
|
||||
export const isDev = !isProduction;
|
||||
export const isLocalhost = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
|
||||
|
||||
// resolve port
|
||||
const STATIC_PORT = 4001;
|
||||
export const serverPort = isProduction ? location.port : STATIC_PORT;
|
||||
export const serverURL = `${location.protocol}//${location.hostname}:${serverPort}`;
|
||||
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
|
||||
|
||||
// API URLs
|
||||
export const apiEntryUrl = `${serverURL}/data`;
|
||||
|
||||
export const projectDataURL = `${serverURL}/project`;
|
||||
@@ -35,3 +26,4 @@ export const ontimeURL = `${serverURL}/ontime`;
|
||||
export const userAssetsPath = 'user';
|
||||
export const cssOverridePath = 'styles/override.css';
|
||||
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
|
||||
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types';
|
||||
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
|
||||
|
||||
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
|
||||
import { makeCSV, makeTable } from '../../views/cuesheet/cuesheet.utils';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import { createBlob, downloadBlob } from './utils';
|
||||
@@ -12,7 +12,7 @@ const dbPath = `${apiEntryUrl}/db`;
|
||||
* HTTP request to the current DB
|
||||
*/
|
||||
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
|
||||
return axios.post(`${dbPath}/download/`, { filename });
|
||||
return axios.post(`${dbPath}/download`, { filename });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,6 +86,14 @@ export async function createProject(
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to create a project file
|
||||
*/
|
||||
export async function quickProject(data: QuickStartData): Promise<MessageResponse> {
|
||||
const res = await axios.post(`${dbPath}/quick`, data);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to get the list of available project files
|
||||
*/
|
||||
@@ -104,6 +112,14 @@ export async function loadProject(filename: string): Promise<MessageResponse> {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { ProjectData } from 'ontime-types';
|
||||
import { ProjectData, ProjectLogoResponse } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
@@ -19,3 +19,18 @@ export async function getProjectData(): Promise<ProjectData> {
|
||||
export async function postProjectData(data: ProjectData): Promise<AxiosResponse<ProjectData>> {
|
||||
return axios.post(projectPath, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to upload a project logo
|
||||
*/
|
||||
export async function uploadProjectLogo(file: File): Promise<AxiosResponse<ProjectLogoResponse>> {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
const response = await axios.post(`${projectPath}/upload`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
|
||||
import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached, TransientEventPayload } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function fetchNormalisedRundown(): Promise<RundownCached> {
|
||||
/**
|
||||
* HTTP request to post new event
|
||||
*/
|
||||
export async function requestPostEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> {
|
||||
export async function requestPostEvent(data: TransientEventPayload): Promise<AxiosResponse<OntimeRundownEntry>> {
|
||||
return axios.post(rundownPath, data);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ export function maybeAxiosError(error: unknown) {
|
||||
}
|
||||
}
|
||||
return `${statusText}: ${data}`;
|
||||
} else if (error instanceof Error) {
|
||||
return error.message;
|
||||
} else {
|
||||
if (typeof error !== 'string') {
|
||||
return JSON.stringify(error);
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
.buttonSection {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $element-spacing;
|
||||
margin-top: -$element-spacing;
|
||||
}
|
||||
@@ -15,8 +15,6 @@ import {
|
||||
|
||||
import { setClientRemote } from '../../hooks/useSocket';
|
||||
|
||||
import style from './ClientModal.module.scss';
|
||||
|
||||
interface RedirectClientModalProps {
|
||||
id: string;
|
||||
name?: string;
|
||||
@@ -54,14 +52,12 @@ export function RedirectClientModal(props: RedirectClientModalProps) {
|
||||
</InputGroup>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<div className={style.buttonSection}>
|
||||
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
@@ -13,8 +13,6 @@ import {
|
||||
|
||||
import { setClientRemote } from '../../hooks/useSocket';
|
||||
|
||||
import style from './ClientModal.module.scss';
|
||||
|
||||
interface RenameClientModalProps {
|
||||
id: string;
|
||||
name?: string;
|
||||
@@ -53,14 +51,12 @@ export function RenameClientModal(props: RenameClientModalProps) {
|
||||
/>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<div className={style.buttonSection}>
|
||||
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
@@ -9,32 +9,23 @@ import style from './DelayIndicator.module.scss';
|
||||
|
||||
interface DelayIndicatorProps {
|
||||
delayValue?: number;
|
||||
tooltipPrefix?: string;
|
||||
}
|
||||
|
||||
export default function DelayIndicator(props: DelayIndicatorProps) {
|
||||
const { delayValue } = props;
|
||||
const { delayValue, tooltipPrefix } = props;
|
||||
|
||||
if (typeof delayValue === 'number') {
|
||||
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>
|
||||
);
|
||||
}
|
||||
if (typeof delayValue !== 'number' || delayValue === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
const delayString = tooltipPrefix
|
||||
? `${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 = () => {
|
||||
onClick?.(color);
|
||||
};
|
||||
const classes = cx([style.swatch, isSelected ? style.selected : null, onClick ? style.selectable : null]);
|
||||
const classes = cx([style.swatch, isSelected && style.selected, onClick && style.selectable]);
|
||||
|
||||
if (!color) {
|
||||
return (
|
||||
<div className={`${classes} ${style.center}`} onClick={handleClick}>
|
||||
<div className={classes} onClick={handleClick}>
|
||||
<IoBan />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
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,15 +1,18 @@
|
||||
.list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 99px;
|
||||
border: 2px solid $gray-1200;
|
||||
color: $ui-white;
|
||||
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
&.selected {
|
||||
border: 2px solid $blue-500;
|
||||
@@ -17,11 +20,9 @@
|
||||
|
||||
&.selectable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border: 2px solid $blue-300;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.center {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import Swatch from './Swatch';
|
||||
import SwatchPicker from './SwatchPicker';
|
||||
|
||||
import style from './SwatchSelect.module.scss';
|
||||
|
||||
@@ -43,6 +44,7 @@ export default function SwatchSelect(props: ColourInputProps) {
|
||||
{colours.map((colour) => (
|
||||
<Swatch key={colour} color={colour} onClick={setColour} isSelected={value === colour} />
|
||||
))}
|
||||
<SwatchPicker color={value} onChange={setColour} isSelected={!colours.includes(value)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
.swatch {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid white;
|
||||
box-shadow: 0 0 0 1px $gray-300;
|
||||
cursor: pointer;
|
||||
.input {
|
||||
color: $gray-200;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0 0 8px 8px;
|
||||
background-color: $gray-1200;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
height: 2rem;
|
||||
outline: none;
|
||||
|
||||
transition-property: box-shadow;
|
||||
transition-duration: $transition-time-action;
|
||||
&:hover {
|
||||
background-color: $gray-1100;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background-color: $gray-1000;
|
||||
color: $gray-50;
|
||||
border: 1px solid $blue-500;
|
||||
}
|
||||
}
|
||||
|
||||
.swatch:hover {
|
||||
box-shadow: 0 0 0 1px $blue-300;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +1,22 @@
|
||||
import { HexAlphaColorPicker } from 'react-colorful';
|
||||
import { useController, UseControllerProps } from 'react-hook-form';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { HexAlphaColorPicker, HexColorInput } from 'react-colorful';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@chakra-ui/react';
|
||||
import { ViewSettings } from 'ontime-types';
|
||||
|
||||
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 {
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
}
|
||||
|
||||
export default function PopoverPicker(props: PopoverPickerProps) {
|
||||
const { color, onChange } = props;
|
||||
|
||||
export default function PopoverPicker(props: PropsWithChildren<PopoverPickerProps>) {
|
||||
const { color, onChange, children } = props;
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<div className={style.swatch} style={{ backgroundColor: color }} />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent style={{ width: 'auto' }}>
|
||||
<PopoverTrigger>{children}</PopoverTrigger>
|
||||
<PopoverContent className={style.small} style={{ borderRadius: '9px', width: 'auto' }}>
|
||||
<HexAlphaColorPicker color={color} onChange={onChange} />
|
||||
<HexColorInput color={color} onChange={onChange} className={style.input} prefixed />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,8 @@ export default function useReactiveTextInput(
|
||||
options?: {
|
||||
submitOnEnter?: boolean;
|
||||
submitOnCtrlEnter?: boolean;
|
||||
onCancelUpdate?: () => void;
|
||||
allowSubmitSameValue?: boolean;
|
||||
},
|
||||
): UseReactiveTextInputReturn {
|
||||
const [text, setText] = useState<string>(initialText);
|
||||
@@ -47,7 +49,9 @@ export default function useReactiveTextInput(
|
||||
const handleSubmit = useCallback(
|
||||
(valueToSubmit: string) => {
|
||||
// No need to update if it hasn't changed
|
||||
if (valueToSubmit !== initialText) {
|
||||
if (valueToSubmit === initialText && !options?.allowSubmitSameValue) {
|
||||
options?.onCancelUpdate?.();
|
||||
} else {
|
||||
const cleanVal = valueToSubmit.trim();
|
||||
submitCallback(cleanVal);
|
||||
if (cleanVal !== valueToSubmit) {
|
||||
@@ -56,7 +60,7 @@ export default function useReactiveTextInput(
|
||||
}
|
||||
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
|
||||
},
|
||||
[initialText, ref, submitCallback],
|
||||
[initialText, options, ref, submitCallback],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -66,8 +70,9 @@ export default function useReactiveTextInput(
|
||||
const handleEscape = useCallback(() => {
|
||||
// No need to update if it hasn't changed
|
||||
setText(initialText);
|
||||
options?.onCancelUpdate?.();
|
||||
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
|
||||
}, [initialText, ref]);
|
||||
}, [initialText, options, ref]);
|
||||
|
||||
const keyHandler = useMemo(() => {
|
||||
const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]];
|
||||
|
||||
@@ -5,6 +5,7 @@ import { millisToString, parseUserTime } from 'ontime-utils';
|
||||
import { useEmitLog } from '../../../stores/logger';
|
||||
|
||||
interface TimeInputProps<T extends string> {
|
||||
id?: T;
|
||||
name: T;
|
||||
submitHandler: (field: T, value: string) => void;
|
||||
time?: number;
|
||||
@@ -15,7 +16,7 @@ interface TimeInputProps<T extends string> {
|
||||
}
|
||||
|
||||
export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
||||
const { name, submitHandler, time = 0, placeholder, disabled, align = 'center', className } = props;
|
||||
const { id, name, submitHandler, time = 0, placeholder, disabled, align = 'center', className } = props;
|
||||
const { emitError } = useEmitLog();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [value, setValue] = useState<string>('');
|
||||
@@ -54,6 +55,7 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
||||
// we dont know the values in the rundown, escalate to handler
|
||||
if (newValue.startsWith('p') || newValue.startsWith('+')) {
|
||||
submitHandler(name, newValue);
|
||||
return true;
|
||||
}
|
||||
|
||||
const valueInMillis = parseUserTime(newValue);
|
||||
@@ -117,6 +119,7 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
size='sm'
|
||||
ref={inputRef}
|
||||
|
||||
@@ -2,6 +2,8 @@ $input-delayed-border-color: $ontime-delay-text;
|
||||
|
||||
.timeInput {
|
||||
border: 1px solid transparent;
|
||||
color: $label-gray;
|
||||
|
||||
&.delayed {
|
||||
border: 1px solid $input-delayed-border-color;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||
|
||||
import { debounce } from '../../utils/debounce';
|
||||
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
|
||||
|
||||
import style from './NavigationMenu.module.scss';
|
||||
|
||||
@@ -13,32 +12,10 @@ interface FloatingNavigationProps {
|
||||
|
||||
export default function FloatingNavigation(props: FloatingNavigationProps) {
|
||||
const { toggleMenu, toggleSettings } = props;
|
||||
const [showButton, setShowButton] = useState(false);
|
||||
|
||||
// show on mouse move
|
||||
useEffect(() => {
|
||||
let fadeOut: NodeJS.Timeout | null = null;
|
||||
const setShowMenuTrue = () => {
|
||||
setShowButton(true);
|
||||
if (fadeOut) {
|
||||
clearTimeout(fadeOut);
|
||||
}
|
||||
fadeOut = setTimeout(() => setShowButton(false), 3000);
|
||||
};
|
||||
|
||||
const debouncedShowMenu = debounce(setShowMenuTrue, 1000);
|
||||
|
||||
document.addEventListener('mousemove', debouncedShowMenu);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', debouncedShowMenu);
|
||||
if (fadeOut) {
|
||||
clearTimeout(fadeOut);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
const isButtonShown = useFadeOutOnInactivity();
|
||||
|
||||
return (
|
||||
<div className={`${style.buttonContainer} ${!showButton ? style.hidden : ''}`}>
|
||||
<div className={`${style.buttonContainer} ${!isButtonShown ? style.hidden : ''}`}>
|
||||
<button
|
||||
onClick={toggleMenu}
|
||||
aria-label='toggle menu'
|
||||
|
||||
@@ -38,6 +38,16 @@ $button-size: 3rem;
|
||||
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 {
|
||||
@extend .button;
|
||||
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 { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
||||
|
||||
import { isLocalhost } from '../../../externals';
|
||||
import { navigatorConstants } from '../../../viewerConfig';
|
||||
import { isLocalhost, serverPort } from '../../api/constants';
|
||||
import useClickOutside from '../../hooks/useClickOutside';
|
||||
import useElectronEvent from '../../hooks/useElectronEvent';
|
||||
import { useElectronEvent } from '../../hooks/useElectronEvent';
|
||||
import useInfo from '../../hooks-query/useInfo';
|
||||
import { useClientStore } from '../../stores/clientStore';
|
||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||
import { isKeyEnter } from '../../utils/keyEvent';
|
||||
import { handleLinks, openLink } from '../../utils/linkUtils';
|
||||
import { handleLinks, linkToOtherHost, openLink } from '../../utils/linkUtils';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import { RenameClientModal } from '../client-modal/RenameClientModal';
|
||||
import CopyTag from '../copy-tag/CopyTag';
|
||||
@@ -45,7 +45,7 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
|
||||
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
|
||||
const { fullscreen, toggle } = useFullscreen();
|
||||
const { toggleMirror } = useViewOptionsStore();
|
||||
const { mirror, toggleMirror } = useViewOptionsStore();
|
||||
const location = useLocation();
|
||||
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -57,7 +57,7 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
|
||||
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent maxWidth='21rem'>
|
||||
<DrawerContent maxWidth='22rem'>
|
||||
<DrawerHeader>
|
||||
<DrawerCloseButton size='lg' />
|
||||
Ontime
|
||||
@@ -65,7 +65,7 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
<DrawerBody padding={0}>
|
||||
<div className={style.buttonsContainer}>
|
||||
<div
|
||||
className={style.link}
|
||||
className={cx([style.link, fullscreen && style.current])}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onClick={toggle}
|
||||
@@ -77,7 +77,7 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
{fullscreen ? <IoContract /> : <IoExpand />}
|
||||
</div>
|
||||
<div
|
||||
className={style.link}
|
||||
className={cx([style.link, mirror && style.current])}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onClick={() => toggleMirror()}
|
||||
@@ -154,7 +154,8 @@ function OtherAddresses(props: OtherAddressesProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const address = `http://${nif.address}:${serverPort}${currentLocation}`;
|
||||
const address = linkToOtherHost(nif.address, currentLocation);
|
||||
|
||||
return (
|
||||
<CopyTag
|
||||
key={nif.name}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
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,13 +2,18 @@ import { memo, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useDisclosure } from '@chakra-ui/react';
|
||||
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
|
||||
import FloatingNavigation from './FloatingNavigation';
|
||||
import NavigationMenu from './NavigationMenu';
|
||||
import ViewLockedIcon from './ViewLockedIcon';
|
||||
|
||||
function ViewNavigationMenu() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
|
||||
|
||||
const isViewLocked = isStringBoolean(searchParams.get('locked'));
|
||||
|
||||
const showEditFormDrawer = useCallback(() => {
|
||||
searchParams.set('edit', 'true');
|
||||
setSearchParams(searchParams);
|
||||
@@ -16,6 +21,10 @@ function ViewNavigationMenu() {
|
||||
|
||||
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
|
||||
|
||||
if (isViewLocked) {
|
||||
return <ViewLockedIcon />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 25vh;
|
||||
|
||||
background: $ui-black;
|
||||
color: $ontime-color;
|
||||
font-weight: 200;
|
||||
font-size: 3rem;
|
||||
@@ -42,4 +46,4 @@
|
||||
to {
|
||||
background: rgba($red-500, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PropsWithChildren, useCallback, useContext } from 'react';
|
||||
|
||||
import { isLocalhost } from '../../../externals';
|
||||
import { AppContext } from '../../context/AppContext';
|
||||
|
||||
import PinPage from './PinPage';
|
||||
@@ -9,7 +10,6 @@ interface ProtectRouteProps {
|
||||
}
|
||||
|
||||
export default function ProtectRoute({ permission, children }: PropsWithChildren<ProtectRouteProps>) {
|
||||
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||
const { editorAuth, operatorAuth, validate } = useContext(AppContext);
|
||||
|
||||
const handleValidation = useCallback(
|
||||
@@ -29,7 +29,7 @@ export default function ProtectRoute({ permission, children }: PropsWithChildren
|
||||
return false;
|
||||
};
|
||||
|
||||
if (isLocal || hasRelevantAuth()) {
|
||||
if (isLocalhost || hasRelevantAuth()) {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment -- trying to make typescript happy
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { projectLogoPath } from '../../api/constants';
|
||||
|
||||
interface ViewLogoProps {
|
||||
name: string;
|
||||
className: string;
|
||||
}
|
||||
|
||||
export default function ViewLogo(props: ViewLogoProps) {
|
||||
const { name, className } = props;
|
||||
return <img src={`${projectLogoPath}/${name}`} className={className} />;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import PopoverPicker from '../input/popover-picker/PopoverPicker';
|
||||
import SwatchPicker from '../input/colour-input/SwatchPicker';
|
||||
|
||||
import style from './InlineColourPicker.module.scss';
|
||||
|
||||
@@ -20,13 +20,10 @@ export default function InlineColourPicker(props: InlineColourPickerProps) {
|
||||
const { name, value } = props;
|
||||
const [colour, setColour] = useState(() => ensureHex(value));
|
||||
|
||||
const debouncedChange = (value: string) => {
|
||||
setColour(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.inline}>
|
||||
<PopoverPicker color={colour} onChange={debouncedChange} />
|
||||
<SwatchPicker color={colour} onChange={setColour} alwaysDisplayColor />
|
||||
<span>{colour}</span>
|
||||
<input type='hidden' name={name} value={colour} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Select,
|
||||
Switch,
|
||||
} from '@chakra-ui/react';
|
||||
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
|
||||
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
|
||||
@@ -108,25 +109,21 @@ function MultiOption(props: EditFormMultiOptionProps) {
|
||||
const { paramField } = props;
|
||||
const { id, defaultValue } = paramField;
|
||||
|
||||
const optionFromParams = (searchParams.get(id) ?? '').toLocaleLowerCase();
|
||||
const defaultOptionValue = optionFromParams || defaultValue?.toLocaleLowerCase() || '';
|
||||
|
||||
const [paramState, setParamState] = useState<string>(defaultOptionValue);
|
||||
const optionFromParams = searchParams.getAll(id);
|
||||
const [paramState, setParamState] = useState<string[]>(optionFromParams || defaultValue || ['']);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input name={id} hidden readOnly value={paramState} />
|
||||
<Menu isLazy closeOnSelect={false} variant='ontime-on-dark'>
|
||||
<MenuButton as={Button} variant='ontime-subtle-white' position='relative' width='fit-content' fontWeight={400}>
|
||||
{paramField.title}
|
||||
{paramField.title} <IoChevronDown style={{ display: 'inline' }} />
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
<MenuOptionGroup
|
||||
type='checkbox'
|
||||
value={paramState.split('_')}
|
||||
onChange={(value) => {
|
||||
setParamState(typeof value === 'object' ? value.filter((v) => v !== '').join('_') : value);
|
||||
}}
|
||||
value={paramState}
|
||||
onChange={(value) => setParamState(Array.isArray(value) ? value : [value])}
|
||||
>
|
||||
{Object.values(paramField.values).map((option) => {
|
||||
const { value, label } = option;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.drawerFooter {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
justify-content: end;
|
||||
gap: $section-spacing;
|
||||
|
||||
button {
|
||||
@@ -8,6 +8,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
font-size: $inner-section-text-size;
|
||||
color: $label-gray;
|
||||
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
DrawerOverlay,
|
||||
useDisclosure,
|
||||
} 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 { isSection, ViewOption } from './types';
|
||||
@@ -55,10 +58,28 @@ const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOp
|
||||
// unfortunately this means we run all the strings through the sanitation
|
||||
const valueWithoutHash = sanitiseColour(value);
|
||||
if (defaultValues[id] !== valueWithoutHash) {
|
||||
newSearchParams.set(id, valueWithoutHash);
|
||||
handleValueString(id, valueWithoutHash);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/** Utility function contains logic to add a value into the searchParams object */
|
||||
function handleValueString(id: string, value: string) {
|
||||
const maybeMultipleValues = value.split(',');
|
||||
|
||||
// we need to check if the value contains comma separated list, for the case of the multi-select data
|
||||
if (Array.isArray(maybeMultipleValues) && maybeMultipleValues.length > 1) {
|
||||
const added = new Set();
|
||||
maybeMultipleValues.forEach((v) => {
|
||||
if (!added.has(v)) {
|
||||
added.add(v);
|
||||
newSearchParams.append(id, v);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
newSearchParams.set(id, value);
|
||||
}
|
||||
}
|
||||
return newSearchParams;
|
||||
};
|
||||
|
||||
@@ -69,6 +90,8 @@ interface EditFormDrawerProps {
|
||||
// TODO: this is a good candidate for memoisation, but needs the paramFields to be stable
|
||||
export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { data: viewSettings } = useViewSettings();
|
||||
|
||||
const { isOpen, onClose, onOpen } = useDisclosure();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -97,8 +120,6 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
||||
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
|
||||
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
|
||||
setSearchParams(newSearchParams);
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -111,6 +132,12 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
||||
</DrawerHeader>
|
||||
|
||||
<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}>
|
||||
{viewOptions.map((option) => {
|
||||
if (isSection(option)) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
|
||||
import { version } from '../../../../../package.json';
|
||||
import { isLocalhost } from '../../externals';
|
||||
import { APP_VERSION } from '../api/constants';
|
||||
import { getLatestVersion, HasUpdate } from '../api/external';
|
||||
|
||||
const placeholder: HasUpdate & { hasUpdates: boolean } = { url: '', version: '', hasUpdates: false };
|
||||
|
||||
export default function useAppVersion() {
|
||||
const {
|
||||
data: fetchData,
|
||||
status,
|
||||
isFetching,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: APP_VERSION,
|
||||
queryFn: getLatestVersion,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: false,
|
||||
staleTime: dayInMs,
|
||||
enabled: isLocalhost,
|
||||
});
|
||||
|
||||
const hasUpdates = fetchData?.version && !fetchData.version.includes(version);
|
||||
|
||||
const data = fetchData ? { ...fetchData, hasUpdates } : placeholder;
|
||||
|
||||
return { data, placeholder, status, isFetching, isError, refetch };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ProjectFileListResponse } from 'ontime-types';
|
||||
|
||||
@@ -22,3 +23,23 @@ export function useProjectList() {
|
||||
});
|
||||
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, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
|
||||
|
||||
@@ -6,6 +6,8 @@ import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { RUNDOWN } from '../api/constants';
|
||||
import { fetchNormalisedRundown } from '../api/rundown';
|
||||
|
||||
import useProjectData from './useProjectData';
|
||||
|
||||
// revision is -1 so that the remote revision is higher
|
||||
const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 };
|
||||
|
||||
@@ -24,7 +26,9 @@ export default function useRundown() {
|
||||
|
||||
export function useFlatRundown() {
|
||||
const { data, status } = useRundown();
|
||||
const { data: projectData } = useProjectData();
|
||||
|
||||
const loadedProject = useRef<string>('');
|
||||
const [prevRevision, setPrevRevision] = useState<number>(-1);
|
||||
const [flatRunDown, setFlatRunDown] = useState<OntimeRundown>([]);
|
||||
|
||||
@@ -37,5 +41,14 @@ export function useFlatRundown() {
|
||||
}
|
||||
}, [data.order, data.revision, data.rundown, prevRevision]);
|
||||
|
||||
// TODO: should we have a project id field?
|
||||
// invalidate current version if project changes
|
||||
useEffect(() => {
|
||||
if (projectData?.title !== loadedProject.current) {
|
||||
setPrevRevision(-1);
|
||||
loadedProject.current = projectData?.title ?? '';
|
||||
}
|
||||
}, [projectData]);
|
||||
|
||||
return { data: flatRunDown, status };
|
||||
}
|
||||
|
||||
@@ -4,16 +4,23 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useClientStore } from '../stores/clientStore';
|
||||
import { socketSendJson } from '../utils/socket';
|
||||
|
||||
import { useIsOnline } from './useSocket';
|
||||
|
||||
export const useClientPath = () => {
|
||||
const navigate = useNavigate();
|
||||
const { pathname, search } = useLocation();
|
||||
const redirect = useClientStore((store) => store.redirect);
|
||||
const setRedirect = useClientStore((store) => store.setRedirect);
|
||||
const { redirect, setRedirect } = useClientStore((store) => ({
|
||||
redirect: store.redirect,
|
||||
setRedirect: store.setRedirect,
|
||||
}));
|
||||
const isOnline = useIsOnline();
|
||||
|
||||
// notify of client path changes
|
||||
useEffect(() => {
|
||||
if (!isOnline) return;
|
||||
|
||||
socketSendJson('set-client-path', pathname + search);
|
||||
}, [pathname, search]);
|
||||
}, [pathname, search, isOnline]);
|
||||
|
||||
// navigate to new path when received from server
|
||||
useEffect(() => {
|
||||
@@ -26,7 +33,7 @@ export const useClientPath = () => {
|
||||
|
||||
// navigate if there is a path change
|
||||
if (redirect !== pathname + search) {
|
||||
navigate(redirect);
|
||||
navigate(redirect, { replace: true });
|
||||
}
|
||||
}, [navigate, pathname, redirect, search, setRedirect]);
|
||||
};
|
||||
|
||||
@@ -1,31 +1,42 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { addDialog } from '../stores/dialogStore';
|
||||
|
||||
const isElectron = window.process?.type === 'renderer';
|
||||
const ipcRenderer = isElectron ? window.require('electron').ipcRenderer : null;
|
||||
|
||||
export default function useElectronEvent() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
export function useElectronEvent() {
|
||||
const sendToElectron = useCallback((channel: string, args?: string | Record<string, unknown>) => {
|
||||
if (isElectron && ipcRenderer) {
|
||||
ipcRenderer.send(channel, args);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { isElectron, sendToElectron };
|
||||
}
|
||||
|
||||
export function useElectronListener() {
|
||||
const navigate = useNavigate();
|
||||
const { isElectron } = useElectronEvent();
|
||||
|
||||
// listen to requests to change the editor location
|
||||
useEffect(() => {
|
||||
if (isElectron) {
|
||||
ipcRenderer.on('request-editor-location', (_event: unknown, location: string) => {
|
||||
navigate(location, { relative: 'route' });
|
||||
});
|
||||
|
||||
ipcRenderer.on('dialog', (_event: unknown, dialog: string) => {
|
||||
if (dialog === 'welcome') {
|
||||
addDialog('welcome');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Clean the listener after the component is dismounted
|
||||
return () => {
|
||||
ipcRenderer?.removeAllListeners();
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
return { isElectron, sendToElectron };
|
||||
}, [isElectron, navigate]);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
|
||||
import {
|
||||
dayInMs,
|
||||
getLinkedTimes,
|
||||
getPreviousEventNormal,
|
||||
MILLIS_PER_SECOND,
|
||||
parseUserTime,
|
||||
reorderArray,
|
||||
swapEventData,
|
||||
} from 'ontime-utils';
|
||||
isOntimeEvent,
|
||||
OntimeBlock,
|
||||
OntimeDelay,
|
||||
OntimeEvent,
|
||||
OntimeRundownEntry,
|
||||
RundownCached,
|
||||
TimeField,
|
||||
TimeStrategy,
|
||||
TransientEventPayload,
|
||||
} from 'ontime-types';
|
||||
import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils';
|
||||
|
||||
import { RUNDOWN } from '../api/constants';
|
||||
import {
|
||||
@@ -27,6 +29,16 @@ import {
|
||||
import { logAxiosError } from '../api/utils';
|
||||
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
|
||||
*/
|
||||
@@ -55,31 +67,19 @@ export const useEventAction = () => {
|
||||
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
|
||||
*/
|
||||
const addEvent = useCallback(
|
||||
async (event: Partial<OntimeRundownEntry>, options?: EventOptions) => {
|
||||
const newEvent: Partial<OntimeRundownEntry> = { ...event };
|
||||
async (event: Partial<OntimeEvent | OntimeDelay | OntimeBlock>, options?: EventOptions) => {
|
||||
const newEvent: TransientEventPayload = { ...event };
|
||||
|
||||
// ************* CHECK OPTIONS specific to events
|
||||
if (isOntimeEvent(newEvent)) {
|
||||
// merge creation time options with event settings
|
||||
const applicationOptions = {
|
||||
after: options?.after,
|
||||
before: options?.before,
|
||||
defaultPublic: options?.defaultPublic ?? defaultPublic,
|
||||
lastEventId: options?.lastEventId,
|
||||
linkPrevious: options?.linkPrevious ?? linkPrevious,
|
||||
@@ -129,11 +129,16 @@ export const useEventAction = () => {
|
||||
|
||||
// handle adding options that concern all event type
|
||||
if (options?.after) {
|
||||
// @ts-expect-error -- not sure how to type this, <after> is a transient property
|
||||
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 {
|
||||
await _addEventMutation.mutateAsync(newEvent);
|
||||
await _addEventMutation.mutateAsync(newEvent as TransientEventPayload);
|
||||
} catch (error) {
|
||||
logAxiosError('Failed adding event', error);
|
||||
}
|
||||
@@ -211,13 +216,75 @@ export const useEventAction = () => {
|
||||
[updateEvent],
|
||||
);
|
||||
|
||||
type TimeField = 'timeStart' | 'timeEnd' | 'duration';
|
||||
/**
|
||||
* 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(
|
||||
async (eventId: string, field: TimeField, value: string) => {
|
||||
const getPreviousEnd = (): number => {
|
||||
async (eventId: string, field: TimeField, value: string, lockOnUpdate?: boolean) => {
|
||||
// an empty value with no lock has no domain validity
|
||||
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);
|
||||
|
||||
if (!cachedRundown?.order || !cachedRundown?.rundown) {
|
||||
@@ -237,72 +304,6 @@ export const useEventAction = () => {
|
||||
}
|
||||
}
|
||||
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],
|
||||
@@ -614,7 +615,6 @@ export const useEventAction = () => {
|
||||
batchUpdateEvents,
|
||||
deleteEvent,
|
||||
deleteAllEvents,
|
||||
linkTimer,
|
||||
reorderEvent,
|
||||
swapEvents,
|
||||
updateEvent,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
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 { isDev } from '../api/constants';
|
||||
import { isDev } from '../../externals';
|
||||
|
||||
type noop = (this: any, ...args: any[]) => any;
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ export const useMessagePreview = () => {
|
||||
showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external),
|
||||
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
|
||||
timerType: state.eventNow?.timerType ?? null,
|
||||
countToEnd: state.eventNow?.countToEnd ?? false,
|
||||
});
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
@@ -148,19 +149,21 @@ export const setAuxTimer = {
|
||||
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
|
||||
};
|
||||
|
||||
export const useCuesheet = () => {
|
||||
export const useSelectedEventId = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
playback: state.timer.playback,
|
||||
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);
|
||||
};
|
||||
|
||||
export const useCurrentBlockId = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
currentBlockId: state.currentBlock.block?.id ?? null,
|
||||
});
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
|
||||
export const setEventPlayback = {
|
||||
loadEvent: (id: string) => socketSendJson('load', { id }),
|
||||
startEvent: (id: string) => socketSendJson('start', { id }),
|
||||
@@ -230,3 +233,20 @@ export const useTimelineStatus = () => {
|
||||
|
||||
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,4 +7,5 @@ export const projectDataPlaceholder: ProjectData = {
|
||||
publicInfo: '',
|
||||
backstageUrl: '',
|
||||
backstageInfo: '',
|
||||
projectLogo: null,
|
||||
};
|
||||
|
||||
@@ -15,4 +15,5 @@ export type ViewExtendedTimer = {
|
||||
|
||||
clock: number;
|
||||
timerType: TimerType;
|
||||
countToEnd: boolean;
|
||||
};
|
||||
|
||||
@@ -15,4 +15,5 @@ export type OverridableOptions = {
|
||||
language?: string;
|
||||
showProgressBar?: boolean;
|
||||
hideTimerSeconds?: boolean;
|
||||
removeLeadingZeros?: boolean;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
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);
|
||||
}
|
||||
@@ -16,11 +16,17 @@ export const useRuntimeStore = <T>(selector: (state: RuntimeStore) => T) =>
|
||||
|
||||
/**
|
||||
* Allows patching a property of the runtime store
|
||||
* @param key
|
||||
* @param value
|
||||
*/
|
||||
export function patchRuntime<K extends keyof RuntimeStore>(key: K, value: RuntimeStore[K]): void {
|
||||
export function patchRuntimeProperty<K extends keyof RuntimeStore>(key: K, value: RuntimeStore[K]) {
|
||||
const state = runtimeStore.getState();
|
||||
state[key] = value;
|
||||
runtimeStore.setState({ ...state });
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows patching the entire runtime store
|
||||
*/
|
||||
export function patchRuntime(patch: Partial<RuntimeStore>) {
|
||||
const state = runtimeStore.getState();
|
||||
runtimeStore.setState({ ...state, ...patch });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* @param millis
|
||||
* @param format
|
||||
* @param millis
|
||||
* @param format
|
||||
*/
|
||||
export function millisToDelayString(millis: MaybeNumber, format: 'compact' | 'expanded' = 'compact'): string {
|
||||
if (millis == null || millis === 0) {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
export function debounce(callback: () => void, wait: number) {
|
||||
export function debounce<T extends any[]>(callback: (...args: T) => void, wait: number) {
|
||||
let timeout: NodeJS.Timeout | null;
|
||||
return () => {
|
||||
return (...args: T) => {
|
||||
if (timeout) {
|
||||
return;
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
timeout = setTimeout(() => {
|
||||
timeout = null;
|
||||
callback();
|
||||
callback(...args);
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { OntimeEvent, SupportedEvent } from 'ontime-types';
|
||||
* @return {OntimeEvent} clean event
|
||||
*/
|
||||
type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>;
|
||||
export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
|
||||
export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
|
||||
return {
|
||||
type: SupportedEvent.Event,
|
||||
title: event.title,
|
||||
@@ -17,13 +17,16 @@ export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
|
||||
timeEnd: event.timeEnd,
|
||||
timerType: event.timerType,
|
||||
timeStrategy: event.timeStrategy,
|
||||
countToEnd: event.countToEnd,
|
||||
linkStart: event.linkStart,
|
||||
endAction: event.endAction,
|
||||
isPublic: event.isPublic,
|
||||
skip: event.skip,
|
||||
colour: event.colour,
|
||||
after,
|
||||
revision: 0,
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
timeWarning: event.timeWarning,
|
||||
timeDanger: event.timeDanger,
|
||||
custom: {},
|
||||
|
||||
@@ -7,3 +7,11 @@ export function isKeyEnter<T>(event: KeyboardEvent<T>): boolean {
|
||||
export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean {
|
||||
return event.key === 'Escape';
|
||||
}
|
||||
|
||||
export function preventEscape<T>(event: KeyboardEvent<T>, callback?: () => void): void {
|
||||
if (isKeyEscape(event)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
callback?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* 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}`);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Log, RundownCached, RuntimeStore } from 'ontime-types';
|
||||
|
||||
import { CLIENT_LIST, CUSTOM_FIELDS, isProduction, RUNDOWN, RUNTIME, websocketUrl } from '../api/constants';
|
||||
import { isProduction, websocketUrl } from '../../externals';
|
||||
import { CLIENT_LIST, CUSTOM_FIELDS, RUNDOWN, RUNTIME } from '../api/constants';
|
||||
import { invalidateAllCaches } from '../api/utils';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
import {
|
||||
getClientId,
|
||||
@@ -10,8 +12,9 @@ import {
|
||||
setClientRedirect,
|
||||
setClients,
|
||||
} from '../stores/clientStore';
|
||||
import { addDialog } from '../stores/dialogStore';
|
||||
import { addLog } from '../stores/logger';
|
||||
import { patchRuntime, runtimeStore } from '../stores/runtime';
|
||||
import { patchRuntime, patchRuntimeProperty } from '../stores/runtime';
|
||||
|
||||
export let websocket: WebSocket | null = null;
|
||||
let reconnectTimeout: NodeJS.Timeout | null = null;
|
||||
@@ -36,12 +39,13 @@ export const connectSocket = () => {
|
||||
}
|
||||
|
||||
socketSendJson('set-client-type', 'ontime');
|
||||
|
||||
socketSendJson('set-client-path', location.pathname + location.search);
|
||||
setOnlineStatus(true);
|
||||
};
|
||||
|
||||
websocket.onclose = () => {
|
||||
console.warn('WebSocket disconnected');
|
||||
setOnlineStatus(false);
|
||||
|
||||
if (shouldReconnect) {
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
console.warn('WebSocket: attempting reconnect');
|
||||
@@ -68,6 +72,12 @@ export const connectSocket = () => {
|
||||
}
|
||||
|
||||
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': {
|
||||
if (typeof payload === 'string') {
|
||||
setClientId(payload);
|
||||
@@ -110,78 +120,88 @@ export const connectSocket = () => {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'dialog': {
|
||||
if (payload.dialog === 'welcome') {
|
||||
addDialog('welcome');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ontime-log': {
|
||||
addLog(payload as Log);
|
||||
break;
|
||||
}
|
||||
case 'ontime': {
|
||||
runtimeStore.setState(payload as RuntimeStore);
|
||||
if (!isProduction) {
|
||||
ontimeQueryClient.setQueryData(RUNTIME, data.payload);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- removing the key from the payload
|
||||
const { ping, ...serverPayload } = payload as Partial<RuntimeStore>;
|
||||
|
||||
patchRuntime(serverPayload);
|
||||
updateDevTools(serverPayload);
|
||||
break;
|
||||
}
|
||||
case 'ontime-clock': {
|
||||
patchRuntime('clock', payload);
|
||||
patchRuntimeProperty('clock', payload);
|
||||
updateDevTools({ clock: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-timer': {
|
||||
patchRuntime('timer', payload);
|
||||
patchRuntimeProperty('timer', payload);
|
||||
updateDevTools({ timer: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-onAir': {
|
||||
patchRuntime('onAir', payload);
|
||||
patchRuntimeProperty('onAir', payload);
|
||||
updateDevTools({ onAir: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-message': {
|
||||
patchRuntime('message', payload);
|
||||
patchRuntimeProperty('message', payload);
|
||||
updateDevTools({ message: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-runtime': {
|
||||
patchRuntime('runtime', payload);
|
||||
patchRuntimeProperty('runtime', payload);
|
||||
updateDevTools({ runtime: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-eventNow': {
|
||||
patchRuntime('eventNow', payload);
|
||||
patchRuntimeProperty('eventNow', payload);
|
||||
updateDevTools({ eventNow: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-currentBlock': {
|
||||
patchRuntime('currentBlock', payload);
|
||||
patchRuntimeProperty('currentBlock', payload);
|
||||
updateDevTools({ currentBlock: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-publicEventNow': {
|
||||
patchRuntime('publicEventNow', payload);
|
||||
patchRuntimeProperty('publicEventNow', payload);
|
||||
updateDevTools({ publicEventNow: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-eventNext': {
|
||||
patchRuntime('eventNext', payload);
|
||||
patchRuntimeProperty('eventNext', payload);
|
||||
updateDevTools({ eventNext: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-publicEventNext': {
|
||||
patchRuntime('publicEventNext', payload);
|
||||
patchRuntimeProperty('publicEventNext', payload);
|
||||
updateDevTools({ publicEventNext: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-auxtimer1': {
|
||||
patchRuntime('auxtimer1', payload);
|
||||
patchRuntimeProperty('auxtimer1', payload);
|
||||
updateDevTools({ auxtimer1: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-refetch': {
|
||||
// the refetch message signals that the rundown has changed in the server side
|
||||
const { revision } = payload;
|
||||
const { revision, reload } = payload;
|
||||
const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1;
|
||||
|
||||
if (revision > currentRevision) {
|
||||
if (reload) {
|
||||
invalidateAllCaches();
|
||||
} else if (revision > currentRevision) {
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
|
||||
}
|
||||
@@ -222,3 +242,15 @@ 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 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
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);
|
||||
};
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export function validateProjectFile(file: File) {
|
||||
|
||||
// Limit file size of a project file to around 1MB
|
||||
if (file.size > 1_000_000) {
|
||||
throw new Error('File size limit (10MB) exceeded');
|
||||
throw new Error('File size limit (1MB) exceeded');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,3 +45,19 @@ export function isExcelFile(file: File | null) {
|
||||
export function isOntimeFile(file: File | null) {
|
||||
return file?.name.endsWith('.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection of rules for pre-validating a project file
|
||||
* @param file
|
||||
*/
|
||||
export function validateLogo(file: File) {
|
||||
// Check if file is empty
|
||||
if (file.size === 0) {
|
||||
throw new Error('File is empty');
|
||||
}
|
||||
|
||||
// Limit file size of a project file to around 1MB
|
||||
if (file.size > 1_000_000) {
|
||||
throw new Error('File size limit (1MB) exceeded');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ declare module '*.scss' {
|
||||
type ListenerType = (event: 'string', args: unknown[]) => void;
|
||||
|
||||
declare global {
|
||||
/**
|
||||
* Register the electron properties
|
||||
*/
|
||||
interface Window {
|
||||
ipcRenderer: {
|
||||
send: (channel: string, args?: string | object) => void;
|
||||
@@ -17,6 +20,24 @@ 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' {
|
||||
interface CSSProperties {
|
||||
[key: `--${string}`]: string | number;
|
||||
|
||||
@@ -1,6 +1,75 @@
|
||||
/**
|
||||
* 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 apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
|
||||
export const websiteUrl = 'https://www.getontime.no';
|
||||
export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
|
||||
|
||||
export const documentationUrl = 'https://docs.getontime.no';
|
||||
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';
|
||||
|
||||
export default function AppSettings() {
|
||||
const { close, panel, location } = useAppSettingsNavigation();
|
||||
const { close, panel, location, setLocation } = useAppSettingsNavigation();
|
||||
useKeyDown(close, 'Escape');
|
||||
|
||||
return (
|
||||
@@ -26,7 +26,7 @@ export default function AppSettings() {
|
||||
<ErrorBoundary>
|
||||
<PanelList selectedPanel={panel} location={location} />
|
||||
<PanelContent onClose={close}>
|
||||
{panel === 'project' && <ProjectPanel location={location} />}
|
||||
{panel === 'project' && <ProjectPanel location={location} setLocation={setLocation} />}
|
||||
{panel === 'general' && <GeneralPanel location={location} />}
|
||||
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
|
||||
{panel === 'sources' && <SourcesPanel />}
|
||||
|
||||
@@ -40,12 +40,16 @@ ul {
|
||||
background-color: $gray-1100;
|
||||
}
|
||||
|
||||
&.unsaved::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background-color: $blue-400;
|
||||
&.highlight {
|
||||
color: $red-400;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 0.5em;
|
||||
height: 0.5em;
|
||||
border-radius: 99px;
|
||||
background-color: $red-400;
|
||||
}
|
||||
}
|
||||
|
||||
&.split {
|
||||
|
||||
@@ -1,66 +1,90 @@
|
||||
import { Fragment } from 'react';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
|
||||
import { isKeyEnter } from '../../../common/utils/keyEvent';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { PanelBaseProps, settingPanels, useSettingsStore } from '../settingsStore';
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
import { SettingsOption, SettingsOptionId, useAppSettingsMenu } from '../useAppSettingsMenu';
|
||||
import useAppSettingsNavigation from '../useAppSettingsNavigation';
|
||||
|
||||
import style from './PanelList.module.scss';
|
||||
|
||||
export interface PanelBaseProps {
|
||||
location?: string;
|
||||
}
|
||||
|
||||
interface PanelListProps extends PanelBaseProps {
|
||||
selectedPanel: string;
|
||||
}
|
||||
|
||||
export default function PanelList({ selectedPanel, location }: PanelListProps) {
|
||||
const { setLocation } = useAppSettingsNavigation();
|
||||
const { hasUnsavedChanges } = useSettingsStore();
|
||||
const { options } = useAppSettingsMenu();
|
||||
|
||||
return (
|
||||
<ul className={style.tabs}>
|
||||
{settingPanels.map((panel) => {
|
||||
const unsaved = hasUnsavedChanges(panel.id);
|
||||
|
||||
const classes = cx([
|
||||
style.primary,
|
||||
selectedPanel === panel.id ? style.active : null,
|
||||
panel.split ? style.split : null,
|
||||
unsaved ? style.unsaved : null,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Fragment key={panel.id}>
|
||||
<li
|
||||
key={panel.id}
|
||||
onClick={() => setLocation(panel.id)}
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && setLocation(panel.id);
|
||||
}}
|
||||
className={classes}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
>
|
||||
{panel.label}
|
||||
</li>
|
||||
{panel.secondary?.map((secondary) => {
|
||||
const id = secondary.id.split('__')[1];
|
||||
const secondaryClasses = cx([style.secondary, location === id ? style.active : null]);
|
||||
return (
|
||||
<li
|
||||
key={secondary.id}
|
||||
onClick={() => setLocation(secondary.id)}
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && setLocation(secondary.id);
|
||||
}}
|
||||
className={secondaryClasses}
|
||||
role='button'
|
||||
>
|
||||
{secondary.label}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
{options.map((panel) => {
|
||||
const isSelected = selectedPanel === panel.id;
|
||||
if (panel.highlight) {
|
||||
return (
|
||||
<Tooltip key={panel.id} label={panel.highlight} openDelay={tooltipDelayFast} shouldWrapChildren>
|
||||
<PanelListItem panel={panel} location={location} isSelected={isSelected} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />;
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
interface PanelListItemProps {
|
||||
panel: SettingsOption;
|
||||
isSelected: boolean;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
function PanelListItem(props: PanelListItemProps) {
|
||||
const { panel, isSelected, location } = props;
|
||||
const { setLocation } = useAppSettingsNavigation();
|
||||
|
||||
const classes = cx([
|
||||
style.primary,
|
||||
isSelected && style.active,
|
||||
panel.split && style.split,
|
||||
panel.highlight && style.highlight,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Fragment key={panel.id}>
|
||||
<li
|
||||
key={panel.id}
|
||||
onClick={() => setLocation(panel.id as SettingsOptionId)}
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && setLocation(panel.id as SettingsOptionId);
|
||||
}}
|
||||
className={classes}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
>
|
||||
{panel.label}
|
||||
</li>
|
||||
{panel.secondary?.map((secondary) => {
|
||||
const id = secondary.id.split('__')[1];
|
||||
const secondaryClasses = cx([style.secondary, 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,15 +26,24 @@ $inner-padding: 1rem;
|
||||
color: $gray-300;
|
||||
}
|
||||
|
||||
.section {
|
||||
position: relative;
|
||||
margin-top: 2rem;
|
||||
.section, .indent {
|
||||
font-size: calc(1rem - 1px);
|
||||
max-width: 800px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
color: $ui-white;
|
||||
|
||||
}
|
||||
|
||||
.section {
|
||||
position: relative;
|
||||
margin-top: 2rem;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.indent {
|
||||
padding: 1rem;
|
||||
background-color: $gray-1350;
|
||||
}
|
||||
|
||||
.paragraph {
|
||||
@@ -49,6 +58,18 @@ $inner-padding: 1rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
font-size: calc(1rem - 2px);
|
||||
color: $orange-500;
|
||||
padding-inline: 0.25rem;
|
||||
}
|
||||
|
||||
.blockquote {
|
||||
padding-left: 0.5rem;
|
||||
border-left: 2px solid $gray-100;
|
||||
background-color: $white-3;
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: $inner-section-text-size;
|
||||
display: block;
|
||||
@@ -72,7 +93,6 @@ $inner-padding: 1rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
box-shadow: 0 1px $white-10;
|
||||
background-color: $gray-1350;
|
||||
}
|
||||
|
||||
@@ -151,6 +171,40 @@ $inner-padding: 1rem;
|
||||
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 {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
@@ -1,8 +1,10 @@
|
||||
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 style from './Panel.module.scss';
|
||||
import style from './PanelUtils.module.scss';
|
||||
|
||||
export function Header({ children }: { children: ReactNode }) {
|
||||
return <h2 className={style.header}>{children}</h2>;
|
||||
@@ -22,10 +24,19 @@ type SectionProps<C extends AllowedTags> = {
|
||||
children: ReactNode;
|
||||
} & JSX.IntrinsicElements[C];
|
||||
|
||||
export function Section<C extends AllowedTags = 'div'>({ as, children, ...props }: SectionProps<C>) {
|
||||
export function Section<C extends AllowedTags = 'div'>({ as, className, children, ...props }: SectionProps<C>) {
|
||||
const Element = as ?? 'div';
|
||||
return (
|
||||
<Element className={style.section} {...(props as HTMLAttributes<HTMLElement>)}>
|
||||
<Element className={cx([style.section, className])} {...(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}
|
||||
</Element>
|
||||
);
|
||||
@@ -35,9 +46,9 @@ export function Paragraph({ children }: { children: ReactNode }) {
|
||||
return <p className={style.paragraph}>{children}</p>;
|
||||
}
|
||||
|
||||
export function Card({ children, ...props }: { children: ReactNode } & JSX.IntrinsicElements['div']) {
|
||||
export function Card({ children, className, ...props }: { children: ReactNode } & JSX.IntrinsicElements['div']) {
|
||||
return (
|
||||
<div className={style.card} {...props}>
|
||||
<div className={cx([style.card, className])} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -52,8 +63,21 @@ export function Table({ className, children }: { className?: string; children: R
|
||||
);
|
||||
}
|
||||
|
||||
export function ListGroup({ children }: { children: ReactNode }) {
|
||||
return <ul className={style.listGroup}>{children}</ul>;
|
||||
export function TableEmpty({ handleClick }: { handleClick: () => void }) {
|
||||
return (
|
||||
<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 }) {
|
||||
@@ -74,6 +98,14 @@ export function Description({ children }: { children: ReactNode }) {
|
||||
return <div className={style.fieldDescription}>{children}</div>;
|
||||
}
|
||||
|
||||
export function Highlight({ children }: { children: ReactNode }) {
|
||||
return <code className={style.highlight}>{children}</code>;
|
||||
}
|
||||
|
||||
export function BlockQuote({ children }: { children: ReactNode }) {
|
||||
return <blockquote className={style.blockquote}>{children}</blockquote>;
|
||||
}
|
||||
|
||||
export function Error({ children }: { children: ReactNode }) {
|
||||
return <div className={style.fieldError}>{children}</div>;
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { version } from '../../../../../package.json';
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import { documentationUrl, githubUrl, websiteUrl } from '../../../../externals';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import { discordUrl, documentationUrl, githubUrl, websiteUrl } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import CheckUpdatesButton from './CheckUpdatesButton';
|
||||
import AppVersion from './AppVersion';
|
||||
|
||||
export default function AboutPanel() {
|
||||
return (
|
||||
@@ -20,11 +19,11 @@ export default function AboutPanel() {
|
||||
<Panel.SubHeader>Links</Panel.SubHeader>
|
||||
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
|
||||
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
|
||||
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
<Panel.SubHeader>Current version</Panel.SubHeader>
|
||||
<Panel.Paragraph>{`You are currently using Ontime ${version}`}</Panel.Paragraph>
|
||||
<CheckUpdatesButton version={version} />
|
||||
<AppVersion />
|
||||
</Panel.Section>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import 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>;
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
|
||||
import { getLatestVersion, HasUpdate } from '../../../../common/api/external';
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
|
||||
import style from '../Panel.module.scss';
|
||||
|
||||
type CheckFail = {
|
||||
error: string;
|
||||
};
|
||||
|
||||
type CheckIsLatest = {
|
||||
latest: true;
|
||||
};
|
||||
|
||||
type CheckRemote = CheckFail | CheckIsLatest | HasUpdate;
|
||||
|
||||
interface CheckUpdatesButtonProps {
|
||||
version: string;
|
||||
}
|
||||
|
||||
export default function CheckUpdatesButton(props: CheckUpdatesButtonProps) {
|
||||
const { version } = props;
|
||||
|
||||
const [updateMessage, setUpdateMessage] = useState<CheckRemote | null>(null);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
|
||||
/**
|
||||
* Handles version comparison and returns component with message
|
||||
*/
|
||||
const versionCheck = async () => {
|
||||
setIsFetching(true);
|
||||
|
||||
try {
|
||||
const latest = await getLatestVersion();
|
||||
|
||||
if (!latest.version.includes(version)) {
|
||||
// new version, pass data to component
|
||||
setUpdateMessage(latest);
|
||||
} else {
|
||||
setUpdateMessage({ latest: true });
|
||||
}
|
||||
} catch {
|
||||
setUpdateMessage({ error: 'Error reaching server' });
|
||||
} finally {
|
||||
setIsFetching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disableButton = Boolean(updateMessage && 'version' in updateMessage);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={versionCheck}
|
||||
variant='ontime-filled'
|
||||
isLoading={isFetching}
|
||||
isDisabled={disableButton}
|
||||
size='sm'
|
||||
maxWidth='max-content'
|
||||
>
|
||||
Check for updates
|
||||
</Button>
|
||||
<ResolveUpdateMessage updateMessage={updateMessage} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ResolveUpdateMessage(props: { updateMessage: CheckRemote | null }) {
|
||||
const { updateMessage } = props;
|
||||
|
||||
if (updateMessage && 'error' in updateMessage) {
|
||||
return <span className={style.error}>{updateMessage.error}</span>;
|
||||
}
|
||||
if (updateMessage && 'url' in updateMessage) {
|
||||
return <ExternalLink href={updateMessage?.url}>{`New version available: ${updateMessage.version}`}</ExternalLink>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as Panel from '../PanelUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import ClientList from './ClientList';
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { RedirectClientModal } from '../../../../common/components/client-modal/
|
||||
import { RenameClientModal } from '../../../../common/components/client-modal/RenameClientModal';
|
||||
import { setClientRemote } from '../../../../common/hooks/useSocket';
|
||||
import { useClientStore } from '../../../../common/stores/clientStore';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './ClientControlPanel.module.scss';
|
||||
|
||||
@@ -74,7 +74,7 @@ export default function ClientList() {
|
||||
)}
|
||||
{name}
|
||||
</td>
|
||||
{isCurrent ? <td /> : <td className={style.pathList}>{path}</td>}
|
||||
<td className={style.pathList}>{path}</td>
|
||||
<td className={style.actionButtons}>
|
||||
<Button
|
||||
size='xs'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { PanelBaseProps } from '../../settingsStore';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import CustomFields from './custom-fields/CustomFields';
|
||||
import UrlPresetsForm from './UrlPresetsForm';
|
||||
|
||||
@@ -13,7 +13,7 @@ import ExternalLink from '../../../../common/components/external-link/ExternalLi
|
||||
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
|
||||
import { handleLinks } from '../../../../common/utils/linkUtils';
|
||||
import { validateUrlPresetPath } from '../../../../common/utils/urlPresets';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './FeatureSettings.module.scss';
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function UrlPresetsForm() {
|
||||
setError,
|
||||
formState: { isSubmitting, isDirty, isValid, errors },
|
||||
} = useForm<FormData>({
|
||||
mode: 'onBlur',
|
||||
mode: 'onChange',
|
||||
defaultValues: { data },
|
||||
values: { data },
|
||||
resetOptions: {
|
||||
@@ -105,12 +105,25 @@ export default function UrlPresetsForm() {
|
||||
<Alert status='info' variant='ontime-on-dark-info'>
|
||||
<AlertIcon />
|
||||
<AlertDescription>
|
||||
URL Presets
|
||||
URL presets are user defined aliases to Ontime URLs
|
||||
<br />
|
||||
<br />
|
||||
Custom presets allow providing a short name for any ontime URL. <br />
|
||||
- Providing dynamic URLs for automation or unattended screens <br />- Simplifying complex URLs
|
||||
<b>Preset Name</b> <br />
|
||||
The alias for the URL. This will be the URL you will be calling. eg: <br />
|
||||
<Panel.BlockQuote>
|
||||
Preset name <Panel.Highlight>cam3</Panel.Highlight> called as{' '}
|
||||
<Panel.Highlight>http://localhost:4001/cam3</Panel.Highlight>
|
||||
</Panel.BlockQuote>
|
||||
<br />
|
||||
<b>URL Segment</b> <br />
|
||||
The corresponding alias path and configuration parameters. eg: <br />
|
||||
<Panel.BlockQuote>
|
||||
URL segment <Panel.Highlight>backstage?hidePast=true&stopCycle=true</Panel.Highlight> corresponds to
|
||||
complete URL
|
||||
<Panel.Highlight>http://localhost:4001/backstage?hidePast=true&stopCycle=true</Panel.Highlight>
|
||||
</Panel.BlockQuote>
|
||||
<br />
|
||||
You will need to save the changes before the presets are functional.
|
||||
<br />
|
||||
<ExternalLink href={urlPresetsDocs}>See the docs</ExternalLink>
|
||||
</AlertDescription>
|
||||
@@ -130,15 +143,20 @@ export default function UrlPresetsForm() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={style.fit}>Active</th>
|
||||
<th className={style.aliasConstrain}>Preset</th>
|
||||
<th className={style.fullWidth}>URL</th>
|
||||
<th className={style.aliasConstrain}>Preset name</th>
|
||||
<th className={style.fullWidth}>URL segment</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fields.length === 0 && <Panel.TableEmpty handleClick={addNew} />}
|
||||
{fields.map((preset, index) => {
|
||||
const maybeAliasError = errors.data?.[index]?.alias?.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 (
|
||||
<tr key={preset.id}>
|
||||
<td className={style.fit}>
|
||||
@@ -177,6 +195,7 @@ export default function UrlPresetsForm() {
|
||||
<td className={style.flex}>
|
||||
<TooltipActionBtn
|
||||
size='sm'
|
||||
isDisabled={!canTest}
|
||||
clickHandler={(event) => handleLinks(event, preset.alias)}
|
||||
tooltip='Test preset'
|
||||
aria-label='Test preset'
|
||||
|
||||
@@ -7,7 +7,7 @@ import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
|
||||
import { maybeAxiosError } from '../../../../../common/api/utils';
|
||||
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
|
||||
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||
import * as Panel from '../../PanelUtils';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import style from '../FeatureSettings.module.scss';
|
||||
|
||||
@@ -67,6 +67,8 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
||||
|
||||
const colour = getValues('colour');
|
||||
const canSubmit = isDirty && isValid;
|
||||
// if initial values are given, we can assume we are in edit mode
|
||||
const isEditMode = initialKey !== undefined;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}>
|
||||
@@ -81,7 +83,9 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
||||
validate: (value) => {
|
||||
if (value.trim().length === 0) return 'Required field';
|
||||
if (!isAlphanumericWithSpace(value)) return 'Only alphanumeric characters and space are allowed';
|
||||
if (Object.keys(data).includes(value)) return 'Custom fields must be unique';
|
||||
if (!isEditMode) {
|
||||
if (isEditMode && Object.keys(data).includes(value)) return 'Custom fields must be unique';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
})}
|
||||
@@ -92,7 +96,7 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Panel.Description>Key (auto-generated value for use in Integrations and API)</Panel.Description>
|
||||
<Panel.Description>Key (use in Integrations and API)</Panel.Description>
|
||||
<Input {...register('key')} disabled size='sm' variant='ontime-filled' autoComplete='off' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { deleteCustomField, editCustomField, postCustomField } from '../../../..
|
||||
import ExternalLink from '../../../../../common/components/external-link/ExternalLink';
|
||||
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||
import { customFieldsDocsUrl } from '../../../../../externals';
|
||||
import * as Panel from '../../PanelUtils';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import CustomFieldEntry from './CustomFieldEntry';
|
||||
import CustomFieldForm from './CustomFieldForm';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { PanelBaseProps } from '../../settingsStore';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import EditorSettingsForm from '../interface-panel/EditorSettingsForm';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import GeneralPanelForm from './GeneralPanelForm';
|
||||
import ViewSettingsForm from './ViewSettingsForm';
|
||||
|
||||
@@ -7,7 +7,7 @@ import { postSettings } from '../../../../common/api/settings';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import useSettings from '../../../../common/hooks-query/useSettings';
|
||||
import { isOnlyNumbers } from '../../../../common/utils/regex';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import GeneralPinInput from './GeneralPinInput';
|
||||
|
||||
@@ -154,7 +154,8 @@ export default function GeneralPanelForm() {
|
||||
<option value='pt'>Portuguese</option>
|
||||
<option value='es'>Spanish</option>
|
||||
<option value='sv'>Swedish</option>
|
||||
<option value='pl'>Polish</option>
|
||||
<option value='pl'>Polish</option>
|
||||
<option value='zh'>Chinese (Simplified)</option>
|
||||
</Select>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
|
||||
@@ -6,10 +6,10 @@ import { ViewSettings } from 'ontime-types';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import { postViewSettings } from '../../../../common/api/viewSettings';
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import { PopoverPickerRHF } from '../../../../common/components/input/popover-picker/PopoverPicker';
|
||||
import { SwatchPickerRHF } from '../../../../common/components/input/colour-input/SwatchPicker';
|
||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||
import useViewSettings from '../../../../common/hooks-query/useViewSettings';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './GeneralPanel.module.scss';
|
||||
|
||||
@@ -111,22 +111,22 @@ export default function ViewSettingsForm() {
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Timer colour' description='Default colour of a running timer' />
|
||||
<PopoverPickerRHF name='normalColor' control={control} />
|
||||
<SwatchPickerRHF name='normalColor' control={control} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Warning colour' description='Colour of a running timer in warning mode' />
|
||||
<PopoverPickerRHF name='warningColor' control={control} />
|
||||
<SwatchPickerRHF name='warningColor' control={control} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Danger colour' description='Colour of a running timer in danger mode' />
|
||||
<PopoverPickerRHF name='dangerColor' control={control} />
|
||||
<SwatchPickerRHF name='dangerColor' control={control} />
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Freeze timer on end'
|
||||
description='Timer in views will stop from going negative and instead stop at 00:00:00'
|
||||
description='When a timer hits 00:00:00, it freezes instead of going negative. It invalidates the End Message.'
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
@@ -139,7 +139,7 @@ export default function ViewSettingsForm() {
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='End message'
|
||||
description='Message to show on negative timers if not frozen. If not provided, timer will continue'
|
||||
description='Message for negative timers; applies only if the timer isn`t frozen on End. If no message is provided, it continues into negative time'
|
||||
/>
|
||||
<Input
|
||||
size='sm'
|
||||
|
||||
@@ -9,7 +9,7 @@ import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import { useHttpSettings, usePostHttpSettings } from '../../../../common/hooks-query/useHttpSettings';
|
||||
import { isKeyEscape } from '../../../../common/utils/keyEvent';
|
||||
import { startsWithHttp } from '../../../../common/utils/regex';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import { cycles } from './integrationUtils';
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function HttpIntegrations() {
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isDirty, isValid },
|
||||
} = useForm<HttpSettings>({
|
||||
mode: 'onBlur',
|
||||
mode: 'onChange',
|
||||
defaultValues: data,
|
||||
values: data,
|
||||
resetOptions: {
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
|
||||
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { PanelBaseProps } from '../../settingsStore';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import HttpIntegrations from './HttpIntegrations';
|
||||
import OscIntegrations from './OscIntegrations';
|
||||
|
||||
@@ -8,9 +8,10 @@ import { generateId } from 'ontime-utils';
|
||||
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
|
||||
import { isKeyEscape } from '../../../../common/utils/keyEvent';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { isASCII, isASCIIorEmpty, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import { isOntimeCloud } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import { cycles } from './integrationUtils';
|
||||
|
||||
@@ -28,7 +29,7 @@ export default function OscIntegrations() {
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isDirty, isValid },
|
||||
} = useForm<OSCSettings>({
|
||||
mode: 'onBlur',
|
||||
mode: 'onChange',
|
||||
defaultValues: data,
|
||||
values: data,
|
||||
resetOptions: {
|
||||
@@ -62,13 +63,6 @@ export default function OscIntegrations() {
|
||||
}
|
||||
};
|
||||
|
||||
const preventEscape = (event: React.KeyboardEvent) => {
|
||||
if (isKeyEscape(event)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddNewSubscription = () => {
|
||||
prepend({
|
||||
id: generateId(),
|
||||
@@ -106,6 +100,9 @@ export default function OscIntegrations() {
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.SubHeader>
|
||||
{isOntimeCloud && (
|
||||
<Panel.Highlight>For security reasons OSC integrations are not available in the cloud service.</Panel.Highlight>
|
||||
)}
|
||||
|
||||
<Panel.Divider />
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { parseUserTime } from 'ontime-utils';
|
||||
|
||||
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
||||
import { editorSettingsDefaults, useEditorSettings } from '../../../../common/stores/editorSettings';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
export default function EditorSettingsForm() {
|
||||
const {
|
||||
@@ -88,8 +88,8 @@ export default function EditorSettingsForm() {
|
||||
>
|
||||
<option value={TimerType.CountDown}>Count down</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.None}>None</option>
|
||||
</Select>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
|
||||
|
||||
import * as Panel from '../PanelUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import EditorSettingsForm from './EditorSettingsForm';
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
|
||||
import { serverPort } from '../../../../common/api/constants';
|
||||
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||
import { openLink } from '../../../../common/utils/linkUtils';
|
||||
import { linkToOtherHost, openLink } from '../../../../common/utils/linkUtils';
|
||||
import { isLocalhost } from '../../../../externals';
|
||||
|
||||
import style from './NetworkInterfaces.module.scss';
|
||||
|
||||
@@ -14,8 +14,10 @@ export default function InfoNif() {
|
||||
|
||||
return (
|
||||
<div className={style.interfaces}>
|
||||
{data?.networkInterfaces?.map((nif) => {
|
||||
const address = `http://${nif.address}:${serverPort}`;
|
||||
{data.networkInterfaces.map((nif) => {
|
||||
// interfaces outside localhost wont have access
|
||||
if (nif.name === 'localhost' && !isLocalhost) return null;
|
||||
const address = linkToOtherHost(nif.address);
|
||||
|
||||
return (
|
||||
<CopyTag
|
||||
|
||||
@@ -4,7 +4,7 @@ import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
|
||||
import { handleLinks } from '../../../../common/utils/linkUtils';
|
||||
import Log from '../../../log/Log';
|
||||
import * as Panel from '../PanelUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './NetworkLogExport.module.scss';
|
||||
|
||||
|
||||