Compare commits
127 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 |
@@ -36,7 +36,7 @@ jobs:
|
|||||||
APPLE_TEAM_ID: ${{ secrets.TEAMID }}
|
APPLE_TEAM_ID: ${{ secrets.TEAMID }}
|
||||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||||
run: pnpm dist-mac
|
run: pnpm dist-mac --env-mode=loose
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ test-results
|
|||||||
playwright-report
|
playwright-report
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
automated-screenshots
|
automated-screenshots
|
||||||
|
e2e/tests/fixtures/tmp/*
|
||||||
|
|
||||||
# production
|
# production
|
||||||
build/
|
build/
|
||||||
@@ -39,6 +40,7 @@ dist/
|
|||||||
# docker utils
|
# docker utils
|
||||||
ontime-db
|
ontime-db
|
||||||
ontime-external/
|
ontime-external/
|
||||||
|
ontime-data/
|
||||||
|
|
||||||
# versioning file
|
# versioning file
|
||||||
**/ONTIME_VERSION.js
|
**/ONTIME_VERSION.js
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ While it should allow for a generic setup, it might need to be modified to fit y
|
|||||||
|
|
||||||
From the project root, run the following commands
|
From the project root, run the following commands
|
||||||
|
|
||||||
- __Build docker image from__ by running `docker build -t getontime/ontime`
|
- __Build docker image from__ by running `docker build -t getontime/ontime .`
|
||||||
- __Run docker image from compose__ by running `docker-compose up -d`
|
- __Run docker image from compose__ by running `docker-compose up -d`
|
||||||
|
|
||||||
Other useful commands
|
Other useful commands
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "3.8.0",
|
"version": "3.10.3",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -1,37 +1,28 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang='en'>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset='utf-8' />
|
<meta charset="utf-8" />
|
||||||
<link rel='icon' href='/favicon.ico' />
|
<base href="/" />
|
||||||
<meta name='viewport' content='width=device-width, initial-scale=1' />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<meta name='theme-color' content='#121212' />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name='ontime' content='ontime - event timer manager' />
|
<meta name="theme-color" content="#101010" />
|
||||||
<link rel='apple-touch-icon' href='/logo192.png' />
|
<meta name="ontime" content="ontime - time keeping for live events" />
|
||||||
<link
|
<link rel="apple-touch-icon" href="/ontime-logo.png" />
|
||||||
rel='icon'
|
<link rel="icon" type="image/png" href="/ontime-logo.png" />
|
||||||
type='image/png'
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
sizes='32x32'
|
<link rel="manifest" href="/manifest.json" />
|
||||||
href='/favicon-32x32.png'
|
<meta name="robots" content="noindex" />
|
||||||
/>
|
<title>ontime</title>
|
||||||
<link
|
<style>
|
||||||
rel='icon'
|
body,
|
||||||
type='image/png'
|
html {
|
||||||
sizes='16x16'
|
background-color: rgba(0, 0, 0, 0) !important;
|
||||||
href='/favicon-16x16.png'
|
}
|
||||||
/>
|
</style>
|
||||||
<link rel='manifest' href='/site.webmanifest' />
|
</head>
|
||||||
<link rel='manifest' href='/manifest.json' />
|
<body>
|
||||||
<title>ontime</title>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<style>
|
<div id="root"></div>
|
||||||
body,
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
html {
|
</body>
|
||||||
background-color: rgba(0, 0, 0, 0) !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
|
||||||
<div id='root'></div>
|
|
||||||
<script type='module' src='/src/index.tsx'></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "3.8.0",
|
"version": "3.10.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -13,10 +13,10 @@
|
|||||||
"@fontsource/open-sans": "^5.0.28",
|
"@fontsource/open-sans": "^5.0.28",
|
||||||
"@mantine/hooks": "^7.13.3",
|
"@mantine/hooks": "^7.13.3",
|
||||||
"@react-icons/all-files": "^4.1.0",
|
"@react-icons/all-files": "^4.1.0",
|
||||||
"@sentry/react": "^8.19.0",
|
"@sentry/react": "^8.43.0",
|
||||||
"@tanstack/react-query": "^5.17.9",
|
"@tanstack/react-query": "^5.62.7",
|
||||||
"@tanstack/react-query-devtools": "^5.17.9",
|
"@tanstack/react-query-devtools": "^5.62.7",
|
||||||
"@tanstack/react-table": "^8.11.3",
|
"@tanstack/react-table": "^8.20.5",
|
||||||
"autosize": "^6.0.1",
|
"autosize": "^6.0.1",
|
||||||
"axios": "^1.2.0",
|
"axios": "^1.2.0",
|
||||||
"color": "^4.2.3",
|
"color": "^4.2.3",
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:local": "cross-env NODE_ENV=local vite build",
|
"build:local": "cross-env NODE_ENV=local vite build",
|
||||||
"build:electron": "cross-env NODE_ENV=local vite build",
|
"build:electron": "cross-env NODE_ENV=local vite build",
|
||||||
"build:docker": "cross-env VITE_IS_CLOUD=true vite build",
|
"build:docker": "cross-env VITE_IS_DOCKER=true vite build",
|
||||||
"build:localdocker": "cross-env NODE_ENV=local vite build",
|
"build:localdocker": "cross-env NODE_ENV=local vite build",
|
||||||
"lint": "eslint . --quiet",
|
"lint": "eslint . --quiet",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
@@ -87,9 +87,9 @@
|
|||||||
"sass": "^1.57.1",
|
"sass": "^1.57.1",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "^5.2.11",
|
"vite": "^5.2.11",
|
||||||
"vite-plugin-compression2": "^0.12.0",
|
"vite-plugin-compression2": "^1.3.3",
|
||||||
"vite-plugin-svgr": "^4.2.0",
|
"vite-plugin-svgr": "^4.2.0",
|
||||||
"vite-tsconfig-paths": "^4.3.1",
|
"vite-tsconfig-paths": "^4.3.1",
|
||||||
"vitest": "^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": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "favicon.ico",
|
"src": "favicon.ico",
|
||||||
"sizes": "64x64 32x32 24x24 16x16",
|
|
||||||
"type": "image/x-icon"
|
"type": "image/x-icon"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "logo192.png",
|
"src": "ontime-logo.png",
|
||||||
"type": "image/png",
|
"type": "image/png"
|
||||||
"sizes": "192x192"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "logo512.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "512x512"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"scope": "/",
|
"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 theme from './theme/theme';
|
||||||
import { TranslationProvider } from './translation/TranslationProvider';
|
import { TranslationProvider } from './translation/TranslationProvider';
|
||||||
import AppRouter from './AppRouter';
|
import AppRouter from './AppRouter';
|
||||||
|
import { baseURI } from './externals';
|
||||||
|
|
||||||
connectSocket();
|
connectSocket();
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ function App() {
|
|||||||
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
|
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
|
||||||
<QueryClientProvider client={ontimeQueryClient}>
|
<QueryClientProvider client={ontimeQueryClient}>
|
||||||
<AppContextProvider>
|
<AppContextProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter basename={baseURI}>
|
||||||
<div className='App'>
|
<div className='App'>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<TranslationProvider>
|
<TranslationProvider>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { ONTIME_VERSION } from './ONTIME_VERSION';
|
|||||||
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
|
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
|
||||||
|
|
||||||
const Editor = React.lazy(() => import('./features/editors/ProtectedEditor'));
|
const Editor = React.lazy(() => import('./features/editors/ProtectedEditor'));
|
||||||
const Cuesheet = React.lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
|
const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
|
||||||
const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
|
const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
|
||||||
|
|
||||||
const TimerView = React.lazy(() => import('./features/viewers/timer/Timer'));
|
const TimerView = React.lazy(() => import('./features/viewers/timer/Timer'));
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import axios, { AxiosResponse } from 'axios';
|
import axios, { AxiosResponse } from 'axios';
|
||||||
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
|
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
|
||||||
|
|
||||||
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
|
import { makeCSV, makeTable } from '../../views/cuesheet/cuesheet.utils';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
import { createBlob, downloadBlob } from './utils';
|
import { createBlob, downloadBlob } from './utils';
|
||||||
@@ -12,7 +12,7 @@ const dbPath = `${apiEntryUrl}/db`;
|
|||||||
* HTTP request to the current DB
|
* HTTP request to the current DB
|
||||||
*/
|
*/
|
||||||
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
|
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
|
||||||
return axios.post(`${dbPath}/download/`, { filename });
|
return axios.post(`${dbPath}/download`, { filename });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,6 +112,14 @@ export async function loadProject(filename: string): Promise<MessageResponse> {
|
|||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to load the demo project file
|
||||||
|
*/
|
||||||
|
export async function loadDemo(): Promise<MessageResponse> {
|
||||||
|
const res = await axios.post(`${dbPath}/demo`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to duplicate a project file
|
* HTTP request to duplicate a project file
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios, { AxiosResponse } from 'axios';
|
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';
|
import { apiEntryUrl } from './constants';
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ export async function fetchNormalisedRundown(): Promise<RundownCached> {
|
|||||||
/**
|
/**
|
||||||
* HTTP request to post new event
|
* HTTP request to post new event
|
||||||
*/
|
*/
|
||||||
export async function requestPostEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> {
|
export async function requestPostEvent(data: TransientEventPayload): Promise<AxiosResponse<OntimeRundownEntry>> {
|
||||||
return axios.post(rundownPath, data);
|
return axios.post(rundownPath, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,32 +9,23 @@ import style from './DelayIndicator.module.scss';
|
|||||||
|
|
||||||
interface DelayIndicatorProps {
|
interface DelayIndicatorProps {
|
||||||
delayValue?: number;
|
delayValue?: number;
|
||||||
|
tooltipPrefix?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DelayIndicator(props: DelayIndicatorProps) {
|
export default function DelayIndicator(props: DelayIndicatorProps) {
|
||||||
const { delayValue } = props;
|
const { delayValue, tooltipPrefix } = props;
|
||||||
|
|
||||||
if (typeof delayValue === 'number') {
|
if (typeof delayValue !== 'number' || delayValue === 0) {
|
||||||
if (delayValue < 0) {
|
return null;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = () => {
|
const handleClick = () => {
|
||||||
onClick?.(color);
|
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) {
|
if (!color) {
|
||||||
return (
|
return (
|
||||||
<div className={`${classes} ${style.center}`} onClick={handleClick}>
|
<div className={classes} onClick={handleClick}>
|
||||||
<IoBan />
|
<IoBan />
|
||||||
</div>
|
</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 {
|
.list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swatch {
|
.swatch {
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
aspect-ratio: 1;
|
|
||||||
border-radius: 99px;
|
border-radius: 99px;
|
||||||
border: 2px solid $gray-1200;
|
border: 2px solid $gray-1200;
|
||||||
|
color: $ui-white;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
|
||||||
&.selected {
|
&.selected {
|
||||||
border: 2px solid $blue-500;
|
border: 2px solid $blue-500;
|
||||||
@@ -17,11 +20,9 @@
|
|||||||
|
|
||||||
&.selectable {
|
&.selectable {
|
||||||
cursor: pointer;
|
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 { useCallback } from 'react';
|
||||||
|
|
||||||
import Swatch from './Swatch';
|
import Swatch from './Swatch';
|
||||||
|
import SwatchPicker from './SwatchPicker';
|
||||||
|
|
||||||
import style from './SwatchSelect.module.scss';
|
import style from './SwatchSelect.module.scss';
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ export default function SwatchSelect(props: ColourInputProps) {
|
|||||||
{colours.map((colour) => (
|
{colours.map((colour) => (
|
||||||
<Swatch key={colour} color={colour} onClick={setColour} isSelected={value === colour} />
|
<Swatch key={colour} color={colour} onClick={setColour} isSelected={value === colour} />
|
||||||
))}
|
))}
|
||||||
|
<SwatchPicker color={value} onChange={setColour} isSelected={!colours.includes(value)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
.swatch {
|
.input {
|
||||||
width: 24px;
|
color: $gray-200;
|
||||||
height: 24px;
|
border: 1px solid transparent;
|
||||||
border-radius: 12px;
|
border-radius: 0 0 8px 8px;
|
||||||
border: 1px solid white;
|
background-color: $gray-1200;
|
||||||
box-shadow: 0 0 0 1px $gray-300;
|
padding: 0 0.5rem;
|
||||||
cursor: pointer;
|
font-size: 1rem;
|
||||||
|
height: 2rem;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
transition-property: box-shadow;
|
&:hover {
|
||||||
transition-duration: $transition-time-action;
|
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 { PropsWithChildren } from 'react';
|
||||||
import { useController, UseControllerProps } from 'react-hook-form';
|
import { HexAlphaColorPicker, HexColorInput } from 'react-colorful';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@chakra-ui/react';
|
import { Popover, PopoverContent, PopoverTrigger } from '@chakra-ui/react';
|
||||||
import { ViewSettings } from 'ontime-types';
|
|
||||||
|
|
||||||
import style from './PopoverPicker.module.scss';
|
import style from './PopoverPicker.module.scss';
|
||||||
|
|
||||||
export function PopoverPickerRHF(props: UseControllerProps<ViewSettings>) {
|
|
||||||
const { name, control } = props;
|
|
||||||
const {
|
|
||||||
field: { onChange, value },
|
|
||||||
} = useController({ control, name });
|
|
||||||
|
|
||||||
return <PopoverPicker color={value as string} onChange={onChange} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PopoverPickerProps {
|
interface PopoverPickerProps {
|
||||||
color: string;
|
color: string;
|
||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PopoverPicker(props: PopoverPickerProps) {
|
export default function PopoverPicker(props: PropsWithChildren<PopoverPickerProps>) {
|
||||||
const { color, onChange } = props;
|
const { color, onChange, children } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger>
|
<PopoverTrigger>{children}</PopoverTrigger>
|
||||||
<div className={style.swatch} style={{ backgroundColor: color }} />
|
<PopoverContent className={style.small} style={{ borderRadius: '9px', width: 'auto' }}>
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent style={{ width: 'auto' }}>
|
|
||||||
<HexAlphaColorPicker color={color} onChange={onChange} />
|
<HexAlphaColorPicker color={color} onChange={onChange} />
|
||||||
|
<HexColorInput color={color} onChange={onChange} className={style.input} prefixed />
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export default function useReactiveTextInput(
|
|||||||
options?: {
|
options?: {
|
||||||
submitOnEnter?: boolean;
|
submitOnEnter?: boolean;
|
||||||
submitOnCtrlEnter?: boolean;
|
submitOnCtrlEnter?: boolean;
|
||||||
|
onCancelUpdate?: () => void;
|
||||||
|
allowSubmitSameValue?: boolean;
|
||||||
},
|
},
|
||||||
): UseReactiveTextInputReturn {
|
): UseReactiveTextInputReturn {
|
||||||
const [text, setText] = useState<string>(initialText);
|
const [text, setText] = useState<string>(initialText);
|
||||||
@@ -47,7 +49,9 @@ export default function useReactiveTextInput(
|
|||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(valueToSubmit: string) => {
|
(valueToSubmit: string) => {
|
||||||
// No need to update if it hasn't changed
|
// No need to update if it hasn't changed
|
||||||
if (valueToSubmit !== initialText) {
|
if (valueToSubmit === initialText && !options?.allowSubmitSameValue) {
|
||||||
|
options?.onCancelUpdate?.();
|
||||||
|
} else {
|
||||||
const cleanVal = valueToSubmit.trim();
|
const cleanVal = valueToSubmit.trim();
|
||||||
submitCallback(cleanVal);
|
submitCallback(cleanVal);
|
||||||
if (cleanVal !== valueToSubmit) {
|
if (cleanVal !== valueToSubmit) {
|
||||||
@@ -56,7 +60,7 @@ export default function useReactiveTextInput(
|
|||||||
}
|
}
|
||||||
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
|
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
|
||||||
},
|
},
|
||||||
[initialText, ref, submitCallback],
|
[initialText, options, ref, submitCallback],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,8 +70,9 @@ export default function useReactiveTextInput(
|
|||||||
const handleEscape = useCallback(() => {
|
const handleEscape = useCallback(() => {
|
||||||
// No need to update if it hasn't changed
|
// No need to update if it hasn't changed
|
||||||
setText(initialText);
|
setText(initialText);
|
||||||
|
options?.onCancelUpdate?.();
|
||||||
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
|
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
|
||||||
}, [initialText, ref]);
|
}, [initialText, options, ref]);
|
||||||
|
|
||||||
const keyHandler = useMemo(() => {
|
const keyHandler = useMemo(() => {
|
||||||
const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]];
|
const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { millisToString, parseUserTime } from 'ontime-utils';
|
|||||||
import { useEmitLog } from '../../../stores/logger';
|
import { useEmitLog } from '../../../stores/logger';
|
||||||
|
|
||||||
interface TimeInputProps<T extends string> {
|
interface TimeInputProps<T extends string> {
|
||||||
|
id?: T;
|
||||||
name: T;
|
name: T;
|
||||||
submitHandler: (field: T, value: string) => void;
|
submitHandler: (field: T, value: string) => void;
|
||||||
time?: number;
|
time?: number;
|
||||||
@@ -15,7 +16,7 @@ interface TimeInputProps<T extends string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
||||||
const { 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 { emitError } = useEmitLog();
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [value, setValue] = useState<string>('');
|
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
|
// we dont know the values in the rundown, escalate to handler
|
||||||
if (newValue.startsWith('p') || newValue.startsWith('+')) {
|
if (newValue.startsWith('p') || newValue.startsWith('+')) {
|
||||||
submitHandler(name, newValue);
|
submitHandler(name, newValue);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const valueInMillis = parseUserTime(newValue);
|
const valueInMillis = parseUserTime(newValue);
|
||||||
@@ -117,6 +119,7 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
|
id={id}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
size='sm'
|
size='sm'
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ $input-delayed-border-color: $ontime-delay-text;
|
|||||||
|
|
||||||
.timeInput {
|
.timeInput {
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
|
color: $label-gray;
|
||||||
|
|
||||||
&.delayed {
|
&.delayed {
|
||||||
border: 1px solid $input-delayed-border-color;
|
border: 1px solid $input-delayed-border-color;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
||||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||||
|
|
||||||
import { debounce } from '../../utils/debounce';
|
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
|
||||||
|
|
||||||
import style from './NavigationMenu.module.scss';
|
import style from './NavigationMenu.module.scss';
|
||||||
|
|
||||||
@@ -13,32 +12,10 @@ interface FloatingNavigationProps {
|
|||||||
|
|
||||||
export default function FloatingNavigation(props: FloatingNavigationProps) {
|
export default function FloatingNavigation(props: FloatingNavigationProps) {
|
||||||
const { toggleMenu, toggleSettings } = props;
|
const { toggleMenu, toggleSettings } = props;
|
||||||
const [showButton, setShowButton] = useState(false);
|
const isButtonShown = useFadeOutOnInactivity();
|
||||||
|
|
||||||
// show on mouse move
|
|
||||||
useEffect(() => {
|
|
||||||
let fadeOut: NodeJS.Timeout | null = null;
|
|
||||||
const setShowMenuTrue = () => {
|
|
||||||
setShowButton(true);
|
|
||||||
if (fadeOut) {
|
|
||||||
clearTimeout(fadeOut);
|
|
||||||
}
|
|
||||||
fadeOut = setTimeout(() => setShowButton(false), 3000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const debouncedShowMenu = debounce(setShowMenuTrue, 1000);
|
|
||||||
|
|
||||||
document.addEventListener('mousemove', debouncedShowMenu);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousemove', debouncedShowMenu);
|
|
||||||
if (fadeOut) {
|
|
||||||
clearTimeout(fadeOut);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${style.buttonContainer} ${!showButton ? style.hidden : ''}`}>
|
<div className={`${style.buttonContainer} ${!isButtonShown ? style.hidden : ''}`}>
|
||||||
<button
|
<button
|
||||||
onClick={toggleMenu}
|
onClick={toggleMenu}
|
||||||
aria-label='toggle menu'
|
aria-label='toggle menu'
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ $button-size: 3rem;
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lockIcon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: transparent;
|
||||||
|
color: $active-indicator;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.navButton {
|
.navButton {
|
||||||
@extend .button;
|
@extend .button;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
|
|||||||
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
|
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
|
||||||
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
||||||
|
|
||||||
import { isLocalhost, serverPort } from '../../../externals';
|
import { isLocalhost } from '../../../externals';
|
||||||
import { navigatorConstants } from '../../../viewerConfig';
|
import { navigatorConstants } from '../../../viewerConfig';
|
||||||
import useClickOutside from '../../hooks/useClickOutside';
|
import useClickOutside from '../../hooks/useClickOutside';
|
||||||
import { useElectronEvent } from '../../hooks/useElectronEvent';
|
import { useElectronEvent } from '../../hooks/useElectronEvent';
|
||||||
@@ -25,7 +25,7 @@ import useInfo from '../../hooks-query/useInfo';
|
|||||||
import { useClientStore } from '../../stores/clientStore';
|
import { useClientStore } from '../../stores/clientStore';
|
||||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||||
import { isKeyEnter } from '../../utils/keyEvent';
|
import { isKeyEnter } from '../../utils/keyEvent';
|
||||||
import { handleLinks, openLink } from '../../utils/linkUtils';
|
import { handleLinks, linkToOtherHost, openLink } from '../../utils/linkUtils';
|
||||||
import { cx } from '../../utils/styleUtils';
|
import { cx } from '../../utils/styleUtils';
|
||||||
import { RenameClientModal } from '../client-modal/RenameClientModal';
|
import { RenameClientModal } from '../client-modal/RenameClientModal';
|
||||||
import CopyTag from '../copy-tag/CopyTag';
|
import CopyTag from '../copy-tag/CopyTag';
|
||||||
@@ -45,7 +45,7 @@ function NavigationMenu(props: NavigationMenuProps) {
|
|||||||
|
|
||||||
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
|
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
|
||||||
const { fullscreen, toggle } = useFullscreen();
|
const { fullscreen, toggle } = useFullscreen();
|
||||||
const { toggleMirror } = useViewOptionsStore();
|
const { mirror, toggleMirror } = useViewOptionsStore();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -65,7 +65,7 @@ function NavigationMenu(props: NavigationMenuProps) {
|
|||||||
<DrawerBody padding={0}>
|
<DrawerBody padding={0}>
|
||||||
<div className={style.buttonsContainer}>
|
<div className={style.buttonsContainer}>
|
||||||
<div
|
<div
|
||||||
className={style.link}
|
className={cx([style.link, fullscreen && style.current])}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role='button'
|
role='button'
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
@@ -77,7 +77,7 @@ function NavigationMenu(props: NavigationMenuProps) {
|
|||||||
{fullscreen ? <IoContract /> : <IoExpand />}
|
{fullscreen ? <IoContract /> : <IoExpand />}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={style.link}
|
className={cx([style.link, mirror && style.current])}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role='button'
|
role='button'
|
||||||
onClick={() => toggleMirror()}
|
onClick={() => toggleMirror()}
|
||||||
@@ -154,7 +154,8 @@ function OtherAddresses(props: OtherAddressesProps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const address = `http://${nif.address}:${serverPort}${currentLocation}`;
|
const address = linkToOtherHost(nif.address, currentLocation);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CopyTag
|
<CopyTag
|
||||||
key={nif.name}
|
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 { useSearchParams } from 'react-router-dom';
|
||||||
import { useDisclosure } from '@chakra-ui/react';
|
import { useDisclosure } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||||
|
|
||||||
import FloatingNavigation from './FloatingNavigation';
|
import FloatingNavigation from './FloatingNavigation';
|
||||||
import NavigationMenu from './NavigationMenu';
|
import NavigationMenu from './NavigationMenu';
|
||||||
|
import ViewLockedIcon from './ViewLockedIcon';
|
||||||
|
|
||||||
function ViewNavigationMenu() {
|
function ViewNavigationMenu() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
|
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
|
||||||
|
|
||||||
|
const isViewLocked = isStringBoolean(searchParams.get('locked'));
|
||||||
|
|
||||||
const showEditFormDrawer = useCallback(() => {
|
const showEditFormDrawer = useCallback(() => {
|
||||||
searchParams.set('edit', 'true');
|
searchParams.set('edit', 'true');
|
||||||
setSearchParams(searchParams);
|
setSearchParams(searchParams);
|
||||||
@@ -16,6 +21,10 @@ function ViewNavigationMenu() {
|
|||||||
|
|
||||||
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
|
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
|
||||||
|
|
||||||
|
if (isViewLocked) {
|
||||||
|
return <ViewLockedIcon />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
|
<FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
.container {
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding-top: 25vh;
|
padding-top: 25vh;
|
||||||
|
|
||||||
|
background: $ui-black;
|
||||||
color: $ontime-color;
|
color: $ontime-color;
|
||||||
font-weight: 200;
|
font-weight: 200;
|
||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
@@ -42,4 +46,4 @@
|
|||||||
to {
|
to {
|
||||||
background: rgba($red-500, 0);
|
background: rgba($red-500, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import PopoverPicker from '../input/popover-picker/PopoverPicker';
|
import SwatchPicker from '../input/colour-input/SwatchPicker';
|
||||||
|
|
||||||
import style from './InlineColourPicker.module.scss';
|
import style from './InlineColourPicker.module.scss';
|
||||||
|
|
||||||
@@ -20,13 +20,10 @@ export default function InlineColourPicker(props: InlineColourPickerProps) {
|
|||||||
const { name, value } = props;
|
const { name, value } = props;
|
||||||
const [colour, setColour] = useState(() => ensureHex(value));
|
const [colour, setColour] = useState(() => ensureHex(value));
|
||||||
|
|
||||||
const debouncedChange = (value: string) => {
|
|
||||||
setColour(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.inline}>
|
<div className={style.inline}>
|
||||||
<PopoverPicker color={colour} onChange={debouncedChange} />
|
<SwatchPicker color={colour} onChange={setColour} alwaysDisplayColor />
|
||||||
|
<span>{colour}</span>
|
||||||
<input type='hidden' name={name} value={colour} />
|
<input type='hidden' name={name} value={colour} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.drawerFooter {
|
.drawerFooter {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: end;
|
justify-content: end;
|
||||||
gap: $section-spacing;
|
gap: $section-spacing;
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@@ -8,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 {
|
.label {
|
||||||
font-size: $inner-section-text-size;
|
font-size: $inner-section-text-size;
|
||||||
color: $label-gray;
|
color: $label-gray;
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import {
|
|||||||
DrawerOverlay,
|
DrawerOverlay,
|
||||||
useDisclosure,
|
useDisclosure,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
|
import { IoAlertCircle } from '@react-icons/all-files/io5/IoAlertCircle';
|
||||||
|
|
||||||
|
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
||||||
|
|
||||||
import ParamInput from './ParamInput';
|
import ParamInput from './ParamInput';
|
||||||
import { isSection, ViewOption } from './types';
|
import { isSection, ViewOption } from './types';
|
||||||
@@ -87,6 +90,8 @@ interface EditFormDrawerProps {
|
|||||||
// TODO: this is a good candidate for memoisation, but needs the paramFields to be stable
|
// TODO: this is a good candidate for memoisation, but needs the paramFields to be stable
|
||||||
export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const { data: viewSettings } = useViewSettings();
|
||||||
|
|
||||||
const { isOpen, onClose, onOpen } = useDisclosure();
|
const { isOpen, onClose, onOpen } = useDisclosure();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -115,8 +120,6 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
|||||||
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
|
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
|
||||||
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
|
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
|
||||||
setSearchParams(newSearchParams);
|
setSearchParams(newSearchParams);
|
||||||
|
|
||||||
onClose();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -129,6 +132,12 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
|||||||
</DrawerHeader>
|
</DrawerHeader>
|
||||||
|
|
||||||
<DrawerBody>
|
<DrawerBody>
|
||||||
|
{viewSettings.overrideStyles && (
|
||||||
|
<div className={style.infoLabel}>
|
||||||
|
<IoAlertCircle />
|
||||||
|
This view style is being modified by a custom CSS file. <br />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<form id='edit-params-form' onSubmit={onParamsFormSubmit}>
|
<form id='edit-params-form' onSubmit={onParamsFormSubmit}>
|
||||||
{viewOptions.map((option) => {
|
{viewOptions.map((option) => {
|
||||||
if (isSection(option)) {
|
if (isSection(option)) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { ProjectFileListResponse } from 'ontime-types';
|
import { ProjectFileListResponse } from 'ontime-types';
|
||||||
|
|
||||||
@@ -22,3 +23,23 @@ export function useProjectList() {
|
|||||||
});
|
});
|
||||||
return { data: data ?? placeholderProjectList, status, refetch };
|
return { data: data ?? placeholderProjectList, status, refetch };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useOrderedProjectList() {
|
||||||
|
const response = useProjectList();
|
||||||
|
const { files, lastLoadedProject } = response.data;
|
||||||
|
|
||||||
|
const reorderedProjectFiles = useMemo(() => {
|
||||||
|
if (!files.length) return [];
|
||||||
|
|
||||||
|
const currentlyLoadedIndex = files.findIndex((project) => project.filename === lastLoadedProject);
|
||||||
|
|
||||||
|
if (currentlyLoadedIndex === -1) return files;
|
||||||
|
|
||||||
|
const projectFiles = [...files];
|
||||||
|
const current = projectFiles.splice(currentlyLoadedIndex, 1)[0];
|
||||||
|
|
||||||
|
return [current, ...projectFiles];
|
||||||
|
}, [files, lastLoadedProject]);
|
||||||
|
|
||||||
|
return { ...response, data: { reorderedProjectFiles, lastLoadedProject: response.data.lastLoadedProject } };
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,16 +4,23 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
|||||||
import { useClientStore } from '../stores/clientStore';
|
import { useClientStore } from '../stores/clientStore';
|
||||||
import { socketSendJson } from '../utils/socket';
|
import { socketSendJson } from '../utils/socket';
|
||||||
|
|
||||||
|
import { useIsOnline } from './useSocket';
|
||||||
|
|
||||||
export const useClientPath = () => {
|
export const useClientPath = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pathname, search } = useLocation();
|
const { pathname, search } = useLocation();
|
||||||
const redirect = useClientStore((store) => store.redirect);
|
const { redirect, setRedirect } = useClientStore((store) => ({
|
||||||
const setRedirect = useClientStore((store) => store.setRedirect);
|
redirect: store.redirect,
|
||||||
|
setRedirect: store.setRedirect,
|
||||||
|
}));
|
||||||
|
const isOnline = useIsOnline();
|
||||||
|
|
||||||
// notify of client path changes
|
// notify of client path changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isOnline) return;
|
||||||
|
|
||||||
socketSendJson('set-client-path', pathname + search);
|
socketSendJson('set-client-path', pathname + search);
|
||||||
}, [pathname, search]);
|
}, [pathname, search, isOnline]);
|
||||||
|
|
||||||
// navigate to new path when received from server
|
// navigate to new path when received from server
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -26,7 +33,7 @@ export const useClientPath = () => {
|
|||||||
|
|
||||||
// navigate if there is a path change
|
// navigate if there is a path change
|
||||||
if (redirect !== pathname + search) {
|
if (redirect !== pathname + search) {
|
||||||
navigate(redirect);
|
navigate(redirect, { replace: true });
|
||||||
}
|
}
|
||||||
}, [navigate, pathname, redirect, search, setRedirect]);
|
}, [navigate, pathname, redirect, search, setRedirect]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { addDialog } from '../stores/dialogStore';
|
||||||
|
|
||||||
const isElectron = window.process?.type === 'renderer';
|
const isElectron = window.process?.type === 'renderer';
|
||||||
const ipcRenderer = isElectron ? window.require('electron').ipcRenderer : null;
|
const ipcRenderer = isElectron ? window.require('electron').ipcRenderer : null;
|
||||||
|
|
||||||
@@ -24,6 +26,12 @@ export function useElectronListener() {
|
|||||||
ipcRenderer.on('request-editor-location', (_event: unknown, location: string) => {
|
ipcRenderer.on('request-editor-location', (_event: unknown, location: string) => {
|
||||||
navigate(location, { relative: 'route' });
|
navigate(location, { relative: 'route' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcRenderer.on('dialog', (_event: unknown, dialog: string) => {
|
||||||
|
if (dialog === 'welcome') {
|
||||||
|
addDialog('welcome');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean the listener after the component is dismounted
|
// Clean the listener after the component is dismounted
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { isOntimeEvent, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
|
import {
|
||||||
|
isOntimeEvent,
|
||||||
|
OntimeBlock,
|
||||||
|
OntimeDelay,
|
||||||
|
OntimeEvent,
|
||||||
|
OntimeRundownEntry,
|
||||||
|
RundownCached,
|
||||||
|
TimeField,
|
||||||
|
TimeStrategy,
|
||||||
|
TransientEventPayload,
|
||||||
|
} from 'ontime-types';
|
||||||
import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils';
|
import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils';
|
||||||
|
|
||||||
import { RUNDOWN } from '../api/constants';
|
import { RUNDOWN } from '../api/constants';
|
||||||
@@ -19,6 +29,16 @@ import {
|
|||||||
import { logAxiosError } from '../api/utils';
|
import { logAxiosError } from '../api/utils';
|
||||||
import { useEditorSettings } from '../stores/editorSettings';
|
import { useEditorSettings } from '../stores/editorSettings';
|
||||||
|
|
||||||
|
export type EventOptions = Partial<{
|
||||||
|
// options to any new block (event / delay / block)
|
||||||
|
after: string;
|
||||||
|
before: string;
|
||||||
|
// options to blocks of type OntimeEvent
|
||||||
|
defaultPublic: boolean;
|
||||||
|
linkPrevious: boolean;
|
||||||
|
lastEventId: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Set of utilities for events //TODO: should this be called useEntryAction and so on
|
* @description Set of utilities for events //TODO: should this be called useEntryAction and so on
|
||||||
*/
|
*/
|
||||||
@@ -47,31 +67,19 @@ export const useEventAction = () => {
|
|||||||
networkMode: 'always',
|
networkMode: 'always',
|
||||||
});
|
});
|
||||||
|
|
||||||
// options to any new block (event / delay / block)
|
|
||||||
type BaseOptions = {
|
|
||||||
after?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// options to blocks of type OntimeEvent
|
|
||||||
type EventOptions = BaseOptions &
|
|
||||||
Partial<{
|
|
||||||
defaultPublic: boolean;
|
|
||||||
linkPrevious: boolean;
|
|
||||||
lastEventId: string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds an event to rundown
|
* Adds an event to rundown
|
||||||
*/
|
*/
|
||||||
const addEvent = useCallback(
|
const addEvent = useCallback(
|
||||||
async (event: Partial<OntimeRundownEntry>, options?: EventOptions) => {
|
async (event: Partial<OntimeEvent | OntimeDelay | OntimeBlock>, options?: EventOptions) => {
|
||||||
const newEvent: Partial<OntimeRundownEntry> = { ...event };
|
const newEvent: TransientEventPayload = { ...event };
|
||||||
|
|
||||||
// ************* CHECK OPTIONS specific to events
|
// ************* CHECK OPTIONS specific to events
|
||||||
if (isOntimeEvent(newEvent)) {
|
if (isOntimeEvent(newEvent)) {
|
||||||
// merge creation time options with event settings
|
// merge creation time options with event settings
|
||||||
const applicationOptions = {
|
const applicationOptions = {
|
||||||
after: options?.after,
|
after: options?.after,
|
||||||
|
before: options?.before,
|
||||||
defaultPublic: options?.defaultPublic ?? defaultPublic,
|
defaultPublic: options?.defaultPublic ?? defaultPublic,
|
||||||
lastEventId: options?.lastEventId,
|
lastEventId: options?.lastEventId,
|
||||||
linkPrevious: options?.linkPrevious ?? linkPrevious,
|
linkPrevious: options?.linkPrevious ?? linkPrevious,
|
||||||
@@ -121,11 +129,16 @@ export const useEventAction = () => {
|
|||||||
|
|
||||||
// handle adding options that concern all event type
|
// handle adding options that concern all event type
|
||||||
if (options?.after) {
|
if (options?.after) {
|
||||||
|
// @ts-expect-error -- not sure how to type this, <after> is a transient property
|
||||||
newEvent.after = options.after;
|
newEvent.after = options.after;
|
||||||
}
|
}
|
||||||
|
if (options?.before) {
|
||||||
|
// @ts-expect-error -- not sure how to type this, <before> is a transient property
|
||||||
|
newEvent.before = options.before;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _addEventMutation.mutateAsync(newEvent);
|
await _addEventMutation.mutateAsync(newEvent as TransientEventPayload);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Failed adding event', error);
|
logAxiosError('Failed adding event', error);
|
||||||
}
|
}
|
||||||
@@ -203,13 +216,75 @@ export const useEventAction = () => {
|
|||||||
[updateEvent],
|
[updateEvent],
|
||||||
);
|
);
|
||||||
|
|
||||||
type TimeField = 'timeStart' | 'timeEnd' | 'duration';
|
|
||||||
/**
|
/**
|
||||||
* Updates time of existing event
|
* Updates time of existing event
|
||||||
|
* @param eventId {string} - id of the event
|
||||||
|
* @param field {TimeField} - field to update
|
||||||
|
* @param value {string} - new value string to be parsed
|
||||||
|
* @param lockOnUpdate {boolean} - whether we will apply the lock / release on update
|
||||||
*/
|
*/
|
||||||
const updateTimer = useCallback(
|
const updateTimer = useCallback(
|
||||||
async (eventId: string, field: TimeField, value: string) => {
|
async (eventId: string, field: TimeField, value: string, lockOnUpdate?: boolean) => {
|
||||||
const getPreviousEnd = (): number => {
|
// 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);
|
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
|
||||||
|
|
||||||
if (!cachedRundown?.order || !cachedRundown?.rundown) {
|
if (!cachedRundown?.order || !cachedRundown?.rundown) {
|
||||||
@@ -229,34 +304,6 @@ export const useEventAction = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return previousEnd;
|
return previousEnd;
|
||||||
};
|
|
||||||
|
|
||||||
let newValMillis = 0;
|
|
||||||
|
|
||||||
// check for previous keyword
|
|
||||||
if (value === 'p' || value === 'prev' || value === 'previous') {
|
|
||||||
newValMillis = getPreviousEnd();
|
|
||||||
|
|
||||||
// check for adding time keyword
|
|
||||||
} else if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) {
|
|
||||||
// TODO: is this logic solid?
|
|
||||||
const remainingString = value.substring(1);
|
|
||||||
newValMillis = getPreviousEnd() + parseUserTime(remainingString);
|
|
||||||
} else {
|
|
||||||
newValMillis = parseUserTime(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// dont allow timer values over 23:59:59
|
|
||||||
const cappedMillis = Math.min(newValMillis, dayInMs - MILLIS_PER_SECOND);
|
|
||||||
|
|
||||||
const newEvent = {
|
|
||||||
id: eventId,
|
|
||||||
[field]: cappedMillis,
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
await _updateEventMutation.mutateAsync(newEvent);
|
|
||||||
} catch (error) {
|
|
||||||
logAxiosError('Error updating event', error);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[_updateEventMutation, queryClient],
|
[_updateEventMutation, queryClient],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -65,6 +65,7 @@ export const useMessagePreview = () => {
|
|||||||
showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external),
|
showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external),
|
||||||
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
|
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
|
||||||
timerType: state.eventNow?.timerType ?? null,
|
timerType: state.eventNow?.timerType ?? null,
|
||||||
|
countToEnd: state.eventNow?.countToEnd ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
return useRuntimeStore(featureSelector);
|
||||||
@@ -148,19 +149,21 @@ export const setAuxTimer = {
|
|||||||
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
|
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useCuesheet = () => {
|
export const useSelectedEventId = () => {
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
playback: state.timer.playback,
|
|
||||||
currentBlockId: state.currentBlock.block?.id ?? null,
|
|
||||||
selectedEventId: state.eventNow?.id ?? null,
|
selectedEventId: state.eventNow?.id ?? null,
|
||||||
selectedEventIndex: state.runtime.selectedEventIndex,
|
|
||||||
numEvents: state.runtime.numEvents,
|
|
||||||
titleNow: state.eventNow?.title || '',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
return useRuntimeStore(featureSelector);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useCurrentBlockId = () => {
|
||||||
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
|
currentBlockId: state.currentBlock.block?.id ?? null,
|
||||||
|
});
|
||||||
|
return useRuntimeStore(featureSelector);
|
||||||
|
};
|
||||||
|
|
||||||
export const setEventPlayback = {
|
export const setEventPlayback = {
|
||||||
loadEvent: (id: string) => socketSendJson('load', { id }),
|
loadEvent: (id: string) => socketSendJson('load', { id }),
|
||||||
startEvent: (id: string) => socketSendJson('start', { id }),
|
startEvent: (id: string) => socketSendJson('start', { id }),
|
||||||
@@ -238,3 +241,12 @@ export const usePing = () => {
|
|||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
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);
|
||||||
|
};
|
||||||
|
|||||||
@@ -15,4 +15,5 @@ export type ViewExtendedTimer = {
|
|||||||
|
|
||||||
clock: number;
|
clock: number;
|
||||||
timerType: TimerType;
|
timerType: TimerType;
|
||||||
|
countToEnd: 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
|
* 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();
|
const state = runtimeStore.getState();
|
||||||
state[key] = value;
|
state[key] = value;
|
||||||
runtimeStore.setState({ ...state });
|
runtimeStore.setState({ ...state });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows patching the entire runtime store
|
||||||
|
*/
|
||||||
|
export function patchRuntime(patch: Partial<RuntimeStore>) {
|
||||||
|
const state = runtimeStore.getState();
|
||||||
|
runtimeStore.setState({ ...state, ...patch });
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-uti
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses a value in millis to a string which encodes a delay
|
* Parses a value in millis to a string which encodes a delay
|
||||||
* @param millis
|
* @param millis
|
||||||
* @param format
|
* @param format
|
||||||
*/
|
*/
|
||||||
export function millisToDelayString(millis: MaybeNumber, format: 'compact' | 'expanded' = 'compact'): string {
|
export function millisToDelayString(millis: MaybeNumber, format: 'compact' | 'expanded' = 'compact'): string {
|
||||||
if (millis == null || millis === 0) {
|
if (millis == null || millis === 0) {
|
||||||
|
|||||||
@@ -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;
|
let timeout: NodeJS.Timeout | null;
|
||||||
return () => {
|
return (...args: T) => {
|
||||||
if (timeout) {
|
if (timeout) {
|
||||||
return;
|
clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
timeout = setTimeout(() => {
|
timeout = setTimeout(() => {
|
||||||
timeout = null;
|
timeout = null;
|
||||||
callback();
|
callback(...args);
|
||||||
}, wait);
|
}, wait);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { OntimeEvent, SupportedEvent } from 'ontime-types';
|
|||||||
* @return {OntimeEvent} clean event
|
* @return {OntimeEvent} clean event
|
||||||
*/
|
*/
|
||||||
type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>;
|
type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>;
|
||||||
export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
|
export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
|
||||||
return {
|
return {
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
title: event.title,
|
title: event.title,
|
||||||
@@ -17,13 +17,16 @@ export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
|
|||||||
timeEnd: event.timeEnd,
|
timeEnd: event.timeEnd,
|
||||||
timerType: event.timerType,
|
timerType: event.timerType,
|
||||||
timeStrategy: event.timeStrategy,
|
timeStrategy: event.timeStrategy,
|
||||||
|
countToEnd: event.countToEnd,
|
||||||
linkStart: event.linkStart,
|
linkStart: event.linkStart,
|
||||||
endAction: event.endAction,
|
endAction: event.endAction,
|
||||||
isPublic: event.isPublic,
|
isPublic: event.isPublic,
|
||||||
skip: event.skip,
|
skip: event.skip,
|
||||||
colour: event.colour,
|
colour: event.colour,
|
||||||
after,
|
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: event.timeWarning,
|
timeWarning: event.timeWarning,
|
||||||
timeDanger: event.timeDanger,
|
timeDanger: event.timeDanger,
|
||||||
custom: {},
|
custom: {},
|
||||||
|
|||||||
@@ -7,3 +7,11 @@ export function isKeyEnter<T>(event: KeyboardEvent<T>): boolean {
|
|||||||
export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean {
|
export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean {
|
||||||
return event.key === 'Escape';
|
return event.key === 'Escape';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function preventEscape<T>(event: KeyboardEvent<T>, callback?: () => void): void {
|
||||||
|
if (isKeyEscape(event)) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
callback?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -12,8 +12,9 @@ import {
|
|||||||
setClientRedirect,
|
setClientRedirect,
|
||||||
setClients,
|
setClients,
|
||||||
} from '../stores/clientStore';
|
} from '../stores/clientStore';
|
||||||
|
import { addDialog } from '../stores/dialogStore';
|
||||||
import { addLog } from '../stores/logger';
|
import { addLog } from '../stores/logger';
|
||||||
import { patchRuntime, runtimeStore } from '../stores/runtime';
|
import { patchRuntime, patchRuntimeProperty } from '../stores/runtime';
|
||||||
|
|
||||||
export let websocket: WebSocket | null = null;
|
export let websocket: WebSocket | null = null;
|
||||||
let reconnectTimeout: NodeJS.Timeout | null = null;
|
let reconnectTimeout: NodeJS.Timeout | null = null;
|
||||||
@@ -38,12 +39,13 @@ export const connectSocket = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
socketSendJson('set-client-type', 'ontime');
|
socketSendJson('set-client-type', 'ontime');
|
||||||
|
setOnlineStatus(true);
|
||||||
socketSendJson('set-client-path', location.pathname + location.search);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
websocket.onclose = () => {
|
websocket.onclose = () => {
|
||||||
console.warn('WebSocket disconnected');
|
console.warn('WebSocket disconnected');
|
||||||
|
setOnlineStatus(false);
|
||||||
|
|
||||||
if (shouldReconnect) {
|
if (shouldReconnect) {
|
||||||
reconnectTimeout = setTimeout(() => {
|
reconnectTimeout = setTimeout(() => {
|
||||||
console.warn('WebSocket: attempting reconnect');
|
console.warn('WebSocket: attempting reconnect');
|
||||||
@@ -72,8 +74,8 @@ export const connectSocket = () => {
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case 'pong': {
|
case 'pong': {
|
||||||
const offset = (new Date().getTime() - new Date(payload).getTime()) * 0.5;
|
const offset = (new Date().getTime() - new Date(payload).getTime()) * 0.5;
|
||||||
patchRuntime('ping', offset);
|
patchRuntimeProperty('ping', offset);
|
||||||
updateDevTools({ ping: offset }, ['PING']);
|
updateDevTools({ ping: offset });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'client-id': {
|
case 'client-id': {
|
||||||
@@ -118,69 +120,77 @@ export const connectSocket = () => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'dialog': {
|
||||||
|
if (payload.dialog === 'welcome') {
|
||||||
|
addDialog('welcome');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'ontime-log': {
|
case 'ontime-log': {
|
||||||
addLog(payload as Log);
|
addLog(payload as Log);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'ontime': {
|
case 'ontime': {
|
||||||
runtimeStore.setState(payload as RuntimeStore);
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- removing the key from the payload
|
||||||
if (!isProduction) {
|
const { ping, ...serverPayload } = payload as Partial<RuntimeStore>;
|
||||||
ontimeQueryClient.setQueryData(RUNTIME, data.payload);
|
|
||||||
}
|
patchRuntime(serverPayload);
|
||||||
|
updateDevTools(serverPayload);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'ontime-clock': {
|
case 'ontime-clock': {
|
||||||
patchRuntime('clock', payload);
|
patchRuntimeProperty('clock', payload);
|
||||||
updateDevTools({ clock: payload });
|
updateDevTools({ clock: payload });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'ontime-timer': {
|
case 'ontime-timer': {
|
||||||
patchRuntime('timer', payload);
|
patchRuntimeProperty('timer', payload);
|
||||||
updateDevTools({ timer: payload });
|
updateDevTools({ timer: payload });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'ontime-onAir': {
|
case 'ontime-onAir': {
|
||||||
patchRuntime('onAir', payload);
|
patchRuntimeProperty('onAir', payload);
|
||||||
updateDevTools({ onAir: payload });
|
updateDevTools({ onAir: payload });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'ontime-message': {
|
case 'ontime-message': {
|
||||||
patchRuntime('message', payload);
|
patchRuntimeProperty('message', payload);
|
||||||
updateDevTools({ message: payload });
|
updateDevTools({ message: payload });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'ontime-runtime': {
|
case 'ontime-runtime': {
|
||||||
patchRuntime('runtime', payload);
|
patchRuntimeProperty('runtime', payload);
|
||||||
updateDevTools({ runtime: payload });
|
updateDevTools({ runtime: payload });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'ontime-eventNow': {
|
case 'ontime-eventNow': {
|
||||||
patchRuntime('eventNow', payload);
|
patchRuntimeProperty('eventNow', payload);
|
||||||
updateDevTools({ eventNow: payload });
|
updateDevTools({ eventNow: payload });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'ontime-currentBlock': {
|
case 'ontime-currentBlock': {
|
||||||
patchRuntime('currentBlock', payload);
|
patchRuntimeProperty('currentBlock', payload);
|
||||||
updateDevTools({ currentBlock: payload });
|
updateDevTools({ currentBlock: payload });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'ontime-publicEventNow': {
|
case 'ontime-publicEventNow': {
|
||||||
patchRuntime('publicEventNow', payload);
|
patchRuntimeProperty('publicEventNow', payload);
|
||||||
updateDevTools({ publicEventNow: payload });
|
updateDevTools({ publicEventNow: payload });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'ontime-eventNext': {
|
case 'ontime-eventNext': {
|
||||||
patchRuntime('eventNext', payload);
|
patchRuntimeProperty('eventNext', payload);
|
||||||
updateDevTools({ eventNext: payload });
|
updateDevTools({ eventNext: payload });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'ontime-publicEventNext': {
|
case 'ontime-publicEventNext': {
|
||||||
patchRuntime('publicEventNext', payload);
|
patchRuntimeProperty('publicEventNext', payload);
|
||||||
updateDevTools({ publicEventNext: payload });
|
updateDevTools({ publicEventNext: payload });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'ontime-auxtimer1': {
|
case 'ontime-auxtimer1': {
|
||||||
patchRuntime('auxtimer1', payload);
|
patchRuntimeProperty('auxtimer1', payload);
|
||||||
updateDevTools({ auxtimer1: payload });
|
updateDevTools({ auxtimer1: payload });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -224,11 +234,23 @@ export const socketSendJson = (type: string, payload?: unknown) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function updateDevTools(newData: Partial<RuntimeStore>, store = RUNTIME) {
|
function updateDevTools(newData: Partial<RuntimeStore>) {
|
||||||
if (!isProduction) {
|
if (!isProduction) {
|
||||||
ontimeQueryClient.setQueryData(store, (oldData: RuntimeStore) => ({
|
ontimeQueryClient.setQueryData(RUNTIME, (oldData: RuntimeStore) => ({
|
||||||
...oldData,
|
...oldData,
|
||||||
...newData,
|
...newData,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ declare module '*.scss' {
|
|||||||
type ListenerType = (event: 'string', args: unknown[]) => void;
|
type ListenerType = (event: 'string', args: unknown[]) => void;
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
/**
|
||||||
|
* Register the electron properties
|
||||||
|
*/
|
||||||
interface Window {
|
interface Window {
|
||||||
ipcRenderer: {
|
ipcRenderer: {
|
||||||
send: (channel: string, args?: string | object) => void;
|
send: (channel: string, args?: string | object) => void;
|
||||||
@@ -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' {
|
declare module 'react' {
|
||||||
interface CSSProperties {
|
interface CSSProperties {
|
||||||
[key: `--${string}`]: string | number;
|
[key: `--${string}`]: string | number;
|
||||||
|
|||||||
@@ -2,24 +2,74 @@
|
|||||||
* This file contains a list of constants that may need to be resolved at runtime
|
* This file contains a list of constants that may need to be resolved at runtime
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { version } from '../../../package.json';
|
||||||
|
|
||||||
export const githubUrl = 'https://www.github.com/cpvalente/ontime';
|
export const githubUrl = 'https://www.github.com/cpvalente/ontime';
|
||||||
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
|
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
|
||||||
export const websiteUrl = 'https://www.getontime.no';
|
export const websiteUrl = 'https://www.getontime.no';
|
||||||
|
export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
|
||||||
|
|
||||||
export const documentationUrl = 'https://docs.getontime.no';
|
export const documentationUrl = 'https://docs.getontime.no';
|
||||||
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
|
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
|
||||||
|
|
||||||
// resolve environment
|
// resolve environment
|
||||||
|
export const appVersion = version;
|
||||||
export const isProduction = import.meta.env.MODE === 'production';
|
export const isProduction = import.meta.env.MODE === 'production';
|
||||||
export const isDev = !isProduction;
|
export const isDev = !isProduction;
|
||||||
export const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
export const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||||
export const isOntimeCloud = Boolean(import.meta.env.VITE_IS_CLOUD);
|
export const isDockerImage = Boolean(import.meta.env.VITE_IS_DOCKER);
|
||||||
|
export const isOntimeCloud = window.location.hostname.includes('cloud.getontime.no');
|
||||||
|
|
||||||
// resolve protocol
|
// resolve entrypoint URLs
|
||||||
const socketProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
|
||||||
|
|
||||||
// resolve port
|
/**
|
||||||
const STATIC_PORT = 4001;
|
* Resolve base
|
||||||
export const serverPort = isProduction ? window.location.port : STATIC_PORT;
|
* @example '' for electron
|
||||||
export const serverURL = `${window.location.protocol}//${location.hostname}:${serverPort}`;
|
* @example '/client-hash' for cloud
|
||||||
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ function PanelListItem(props: PanelListItemProps) {
|
|||||||
</li>
|
</li>
|
||||||
{panel.secondary?.map((secondary) => {
|
{panel.secondary?.map((secondary) => {
|
||||||
const id = secondary.id.split('__')[1];
|
const id = secondary.id.split('__')[1];
|
||||||
const secondaryClasses = cx([style.secondary, location === id ? style.active : null]);
|
const secondaryClasses = cx([style.secondary, isSelected && location === id ? style.active : null]);
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={secondary.id}
|
key={secondary.id}
|
||||||
|
|||||||
@@ -26,15 +26,24 @@ $inner-padding: 1rem;
|
|||||||
color: $gray-300;
|
color: $gray-300;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
.section, .indent {
|
||||||
position: relative;
|
|
||||||
margin-top: 2rem;
|
|
||||||
font-size: calc(1rem - 1px);
|
font-size: calc(1rem - 1px);
|
||||||
max-width: 800px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
position: relative;
|
||||||
|
margin-top: 2rem;
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indent {
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: $gray-1350;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paragraph {
|
.paragraph {
|
||||||
@@ -84,7 +93,6 @@ $inner-padding: 1rem;
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
box-shadow: 0 1px $white-10;
|
|
||||||
background-color: $gray-1350;
|
background-color: $gray-1350;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +171,40 @@ $inner-padding: 1rem;
|
|||||||
animation: animloader 1s ease-in infinite;
|
animation: animloader 1s ease-in infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
background-color: $black-10;
|
||||||
|
text-align: center;
|
||||||
|
color: $muted-gray;
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inlineSiblings {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
background-color: $black-10;
|
||||||
|
text-align: center;
|
||||||
|
color: $muted-gray;
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes animloader {
|
@keyframes animloader {
|
||||||
0% {
|
0% {
|
||||||
transform: scale(0);
|
transform: scale(0);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { HTMLAttributes, ReactNode } from 'react';
|
import { HTMLAttributes, ReactNode } from 'react';
|
||||||
|
import { Button } from '@chakra-ui/react';
|
||||||
|
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||||
|
|
||||||
import { cx } from '../../../common/utils/styleUtils';
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
|
|
||||||
@@ -22,10 +24,19 @@ type SectionProps<C extends AllowedTags> = {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
} & JSX.IntrinsicElements[C];
|
} & 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';
|
const Element = as ?? 'div';
|
||||||
return (
|
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}
|
{children}
|
||||||
</Element>
|
</Element>
|
||||||
);
|
);
|
||||||
@@ -52,8 +63,21 @@ export function Table({ className, children }: { className?: string; children: R
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ListGroup({ children }: { children: ReactNode }) {
|
export function TableEmpty({ handleClick }: { handleClick: () => void }) {
|
||||||
return <ul className={style.listGroup}>{children}</ul>;
|
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 }) {
|
export function ListItem({ children }: { children: ReactNode }) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||||
import { documentationUrl, githubUrl, websiteUrl } from '../../../../externals';
|
import { discordUrl, documentationUrl, githubUrl, websiteUrl } from '../../../../externals';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import AppVersion from './AppVersion';
|
import AppVersion from './AppVersion';
|
||||||
@@ -19,6 +19,7 @@ export default function AboutPanel() {
|
|||||||
<Panel.SubHeader>Links</Panel.SubHeader>
|
<Panel.SubHeader>Links</Panel.SubHeader>
|
||||||
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
|
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
|
||||||
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
|
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
|
||||||
|
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Panel.SubHeader>Current version</Panel.SubHeader>
|
<Panel.SubHeader>Current version</Panel.SubHeader>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { version } from '../../../../../package.json';
|
|
||||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||||
import useAppVersion from '../../../../common/hooks-query/useAppVersion';
|
import useAppVersion from '../../../../common/hooks-query/useAppVersion';
|
||||||
|
import { appVersion } from '../../../../externals';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
export default function AppVersion() {
|
export default function AppVersion() {
|
||||||
@@ -9,7 +9,7 @@ export default function AppVersion() {
|
|||||||
if (isError) {
|
if (isError) {
|
||||||
return (
|
return (
|
||||||
<Panel.Paragraph>
|
<Panel.Paragraph>
|
||||||
{`You are currently using Ontime version ${version}`}
|
{`You are currently using Ontime version ${appVersion}`}
|
||||||
<Panel.Error>{`Could not fetch version information: ${isError}`}</Panel.Error>
|
<Panel.Error>{`Could not fetch version information: ${isError}`}</Panel.Error>
|
||||||
</Panel.Paragraph>
|
</Panel.Paragraph>
|
||||||
);
|
);
|
||||||
@@ -18,7 +18,7 @@ export default function AppVersion() {
|
|||||||
if (data.hasUpdates) {
|
if (data.hasUpdates) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Panel.Paragraph>{`You are currently using Ontime version ${version}.`}</Panel.Paragraph>
|
<Panel.Paragraph>{`You are currently using Ontime version ${appVersion}.`}</Panel.Paragraph>
|
||||||
<Panel.Paragraph>
|
<Panel.Paragraph>
|
||||||
{`A new version ${data.version} is available.`}
|
{`A new version ${data.version} is available.`}
|
||||||
<ExternalLink href={data.url}> Please visit the release page to download</ExternalLink>
|
<ExternalLink href={data.url}> Please visit the release page to download</ExternalLink>
|
||||||
@@ -27,5 +27,5 @@ export default function AppVersion() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Panel.Paragraph>{`You are currently using the latest version of Ontime: ${version}`}</Panel.Paragraph>;
|
return <Panel.Paragraph>{`You are currently using the latest version of Ontime: ${appVersion}`}</Panel.Paragraph>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export default function ClientList() {
|
|||||||
)}
|
)}
|
||||||
{name}
|
{name}
|
||||||
</td>
|
</td>
|
||||||
{isCurrent ? <td /> : <td className={style.pathList}>{path}</td>}
|
<td className={style.pathList}>{path}</td>
|
||||||
<td className={style.actionButtons}>
|
<td className={style.actionButtons}>
|
||||||
<Button
|
<Button
|
||||||
size='xs'
|
size='xs'
|
||||||
|
|||||||
@@ -149,9 +149,14 @@ export default function UrlPresetsForm() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
{fields.length === 0 && <Panel.TableEmpty handleClick={addNew} />}
|
||||||
{fields.map((preset, index) => {
|
{fields.map((preset, index) => {
|
||||||
const maybeAliasError = errors.data?.[index]?.alias?.message;
|
const maybeAliasError = errors.data?.[index]?.alias?.message;
|
||||||
const maybeUrlError = errors.data?.[index]?.pathAndParams?.message;
|
const maybeUrlError = errors.data?.[index]?.pathAndParams?.message;
|
||||||
|
// only saved and enabled URLs can be tested
|
||||||
|
const canTest =
|
||||||
|
preset.alias && preset.enabled && preset.pathAndParams && !maybeAliasError && !maybeUrlError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={preset.id}>
|
<tr key={preset.id}>
|
||||||
<td className={style.fit}>
|
<td className={style.fit}>
|
||||||
@@ -190,6 +195,7 @@ export default function UrlPresetsForm() {
|
|||||||
<td className={style.flex}>
|
<td className={style.flex}>
|
||||||
<TooltipActionBtn
|
<TooltipActionBtn
|
||||||
size='sm'
|
size='sm'
|
||||||
|
isDisabled={!canTest}
|
||||||
clickHandler={(event) => handleLinks(event, preset.alias)}
|
clickHandler={(event) => handleLinks(event, preset.alias)}
|
||||||
tooltip='Test preset'
|
tooltip='Test preset'
|
||||||
aria-label='Test preset'
|
aria-label='Test preset'
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ export default function GeneralPanelForm() {
|
|||||||
<option value='es'>Spanish</option>
|
<option value='es'>Spanish</option>
|
||||||
<option value='sv'>Swedish</option>
|
<option value='sv'>Swedish</option>
|
||||||
<option value='pl'>Polish</option>
|
<option value='pl'>Polish</option>
|
||||||
|
<option value='zh'>Chinese (Simplified)</option>
|
||||||
</Select>
|
</Select>
|
||||||
</Panel.ListItem>
|
</Panel.ListItem>
|
||||||
</Panel.ListGroup>
|
</Panel.ListGroup>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ViewSettings } from 'ontime-types';
|
|||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import { postViewSettings } from '../../../../common/api/viewSettings';
|
import { postViewSettings } from '../../../../common/api/viewSettings';
|
||||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||||
import { 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 useInfo from '../../../../common/hooks-query/useInfo';
|
||||||
import useViewSettings from '../../../../common/hooks-query/useViewSettings';
|
import useViewSettings from '../../../../common/hooks-query/useViewSettings';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
@@ -111,15 +111,15 @@ export default function ViewSettingsForm() {
|
|||||||
<Panel.ListGroup>
|
<Panel.ListGroup>
|
||||||
<Panel.ListItem>
|
<Panel.ListItem>
|
||||||
<Panel.Field title='Timer colour' description='Default colour of a running timer' />
|
<Panel.Field title='Timer colour' description='Default colour of a running timer' />
|
||||||
<PopoverPickerRHF name='normalColor' control={control} />
|
<SwatchPickerRHF name='normalColor' control={control} />
|
||||||
</Panel.ListItem>
|
</Panel.ListItem>
|
||||||
<Panel.ListItem>
|
<Panel.ListItem>
|
||||||
<Panel.Field title='Warning colour' description='Colour of a running timer in warning mode' />
|
<Panel.Field title='Warning colour' description='Colour of a running timer in warning mode' />
|
||||||
<PopoverPickerRHF name='warningColor' control={control} />
|
<SwatchPickerRHF name='warningColor' control={control} />
|
||||||
</Panel.ListItem>
|
</Panel.ListItem>
|
||||||
<Panel.ListItem>
|
<Panel.ListItem>
|
||||||
<Panel.Field title='Danger colour' description='Colour of a running timer in danger mode' />
|
<Panel.Field title='Danger colour' description='Colour of a running timer in danger mode' />
|
||||||
<PopoverPickerRHF name='dangerColor' control={control} />
|
<SwatchPickerRHF name='dangerColor' control={control} />
|
||||||
</Panel.ListItem>
|
</Panel.ListItem>
|
||||||
</Panel.ListGroup>
|
</Panel.ListGroup>
|
||||||
<Panel.ListGroup>
|
<Panel.ListGroup>
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import { generateId } from 'ontime-utils';
|
|||||||
|
|
||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
|
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
|
||||||
import { isKeyEscape } from '../../../../common/utils/keyEvent';
|
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||||
import { isASCII, isASCIIorEmpty, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
|
import { isASCII, isASCIIorEmpty, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
|
||||||
|
import { isOntimeCloud } from '../../../../externals';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import { cycles } from './integrationUtils';
|
import { cycles } from './integrationUtils';
|
||||||
@@ -62,13 +63,6 @@ export default function OscIntegrations() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const preventEscape = (event: React.KeyboardEvent) => {
|
|
||||||
if (isKeyEscape(event)) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddNewSubscription = () => {
|
const handleAddNewSubscription = () => {
|
||||||
prepend({
|
prepend({
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
@@ -106,6 +100,9 @@ export default function OscIntegrations() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Panel.SubHeader>
|
</Panel.SubHeader>
|
||||||
|
{isOntimeCloud && (
|
||||||
|
<Panel.Highlight>For security reasons OSC integrations are not available in the cloud service.</Panel.Highlight>
|
||||||
|
)}
|
||||||
|
|
||||||
<Panel.Divider />
|
<Panel.Divider />
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ export default function EditorSettingsForm() {
|
|||||||
>
|
>
|
||||||
<option value={TimerType.CountDown}>Count down</option>
|
<option value={TimerType.CountDown}>Count down</option>
|
||||||
<option value={TimerType.CountUp}>Count up</option>
|
<option value={TimerType.CountUp}>Count up</option>
|
||||||
<option value={TimerType.TimeToEnd}>Time to end</option>
|
|
||||||
<option value={TimerType.Clock}>Clock</option>
|
<option value={TimerType.Clock}>Clock</option>
|
||||||
<option value={TimerType.None}>None</option>
|
<option value={TimerType.None}>None</option>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
|||||||
|
|
||||||
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
||||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||||
import { openLink } from '../../../../common/utils/linkUtils';
|
import { linkToOtherHost, openLink } from '../../../../common/utils/linkUtils';
|
||||||
import { isLocalhost, serverPort } from '../../../../externals';
|
import { isLocalhost } from '../../../../externals';
|
||||||
|
|
||||||
import style from './NetworkInterfaces.module.scss';
|
import style from './NetworkInterfaces.module.scss';
|
||||||
|
|
||||||
@@ -14,11 +14,11 @@ export default function InfoNif() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.interfaces}>
|
<div className={style.interfaces}>
|
||||||
{data.networkInterfaces?.map((nif) => {
|
{data.networkInterfaces.map((nif) => {
|
||||||
// interfaces outside localhost wont have access
|
// interfaces outside localhost wont have access
|
||||||
if (nif.name === 'localhost' && !isLocalhost) return null;
|
if (nif.name === 'localhost' && !isLocalhost) return null;
|
||||||
|
const address = linkToOtherHost(nif.address);
|
||||||
|
|
||||||
const address = `http://${nif.address}:${serverPort}`;
|
|
||||||
return (
|
return (
|
||||||
<CopyTag
|
<CopyTag
|
||||||
key={nif.name}
|
key={nif.name}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useEffect } from 'react';
|
|||||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||||
import { usePing } from '../../../../common/hooks/useSocket';
|
import { usePing } from '../../../../common/hooks/useSocket';
|
||||||
import { socketSendJson } from '../../../../common/utils/socket';
|
import { socketSendJson } from '../../../../common/utils/socket';
|
||||||
import { isOntimeCloud } from '../../../../externals';
|
import { isDockerImage } from '../../../../externals';
|
||||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
import ClientControlPanel from '../client-control-panel/ClientControlPanel';
|
import ClientControlPanel from '../client-control-panel/ClientControlPanel';
|
||||||
@@ -19,7 +19,7 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
|||||||
<>
|
<>
|
||||||
<Panel.Header>Network</Panel.Header>
|
<Panel.Header>Network</Panel.Header>
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
{isOntimeCloud && <OntimeCloudStats />}
|
{isDockerImage && <OntimeCloudStats />}
|
||||||
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
|
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
<InfoNif />
|
<InfoNif />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
|||||||
import { PROJECT_LIST } from '../../../../common/api/constants';
|
import { PROJECT_LIST } from '../../../../common/api/constants';
|
||||||
import { createProject } from '../../../../common/api/db';
|
import { createProject } from '../../../../common/api/db';
|
||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
|
import { documentationUrl, websiteUrl } from '../../../../externals';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import style from './ProjectPanel.module.scss';
|
import style from './ProjectPanel.module.scss';
|
||||||
@@ -118,7 +119,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
|||||||
<Input
|
<Input
|
||||||
variant='ontime-filled'
|
variant='ontime-filled'
|
||||||
size='sm'
|
size='sm'
|
||||||
placeholder='www.getontime.no'
|
placeholder={websiteUrl}
|
||||||
autoComplete='off'
|
autoComplete='off'
|
||||||
{...register('publicUrl')}
|
{...register('publicUrl')}
|
||||||
/>
|
/>
|
||||||
@@ -140,7 +141,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
|||||||
<Input
|
<Input
|
||||||
variant='ontime-filled'
|
variant='ontime-filled'
|
||||||
size='sm'
|
size='sm'
|
||||||
placeholder='http://docs.getontime.no'
|
placeholder={documentationUrl}
|
||||||
autoComplete='off'
|
autoComplete='off'
|
||||||
{...register('backstageUrl')}
|
{...register('backstageUrl')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { postProjectData, uploadProjectLogo } from '../../../../common/api/proje
|
|||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import useProjectData from '../../../../common/hooks-query/useProjectData';
|
import useProjectData from '../../../../common/hooks-query/useProjectData';
|
||||||
import { validateLogo } from '../../../../common/utils/uploadUtils';
|
import { validateLogo } from '../../../../common/utils/uploadUtils';
|
||||||
|
import { documentationUrl, websiteUrl } from '../../../../externals';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import style from './ProjectPanel.module.scss';
|
import style from './ProjectPanel.module.scss';
|
||||||
@@ -68,7 +69,9 @@ export default function ProjectData() {
|
|||||||
uploadInputRef.current?.click();
|
uploadInputRef.current?.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteLogo = () => {
|
const handleDeleteLogo = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
setValue('projectLogo', null, {
|
setValue('projectLogo', null, {
|
||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
});
|
});
|
||||||
@@ -201,7 +204,7 @@ export default function ProjectData() {
|
|||||||
<Input
|
<Input
|
||||||
variant='ontime-filled'
|
variant='ontime-filled'
|
||||||
size='sm'
|
size='sm'
|
||||||
placeholder='www.getontime.no'
|
placeholder={websiteUrl}
|
||||||
autoComplete='off'
|
autoComplete='off'
|
||||||
{...register('publicUrl')}
|
{...register('publicUrl')}
|
||||||
/>
|
/>
|
||||||
@@ -223,7 +226,7 @@ export default function ProjectData() {
|
|||||||
<Input
|
<Input
|
||||||
variant='ontime-filled'
|
variant='ontime-filled'
|
||||||
size='sm'
|
size='sm'
|
||||||
placeholder='http://docs.getontime.no'
|
placeholder={documentationUrl}
|
||||||
autoComplete='off'
|
autoComplete='off'
|
||||||
{...register('backstageUrl')}
|
{...register('backstageUrl')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useProjectList } from '../../../../common/hooks-query/useProjectList';
|
import { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import ProjectListItem, { EditMode } from './ProjectListItem';
|
import ProjectListItem, { EditMode } from './ProjectListItem';
|
||||||
@@ -8,8 +8,7 @@ import ProjectListItem, { EditMode } from './ProjectListItem';
|
|||||||
import style from './ProjectPanel.module.scss';
|
import style from './ProjectPanel.module.scss';
|
||||||
|
|
||||||
export default function ProjectList() {
|
export default function ProjectList() {
|
||||||
const { data, refetch } = useProjectList();
|
const { data, refetch } = useOrderedProjectList();
|
||||||
const { files, lastLoadedProject } = data;
|
|
||||||
|
|
||||||
const [editingMode, setEditingMode] = useState<EditMode | null>(null);
|
const [editingMode, setEditingMode] = useState<EditMode | null>(null);
|
||||||
const [editingFilename, setEditingFilename] = useState<string | null>(null);
|
const [editingFilename, setEditingFilename] = useState<string | null>(null);
|
||||||
@@ -28,16 +27,6 @@ export default function ProjectList() {
|
|||||||
await refetch();
|
await refetch();
|
||||||
};
|
};
|
||||||
|
|
||||||
const reorderedProjectFiles = useMemo(() => {
|
|
||||||
if (!data.files?.length) return [];
|
|
||||||
|
|
||||||
const currentlyLoadedIndex = files.findIndex((project) => project.filename === lastLoadedProject);
|
|
||||||
const projectFiles = [...files];
|
|
||||||
const current = projectFiles.splice(currentlyLoadedIndex, 1)?.[0];
|
|
||||||
|
|
||||||
return [current, ...projectFiles];
|
|
||||||
}, [data.files?.length, files, lastLoadedProject]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Table>
|
<Panel.Table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -48,7 +37,7 @@ export default function ProjectList() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{reorderedProjectFiles.map((project) => (
|
{data.reorderedProjectFiles.map((project) => (
|
||||||
<ProjectListItem
|
<ProjectListItem
|
||||||
key={project.filename}
|
key={project.filename}
|
||||||
filename={project.filename}
|
filename={project.filename}
|
||||||
@@ -58,7 +47,7 @@ export default function ProjectList() {
|
|||||||
onRefetch={handleRefetch}
|
onRefetch={handleRefetch}
|
||||||
editingFilename={editingFilename}
|
editingFilename={editingFilename}
|
||||||
editingMode={editingMode}
|
editingMode={editingMode}
|
||||||
current={project.filename === lastLoadedProject}
|
current={project.filename === data.lastLoadedProject}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
import { useElectronEvent } from '../../../../common/hooks/useElectronEvent';
|
import { useElectronEvent } from '../../../../common/hooks/useElectronEvent';
|
||||||
import { isLocalhost } from '../../../../externals';
|
import { isLocalhost, isOntimeCloud } from '../../../../externals';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
export default function ShutdownPanel() {
|
export default function ShutdownPanel() {
|
||||||
@@ -24,18 +24,28 @@ export default function ShutdownPanel() {
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canShutdown = isElectron || isLocalhost;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Panel.Header>Shutdown Ontime</Panel.Header>
|
<Panel.Header>Shutdown Ontime</Panel.Header>
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Panel.Paragraph>
|
{isOntimeCloud ? (
|
||||||
This will shutdown the Ontime server. <br />
|
<Panel.Highlight>
|
||||||
The runtime state will be lost, but your project is kept for next time.
|
For security reasons, shutting down the server must be done from the Ontime Cloud dashboard.
|
||||||
</Panel.Paragraph>
|
</Panel.Highlight>
|
||||||
|
) : (
|
||||||
|
<Panel.Paragraph>
|
||||||
|
This will shutdown the Ontime server. <br />
|
||||||
|
The runtime state will be lost, but your project is kept for next time.
|
||||||
|
</Panel.Paragraph>
|
||||||
|
)}
|
||||||
<Button colorScheme='red' onClick={onOpen} maxWidth='350px' isDisabled={!(isElectron || isLocalhost)}>
|
<Button colorScheme='red' onClick={onOpen} maxWidth='350px' isDisabled={!(isElectron || isLocalhost)}>
|
||||||
Shutdown ontime
|
Shutdown ontime
|
||||||
</Button>
|
</Button>
|
||||||
<Panel.Description>Note: Ontime can only be shutdown from the machine it is running in.</Panel.Description>
|
{!canShutdown && (
|
||||||
|
<Panel.Description>Note: Ontime can only be shutdown from the machine it is running in.</Panel.Description>
|
||||||
|
)}
|
||||||
<AlertDialog variant='ontime' isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}>
|
<AlertDialog variant='ontime' isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}>
|
||||||
<AlertDialogOverlay>
|
<AlertDialogOverlay>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
@@ -49,7 +59,7 @@ export default function ShutdownPanel() {
|
|||||||
<Button ref={cancelRef} onClick={onClose} variant='ontime-ghosted-white'>
|
<Button ref={cancelRef} onClick={onClose} variant='ontime-ghosted-white'>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button colorScheme='red' onClick={sendShutdown} ml={4}>
|
<Button colorScheme='red' onClick={sendShutdown} disabled={!canShutdown}>
|
||||||
Shutdown
|
Shutdown
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const namedImportMap = {
|
|||||||
Duration: 'duration',
|
Duration: 'duration',
|
||||||
Cue: 'cue',
|
Cue: 'cue',
|
||||||
Title: 'title',
|
Title: 'title',
|
||||||
|
'Count to end': 'count to end',
|
||||||
'Is Public': 'public',
|
'Is Public': 'public',
|
||||||
Skip: 'skip',
|
Skip: 'skip',
|
||||||
Note: 'notes',
|
Note: 'notes',
|
||||||
@@ -47,6 +48,7 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
|
|||||||
duration: namedImportMap.Duration,
|
duration: namedImportMap.Duration,
|
||||||
cue: namedImportMap.Cue,
|
cue: namedImportMap.Cue,
|
||||||
title: namedImportMap.Title,
|
title: namedImportMap.Title,
|
||||||
|
countToEnd: namedImportMap['Count to end'],
|
||||||
isPublic: namedImportMap['Is Public'],
|
isPublic: namedImportMap['Is Public'],
|
||||||
skip: namedImportMap.Skip,
|
skip: namedImportMap.Skip,
|
||||||
note: namedImportMap.Note,
|
note: namedImportMap.Note,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
|||||||
<th>Duration</th>
|
<th>Duration</th>
|
||||||
<th>Warning Time</th>
|
<th>Warning Time</th>
|
||||||
<th>Danger Time</th>
|
<th>Danger Time</th>
|
||||||
|
<th>Count to end</th>
|
||||||
<th>Is Public</th>
|
<th>Is Public</th>
|
||||||
<th>Skip</th>
|
<th>Skip</th>
|
||||||
<th>Colour</th>
|
<th>Colour</th>
|
||||||
@@ -71,6 +72,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
|||||||
}
|
}
|
||||||
eventIndex += 1;
|
eventIndex += 1;
|
||||||
const colour = event.colour ? getAccessibleColour(event.colour) : {};
|
const colour = event.colour ? getAccessibleColour(event.colour) : {};
|
||||||
|
const countToEnd = booleanToText(event.countToEnd);
|
||||||
const isPublic = booleanToText(event.isPublic);
|
const isPublic = booleanToText(event.isPublic);
|
||||||
const skip = booleanToText(event.skip);
|
const skip = booleanToText(event.skip);
|
||||||
|
|
||||||
@@ -93,6 +95,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
|||||||
<td>{millisToString(event.duration)}</td>
|
<td>{millisToString(event.duration)}</td>
|
||||||
<td>{millisToString(event.timeWarning)}</td>
|
<td>{millisToString(event.timeWarning)}</td>
|
||||||
<td>{millisToString(event.timeDanger)}</td>
|
<td>{millisToString(event.timeDanger)}</td>
|
||||||
|
<td className={style.center}>{countToEnd && <Tag>{countToEnd}</Tag>}</td>
|
||||||
<td className={style.center}>{isPublic && <Tag>{isPublic}</Tag>}</td>
|
<td className={style.center}>{isPublic && <Tag>{isPublic}</Tag>}</td>
|
||||||
<td>{skip && <Tag>{skip}</Tag>}</td>
|
<td>{skip && <Tag>{skip}</Tag>}</td>
|
||||||
<td style={{ ...colour }}>{event.colour}</td>
|
<td style={{ ...colour }}>{event.colour}</td>
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export default function QuickStart(props: QuickStartProps) {
|
|||||||
<option value='es'>Spanish</option>
|
<option value='es'>Spanish</option>
|
||||||
<option value='sv'>Swedish</option>
|
<option value='sv'>Swedish</option>
|
||||||
<option value='pl'>Polish</option>
|
<option value='pl'>Polish</option>
|
||||||
|
<option value='zh'>Chinese (Simplified)</option>
|
||||||
</Select>
|
</Select>
|
||||||
</Panel.ListItem>
|
</Panel.ListItem>
|
||||||
</Panel.ListGroup>
|
</Panel.ListGroup>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { Corner } from '../../editors/editor-utils/EditorUtils';
|
|||||||
import style from './MessageControl.module.scss';
|
import style from './MessageControl.module.scss';
|
||||||
|
|
||||||
export default function TimerPreview() {
|
export default function TimerPreview() {
|
||||||
const { blink, blackout, phase, showAuxTimer, showExternalMessage, showTimerMessage, timerType } =
|
const { blink, blackout, countToEnd, phase, showAuxTimer, showExternalMessage, showTimerMessage, timerType } =
|
||||||
useMessagePreview();
|
useMessagePreview();
|
||||||
const { data } = useViewSettings();
|
const { data } = useViewSettings();
|
||||||
|
|
||||||
@@ -24,11 +24,11 @@ export default function TimerPreview() {
|
|||||||
|
|
||||||
const main = (() => {
|
const main = (() => {
|
||||||
if (showTimerMessage) return 'Message';
|
if (showTimerMessage) return 'Message';
|
||||||
|
if (timerType === TimerType.None) return timerPlaceholder;
|
||||||
if (phase === TimerPhase.Pending) return 'Standby to start';
|
if (phase === TimerPhase.Pending) return 'Standby to start';
|
||||||
if (phase === TimerPhase.Overtime && data.endMessage) return 'Custom end message';
|
if (phase === TimerPhase.Overtime && data.endMessage) return 'Custom end message';
|
||||||
if (timerType === TimerType.TimeToEnd) return 'Time to end';
|
|
||||||
if (timerType === TimerType.Clock) return 'Clock';
|
if (timerType === TimerType.Clock) return 'Clock';
|
||||||
if (timerType === TimerType.None) return timerPlaceholder;
|
if (countToEnd) return 'Count to End';
|
||||||
return 'Timer';
|
return 'Timer';
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -74,12 +74,12 @@ export default function TimerPreview() {
|
|||||||
<Tooltip label='Time type: Clock' openDelay={tooltipDelayMid} shouldWrapChildren>
|
<Tooltip label='Time type: Clock' openDelay={tooltipDelayMid} shouldWrapChildren>
|
||||||
<IoTime className={style.statusIcon} data-active={timerType === TimerType.Clock} />
|
<IoTime className={style.statusIcon} data-active={timerType === TimerType.Clock} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label='Time type: Time to end' openDelay={tooltipDelayMid} shouldWrapChildren>
|
|
||||||
<IoFlag className={style.statusIcon} data-active={timerType === TimerType.TimeToEnd} />
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label='Time type: None' openDelay={tooltipDelayMid} shouldWrapChildren>
|
<Tooltip label='Time type: None' openDelay={tooltipDelayMid} shouldWrapChildren>
|
||||||
<IoBan className={style.statusIcon} data-active={timerType === TimerType.None} />
|
<IoBan className={style.statusIcon} data-active={timerType === TimerType.None} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip label={countToEnd ? 'Count to end' : 'Count duration'} openDelay={tooltipDelayMid} shouldWrapChildren>
|
||||||
|
<IoFlag className={style.statusIcon} data-active={countToEnd} />
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
import { Tooltip } from '@chakra-ui/react';
|
import { Tooltip } from '@chakra-ui/react';
|
||||||
import { Playback, TimerPhase } from 'ontime-types';
|
import { Playback, TimerPhase } from 'ontime-types';
|
||||||
import { dayInMs, millisToMinutes, millisToSeconds, millisToString } from 'ontime-utils';
|
import { dayInMs, millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
import { useTimer } from '../../../../common/hooks/useSocket';
|
import { useTimer } from '../../../../common/hooks/useSocket';
|
||||||
|
import { formatDuration } from '../../../../common/utils/time';
|
||||||
import TimerDisplay from '../timer-display/TimerDisplay';
|
import TimerDisplay from '../timer-display/TimerDisplay';
|
||||||
|
|
||||||
import style from './PlaybackTimer.module.scss';
|
import style from './PlaybackTimer.module.scss';
|
||||||
@@ -13,22 +14,12 @@ interface PlaybackTimerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveAddedTimeLabel(addedTime: number) {
|
function resolveAddedTimeLabel(addedTime: number) {
|
||||||
function resolveClosestUnit(ms: number) {
|
|
||||||
if (ms < 6000) {
|
|
||||||
return `${millisToSeconds(ms)} seconds`;
|
|
||||||
} else if (ms < 12000) {
|
|
||||||
return '1 minute';
|
|
||||||
} else {
|
|
||||||
return `${millisToMinutes(ms)} minutes`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (addedTime > 0) {
|
if (addedTime > 0) {
|
||||||
return `Added ${resolveClosestUnit(addedTime)}`;
|
return `Added ${formatDuration(addedTime, false)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (addedTime < 0) {
|
if (addedTime < 0) {
|
||||||
return `Removed ${resolveClosestUnit(addedTime)}`;
|
return `Removed ${formatDuration(Math.abs(addedTime), false)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return '';
|
return '';
|
||||||
|
|||||||
@@ -16,4 +16,8 @@
|
|||||||
&.finished {
|
&.finished {
|
||||||
color: $timer-finished-color;
|
color: $timer-finished-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.muted {
|
||||||
|
color: $muted-gray;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function TimerDisplay(props: TimerDisplayProps) {
|
|||||||
const isNegative = (time ?? 0) < 0;
|
const isNegative = (time ?? 0) < 0;
|
||||||
const display =
|
const display =
|
||||||
time == null ? timerPlaceholder : millisToString(time, { fallback: timerPlaceholder }).replace('-', '');
|
time == null ? timerPlaceholder : millisToString(time, { fallback: timerPlaceholder }).replace('-', '');
|
||||||
const classes = cx([style.timer, isNegative ? style.finished : null]);
|
const classes = cx([style.timer, isNegative ? style.finished : null, time === null && style.muted]);
|
||||||
|
|
||||||
return <div className={classes}>{display}</div>;
|
return <div className={classes}>{display}</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
import { useCallback, useRef } from 'react';
|
|
||||||
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
|
||||||
import Color from 'color';
|
|
||||||
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
|
|
||||||
|
|
||||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
|
||||||
import { getAccessibleColour } from '../../common/utils/styleUtils';
|
|
||||||
|
|
||||||
import BlockRow from './cuesheet-table-elements/BlockRow';
|
|
||||||
import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
|
|
||||||
import DelayRow from './cuesheet-table-elements/DelayRow';
|
|
||||||
import EventRow from './cuesheet-table-elements/EventRow';
|
|
||||||
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
|
|
||||||
import { useCuesheetSettings } from './store/CuesheetSettings';
|
|
||||||
import useColumnManager from './useColumnManager';
|
|
||||||
|
|
||||||
import style from './Cuesheet.module.scss';
|
|
||||||
|
|
||||||
interface CuesheetProps {
|
|
||||||
data: OntimeRundown;
|
|
||||||
columns: ColumnDef<OntimeRundownEntry>[];
|
|
||||||
handleUpdate: (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: unknown) => void;
|
|
||||||
selectedId: string | null;
|
|
||||||
currentBlockId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Cuesheet({ data, columns, handleUpdate, selectedId, currentBlockId }: CuesheetProps) {
|
|
||||||
const { followSelected, showSettings, showDelayBlock, showPrevious, showIndexColumn } = useCuesheetSettings();
|
|
||||||
const {
|
|
||||||
columnVisibility,
|
|
||||||
columnOrder,
|
|
||||||
columnSizing,
|
|
||||||
resetColumnOrder,
|
|
||||||
setColumnVisibility,
|
|
||||||
saveColumnOrder,
|
|
||||||
setColumnSizing,
|
|
||||||
} = useColumnManager(columns);
|
|
||||||
|
|
||||||
const selectedRef = useRef<HTMLTableRowElement | null>(null);
|
|
||||||
const tableContainerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected });
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data,
|
|
||||||
columns,
|
|
||||||
columnResizeMode: 'onChange',
|
|
||||||
state: {
|
|
||||||
columnOrder,
|
|
||||||
columnVisibility,
|
|
||||||
columnSizing,
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
handleUpdate,
|
|
||||||
},
|
|
||||||
onColumnVisibilityChange: setColumnVisibility,
|
|
||||||
onColumnSizingChange: setColumnSizing,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const setAllVisible = () => {
|
|
||||||
table.toggleAllColumnsVisible(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetColumnResizing = () => {
|
|
||||||
setColumnSizing({});
|
|
||||||
};
|
|
||||||
|
|
||||||
const reorder = useCallback(
|
|
||||||
(fromId: string, toId: string) => {
|
|
||||||
// get index of from
|
|
||||||
const fromIndex = columnOrder.indexOf(fromId);
|
|
||||||
|
|
||||||
// get index of to
|
|
||||||
const toIndex = columnOrder.indexOf(toId);
|
|
||||||
|
|
||||||
if (toIndex === -1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reorderedCols = [...columnOrder];
|
|
||||||
const reorderedItem = reorderedCols.splice(fromIndex, 1);
|
|
||||||
reorderedCols.splice(toIndex, 0, reorderedItem[0]);
|
|
||||||
saveColumnOrder(reorderedCols);
|
|
||||||
},
|
|
||||||
[columnOrder, saveColumnOrder],
|
|
||||||
);
|
|
||||||
|
|
||||||
const headerGroups = table.getHeaderGroups();
|
|
||||||
const rowModel = table.getRowModel();
|
|
||||||
const allLeafColumns = table.getAllLeafColumns();
|
|
||||||
|
|
||||||
let eventIndex = 0;
|
|
||||||
let isPast = Boolean(selectedId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{showSettings && (
|
|
||||||
<CuesheetTableSettings
|
|
||||||
columns={allLeafColumns}
|
|
||||||
handleResetResizing={resetColumnResizing}
|
|
||||||
handleResetReordering={resetColumnOrder}
|
|
||||||
handleClearToggles={setAllVisible}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div ref={tableContainerRef} className={style.cuesheetContainer}>
|
|
||||||
<table className={style.cuesheet}>
|
|
||||||
<CuesheetHeader headerGroups={headerGroups} saveColumnOrder={reorder} showIndexColumn={showIndexColumn} />
|
|
||||||
<tbody>
|
|
||||||
{rowModel.rows.map((row) => {
|
|
||||||
const key = row.original.id;
|
|
||||||
const isSelected = selectedId === key;
|
|
||||||
if (isSelected) {
|
|
||||||
isPast = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isOntimeBlock(row.original)) {
|
|
||||||
if (isPast && !showPrevious && key !== currentBlockId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return <BlockRow key={key} title={row.original.title} />;
|
|
||||||
}
|
|
||||||
if (isOntimeDelay(row.original)) {
|
|
||||||
if (isPast && !showPrevious) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const delayVal = row.original.duration;
|
|
||||||
if (!showDelayBlock || delayVal === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <DelayRow key={key} duration={delayVal} />;
|
|
||||||
}
|
|
||||||
if (isOntimeEvent(row.original)) {
|
|
||||||
eventIndex++;
|
|
||||||
const isSelected = key === selectedId;
|
|
||||||
|
|
||||||
if (isPast && !showPrevious) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let rowBgColour: string | undefined;
|
|
||||||
if (isSelected) {
|
|
||||||
rowBgColour = '#D20300'; // $red-700
|
|
||||||
} else if (row.original.colour) {
|
|
||||||
try {
|
|
||||||
// the colour is user defined and might be invalid
|
|
||||||
const accessibleBackgroundColor = Color(getAccessibleColour(row.original.colour).backgroundColor);
|
|
||||||
rowBgColour = accessibleBackgroundColor.fade(0.75).hexa();
|
|
||||||
} catch (_error) {
|
|
||||||
/* we do not handle errors here */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EventRow
|
|
||||||
key={key}
|
|
||||||
eventIndex={eventIndex}
|
|
||||||
isPast={isPast}
|
|
||||||
selectedRef={isSelected ? selectedRef : undefined}
|
|
||||||
skip={row.original.skip}
|
|
||||||
colour={row.original.colour}
|
|
||||||
showIndexColumn={showIndexColumn}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => {
|
|
||||||
return (
|
|
||||||
<td key={cell.id} style={{ width: cell.column.getSize(), backgroundColor: rowBgColour }}>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</EventRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// currently there is no scenario where entryType is not handled above, either way...
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
|
||||||
import { IconButton, useDisclosure } from '@chakra-ui/react';
|
|
||||||
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
|
||||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
|
||||||
import { CustomFieldLabel, isOntimeEvent } from 'ontime-types';
|
|
||||||
|
|
||||||
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
|
|
||||||
import Empty from '../../common/components/state/Empty';
|
|
||||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
|
||||||
import { useCuesheet } from '../../common/hooks/useSocket';
|
|
||||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
|
||||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
|
||||||
import { useFlatRundown } from '../../common/hooks-query/useRundown';
|
|
||||||
import { CuesheetOverview } from '../overview/Overview';
|
|
||||||
|
|
||||||
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
|
|
||||||
import { useCuesheetSettings } from './store/CuesheetSettings';
|
|
||||||
import Cuesheet from './Cuesheet';
|
|
||||||
import { makeCuesheetColumns } from './cuesheetCols';
|
|
||||||
|
|
||||||
import styles from './CuesheetWrapper.module.scss';
|
|
||||||
|
|
||||||
export default function CuesheetWrapper() {
|
|
||||||
// TODO: can we use the normalised rundown for the table?
|
|
||||||
const { data: flatRundown, status: rundownStatus } = useFlatRundown();
|
|
||||||
const { data: customFields } = useCustomFields();
|
|
||||||
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
|
|
||||||
|
|
||||||
const { updateCustomField } = useEventAction();
|
|
||||||
const featureData = useCuesheet();
|
|
||||||
const columns = useMemo(() => makeCuesheetColumns(customFields), [customFields]);
|
|
||||||
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
|
|
||||||
|
|
||||||
useWindowTitle('Cuesheet');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles updating a field
|
|
||||||
* Currently, only custom fields can be updated from the cuesheet
|
|
||||||
*/
|
|
||||||
const handleUpdate = useCallback(
|
|
||||||
async (rowIndex: number, accessor: CustomFieldLabel, payload: unknown) => {
|
|
||||||
if (!flatRundown || rundownStatus !== 'success') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rowIndex == null || accessor == null || payload == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if value is the same
|
|
||||||
const event = flatRundown[rowIndex];
|
|
||||||
if (!event || !isOntimeEvent(event)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousValue = event.custom[accessor];
|
|
||||||
|
|
||||||
if (previousValue === payload) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if value is valid
|
|
||||||
// in anticipation to different types of event here
|
|
||||||
if (typeof payload !== 'string') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanup
|
|
||||||
const cleanVal = payload.trim();
|
|
||||||
|
|
||||||
// submit
|
|
||||||
try {
|
|
||||||
await updateCustomField(event.id, accessor, cleanVal);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[flatRundown, rundownStatus, updateCustomField],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!customFields || !flatRundown || rundownStatus !== 'success') {
|
|
||||||
return <Empty text='Loading...' />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
|
||||||
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
|
|
||||||
<CuesheetOverview>
|
|
||||||
<IconButton
|
|
||||||
aria-label='Toggle settings'
|
|
||||||
variant='ontime-subtle-white'
|
|
||||||
size='lg'
|
|
||||||
icon={<IoApps />}
|
|
||||||
onClick={onOpen}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
aria-label='Toggle navigation'
|
|
||||||
variant='ontime-subtle-white'
|
|
||||||
size='lg'
|
|
||||||
icon={<IoSettingsOutline />}
|
|
||||||
onClick={() => toggleSettings()}
|
|
||||||
/>
|
|
||||||
</CuesheetOverview>
|
|
||||||
<CuesheetProgress />
|
|
||||||
<Cuesheet
|
|
||||||
data={flatRundown}
|
|
||||||
columns={columns}
|
|
||||||
handleUpdate={handleUpdate}
|
|
||||||
selectedId={featureData.selectedEventId}
|
|
||||||
currentBlockId={featureData.currentBlockId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
||||||
|
|
||||||
exports[`makeTable() > returns array of arrays with given fields 1`] = `
|
|
||||||
[
|
|
||||||
[
|
|
||||||
"Ontime · Rundown export",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"Project title: test title",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"Project description: test description",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"Time Start",
|
|
||||||
"Time End",
|
|
||||||
"Duration",
|
|
||||||
"ID",
|
|
||||||
"Colour",
|
|
||||||
"Cue",
|
|
||||||
"Title",
|
|
||||||
"Note",
|
|
||||||
"Is Public? (x)",
|
|
||||||
"Skip?",
|
|
||||||
"lighting",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"00:00:00",
|
|
||||||
"00:00:00",
|
|
||||||
"...",
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"test title 1",
|
|
||||||
"",
|
|
||||||
"x",
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
],
|
|
||||||
]
|
|
||||||
`;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
.progressOverride {
|
|
||||||
height: 1rem;
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { memo } from 'react';
|
|
||||||
|
|
||||||
import style from '../Cuesheet.module.scss';
|
|
||||||
|
|
||||||
interface BlockRowProps {
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function BlockRow(props: BlockRowProps) {
|
|
||||||
const { title } = props;
|
|
||||||
return (
|
|
||||||
<tr className={style.blockRow}>
|
|
||||||
<td>{title}</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default memo(BlockRow);
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import {
|
|
||||||
closestCorners,
|
|
||||||
DndContext,
|
|
||||||
DragEndEvent,
|
|
||||||
PointerSensor,
|
|
||||||
TouchSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
} from '@dnd-kit/core';
|
|
||||||
import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable';
|
|
||||||
import { flexRender, HeaderGroup } from '@tanstack/react-table';
|
|
||||||
import { OntimeRundownEntry } from 'ontime-types';
|
|
||||||
|
|
||||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
|
||||||
|
|
||||||
import { SortableCell } from './SortableCell';
|
|
||||||
|
|
||||||
import style from '../Cuesheet.module.scss';
|
|
||||||
|
|
||||||
interface CuesheetHeaderProps {
|
|
||||||
headerGroups: HeaderGroup<OntimeRundownEntry>[];
|
|
||||||
saveColumnOrder: (fromId: string, toId: string) => void;
|
|
||||||
showIndexColumn: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CuesheetHeader(props: CuesheetHeaderProps) {
|
|
||||||
const { headerGroups, saveColumnOrder, showIndexColumn } = props;
|
|
||||||
|
|
||||||
const handleOnDragEnd = (event: DragEndEvent) => {
|
|
||||||
const { delta, active, over } = event;
|
|
||||||
|
|
||||||
// cancel if delta y is greater than 200
|
|
||||||
if (delta.y > 200) return;
|
|
||||||
// cancel if we do not have an over id
|
|
||||||
if (over?.id == null) return;
|
|
||||||
|
|
||||||
saveColumnOrder(active.id as string, over.id as string);
|
|
||||||
};
|
|
||||||
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor, {
|
|
||||||
activationConstraint: {
|
|
||||||
delay: 100,
|
|
||||||
tolerance: 50,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
useSensor(TouchSensor, {
|
|
||||||
activationConstraint: {
|
|
||||||
delay: 100,
|
|
||||||
tolerance: 50,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<thead className={style.tableHeader}>
|
|
||||||
{headerGroups.map((headerGroup) => {
|
|
||||||
const key = headerGroup.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DndContext key={key} sensors={sensors} collisionDetection={closestCorners} onDragEnd={handleOnDragEnd}>
|
|
||||||
<tr key={headerGroup.id}>
|
|
||||||
<th className={style.indexColumn}>{showIndexColumn && '#'}</th>
|
|
||||||
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
|
|
||||||
{headerGroup.headers.map((header) => {
|
|
||||||
const width = header.getSize();
|
|
||||||
// @ts-expect-error -- we inject this into react-table
|
|
||||||
const customBackground = header.column.columnDef?.meta?.colour;
|
|
||||||
|
|
||||||
let customStyles = {};
|
|
||||||
if (customBackground) {
|
|
||||||
const customColour = getAccessibleColour(customBackground);
|
|
||||||
customStyles = { backgroundColor: customColour.backgroundColor, color: customColour.color };
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SortableCell key={header.column.columnDef.id} header={header} style={{ width, ...customStyles }}>
|
|
||||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
|
||||||
</SortableCell>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SortableContext>
|
|
||||||
</tr>
|
|
||||||
</DndContext>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</thead>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { ChangeEvent, memo, useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import { getHotkeyHandler } from '@mantine/hooks';
|
|
||||||
|
|
||||||
import { AutoTextArea } from '../../../common/components/input/auto-text-area/AutoTextArea';
|
|
||||||
|
|
||||||
interface EditableCellProps {
|
|
||||||
value: string;
|
|
||||||
handleUpdate: (newValue: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EditableCell = (props: EditableCellProps) => {
|
|
||||||
const { value: initialValue, handleUpdate } = props;
|
|
||||||
|
|
||||||
// We need to keep and update the state of the cell normally
|
|
||||||
const [value, setValue] = useState(initialValue);
|
|
||||||
const ref = useRef<HTMLAreaElement>();
|
|
||||||
const onChange = useCallback((event: ChangeEvent<HTMLTextAreaElement>) => setValue(event.target.value), []);
|
|
||||||
|
|
||||||
// We'll only update the external data when the input is blurred
|
|
||||||
const onBlur = useCallback(() => handleUpdate(value), [handleUpdate, value]);
|
|
||||||
|
|
||||||
//TODO: maybe we can unify this with `useReactiveTextInput`
|
|
||||||
const onKeyDown = getHotkeyHandler([
|
|
||||||
['mod + Enter', () => ref.current?.blur()],
|
|
||||||
[
|
|
||||||
'Escape',
|
|
||||||
() => {
|
|
||||||
setValue(initialValue);
|
|
||||||
setTimeout(() => ref.current?.blur());
|
|
||||||
},
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// If the initialValue is changed external, sync it up with our state
|
|
||||||
useEffect(() => {
|
|
||||||
setValue(initialValue);
|
|
||||||
}, [initialValue]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AutoTextArea
|
|
||||||
size='sm'
|
|
||||||
value={value}
|
|
||||||
inputref={ref}
|
|
||||||
onChange={onChange}
|
|
||||||
onBlur={onBlur}
|
|
||||||
rows={1}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
transition='none'
|
|
||||||
spellCheck={false}
|
|
||||||
style={{ padding: 0 }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default memo(EditableCell);
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
$label-colour: $gray-700;
|
|
||||||
$active-colour: $gray-500;
|
|
||||||
|
|
||||||
@mixin label {
|
|
||||||
font-size: $inner-section-text-size;
|
|
||||||
color: $label-colour;
|
|
||||||
text-align: center;
|
|
||||||
align-self: end;
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin time {
|
|
||||||
font-family: 'Open Sans Light', $ontime-font-family;
|
|
||||||
font-size: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
grid-area: header;
|
|
||||||
display: grid;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.25rem 1rem;
|
|
||||||
height: max-content;
|
|
||||||
column-gap: 2rem;
|
|
||||||
|
|
||||||
grid-template-areas: 'event playback timer clock actions';
|
|
||||||
grid-template-columns: 1fr auto auto auto auto;
|
|
||||||
align-items: center;
|
|
||||||
justify-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event {
|
|
||||||
grid-area: event;
|
|
||||||
justify-self: start;
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eventNow {
|
|
||||||
justify-self: start;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.playback {
|
|
||||||
grid-area: playback;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-items: center;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.playbackLabel {
|
|
||||||
@include label;
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
font-size: 2rem;
|
|
||||||
height: 3rem;
|
|
||||||
color: $label-colour;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.timer {
|
|
||||||
grid-area: timer;
|
|
||||||
|
|
||||||
.timerLabel {
|
|
||||||
@include label;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value {
|
|
||||||
@include time;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.clock {
|
|
||||||
grid-area: clock;
|
|
||||||
|
|
||||||
.clockLabel {
|
|
||||||
@include label;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value {
|
|
||||||
grid-area: clock;
|
|
||||||
@include time;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.headerActions {
|
|
||||||
grid-area: actions;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
color: $label-colour;
|
|
||||||
height: 100%;
|
|
||||||
font-size: 1rem;
|
|
||||||
|
|
||||||
.actionIcon,
|
|
||||||
.actionText {
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&.enabled {
|
|
||||||
color: $active-indicator;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: $active-colour;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.actionIcon {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1200px) {
|
|
||||||
// in large screens we want to space the buttons
|
|
||||||
.headerActions {
|
|
||||||
padding-left: 10vw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { Tooltip } from '@chakra-ui/react';
|
|
||||||
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
|
|
||||||
import { Playback, ProjectData } from 'ontime-types';
|
|
||||||
|
|
||||||
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
|
|
||||||
import useProjectData from '../../../common/hooks-query/useProjectData';
|
|
||||||
import { cx, enDash } from '../../../common/utils/styleUtils';
|
|
||||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
|
||||||
import { useCuesheetSettings } from '../store/CuesheetSettings';
|
|
||||||
|
|
||||||
import CuesheetTableHeaderTimers from './CuesheetTableHeaderTimers';
|
|
||||||
|
|
||||||
import style from './CuesheetTableHeader.module.scss';
|
|
||||||
|
|
||||||
interface CuesheetTableHeaderProps {
|
|
||||||
handleExport: (headerData: ProjectData) => void;
|
|
||||||
featureData: {
|
|
||||||
playback: Playback;
|
|
||||||
selectedEventIndex: number | null;
|
|
||||||
numEvents: number;
|
|
||||||
titleNow: string | null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CuesheetTableHeader({ handleExport, featureData }: CuesheetTableHeaderProps) {
|
|
||||||
const followSelected = useCuesheetSettings((state) => state.followSelected);
|
|
||||||
const toggleFollow = useCuesheetSettings((state) => state.toggleFollow);
|
|
||||||
const { data: project } = useProjectData();
|
|
||||||
|
|
||||||
const exportProject = () => {
|
|
||||||
if (project) {
|
|
||||||
handleExport(project);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const selected = !featureData.numEvents
|
|
||||||
? 'No events'
|
|
||||||
: `Event ${featureData.selectedEventIndex != null ? featureData.selectedEventIndex + 1 : enDash}/${
|
|
||||||
featureData.numEvents ? featureData.numEvents : enDash
|
|
||||||
}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={style.header}>
|
|
||||||
<div className={style.event}>
|
|
||||||
<div className={style.title}>{project?.title || enDash}</div>
|
|
||||||
<div className={style.eventNow}>{featureData?.titleNow || enDash}</div>
|
|
||||||
</div>
|
|
||||||
<div className={style.playback}>
|
|
||||||
<div className={style.playbackLabel}>{selected}</div>
|
|
||||||
<PlaybackIcon state={featureData.playback} />
|
|
||||||
</div>
|
|
||||||
<CuesheetTableHeaderTimers />
|
|
||||||
<div className={style.headerActions}>
|
|
||||||
<Tooltip openDelay={tooltipDelayFast} label='Toggle follow'>
|
|
||||||
<span
|
|
||||||
onClick={() => toggleFollow()}
|
|
||||||
className={cx([style.actionIcon, followSelected ? style.enabled : null])}
|
|
||||||
>
|
|
||||||
<IoLocate />
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip openDelay={tooltipDelayFast} label='Export rundown'>
|
|
||||||
<span className={style.actionText} onClick={exportProject}>
|
|
||||||
Export CSV
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { useClock, useTimer } from '../../../common/hooks/useSocket';
|
|
||||||
import ClockTime from '../../viewers/common/clock-time/ClockTime';
|
|
||||||
import RunningTime from '../../viewers/common/running-time/RunningTime';
|
|
||||||
|
|
||||||
import style from './CuesheetTableHeader.module.scss';
|
|
||||||
|
|
||||||
export default function CuesheetTableHeaderTimers() {
|
|
||||||
const { current } = useTimer();
|
|
||||||
const { clock } = useClock();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={style.timer}>
|
|
||||||
<div className={style.timerLabel}>Running Timer</div>
|
|
||||||
<RunningTime className={style.value} value={current} hideLeadingZero />
|
|
||||||
</div>
|
|
||||||
<div className={style.clock}>
|
|
||||||
<div className={style.clockLabel}>Time Now</div>
|
|
||||||
<ClockTime className={style.value} value={clock} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import { memo, ReactNode } from 'react';
|
|
||||||
import { Button, Checkbox, Switch } from '@chakra-ui/react';
|
|
||||||
import { Column } from '@tanstack/react-table';
|
|
||||||
import { OntimeRundownEntry } from 'ontime-types';
|
|
||||||
|
|
||||||
import { useCuesheetSettings } from '../store/CuesheetSettings';
|
|
||||||
|
|
||||||
import style from './CuesheetTableSettings.module.scss';
|
|
||||||
|
|
||||||
// reusable button styles
|
|
||||||
const buttonProps = {
|
|
||||||
size: 'sm',
|
|
||||||
variant: 'ontime-subtle',
|
|
||||||
};
|
|
||||||
|
|
||||||
interface CuesheetTableSettingsProps {
|
|
||||||
columns: Column<OntimeRundownEntry, unknown>[];
|
|
||||||
handleResetResizing: () => void;
|
|
||||||
handleResetReordering: () => void;
|
|
||||||
handleClearToggles: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
|
|
||||||
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
|
|
||||||
const {
|
|
||||||
followSelected,
|
|
||||||
showIndexColumn,
|
|
||||||
toggleFollow,
|
|
||||||
showPrevious,
|
|
||||||
togglePreviousVisibility,
|
|
||||||
showDelayBlock,
|
|
||||||
hideSeconds,
|
|
||||||
showDelayedTimes,
|
|
||||||
toggleIndexColumn,
|
|
||||||
toggleDelayedTimes,
|
|
||||||
toggleDelayVisibility,
|
|
||||||
toggleSecondsVisibility,
|
|
||||||
} = useCuesheetSettings();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={style.tableSettings}>
|
|
||||||
<div className={style.leftPanel}>
|
|
||||||
<div className={style.sectionTitle}>Toggle column visibility</div>
|
|
||||||
<div className={style.options}>
|
|
||||||
{columns.map((column) => {
|
|
||||||
const columnHeader = column.columnDef.header;
|
|
||||||
const visible = column.getIsVisible();
|
|
||||||
return (
|
|
||||||
<label key={`${column.id}-${visible}`} className={style.option}>
|
|
||||||
<Checkbox
|
|
||||||
variant='ontime-ondark'
|
|
||||||
defaultChecked={visible}
|
|
||||||
onChange={column.getToggleVisibilityHandler()}
|
|
||||||
/>
|
|
||||||
{columnHeader as ReactNode}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className={style.sectionTitle}>Table Options</div>
|
|
||||||
<div className={style.options}>
|
|
||||||
<label className={style.option}>
|
|
||||||
<Switch variant='ontime' size='sm' isChecked={followSelected} onChange={() => toggleFollow()} />
|
|
||||||
Follow selected event
|
|
||||||
</label>
|
|
||||||
<label className={style.option}>
|
|
||||||
<Switch variant='ontime' size='sm' isChecked={showPrevious} onChange={() => togglePreviousVisibility()} />
|
|
||||||
Show past events
|
|
||||||
</label>
|
|
||||||
<label className={style.option}>
|
|
||||||
<Switch variant='ontime' size='sm' isChecked={showIndexColumn} onChange={() => toggleIndexColumn()} />
|
|
||||||
Show Event Order
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className={style.sectionTitle}>Delay Flow</div>
|
|
||||||
<div className={style.options}>
|
|
||||||
<label className={style.option}>
|
|
||||||
<Switch variant='ontime' size='sm' isChecked={showDelayedTimes} onChange={() => toggleDelayedTimes()} />
|
|
||||||
Show delayed times
|
|
||||||
</label>
|
|
||||||
<label className={style.option}>
|
|
||||||
<Switch variant='ontime' size='sm' isChecked={showDelayBlock} onChange={() => toggleDelayVisibility()} />
|
|
||||||
Show delay blocks
|
|
||||||
</label>
|
|
||||||
<label className={style.option}>
|
|
||||||
<Switch variant='ontime' size='sm' isChecked={hideSeconds} onChange={() => toggleSecondsVisibility()} />
|
|
||||||
Hide seconds
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={style.rightPanel}>
|
|
||||||
<Button onClick={handleClearToggles} {...buttonProps}>
|
|
||||||
Show All
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleResetResizing} {...buttonProps}>
|
|
||||||
Reset Resizing
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleResetReordering} {...buttonProps}>
|
|
||||||
Reset Reordering
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default memo(CuesheetTableSettings);
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import { useCallback } from 'react';
|
|
||||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
|
||||||
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
|
||||||
import { CustomFields, isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
|
|
||||||
|
|
||||||
import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator';
|
|
||||||
import RunningTime from '../viewers/common/running-time/RunningTime';
|
|
||||||
|
|
||||||
import EditableCell from './cuesheet-table-elements/EditableCell';
|
|
||||||
import { useCuesheetSettings } from './store/CuesheetSettings';
|
|
||||||
|
|
||||||
import style from './Cuesheet.module.scss';
|
|
||||||
|
|
||||||
function makePublic(row: CellContext<OntimeRundownEntry, unknown>) {
|
|
||||||
const cellValue = row.getValue();
|
|
||||||
return cellValue ? <IoCheckmark className={style.check} /> : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEntry, unknown>) {
|
|
||||||
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
|
|
||||||
const hideSeconds = useCuesheetSettings((state) => state.hideSeconds);
|
|
||||||
const cellValue = (getValue() as number | null) ?? 0;
|
|
||||||
const delayValue = (original as OntimeEvent)?.delay ?? 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={style.time}>
|
|
||||||
<DelayIndicator delayValue={delayValue} />
|
|
||||||
<RunningTime value={cellValue} hideSeconds={hideSeconds} />
|
|
||||||
{delayValue !== 0 && showDelayedTimes && (
|
|
||||||
<RunningTime className={style.delayedTime} value={cellValue + delayValue} hideSeconds={hideSeconds} />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MakeDuration({ getValue }: CellContext<OntimeRundownEntry, unknown>) {
|
|
||||||
const hideSeconds = useCuesheetSettings((state) => state.hideSeconds);
|
|
||||||
const cellValue = (getValue() as number | null) ?? 0;
|
|
||||||
|
|
||||||
return <RunningTime value={cellValue} hideSeconds={hideSeconds} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
|
|
||||||
const update = useCallback(
|
|
||||||
(newValue: string) => {
|
|
||||||
// @ts-expect-error -- we inject this into react-table
|
|
||||||
table.options.meta?.handleUpdate(row.index, column.id, newValue);
|
|
||||||
},
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
|
|
||||||
[column.id, row.index],
|
|
||||||
);
|
|
||||||
|
|
||||||
const event = row.original;
|
|
||||||
if (!isOntimeEvent(event)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// events dont necessarily contain all custom fields
|
|
||||||
const initialValue = event.custom[column.id] ?? '';
|
|
||||||
|
|
||||||
return <EditableCell value={initialValue} handleUpdate={update} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<OntimeRundownEntry>[] {
|
|
||||||
const dynamicCustomFields = Object.keys(customFields).map((key) => ({
|
|
||||||
accessorKey: key,
|
|
||||||
id: key,
|
|
||||||
header: customFields[key].label,
|
|
||||||
meta: { colour: customFields[key].colour },
|
|
||||||
cell: MakeCustomField,
|
|
||||||
size: 250,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
accessorKey: 'cue',
|
|
||||||
id: 'cue',
|
|
||||||
header: 'Cue',
|
|
||||||
cell: (row) => row.getValue(),
|
|
||||||
size: 75,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'isPublic',
|
|
||||||
id: 'isPublic',
|
|
||||||
header: 'Public',
|
|
||||||
cell: makePublic,
|
|
||||||
size: 45,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'timeStart',
|
|
||||||
id: 'timeStart',
|
|
||||||
header: 'Start',
|
|
||||||
cell: MakeTimer,
|
|
||||||
size: 75,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'timeEnd',
|
|
||||||
id: 'timeEnd',
|
|
||||||
header: 'End',
|
|
||||||
cell: MakeTimer,
|
|
||||||
size: 75,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'duration',
|
|
||||||
id: 'duration',
|
|
||||||
header: 'Duration',
|
|
||||||
cell: MakeDuration,
|
|
||||||
size: 75,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'title',
|
|
||||||
id: 'title',
|
|
||||||
header: 'Title',
|
|
||||||
cell: (row) => row.getValue(),
|
|
||||||
size: 250,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'note',
|
|
||||||
id: 'note',
|
|
||||||
header: 'Note',
|
|
||||||
cell: (row) => row.getValue(),
|
|
||||||
size: 250,
|
|
||||||
},
|
|
||||||
...dynamicCustomFields,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { OntimeEntryCommonKeys, OntimeEvent } from 'ontime-types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description set default column order
|
|
||||||
*/
|
|
||||||
export const defaultColumnOrder: OntimeEntryCommonKeys[] = [
|
|
||||||
'isPublic',
|
|
||||||
'cue',
|
|
||||||
'timeStart',
|
|
||||||
'timeEnd',
|
|
||||||
'duration',
|
|
||||||
'title',
|
|
||||||
'note',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description set default hidden columns
|
|
||||||
*/
|
|
||||||
export const defaultHiddenColumns: (keyof OntimeEvent)[] = [];
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { create } from 'zustand';
|
|
||||||
|
|
||||||
import { booleanFromLocalStorage } from '../../../common/utils/localStorage';
|
|
||||||
|
|
||||||
interface CuesheetSettings {
|
|
||||||
showSettings: boolean;
|
|
||||||
showIndexColumn: boolean;
|
|
||||||
followSelected: boolean;
|
|
||||||
showPrevious: boolean;
|
|
||||||
showDelayBlock: boolean;
|
|
||||||
showDelayedTimes: boolean;
|
|
||||||
hideSeconds: boolean;
|
|
||||||
|
|
||||||
toggleSettings: (newValue?: boolean) => void;
|
|
||||||
toggleFollow: (newValue?: boolean) => void;
|
|
||||||
togglePreviousVisibility: (newValue?: boolean) => void;
|
|
||||||
toggleIndexColumn: (newValue?: boolean) => void;
|
|
||||||
toggleDelayVisibility: (newValue?: boolean) => void;
|
|
||||||
toggleDelayedTimes: (newValue?: boolean) => void;
|
|
||||||
toggleSecondsVisibility: (newValue?: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggle(oldValue: boolean, value?: boolean) {
|
|
||||||
if (typeof value === 'undefined') {
|
|
||||||
return !oldValue;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
enum CuesheetKeys {
|
|
||||||
Follow = 'ontime-cuesheet-follow-selected',
|
|
||||||
DelayVisibility = 'ontime-cuesheet-show-delay',
|
|
||||||
PreviousVisibility = 'ontime-cuesheet-show-previous',
|
|
||||||
ColumnIndex = 'ontime-cuesheet-show-index-column',
|
|
||||||
DelayedTimes = 'ontime-cuesheet-show-delayed',
|
|
||||||
Seconds = 'ontime-cuesheet-hide-sceconds',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
|
|
||||||
showSettings: false,
|
|
||||||
showIndexColumn: booleanFromLocalStorage(CuesheetKeys.ColumnIndex, true),
|
|
||||||
followSelected: booleanFromLocalStorage(CuesheetKeys.Follow, false),
|
|
||||||
showPrevious: booleanFromLocalStorage(CuesheetKeys.PreviousVisibility, true),
|
|
||||||
showDelayBlock: booleanFromLocalStorage(CuesheetKeys.DelayVisibility, true),
|
|
||||||
showDelayedTimes: booleanFromLocalStorage(CuesheetKeys.DelayedTimes, false),
|
|
||||||
hideSeconds: booleanFromLocalStorage(CuesheetKeys.Seconds, false),
|
|
||||||
|
|
||||||
toggleSettings: (newValue?: boolean) => set((state) => ({ showSettings: toggle(state.showSettings, newValue) })),
|
|
||||||
toggleFollow: (newValue?: boolean) =>
|
|
||||||
set((state) => {
|
|
||||||
const followSelected = toggle(state.followSelected, newValue);
|
|
||||||
localStorage.setItem(CuesheetKeys.Follow, String(followSelected));
|
|
||||||
return { followSelected };
|
|
||||||
}),
|
|
||||||
toggleIndexColumn: (newValue?: boolean) =>
|
|
||||||
set((state) => {
|
|
||||||
const showIndexColumn = toggle(state.showIndexColumn, newValue);
|
|
||||||
localStorage.setItem(CuesheetKeys.ColumnIndex, String(showIndexColumn));
|
|
||||||
return { showIndexColumn };
|
|
||||||
}),
|
|
||||||
togglePreviousVisibility: (newValue?: boolean) =>
|
|
||||||
set((state) => {
|
|
||||||
const showPrevious = toggle(state.showPrevious, newValue);
|
|
||||||
localStorage.setItem(CuesheetKeys.PreviousVisibility, String(showPrevious));
|
|
||||||
return { showPrevious };
|
|
||||||
}),
|
|
||||||
toggleDelayVisibility: (newValue?: boolean) =>
|
|
||||||
set((state) => {
|
|
||||||
const showDelayBlock = toggle(state.showDelayBlock, newValue);
|
|
||||||
localStorage.setItem(CuesheetKeys.DelayVisibility, String(showDelayBlock));
|
|
||||||
return { showDelayBlock };
|
|
||||||
}),
|
|
||||||
toggleDelayedTimes: (newValue?: boolean) =>
|
|
||||||
set((state) => {
|
|
||||||
const showDelayedTimes = toggle(state.showDelayedTimes, newValue);
|
|
||||||
localStorage.setItem(CuesheetKeys.DelayedTimes, String(showDelayedTimes));
|
|
||||||
return { showDelayedTimes };
|
|
||||||
}),
|
|
||||||
toggleSecondsVisibility: (newValue?: boolean) =>
|
|
||||||
set((state) => {
|
|
||||||
const hideSeconds = toggle(state.hideSeconds, newValue);
|
|
||||||
localStorage.setItem(CuesheetKeys.Seconds, String(hideSeconds));
|
|
||||||
return { hideSeconds };
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
@@ -13,6 +13,7 @@ import useAppSettingsNavigation from '../app-settings/useAppSettingsNavigation';
|
|||||||
import { EditorOverview } from '../overview/Overview';
|
import { EditorOverview } from '../overview/Overview';
|
||||||
|
|
||||||
import Finder from './finder/Finder';
|
import Finder from './finder/Finder';
|
||||||
|
import WelcomePlacement from './welcome/WelcomePlacement';
|
||||||
|
|
||||||
import styles from './Editor.module.scss';
|
import styles from './Editor.module.scss';
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ export default function Editor() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.mainContainer} data-testid='event-editor'>
|
<div className={styles.mainContainer} data-testid='event-editor'>
|
||||||
|
<WelcomePlacement />
|
||||||
<Finder isOpen={isFinderOpen} onClose={onFinderClose} />
|
<Finder isOpen={isFinderOpen} onClose={onFinderClose} />
|
||||||
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
|
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
|
||||||
<EditorOverview>
|
<EditorOverview>
|
||||||
|
|||||||