Compare commits
132 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa7ec623f1 | |||
| bfb9b51073 | |||
| 8bc3f5a56c | |||
| acf1afb5e9 | |||
| d8e2a8d092 | |||
| 8e587128a1 | |||
| 607bc40673 | |||
| e6aa7404f7 | |||
| d622a7738f | |||
| b9ba416366 | |||
| b3ce247f54 | |||
| 82a2660fbc | |||
| 1e8706aa73 | |||
| 7dec32aea7 | |||
| 7e4cf6ffce | |||
| e6dfe9c363 | |||
| 29e682ee06 | |||
| 55d349877b | |||
| 8f2a93d6a3 | |||
| 6529a05e2a | |||
| 0f5c47bc82 | |||
| f561e52bb5 | |||
| 35f4472daa | |||
| 272f788c15 | |||
| 85b71627a8 | |||
| 6e51e5083b | |||
| 1f998fda74 | |||
| e7b5baa49a | |||
| f3f4cdb19f | |||
| b489214004 | |||
| cc55414b57 | |||
| e694cb7556 | |||
| 988100c1d9 | |||
| b4eabfae72 | |||
| 22fe65c655 | |||
| 7bf90d669a | |||
| a518dc4882 | |||
| dd5ffb2f47 | |||
| c5a5eab225 | |||
| 5d6345717f | |||
| 4c88f90a15 | |||
| 238eb49b5c | |||
| 7aec3e0d73 | |||
| 312b294a61 | |||
| 9bfa3e9f8e | |||
| 9a2cfb59e7 | |||
| a6e1cbbbce | |||
| 8dd448db44 | |||
| d87542dcf7 | |||
| 18575fc558 | |||
| 04e2593939 | |||
| 73d6b97afa | |||
| b55724ad0e | |||
| 3a7c2b8a55 | |||
| 420dfae652 | |||
| fb6896de76 | |||
| f574fdbc02 | |||
| 597be4e710 | |||
| 6e8734c73f | |||
| 9440ad17d2 | |||
| 9ba94288a1 | |||
| 6c78d25af1 | |||
| 0d8c186c77 | |||
| 7ccd41eeee | |||
| 43b390a079 | |||
| 80ff3251ea | |||
| 3591b93c2d | |||
| b637621871 | |||
| ed2efe183b | |||
| 27f99b4c87 | |||
| ed4d0f060b | |||
| 7c19c52332 | |||
| b1bff22b84 | |||
| 83b9fde45e | |||
| 37974df5c6 | |||
| 161ec383fd | |||
| de38f9d467 | |||
| 8ad1f2cb38 | |||
| 143917f11a | |||
| a8756c8afe | |||
| 5501f57b45 | |||
| 46e9fcb3cb | |||
| f2d7f4ab82 | |||
| a5496434f8 | |||
| 42984eed87 | |||
| 59c1c77d48 | |||
| dd13eb31d8 | |||
| 82810f1cb1 | |||
| 71c468d069 | |||
| cf40001a2a | |||
| d458928090 | |||
| 76e12af116 | |||
| b577057768 | |||
| dd4a165b08 | |||
| ab847a324b | |||
| cc5c097ddc | |||
| ea2330e23b | |||
| c5870452e3 | |||
| b73b529c00 | |||
| 8ef807d6cb | |||
| 1d4ddea596 | |||
| 4cba970cda | |||
| bad9dab0a0 | |||
| f11218a757 | |||
| 77e87e1d0c | |||
| a4aa513d1f | |||
| b73448f827 | |||
| 687ad68726 | |||
| 089eba3877 | |||
| 24b0cfd24e | |||
| 5950551da2 | |||
| e46948772e | |||
| 78fa1df99c | |||
| 0500368982 | |||
| 2d53323f60 | |||
| 08ca3cd3a5 | |||
| 16fd44a441 | |||
| 9a6ca7ac67 | |||
| 2c63bbff76 | |||
| d2c34757f9 | |||
| aeadc78578 | |||
| bf98f9792a | |||
| 6d2b59ef73 | |||
| 0a53018930 | |||
| f1cac8d1c4 | |||
| cfb76153a0 | |||
| fee2021ad7 | |||
| db956f7955 | |||
| 887e5c448e | |||
| 6ce275da7a | |||
| 213f516f71 | |||
| 86e6f8b58c |
@@ -39,9 +39,7 @@ dist/
|
|||||||
# docker utils
|
# docker utils
|
||||||
ontime-db
|
ontime-db
|
||||||
ontime-external/
|
ontime-external/
|
||||||
|
ontime-data/
|
||||||
# working database
|
|
||||||
apps/server/src/preloaded-db/db.json
|
|
||||||
|
|
||||||
# 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
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ COPY --from=builder /app/apps/client/build ./client/
|
|||||||
# Prepare Backend
|
# Prepare Backend
|
||||||
COPY --from=builder /app/apps/server/dist/ ./server/
|
COPY --from=builder /app/apps/server/dist/ ./server/
|
||||||
COPY --from=builder /app/apps/server/src/external/ ./external/
|
COPY --from=builder /app/apps/server/src/external/ ./external/
|
||||||
|
COPY --from=builder /app/apps/server/src/user/ ./user/
|
||||||
|
|
||||||
# Export default ports
|
# Export default ports
|
||||||
EXPOSE 4001/tcp 8888/udp 9999/udp
|
EXPOSE 4001/tcp 8888/udp 9999/udp
|
||||||
@@ -32,4 +33,4 @@ CMD ["node", "server/docker.cjs"]
|
|||||||
# Build and run commands
|
# Build and run commands
|
||||||
# !!! Note that this command needs pre-build versions of the UI and server apps
|
# !!! Note that this command needs pre-build versions of the UI and server apps
|
||||||
# docker buildx build . -t getontime/ontime
|
# docker buildx build . -t getontime/ontime
|
||||||
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/external/db/ -v ./ontime-styles:/external/styles/ getontime/ontime
|
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "3.6.0",
|
"version": "3.9.4",
|
||||||
"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,27 @@
|
|||||||
<!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'
|
<title>ontime</title>
|
||||||
/>
|
<style>
|
||||||
<link
|
body,
|
||||||
rel='icon'
|
html {
|
||||||
type='image/png'
|
background-color: rgba(0, 0, 0, 0) !important;
|
||||||
sizes='16x16'
|
}
|
||||||
href='/favicon-16x16.png'
|
</style>
|
||||||
/>
|
</head>
|
||||||
<link rel='manifest' href='/site.webmanifest' />
|
<body>
|
||||||
<link rel='manifest' href='/manifest.json' />
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<title>ontime</title>
|
<div id="root"></div>
|
||||||
<style>
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
body,
|
</body>
|
||||||
html {
|
|
||||||
background-color: rgba(0, 0, 0, 0) !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
|
||||||
<div id='root'></div>
|
|
||||||
<script type='module' src='/src/index.tsx'></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "3.6.0",
|
"version": "3.9.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"@emotion/react": "^11.10.6",
|
"@emotion/react": "^11.10.6",
|
||||||
"@emotion/styled": "^11.10.6",
|
"@emotion/styled": "^11.10.6",
|
||||||
"@fontsource/open-sans": "^5.0.28",
|
"@fontsource/open-sans": "^5.0.28",
|
||||||
"@mantine/hooks": "^7.6.2",
|
"@mantine/hooks": "^7.13.3",
|
||||||
"@react-icons/all-files": "^4.1.0",
|
"@react-icons/all-files": "^4.1.0",
|
||||||
"@sentry/react": "^8.19.0",
|
"@sentry/react": "^8.19.0",
|
||||||
"@tanstack/react-query": "^5.17.9",
|
"@tanstack/react-query": "^5.17.9",
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"react-colorful": "^5.6.1",
|
"react-colorful": "^5.6.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-fast-compare": "^3.2.2",
|
"react-fast-compare": "^3.2.2",
|
||||||
"react-hook-form": "^7.49.2",
|
"react-hook-form": "^7.53.1",
|
||||||
"react-qr-code": "^2.0.12",
|
"react-qr-code": "^2.0.12",
|
||||||
"react-router-dom": "^6.3.0",
|
"react-router-dom": "^6.3.0",
|
||||||
"web-vitals": "^3.1.1",
|
"web-vitals": "^3.1.1",
|
||||||
@@ -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": "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 |
@@ -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>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useClientPath } from './common/hooks/useClientPath';
|
|||||||
import Log from './features/log/Log';
|
import Log from './features/log/Log';
|
||||||
import withPreset from './features/PresetWrapper';
|
import withPreset from './features/PresetWrapper';
|
||||||
import withData from './features/viewers/ViewWrapper';
|
import withData from './features/viewers/ViewWrapper';
|
||||||
|
import ViewLoader from './views/ViewLoader';
|
||||||
import { ONTIME_VERSION } from './ONTIME_VERSION';
|
import { ONTIME_VERSION } from './ONTIME_VERSION';
|
||||||
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
|
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
|
||||||
|
|
||||||
@@ -27,16 +28,18 @@ const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
|
|||||||
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
|
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
|
||||||
|
|
||||||
const Backstage = React.lazy(() => import('./features/viewers/backstage/Backstage'));
|
const Backstage = React.lazy(() => import('./features/viewers/backstage/Backstage'));
|
||||||
const Timeline = React.lazy(() => import('./features/viewers/timeline/TimelinePage'));
|
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
|
||||||
const Public = React.lazy(() => import('./features/viewers/public/Public'));
|
const Public = React.lazy(() => import('./features/viewers/public/Public'));
|
||||||
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
||||||
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
|
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
|
||||||
|
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
|
||||||
|
|
||||||
const STimer = withPreset(withData(TimerView));
|
const STimer = withPreset(withData(TimerView));
|
||||||
const SMinimalTimer = withPreset(withData(MinimalTimerView));
|
const SMinimalTimer = withPreset(withData(MinimalTimerView));
|
||||||
const SClock = withPreset(withData(ClockView));
|
const SClock = withPreset(withData(ClockView));
|
||||||
const SCountdown = withPreset(withData(Countdown));
|
const SCountdown = withPreset(withData(Countdown));
|
||||||
const SBackstage = withPreset(withData(Backstage));
|
const SBackstage = withPreset(withData(Backstage));
|
||||||
|
const SProjectInfo = withPreset(withData(ProjectInfo));
|
||||||
const SPublic = withPreset(withData(Public));
|
const SPublic = withPreset(withData(Public));
|
||||||
const SLowerThird = withPreset(withData(Lower));
|
const SLowerThird = withPreset(withData(Lower));
|
||||||
const SStudio = withPreset(withData(StudioClock));
|
const SStudio = withPreset(withData(StudioClock));
|
||||||
@@ -75,16 +78,80 @@ export default function AppRouter() {
|
|||||||
<React.Suspense fallback={null}>
|
<React.Suspense fallback={null}>
|
||||||
<SentryRoutes>
|
<SentryRoutes>
|
||||||
<Route path='/' element={<Navigate to='/timer' />} />
|
<Route path='/' element={<Navigate to='/timer' />} />
|
||||||
<Route path='/timer' element={<STimer />} />
|
<Route
|
||||||
<Route path='/public' element={<SPublic />} />
|
path='/timer'
|
||||||
<Route path='/minimal' element={<SMinimalTimer />} />
|
element={
|
||||||
<Route path='/clock' element={<SClock />} />
|
<ViewLoader>
|
||||||
|
<STimer />
|
||||||
<Route path='/countdown' element={<SCountdown />} />
|
</ViewLoader>
|
||||||
<Route path='/backstage' element={<SBackstage />} />
|
}
|
||||||
<Route path='/studio' element={<SStudio />} />
|
/>
|
||||||
|
<Route
|
||||||
|
path='/public'
|
||||||
|
element={
|
||||||
|
<ViewLoader>
|
||||||
|
<SPublic />
|
||||||
|
</ViewLoader>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path='/minimal'
|
||||||
|
element={
|
||||||
|
<ViewLoader>
|
||||||
|
<SMinimalTimer />
|
||||||
|
</ViewLoader>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path='/clock'
|
||||||
|
element={
|
||||||
|
<ViewLoader>
|
||||||
|
<SClock />
|
||||||
|
</ViewLoader>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path='/countdown'
|
||||||
|
element={
|
||||||
|
<ViewLoader>
|
||||||
|
<SCountdown />
|
||||||
|
</ViewLoader>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path='/backstage'
|
||||||
|
element={
|
||||||
|
<ViewLoader>
|
||||||
|
<SBackstage />
|
||||||
|
</ViewLoader>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path='/studio'
|
||||||
|
element={
|
||||||
|
<ViewLoader>
|
||||||
|
<SStudio />
|
||||||
|
</ViewLoader>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/*/!* Lower third cannot have a loading screen *!/*/}
|
||||||
<Route path='/lower' element={<SLowerThird />} />
|
<Route path='/lower' element={<SLowerThird />} />
|
||||||
<Route path='/timeline' element={<STimeline />} />
|
<Route
|
||||||
|
path='/timeline'
|
||||||
|
element={
|
||||||
|
<ViewLoader>
|
||||||
|
<STimeline />
|
||||||
|
</ViewLoader>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path='/info'
|
||||||
|
element={
|
||||||
|
<ViewLoader>
|
||||||
|
<SProjectInfo />
|
||||||
|
</ViewLoader>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/*/!* Protected Routes *!/*/}
|
{/*/!* Protected Routes *!/*/}
|
||||||
<Route path='/editor' element={<Editor />} />
|
<Route path='/editor' element={<Editor />} />
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import { serverURL } from '../../externals';
|
||||||
|
|
||||||
// keys in tanstack store
|
// keys in tanstack store
|
||||||
export const APP_INFO = ['appinfo'];
|
export const APP_INFO = ['appinfo'];
|
||||||
export const APP_SETTINGS = ['appSettings'];
|
export const APP_SETTINGS = ['appSettings'];
|
||||||
|
export const APP_VERSION = ['appVersion'];
|
||||||
export const CUSTOM_FIELDS = ['customFields'];
|
export const CUSTOM_FIELDS = ['customFields'];
|
||||||
export const HTTP_SETTINGS = ['httpSettings'];
|
export const HTTP_SETTINGS = ['httpSettings'];
|
||||||
export const OSC_SETTINGS = ['oscSettings'];
|
export const OSC_SETTINGS = ['oscSettings'];
|
||||||
@@ -13,24 +16,14 @@ export const URL_PRESETS = ['urlpresets'];
|
|||||||
export const VIEW_SETTINGS = ['viewSettings'];
|
export const VIEW_SETTINGS = ['viewSettings'];
|
||||||
export const CLIENT_LIST = ['clientList'];
|
export const CLIENT_LIST = ['clientList'];
|
||||||
|
|
||||||
// resolve location
|
// API URLs
|
||||||
const location = window.location;
|
|
||||||
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
||||||
export const isProduction = import.meta.env.MODE === 'production';
|
|
||||||
export const isDev = !isProduction;
|
|
||||||
export const isLocalhost = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
|
|
||||||
|
|
||||||
// resolve port
|
|
||||||
const STATIC_PORT = 4001;
|
|
||||||
export const serverPort = isProduction ? location.port : STATIC_PORT;
|
|
||||||
export const serverURL = `${location.protocol}//${location.hostname}:${serverPort}`;
|
|
||||||
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
|
|
||||||
|
|
||||||
export const apiEntryUrl = `${serverURL}/data`;
|
export const apiEntryUrl = `${serverURL}/data`;
|
||||||
|
|
||||||
export const projectDataURL = `${serverURL}/project`;
|
export const projectDataURL = `${serverURL}/project`;
|
||||||
export const rundownURL = `${serverURL}/events`;
|
export const rundownURL = `${serverURL}/events`;
|
||||||
export const ontimeURL = `${serverURL}/ontime`;
|
export const ontimeURL = `${serverURL}/ontime`;
|
||||||
|
|
||||||
export const stylesPath = 'external/styles/override.css';
|
export const userAssetsPath = 'user';
|
||||||
export const overrideStylesURL = `${serverURL}/${stylesPath}`;
|
export const cssOverridePath = 'styles/override.css';
|
||||||
|
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
|
||||||
|
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios, { AxiosResponse } from 'axios';
|
import axios, { AxiosResponse } from 'axios';
|
||||||
import { DatabaseModel, GetInfo, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types';
|
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
|
||||||
|
|
||||||
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
|
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
|
||||||
|
|
||||||
@@ -86,6 +86,14 @@ export async function createProject(
|
|||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to create a project file
|
||||||
|
*/
|
||||||
|
export async function quickProject(data: QuickStartData): Promise<MessageResponse> {
|
||||||
|
const res = await axios.post(`${dbPath}/quick`, data);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to get the list of available project files
|
* HTTP request to get the list of available project files
|
||||||
*/
|
*/
|
||||||
@@ -104,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
|
||||||
*/
|
*/
|
||||||
@@ -138,14 +154,6 @@ export async function deleteProject(filename: string): Promise<MessageResponse>
|
|||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* HTTP request to retrieve application info
|
|
||||||
*/
|
|
||||||
export async function getInfo(): Promise<GetInfo> {
|
|
||||||
const res = await axios.get(`${dbPath}/info`);
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility function gets project from db
|
* Utility function gets project from db
|
||||||
* @param fileName
|
* @param fileName
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios, { AxiosResponse } from 'axios';
|
import axios, { AxiosResponse } from 'axios';
|
||||||
import { ProjectData } from 'ontime-types';
|
import { ProjectData, ProjectLogoResponse } from 'ontime-types';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
|
||||||
@@ -19,3 +19,18 @@ export async function getProjectData(): Promise<ProjectData> {
|
|||||||
export async function postProjectData(data: ProjectData): Promise<AxiosResponse<ProjectData>> {
|
export async function postProjectData(data: ProjectData): Promise<AxiosResponse<ProjectData>> {
|
||||||
return axios.post(projectPath, data);
|
return axios.post(projectPath, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to upload a project logo
|
||||||
|
*/
|
||||||
|
export async function uploadProjectLogo(file: File): Promise<AxiosResponse<ProjectLogoResponse>> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
const response = await axios.post(`${projectPath}/upload`, formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { GetInfo } from 'ontime-types';
|
||||||
|
|
||||||
|
import { apiEntryUrl } from './constants';
|
||||||
|
|
||||||
|
const sessionPath = `${apiEntryUrl}/session`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to retrieve application info
|
||||||
|
*/
|
||||||
|
export async function getInfo(): Promise<GetInfo> {
|
||||||
|
const res = await axios.get(`${sessionPath}/info`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ export function maybeAxiosError(error: unknown) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return `${statusText}: ${data}`;
|
return `${statusText}: ${data}`;
|
||||||
|
} else if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
} else {
|
} else {
|
||||||
if (typeof error !== 'string') {
|
if (typeof error !== 'string') {
|
||||||
return JSON.stringify(error);
|
return JSON.stringify(error);
|
||||||
|
|||||||
@@ -8,5 +8,5 @@
|
|||||||
.linkIcon {
|
.linkIcon {
|
||||||
margin-left: $element-inner-spacing;
|
margin-left: $element-inner-spacing;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
transform: rotate(45deg);
|
@include rotate-fourty-five;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
.buttonSection {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: $element-spacing;
|
|
||||||
margin-top: -$element-spacing;
|
|
||||||
}
|
|
||||||
@@ -15,8 +15,6 @@ import {
|
|||||||
|
|
||||||
import { setClientRemote } from '../../hooks/useSocket';
|
import { setClientRemote } from '../../hooks/useSocket';
|
||||||
|
|
||||||
import style from './ClientModal.module.scss';
|
|
||||||
|
|
||||||
interface RedirectClientModalProps {
|
interface RedirectClientModalProps {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -54,14 +52,12 @@ export function RedirectClientModal(props: RedirectClientModalProps) {
|
|||||||
</InputGroup>
|
</InputGroup>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<div className={style.buttonSection}>
|
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
||||||
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
Cancel
|
||||||
Cancel
|
</Button>
|
||||||
</Button>
|
<Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}>
|
||||||
<Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}>
|
Submit
|
||||||
Submit
|
</Button>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ import {
|
|||||||
|
|
||||||
import { setClientRemote } from '../../hooks/useSocket';
|
import { setClientRemote } from '../../hooks/useSocket';
|
||||||
|
|
||||||
import style from './ClientModal.module.scss';
|
|
||||||
|
|
||||||
interface RenameClientModalProps {
|
interface RenameClientModalProps {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -53,14 +51,12 @@ export function RenameClientModal(props: RenameClientModalProps) {
|
|||||||
/>
|
/>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<div className={style.buttonSection}>
|
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
||||||
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
Cancel
|
||||||
Cancel
|
</Button>
|
||||||
</Button>
|
<Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}>
|
||||||
<Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}>
|
Submit
|
||||||
Submit
|
</Button>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren, useState } from 'react';
|
||||||
import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react';
|
import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react';
|
||||||
|
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
||||||
import { IoCopy } from '@react-icons/all-files/io5/IoCopy';
|
import { IoCopy } from '@react-icons/all-files/io5/IoCopy';
|
||||||
|
|
||||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||||
@@ -7,26 +8,34 @@ import { Size } from '../../models/Util.type';
|
|||||||
import copyToClipboard from '../../utils/copyToClipboard';
|
import copyToClipboard from '../../utils/copyToClipboard';
|
||||||
|
|
||||||
interface CopyTagProps {
|
interface CopyTagProps {
|
||||||
|
copyValue: string;
|
||||||
label: string;
|
label: string;
|
||||||
className?: string;
|
|
||||||
size?: Size;
|
size?: Size;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
|
export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
|
||||||
const { label, className, size = 'xs', disabled, children } = props;
|
const { copyValue, label, size = 'xs', disabled, children, onClick } = props;
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const handleClick = () => copyToClipboard(children as string);
|
const handleClick = () => {
|
||||||
|
copyToClipboard(copyValue);
|
||||||
|
setCopied(true);
|
||||||
|
|
||||||
|
// reset copied state
|
||||||
|
setTimeout(() => setCopied(false), 1000);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip label={label} openDelay={tooltipDelayFast}>
|
<Tooltip label={label} openDelay={tooltipDelayFast}>
|
||||||
<ButtonGroup size={size} isAttached className={className}>
|
<ButtonGroup size={size} isAttached>
|
||||||
<Button variant='ontime-subtle' tabIndex={-1} isDisabled={disabled}>
|
<Button variant='ontime-subtle' tabIndex={-1} onClick={onClick} isDisabled={disabled}>
|
||||||
{children}
|
{children}
|
||||||
</Button>
|
</Button>
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
icon={<IoCopy />}
|
icon={copied ? <IoCheckmark /> : <IoCopy />}
|
||||||
variant='ontime-filled'
|
variant='ontime-filled'
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|||||||
@@ -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>('');
|
||||||
@@ -117,6 +118,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}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -72,9 +82,33 @@ $button-size: 3rem;
|
|||||||
|
|
||||||
.linkIcon {
|
.linkIcon {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
transform: rotate(45deg);
|
@include rotate-fourty-five;
|
||||||
}
|
}
|
||||||
|
|
||||||
.separator {
|
.separator {
|
||||||
border-color: $border-color-ondark;
|
border-color: $border-color-ondark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sectionHeader {
|
||||||
|
font-size: calc(1rem - 2px);
|
||||||
|
margin-left: 1rem;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom {
|
||||||
|
margin-top: auto;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interfaces {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goIcon {
|
||||||
|
@include rotate-fourty-five;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,15 +17,18 @@ import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
|
|||||||
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
|
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
|
||||||
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
||||||
|
|
||||||
|
import { isLocalhost } from '../../../externals';
|
||||||
import { navigatorConstants } from '../../../viewerConfig';
|
import { navigatorConstants } from '../../../viewerConfig';
|
||||||
import useClickOutside from '../../hooks/useClickOutside';
|
import useClickOutside from '../../hooks/useClickOutside';
|
||||||
import useElectronEvent from '../../hooks/useElectronEvent';
|
import { useElectronEvent } from '../../hooks/useElectronEvent';
|
||||||
|
import useInfo from '../../hooks-query/useInfo';
|
||||||
import { 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 } 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 style from './NavigationMenu.module.scss';
|
import style from './NavigationMenu.module.scss';
|
||||||
|
|
||||||
@@ -54,7 +57,7 @@ function NavigationMenu(props: NavigationMenuProps) {
|
|||||||
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
|
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
|
||||||
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
|
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
|
||||||
<DrawerOverlay />
|
<DrawerOverlay />
|
||||||
<DrawerContent>
|
<DrawerContent maxWidth='22rem'>
|
||||||
<DrawerHeader>
|
<DrawerHeader>
|
||||||
<DrawerCloseButton size='lg' />
|
<DrawerCloseButton size='lg' />
|
||||||
Ontime
|
Ontime
|
||||||
@@ -105,25 +108,22 @@ function NavigationMenu(props: NavigationMenuProps) {
|
|||||||
>
|
>
|
||||||
<IoLockClosedOutline />
|
<IoLockClosedOutline />
|
||||||
Editor
|
Editor
|
||||||
<IoArrowUp className={style.linkIcon} />
|
|
||||||
</Link>
|
</Link>
|
||||||
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}>
|
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}>
|
||||||
<IoLockClosedOutline />
|
<IoLockClosedOutline />
|
||||||
Cuesheet
|
Cuesheet
|
||||||
<IoArrowUp className={style.linkIcon} />
|
|
||||||
</ClientLink>
|
</ClientLink>
|
||||||
<ClientLink to='op' current={location.pathname === '/op'}>
|
<ClientLink to='op' current={location.pathname === '/op'}>
|
||||||
<IoLockClosedOutline />
|
<IoLockClosedOutline />
|
||||||
Operator
|
Operator
|
||||||
<IoArrowUp className={style.linkIcon} />
|
|
||||||
</ClientLink>
|
</ClientLink>
|
||||||
<hr className={style.separator} />
|
<hr className={style.separator} />
|
||||||
{navigatorConstants.map((route) => (
|
{navigatorConstants.map((route) => (
|
||||||
<ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}>
|
<ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}>
|
||||||
{route.label}
|
{route.label}
|
||||||
<IoArrowUp className={style.linkIcon} />
|
|
||||||
</ClientLink>
|
</ClientLink>
|
||||||
))}
|
))}
|
||||||
|
{isLocalhost && <OtherAddresses currentLocation={location.pathname} />}
|
||||||
</DrawerBody>
|
</DrawerBody>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
@@ -132,6 +132,46 @@ function NavigationMenu(props: NavigationMenuProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OtherAddressesProps {
|
||||||
|
currentLocation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OtherAddresses(props: OtherAddressesProps) {
|
||||||
|
const { currentLocation } = props;
|
||||||
|
const { data } = useInfo();
|
||||||
|
|
||||||
|
// there is no point showing this if we only have one interface
|
||||||
|
if (data.networkInterfaces.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.bottom}>
|
||||||
|
<div className={style.sectionHeader}>Accessible on external networks</div>
|
||||||
|
<div className={style.interfaces}>
|
||||||
|
{data?.networkInterfaces?.map((nif) => {
|
||||||
|
if (nif.name === 'localhost') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = linkToOtherHost(nif.address, currentLocation);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CopyTag
|
||||||
|
key={nif.name}
|
||||||
|
copyValue={address}
|
||||||
|
onClick={() => openLink(address)}
|
||||||
|
label='Copy IP or navigate to address'
|
||||||
|
>
|
||||||
|
{nif.address} <IoArrowUp className={style.goIcon} />
|
||||||
|
</CopyTag>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface ClientLinkProps {
|
interface ClientLinkProps {
|
||||||
current: boolean;
|
current: boolean;
|
||||||
to: string;
|
to: string;
|
||||||
@@ -147,6 +187,7 @@ function ClientLink(props: PropsWithChildren<ClientLinkProps>) {
|
|||||||
return (
|
return (
|
||||||
<button className={classes} tabIndex={0} onClick={(event) => handleLinks(event, to)}>
|
<button className={classes} tabIndex={0} onClick={(event) => handleLinks(event, to)}>
|
||||||
{children}
|
{children}
|
||||||
|
<IoArrowUp className={style.linkIcon} />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,5 +1,6 @@
|
|||||||
import { PropsWithChildren, useCallback, useContext } from 'react';
|
import { PropsWithChildren, useCallback, useContext } from 'react';
|
||||||
|
|
||||||
|
import { isLocalhost } from '../../../externals';
|
||||||
import { AppContext } from '../../context/AppContext';
|
import { AppContext } from '../../context/AppContext';
|
||||||
|
|
||||||
import PinPage from './PinPage';
|
import PinPage from './PinPage';
|
||||||
@@ -9,7 +10,6 @@ interface ProtectRouteProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProtectRoute({ permission, children }: PropsWithChildren<ProtectRouteProps>) {
|
export default function ProtectRoute({ permission, children }: PropsWithChildren<ProtectRouteProps>) {
|
||||||
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
|
||||||
const { editorAuth, operatorAuth, validate } = useContext(AppContext);
|
const { editorAuth, operatorAuth, validate } = useContext(AppContext);
|
||||||
|
|
||||||
const handleValidation = useCallback(
|
const handleValidation = useCallback(
|
||||||
@@ -29,7 +29,7 @@ export default function ProtectRoute({ permission, children }: PropsWithChildren
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLocal || hasRelevantAuth()) {
|
if (isLocalhost || hasRelevantAuth()) {
|
||||||
// eslint-disable-next-line react/jsx-no-useless-fragment -- trying to make typescript happy
|
// eslint-disable-next-line react/jsx-no-useless-fragment -- trying to make typescript happy
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { projectLogoPath } from '../../api/constants';
|
||||||
|
|
||||||
|
interface ViewLogoProps {
|
||||||
|
name: string;
|
||||||
|
className: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ViewLogo(props: ViewLogoProps) {
|
||||||
|
const { name, className } = props;
|
||||||
|
return <img src={`${projectLogoPath}/${name}`} className={className} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
.inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// attempt to match with ontimeTextInputs
|
||||||
|
.input {
|
||||||
|
color: $gray-200;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: $gray-1200;
|
||||||
|
padding: 0 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $gray-1100;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
background-color: $gray-1000;
|
||||||
|
color: $gray-50;
|
||||||
|
border: 1px solid $blue-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import PopoverPicker from '../input/popover-picker/PopoverPicker';
|
||||||
|
|
||||||
|
import style from './InlineColourPicker.module.scss';
|
||||||
|
|
||||||
|
interface InlineColourPickerProps {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureHex = (value: string) => {
|
||||||
|
if (!value.startsWith('#')) {
|
||||||
|
return `#${value}`;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function InlineColourPicker(props: InlineColourPickerProps) {
|
||||||
|
const { name, value } = props;
|
||||||
|
const [colour, setColour] = useState(() => ensureHex(value));
|
||||||
|
|
||||||
|
const debouncedChange = (value: string) => {
|
||||||
|
setColour(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.inline}>
|
||||||
|
<PopoverPicker color={colour} onChange={debouncedChange} />
|
||||||
|
<input type='hidden' name={name} value={colour} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,9 +13,11 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Switch,
|
Switch,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
|
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
|
||||||
|
|
||||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||||
|
|
||||||
|
import InlineColourPicker from './InlineColourPicker';
|
||||||
import { ParamField } from './types';
|
import { ParamField } from './types';
|
||||||
|
|
||||||
interface EditFormInputProps {
|
interface EditFormInputProps {
|
||||||
@@ -23,8 +25,8 @@ interface EditFormInputProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ParamInput(props: EditFormInputProps) {
|
export default function ParamInput(props: EditFormInputProps) {
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const { paramField } = props;
|
const { paramField } = props;
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const { id, type, defaultValue } = paramField;
|
const { id, type, defaultValue } = paramField;
|
||||||
|
|
||||||
if (type === 'persist') {
|
if (type === 'persist') {
|
||||||
@@ -81,6 +83,12 @@ export default function ParamInput(props: EditFormInputProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === 'colour') {
|
||||||
|
const currentvalue = `#${searchParams.get(id) ?? defaultValue}`;
|
||||||
|
|
||||||
|
return <InlineColourPicker name={id} value={currentvalue} />;
|
||||||
|
}
|
||||||
|
|
||||||
const defaultStringValue = searchParams.get(id) ?? defaultValue;
|
const defaultStringValue = searchParams.get(id) ?? defaultValue;
|
||||||
const { prefix, placeholder } = paramField;
|
const { prefix, placeholder } = paramField;
|
||||||
|
|
||||||
@@ -101,25 +109,21 @@ function MultiOption(props: EditFormMultiOptionProps) {
|
|||||||
const { paramField } = props;
|
const { paramField } = props;
|
||||||
const { id, defaultValue } = paramField;
|
const { id, defaultValue } = paramField;
|
||||||
|
|
||||||
const optionFromParams = (searchParams.get(id) ?? '').toLocaleLowerCase();
|
const optionFromParams = searchParams.getAll(id);
|
||||||
const defaultOptionValue = optionFromParams || defaultValue?.toLocaleLowerCase() || '';
|
const [paramState, setParamState] = useState<string[]>(optionFromParams || defaultValue || ['']);
|
||||||
|
|
||||||
const [paramState, setParamState] = useState<string>(defaultOptionValue);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<input name={id} hidden readOnly value={paramState} />
|
<input name={id} hidden readOnly value={paramState} />
|
||||||
<Menu isLazy closeOnSelect={false} variant='ontime-on-dark'>
|
<Menu isLazy closeOnSelect={false} variant='ontime-on-dark'>
|
||||||
<MenuButton as={Button} variant='ontime-subtle-white' position='relative' width='fit-content' fontWeight={400}>
|
<MenuButton as={Button} variant='ontime-subtle-white' position='relative' width='fit-content' fontWeight={400}>
|
||||||
{paramField.title}
|
{paramField.title} <IoChevronDown style={{ display: 'inline' }} />
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
<MenuList>
|
<MenuList>
|
||||||
<MenuOptionGroup
|
<MenuOptionGroup
|
||||||
type='checkbox'
|
type='checkbox'
|
||||||
value={paramState.split('_')}
|
value={paramState}
|
||||||
onChange={(value) => {
|
onChange={(value) => setParamState(Array.isArray(value) ? value : [value])}
|
||||||
setParamState(typeof value === 'object' ? value.filter((v) => v !== '').join('_') : value);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{Object.values(paramField.values).map((option) => {
|
{Object.values(paramField.values).map((option) => {
|
||||||
const { value, label } = option;
|
const { value, label } = option;
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ import style from './ViewParamsEditor.module.scss';
|
|||||||
|
|
||||||
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
|
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility remove the # character from a hex string
|
||||||
|
*/
|
||||||
|
function sanitiseColour(colour: string) {
|
||||||
|
if (colour.startsWith('#')) {
|
||||||
|
return colour.substring(1);
|
||||||
|
}
|
||||||
|
return colour;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Makes a new URLSearchParams object from the given params object
|
* Makes a new URLSearchParams object from the given params object
|
||||||
*/
|
*/
|
||||||
@@ -40,10 +50,33 @@ const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOp
|
|||||||
|
|
||||||
// compare which values are different from the default values
|
// compare which values are different from the default values
|
||||||
Object.entries(paramsObj).forEach(([id, value]) => {
|
Object.entries(paramsObj).forEach(([id, value]) => {
|
||||||
if (typeof value === 'string' && value.length && defaultValues[id] !== value) {
|
if (typeof value === 'string' && value.length) {
|
||||||
newSearchParams.set(id, value);
|
// we dont know which values contain colours
|
||||||
|
// unfortunately this means we run all the strings through the sanitation
|
||||||
|
const valueWithoutHash = sanitiseColour(value);
|
||||||
|
if (defaultValues[id] !== valueWithoutHash) {
|
||||||
|
handleValueString(id, valueWithoutHash);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Utility function contains logic to add a value into the searchParams object */
|
||||||
|
function handleValueString(id: string, value: string) {
|
||||||
|
const maybeMultipleValues = value.split(',');
|
||||||
|
|
||||||
|
// we need to check if the value contains comma separated list, for the case of the multi-select data
|
||||||
|
if (Array.isArray(maybeMultipleValues) && maybeMultipleValues.length > 1) {
|
||||||
|
const added = new Set();
|
||||||
|
maybeMultipleValues.forEach((v) => {
|
||||||
|
if (!added.has(v)) {
|
||||||
|
added.add(v);
|
||||||
|
newSearchParams.append(id, v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
newSearchParams.set(id, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
return newSearchParams;
|
return newSearchParams;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,11 @@ type MultiOptionsField = {
|
|||||||
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
|
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
|
||||||
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
|
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
|
||||||
type BooleanField = { type: 'boolean'; defaultValue: boolean };
|
type BooleanField = { type: 'boolean'; defaultValue: boolean };
|
||||||
|
type ColourField = { type: 'colour'; defaultValue: string; placeholder?: string };
|
||||||
type PersistedField = { type: 'persist'; defaultValue?: string; value: string };
|
type PersistedField = { type: 'persist'; defaultValue?: string; value: string };
|
||||||
|
|
||||||
export type ParamField = BaseField &
|
export type ParamField = BaseField &
|
||||||
(StringField | BooleanField | NumberField | OptionsField | MultiOptionsField | PersistedField);
|
(OptionsField | MultiOptionsField | StringField | NumberField | BooleanField | ColourField | PersistedField);
|
||||||
export type ViewOption = ParamSection | ParamField;
|
export type ViewOption = ParamSection | ParamField;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { dayInMs } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { version } from '../../../../../package.json';
|
||||||
|
import { isLocalhost } from '../../externals';
|
||||||
|
import { APP_VERSION } from '../api/constants';
|
||||||
|
import { getLatestVersion, HasUpdate } from '../api/external';
|
||||||
|
|
||||||
|
const placeholder: HasUpdate & { hasUpdates: boolean } = { url: '', version: '', hasUpdates: false };
|
||||||
|
|
||||||
|
export default function useAppVersion() {
|
||||||
|
const {
|
||||||
|
data: fetchData,
|
||||||
|
status,
|
||||||
|
isFetching,
|
||||||
|
isError,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: APP_VERSION,
|
||||||
|
queryFn: getLatestVersion,
|
||||||
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
retry: false,
|
||||||
|
staleTime: dayInMs,
|
||||||
|
enabled: isLocalhost,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasUpdates = fetchData?.version && !fetchData.version.includes(version);
|
||||||
|
|
||||||
|
const data = fetchData ? { ...fetchData, hasUpdates } : placeholder;
|
||||||
|
|
||||||
|
return { data, placeholder, status, isFetching, isError, refetch };
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { GetInfo } from 'ontime-types';
|
|||||||
|
|
||||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||||
import { APP_INFO } from '../api/constants';
|
import { APP_INFO } from '../api/constants';
|
||||||
import { getInfo } from '../api/db';
|
import { getInfo } from '../api/session';
|
||||||
import { ontimePlaceholderInfo } from '../models/Info';
|
import { ontimePlaceholderInfo } from '../models/Info';
|
||||||
|
|
||||||
export default function useInfo() {
|
export default function useInfo() {
|
||||||
|
|||||||
@@ -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 } };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
|
import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
|
||||||
|
|
||||||
@@ -6,6 +6,8 @@ import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
|||||||
import { RUNDOWN } from '../api/constants';
|
import { RUNDOWN } from '../api/constants';
|
||||||
import { fetchNormalisedRundown } from '../api/rundown';
|
import { fetchNormalisedRundown } from '../api/rundown';
|
||||||
|
|
||||||
|
import useProjectData from './useProjectData';
|
||||||
|
|
||||||
// revision is -1 so that the remote revision is higher
|
// revision is -1 so that the remote revision is higher
|
||||||
const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 };
|
const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 };
|
||||||
|
|
||||||
@@ -24,7 +26,9 @@ export default function useRundown() {
|
|||||||
|
|
||||||
export function useFlatRundown() {
|
export function useFlatRundown() {
|
||||||
const { data, status } = useRundown();
|
const { data, status } = useRundown();
|
||||||
|
const { data: projectData } = useProjectData();
|
||||||
|
|
||||||
|
const loadedProject = useRef<string>('');
|
||||||
const [prevRevision, setPrevRevision] = useState<number>(-1);
|
const [prevRevision, setPrevRevision] = useState<number>(-1);
|
||||||
const [flatRunDown, setFlatRunDown] = useState<OntimeRundown>([]);
|
const [flatRunDown, setFlatRunDown] = useState<OntimeRundown>([]);
|
||||||
|
|
||||||
@@ -37,5 +41,14 @@ export function useFlatRundown() {
|
|||||||
}
|
}
|
||||||
}, [data.order, data.revision, data.rundown, prevRevision]);
|
}, [data.order, data.revision, data.rundown, prevRevision]);
|
||||||
|
|
||||||
|
// TODO: should we have a project id field?
|
||||||
|
// invalidate current version if project changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (projectData?.title !== loadedProject.current) {
|
||||||
|
setPrevRevision(-1);
|
||||||
|
loadedProject.current = projectData?.title ?? '';
|
||||||
|
}
|
||||||
|
}, [projectData]);
|
||||||
|
|
||||||
return { data: flatRunDown, status };
|
return { data: flatRunDown, status };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,42 @@
|
|||||||
export default function useElectronEvent() {
|
import { useCallback, useEffect } from 'react';
|
||||||
const isElectron = window?.process?.type === 'renderer';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
const sendToElectron = (channel: string, args?: string | Record<string, unknown>) => {
|
import { addDialog } from '../stores/dialogStore';
|
||||||
if (isElectron) {
|
|
||||||
window?.ipcRenderer.send(channel, args);
|
const isElectron = window.process?.type === 'renderer';
|
||||||
|
const ipcRenderer = isElectron ? window.require('electron').ipcRenderer : null;
|
||||||
|
|
||||||
|
export function useElectronEvent() {
|
||||||
|
const sendToElectron = useCallback((channel: string, args?: string | Record<string, unknown>) => {
|
||||||
|
if (isElectron && ipcRenderer) {
|
||||||
|
ipcRenderer.send(channel, args);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
return { isElectron, sendToElectron };
|
return { isElectron, sendToElectron };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useElectronListener() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { isElectron } = useElectronEvent();
|
||||||
|
|
||||||
|
// listen to requests to change the editor location
|
||||||
|
useEffect(() => {
|
||||||
|
if (isElectron) {
|
||||||
|
ipcRenderer.on('request-editor-location', (_event: unknown, location: string) => {
|
||||||
|
navigate(location, { relative: 'route' });
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcRenderer.on('dialog', (_event: unknown, dialog: string) => {
|
||||||
|
if (dialog === 'welcome') {
|
||||||
|
addDialog('welcome');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean the listener after the component is dismounted
|
||||||
|
return () => {
|
||||||
|
ipcRenderer?.removeAllListeners();
|
||||||
|
};
|
||||||
|
}, [isElectron, navigate]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,7 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
|
import { isOntimeEvent, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
|
||||||
import {
|
import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils';
|
||||||
dayInMs,
|
|
||||||
getLinkedTimes,
|
|
||||||
getPreviousEventNormal,
|
|
||||||
MILLIS_PER_SECOND,
|
|
||||||
parseUserTime,
|
|
||||||
reorderArray,
|
|
||||||
swapEventData,
|
|
||||||
validateEndAction,
|
|
||||||
validateTimerType,
|
|
||||||
} from 'ontime-utils';
|
|
||||||
|
|
||||||
import { RUNDOWN } from '../api/constants';
|
import { RUNDOWN } from '../api/constants';
|
||||||
import {
|
import {
|
||||||
@@ -37,6 +27,7 @@ export const useEventAction = () => {
|
|||||||
const {
|
const {
|
||||||
defaultPublic,
|
defaultPublic,
|
||||||
linkPrevious,
|
linkPrevious,
|
||||||
|
defaultTimeStrategy,
|
||||||
defaultDuration,
|
defaultDuration,
|
||||||
defaultWarnTime,
|
defaultWarnTime,
|
||||||
defaultDangerTime,
|
defaultDangerTime,
|
||||||
@@ -116,11 +107,15 @@ export const useEventAction = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (newEvent.timerType === undefined) {
|
if (newEvent.timerType === undefined) {
|
||||||
newEvent.timerType = validateTimerType(defaultTimerType);
|
newEvent.timerType = defaultTimerType;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newEvent.endAction === undefined) {
|
if (newEvent.endAction === undefined) {
|
||||||
newEvent.endAction = validateEndAction(defaultEndAction);
|
newEvent.endAction = defaultEndAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newEvent.timeStrategy === undefined) {
|
||||||
|
newEvent.timeStrategy = defaultTimeStrategy;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +137,7 @@ export const useEventAction = () => {
|
|||||||
defaultEndAction,
|
defaultEndAction,
|
||||||
defaultPublic,
|
defaultPublic,
|
||||||
defaultTimerType,
|
defaultTimerType,
|
||||||
|
defaultTimeStrategy,
|
||||||
defaultWarnTime,
|
defaultWarnTime,
|
||||||
linkPrevious,
|
linkPrevious,
|
||||||
queryClient,
|
queryClient,
|
||||||
@@ -266,44 +262,6 @@ export const useEventAction = () => {
|
|||||||
[_updateEventMutation, queryClient],
|
[_updateEventMutation, queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggles link of an event to the previous
|
|
||||||
*/
|
|
||||||
const linkTimer = useCallback(
|
|
||||||
async (eventId: string, linkStart: MaybeString) => {
|
|
||||||
let newEvent: Partial<OntimeEvent> = { id: eventId };
|
|
||||||
|
|
||||||
if (!linkStart) {
|
|
||||||
newEvent.linkStart = null;
|
|
||||||
} else {
|
|
||||||
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
|
|
||||||
if (!cachedRundown) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const currentEvent = cachedRundown.rundown[eventId] as OntimeEvent;
|
|
||||||
if (!isOntimeEvent(currentEvent)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { previousEvent } = getPreviousEventNormal(cachedRundown.rundown, cachedRundown.order, eventId);
|
|
||||||
|
|
||||||
if (!previousEvent) {
|
|
||||||
newEvent.linkStart = null;
|
|
||||||
} else {
|
|
||||||
newEvent.linkStart = previousEvent.id;
|
|
||||||
const timePatch = getLinkedTimes(currentEvent, previousEvent);
|
|
||||||
newEvent = { ...newEvent, ...timePatch };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await _updateEventMutation.mutateAsync(newEvent);
|
|
||||||
} catch (error) {
|
|
||||||
logAxiosError('Error updating event', error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[_updateEventMutation, queryClient],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calls mutation to edit multiple events
|
* Calls mutation to edit multiple events
|
||||||
* @private
|
* @private
|
||||||
@@ -610,7 +568,6 @@ export const useEventAction = () => {
|
|||||||
batchUpdateEvents,
|
batchUpdateEvents,
|
||||||
deleteEvent,
|
deleteEvent,
|
||||||
deleteAllEvents,
|
deleteAllEvents,
|
||||||
linkTimer,
|
|
||||||
reorderEvent,
|
reorderEvent,
|
||||||
swapEvents,
|
swapEvents,
|
||||||
updateEvent,
|
updateEvent,
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { debounce } from '../../common/utils/debounce';
|
||||||
|
|
||||||
|
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 debouncedShowMenu = debounce(setShowMenuTrue, 1000);
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', debouncedShowMenu);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', debouncedShowMenu);
|
||||||
|
if (fadeOut) {
|
||||||
|
clearTimeout(fadeOut);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMouseMoved;
|
||||||
|
};
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import { useMemo, useRef } from 'react';
|
import { useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { isDev } from '../api/constants';
|
import { isDev } from '../../externals';
|
||||||
|
|
||||||
type noop = (this: any, ...args: any[]) => any;
|
type noop = (this: any, ...args: any[]) => any;
|
||||||
|
|
||||||
|
|||||||
@@ -230,3 +230,11 @@ export const useTimelineStatus = () => {
|
|||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
return useRuntimeStore(featureSelector);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const usePing = () => {
|
||||||
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
|
ping: state.ping,
|
||||||
|
});
|
||||||
|
|
||||||
|
return useRuntimeStore(featureSelector);
|
||||||
|
};
|
||||||
|
|||||||
@@ -7,5 +7,5 @@ export const ontimePlaceholderInfo: GetInfo = {
|
|||||||
version: '2.0.0',
|
version: '2.0.0',
|
||||||
serverPort: 4001,
|
serverPort: 4001,
|
||||||
osc: oscPlaceholderSettings,
|
osc: oscPlaceholderSettings,
|
||||||
cssOverride: '',
|
publicDir: '',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ export const projectDataPlaceholder: ProjectData = {
|
|||||||
publicInfo: '',
|
publicInfo: '',
|
||||||
backstageUrl: '',
|
backstageUrl: '',
|
||||||
backstageInfo: '',
|
backstageInfo: '',
|
||||||
|
projectLogo: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,4 +15,5 @@ export type OverridableOptions = {
|
|||||||
language?: string;
|
language?: string;
|
||||||
showProgressBar?: boolean;
|
showProgressBar?: boolean;
|
||||||
hideTimerSeconds?: boolean;
|
hideTimerSeconds?: boolean;
|
||||||
|
removeLeadingZeros?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { MaybeString } from 'ontime-types';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface DialogStore {
|
||||||
|
showDialog: MaybeString;
|
||||||
|
setDialog: (name: string) => void;
|
||||||
|
clearDialog: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to allow the server to show a dialog in the client
|
||||||
|
*/
|
||||||
|
export const useDialogStore = create<DialogStore>((set) => ({
|
||||||
|
showDialog: null,
|
||||||
|
clearDialog: () => set({ showDialog: null }),
|
||||||
|
setDialog: (name) => set({ showDialog: name }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows setting a dialog to show (outside react)
|
||||||
|
*/
|
||||||
|
export function addDialog(name: string): void {
|
||||||
|
useDialogStore.getState().setDialog(name);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { EndAction, TimerType } from 'ontime-types';
|
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
|
||||||
import { validateEndAction, validateTimerType } from 'ontime-utils';
|
import { validateEndAction, validateTimerType, validateTimeStrategy } from 'ontime-utils';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
import { booleanFromLocalStorage } from '../utils/localStorage';
|
import { booleanFromLocalStorage } from '../utils/localStorage';
|
||||||
@@ -7,6 +7,7 @@ import { booleanFromLocalStorage } from '../utils/localStorage';
|
|||||||
type EditorSettingsStore = {
|
type EditorSettingsStore = {
|
||||||
defaultDuration: string;
|
defaultDuration: string;
|
||||||
linkPrevious: boolean;
|
linkPrevious: boolean;
|
||||||
|
defaultTimeStrategy: TimeStrategy;
|
||||||
defaultWarnTime: string;
|
defaultWarnTime: string;
|
||||||
defaultDangerTime: string;
|
defaultDangerTime: string;
|
||||||
defaultPublic: boolean;
|
defaultPublic: boolean;
|
||||||
@@ -14,6 +15,7 @@ type EditorSettingsStore = {
|
|||||||
defaultEndAction: EndAction;
|
defaultEndAction: EndAction;
|
||||||
setDefaultDuration: (defaultDuration: string) => void;
|
setDefaultDuration: (defaultDuration: string) => void;
|
||||||
setLinkPrevious: (linkPrevious: boolean) => void;
|
setLinkPrevious: (linkPrevious: boolean) => void;
|
||||||
|
setTimeStrategy: (timeStrategy: TimeStrategy) => void;
|
||||||
setWarnTime: (warnTime: string) => void;
|
setWarnTime: (warnTime: string) => void;
|
||||||
setDangerTime: (dangerTime: string) => void;
|
setDangerTime: (dangerTime: string) => void;
|
||||||
setDefaultPublic: (defaultPublic: boolean) => void;
|
setDefaultPublic: (defaultPublic: boolean) => void;
|
||||||
@@ -24,6 +26,7 @@ type EditorSettingsStore = {
|
|||||||
export const editorSettingsDefaults = {
|
export const editorSettingsDefaults = {
|
||||||
duration: '00:10:00',
|
duration: '00:10:00',
|
||||||
linkPrevious: true,
|
linkPrevious: true,
|
||||||
|
timeStrategy: TimeStrategy.LockDuration,
|
||||||
warnTime: '00:02:00', // 120000 same as backend
|
warnTime: '00:02:00', // 120000 same as backend
|
||||||
dangerTime: '00:01:00', // 60000 same as backend
|
dangerTime: '00:01:00', // 60000 same as backend
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
@@ -34,6 +37,7 @@ export const editorSettingsDefaults = {
|
|||||||
enum EditorSettingsKeys {
|
enum EditorSettingsKeys {
|
||||||
DefaultDuration = 'ontime-default-duration',
|
DefaultDuration = 'ontime-default-duration',
|
||||||
LinkPrevious = 'ontime-link-previous',
|
LinkPrevious = 'ontime-link-previous',
|
||||||
|
DefaultTimeStrategy = 'ontime-time-strategy',
|
||||||
DefaultWarnTime = 'ontime-default-warn-time',
|
DefaultWarnTime = 'ontime-default-warn-time',
|
||||||
DefaultDangerTime = 'ontime-default-danger-time',
|
DefaultDangerTime = 'ontime-default-danger-time',
|
||||||
DefaultPublic = 'ontime-default-public',
|
DefaultPublic = 'ontime-default-public',
|
||||||
@@ -45,6 +49,10 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
|||||||
return {
|
return {
|
||||||
defaultDuration: localStorage.getItem(EditorSettingsKeys.DefaultDuration) ?? editorSettingsDefaults.duration,
|
defaultDuration: localStorage.getItem(EditorSettingsKeys.DefaultDuration) ?? editorSettingsDefaults.duration,
|
||||||
linkPrevious: booleanFromLocalStorage(EditorSettingsKeys.LinkPrevious, editorSettingsDefaults.linkPrevious),
|
linkPrevious: booleanFromLocalStorage(EditorSettingsKeys.LinkPrevious, editorSettingsDefaults.linkPrevious),
|
||||||
|
defaultTimeStrategy: validateTimeStrategy(
|
||||||
|
localStorage.getItem(EditorSettingsKeys.DefaultTimeStrategy),
|
||||||
|
editorSettingsDefaults.timeStrategy,
|
||||||
|
),
|
||||||
defaultWarnTime: localStorage.getItem(EditorSettingsKeys.DefaultWarnTime) ?? editorSettingsDefaults.warnTime,
|
defaultWarnTime: localStorage.getItem(EditorSettingsKeys.DefaultWarnTime) ?? editorSettingsDefaults.warnTime,
|
||||||
defaultDangerTime: localStorage.getItem(EditorSettingsKeys.DefaultDangerTime) ?? editorSettingsDefaults.dangerTime,
|
defaultDangerTime: localStorage.getItem(EditorSettingsKeys.DefaultDangerTime) ?? editorSettingsDefaults.dangerTime,
|
||||||
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, editorSettingsDefaults.isPublic),
|
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, editorSettingsDefaults.isPublic),
|
||||||
@@ -68,6 +76,12 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
|||||||
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(linkPrevious));
|
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(linkPrevious));
|
||||||
return { linkPrevious };
|
return { linkPrevious };
|
||||||
}),
|
}),
|
||||||
|
setTimeStrategy: (defaultTimeStrategy) =>
|
||||||
|
set(() => {
|
||||||
|
localStorage.setItem(EditorSettingsKeys.DefaultTimeStrategy, String(defaultTimeStrategy));
|
||||||
|
return { defaultTimeStrategy };
|
||||||
|
}),
|
||||||
|
|
||||||
setWarnTime: (defaultWarnTime) =>
|
setWarnTime: (defaultWarnTime) =>
|
||||||
set(() => {
|
set(() => {
|
||||||
localStorage.setItem(EditorSettingsKeys.DefaultWarnTime, String(defaultWarnTime));
|
localStorage.setItem(EditorSettingsKeys.DefaultWarnTime, String(defaultWarnTime));
|
||||||
|
|||||||
@@ -1,58 +1,7 @@
|
|||||||
import isEqual from 'react-fast-compare';
|
import isEqual from 'react-fast-compare';
|
||||||
import { Playback, RuntimeStore, SimpleDirection, SimplePlayback, TimerPhase } from 'ontime-types';
|
import { RuntimeStore, runtimeStorePlaceholder } from 'ontime-types';
|
||||||
import { createWithEqualityFn, useStoreWithEqualityFn } from 'zustand/traditional';
|
import { createWithEqualityFn, useStoreWithEqualityFn } from 'zustand/traditional';
|
||||||
|
|
||||||
export const runtimeStorePlaceholder: RuntimeStore = {
|
|
||||||
clock: 0,
|
|
||||||
timer: {
|
|
||||||
addedTime: 0,
|
|
||||||
current: null,
|
|
||||||
duration: null,
|
|
||||||
elapsed: null,
|
|
||||||
expectedFinish: null,
|
|
||||||
finishedAt: null,
|
|
||||||
phase: TimerPhase.None,
|
|
||||||
playback: Playback.Stop,
|
|
||||||
secondaryTimer: null,
|
|
||||||
startedAt: null,
|
|
||||||
},
|
|
||||||
onAir: false,
|
|
||||||
message: {
|
|
||||||
timer: {
|
|
||||||
text: '',
|
|
||||||
visible: false,
|
|
||||||
blink: false,
|
|
||||||
blackout: false,
|
|
||||||
secondarySource: null,
|
|
||||||
},
|
|
||||||
external: '',
|
|
||||||
},
|
|
||||||
runtime: {
|
|
||||||
selectedEventIndex: null,
|
|
||||||
numEvents: 0,
|
|
||||||
offset: 0,
|
|
||||||
plannedStart: 0,
|
|
||||||
plannedEnd: 0,
|
|
||||||
actualStart: null,
|
|
||||||
expectedEnd: null,
|
|
||||||
},
|
|
||||||
currentBlock: {
|
|
||||||
block: null,
|
|
||||||
startedAt: null,
|
|
||||||
},
|
|
||||||
eventNow: null,
|
|
||||||
eventNext: null,
|
|
||||||
publicEventNow: null,
|
|
||||||
publicEventNext: null,
|
|
||||||
auxtimer1: {
|
|
||||||
current: 0,
|
|
||||||
direction: SimpleDirection.CountUp,
|
|
||||||
duration: 0,
|
|
||||||
playback: SimplePlayback.Stop,
|
|
||||||
},
|
|
||||||
frozen: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const deepCompare = <T>(a: T, b: T) => isEqual(a, b);
|
const deepCompare = <T>(a: T, b: T) => isEqual(a, b);
|
||||||
|
|
||||||
export const runtimeStore = createWithEqualityFn<RuntimeStore>(
|
export const runtimeStore = createWithEqualityFn<RuntimeStore>(
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
/**
|
|
||||||
* Returns hostname
|
|
||||||
* @type {string}
|
|
||||||
*/
|
|
||||||
export const host = window?.location?.host;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open an external URLs: specifically for a electron / browser case
|
|
||||||
* If electron: ask main process to call a new browser window
|
|
||||||
* If browser: open in new tab
|
|
||||||
* @param url
|
|
||||||
*/
|
|
||||||
export function openLink(url) {
|
|
||||||
if (window.process?.type === 'renderer') {
|
|
||||||
window.ipcRenderer.send('send-to-link', url);
|
|
||||||
} else {
|
|
||||||
window.open(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles opening external links
|
|
||||||
* @param event
|
|
||||||
* @param location
|
|
||||||
*/
|
|
||||||
export function handleLinks(event, location) {
|
|
||||||
// we handle the link manually
|
|
||||||
event.preventDefault();
|
|
||||||
openLink(`http://${host}/${location}`);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { MouseEvent } from 'react';
|
||||||
|
|
||||||
|
import { baseURI, serverURL } from '../../externals';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open an external URLs: specifically for a electron / browser case
|
||||||
|
* If electron: ask main process to call a new browser window
|
||||||
|
* If browser: open in new tab
|
||||||
|
* @param url
|
||||||
|
*/
|
||||||
|
export function openLink(url: string) {
|
||||||
|
if (window.process?.type === 'renderer') {
|
||||||
|
window.ipcRenderer.send('send-to-link', url);
|
||||||
|
} else {
|
||||||
|
window.open(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles opening external links
|
||||||
|
* @param event
|
||||||
|
* @param location
|
||||||
|
*/
|
||||||
|
export function handleLinks(event: MouseEvent, location: string) {
|
||||||
|
// we handle the link manually
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const destination = new URL(serverURL);
|
||||||
|
destination.pathname = baseURI ? `${baseURI}/${location}` : location;
|
||||||
|
openLink(destination.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function linkToOtherHost(host: string, path?: string) {
|
||||||
|
const destination = new URL(serverURL);
|
||||||
|
destination.hostname = host;
|
||||||
|
if (path) {
|
||||||
|
destination.pathname = baseURI ? `${baseURI}/${path}` : path;
|
||||||
|
}
|
||||||
|
return destination.toString();
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Log, RundownCached, RuntimeStore } from 'ontime-types';
|
import { Log, RundownCached, RuntimeStore } from 'ontime-types';
|
||||||
|
|
||||||
import { CLIENT_LIST, CUSTOM_FIELDS, isProduction, RUNDOWN, RUNTIME, websocketUrl } from '../api/constants';
|
import { isProduction, websocketUrl } from '../../externals';
|
||||||
|
import { CLIENT_LIST, CUSTOM_FIELDS, RUNDOWN, RUNTIME } from '../api/constants';
|
||||||
|
import { invalidateAllCaches } from '../api/utils';
|
||||||
import { ontimeQueryClient } from '../queryClient';
|
import { ontimeQueryClient } from '../queryClient';
|
||||||
import {
|
import {
|
||||||
getClientId,
|
getClientId,
|
||||||
@@ -10,6 +12,7 @@ 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, runtimeStore } from '../stores/runtime';
|
||||||
|
|
||||||
@@ -68,6 +71,12 @@ export const connectSocket = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
case 'pong': {
|
||||||
|
const offset = (new Date().getTime() - new Date(payload).getTime()) * 0.5;
|
||||||
|
patchRuntime('ping', offset);
|
||||||
|
updateDevTools({ ping: offset }, ['PING']);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'client-id': {
|
case 'client-id': {
|
||||||
if (typeof payload === 'string') {
|
if (typeof payload === 'string') {
|
||||||
setClientId(payload);
|
setClientId(payload);
|
||||||
@@ -110,6 +119,13 @@ 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;
|
||||||
@@ -178,10 +194,12 @@ export const connectSocket = () => {
|
|||||||
}
|
}
|
||||||
case 'ontime-refetch': {
|
case 'ontime-refetch': {
|
||||||
// the refetch message signals that the rundown has changed in the server side
|
// the refetch message signals that the rundown has changed in the server side
|
||||||
const { revision } = payload;
|
const { revision, reload } = payload;
|
||||||
const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1;
|
const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1;
|
||||||
|
|
||||||
if (revision > currentRevision) {
|
if (reload) {
|
||||||
|
invalidateAllCaches();
|
||||||
|
} else if (revision > currentRevision) {
|
||||||
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
|
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||||
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
|
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
|
||||||
}
|
}
|
||||||
@@ -214,9 +232,9 @@ export const socketSendJson = (type: string, payload?: unknown) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function updateDevTools(newData: Partial<RuntimeStore>) {
|
function updateDevTools(newData: Partial<RuntimeStore>, store = RUNTIME) {
|
||||||
if (!isProduction) {
|
if (!isProduction) {
|
||||||
ontimeQueryClient.setQueryData(RUNTIME, (oldData: RuntimeStore) => ({
|
ontimeQueryClient.setQueryData(store, (oldData: RuntimeStore) => ({
|
||||||
...oldData,
|
...oldData,
|
||||||
...newData,
|
...newData,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function validateProjectFile(file: File) {
|
|||||||
|
|
||||||
// Limit file size of a project file to around 1MB
|
// Limit file size of a project file to around 1MB
|
||||||
if (file.size > 1_000_000) {
|
if (file.size > 1_000_000) {
|
||||||
throw new Error('File size limit (10MB) exceeded');
|
throw new Error('File size limit (1MB) exceeded');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,3 +45,19 @@ export function isExcelFile(file: File | null) {
|
|||||||
export function isOntimeFile(file: File | null) {
|
export function isOntimeFile(file: File | null) {
|
||||||
return file?.name.endsWith('.json');
|
return file?.name.endsWith('.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collection of rules for pre-validating a project file
|
||||||
|
* @param file
|
||||||
|
*/
|
||||||
|
export function validateLogo(file: File) {
|
||||||
|
// Check if file is empty
|
||||||
|
if (file.size === 0) {
|
||||||
|
throw new Error('File is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit file size of a project file to around 1MB
|
||||||
|
if (file.size > 1_000_000) {
|
||||||
|
throw new Error('File size limit (1MB) exceeded');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* This file contains a list of constants that may need to be resolved at runtime
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { version } from '../../../package.json';
|
||||||
|
|
||||||
export const githubUrl = 'https://www.github.com/cpvalente/ontime';
|
export const 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/';
|
||||||
|
|
||||||
|
// resolve environment
|
||||||
|
export const appVersion = version;
|
||||||
|
export const isProduction = import.meta.env.MODE === 'production';
|
||||||
|
export const isDev = !isProduction;
|
||||||
|
export const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||||
|
export const isDockerImage = Boolean(import.meta.env.VITE_IS_DOCKER);
|
||||||
|
export const isOntimeCloud = window.location.hostname.includes('cloud.getontime.no');
|
||||||
|
|
||||||
|
// resolve entrypoint URLs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve base
|
||||||
|
* @example '' for electron
|
||||||
|
* @example '/client-hash' for cloud
|
||||||
|
*/
|
||||||
|
export const baseURI = resolveBaseURI();
|
||||||
|
export const serverURL = resolveUrl('http', '');
|
||||||
|
export const websocketUrl = resolveUrl('ws', 'ws');
|
||||||
|
|
||||||
|
function resolveUrl(protocol: 'http' | 'ws', path: string) {
|
||||||
|
const url = new URL(window.location.origin);
|
||||||
|
|
||||||
|
// generate ws url
|
||||||
|
if (protocol === 'ws') {
|
||||||
|
// ensure we remain in a secure context
|
||||||
|
const isSecure = window.location.protocol === 'https:';
|
||||||
|
url.protocol = isSecure ? 'wss' : 'ws';
|
||||||
|
}
|
||||||
|
|
||||||
|
// make path name relative to the base URI
|
||||||
|
url.pathname = baseURI ? `${baseURI}/${path}` : path;
|
||||||
|
|
||||||
|
// in development mode, we use the React port for UI, but need the requests to target the server
|
||||||
|
if (isDev) {
|
||||||
|
// this is used as a fallback port for development
|
||||||
|
url.port = '4001';
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = url.toString();
|
||||||
|
|
||||||
|
// prevent trailing slash
|
||||||
|
return result.endsWith('/') ? result.slice(0, -1) : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a base URI for a client that is not at the root segment
|
||||||
|
* ie: https://cloud.getontime.com/client-hash/timer
|
||||||
|
* This is necessary for ontime cloud and should otherwise not affect the client
|
||||||
|
*/
|
||||||
|
function resolveBaseURI(): string {
|
||||||
|
// in ontime cloud, the base tag is set by the server
|
||||||
|
const baseHref = document.querySelector('base')?.getAttribute('href');
|
||||||
|
const base = baseHref ?? '';
|
||||||
|
|
||||||
|
// prevent a trailing slash from either an empty base or a base with a trailing slash
|
||||||
|
if (base.endsWith('/')) {
|
||||||
|
return base.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import useAppSettingsNavigation from './useAppSettingsNavigation';
|
|||||||
import style from './AppSettings.module.scss';
|
import style from './AppSettings.module.scss';
|
||||||
|
|
||||||
export default function AppSettings() {
|
export default function AppSettings() {
|
||||||
const { close, panel, location } = useAppSettingsNavigation();
|
const { close, panel, location, setLocation } = useAppSettingsNavigation();
|
||||||
useKeyDown(close, 'Escape');
|
useKeyDown(close, 'Escape');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -26,7 +26,7 @@ export default function AppSettings() {
|
|||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<PanelList selectedPanel={panel} location={location} />
|
<PanelList selectedPanel={panel} location={location} />
|
||||||
<PanelContent onClose={close}>
|
<PanelContent onClose={close}>
|
||||||
{panel === 'project' && <ProjectPanel location={location} />}
|
{panel === 'project' && <ProjectPanel location={location} setLocation={setLocation} />}
|
||||||
{panel === 'general' && <GeneralPanel location={location} />}
|
{panel === 'general' && <GeneralPanel location={location} />}
|
||||||
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
|
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
|
||||||
{panel === 'sources' && <SourcesPanel />}
|
{panel === 'sources' && <SourcesPanel />}
|
||||||
|
|||||||
@@ -40,12 +40,16 @@ ul {
|
|||||||
background-color: $gray-1100;
|
background-color: $gray-1100;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.unsaved::before {
|
&.highlight {
|
||||||
content: '';
|
color: $red-400;
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
&::before {
|
||||||
border-radius: 3px;
|
content: '';
|
||||||
background-color: $blue-400;
|
width: 0.5em;
|
||||||
|
height: 0.5em;
|
||||||
|
border-radius: 99px;
|
||||||
|
background-color: $red-400;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.split {
|
&.split {
|
||||||
|
|||||||
@@ -1,66 +1,90 @@
|
|||||||
import { Fragment } from 'react';
|
import { Fragment } from 'react';
|
||||||
|
import { Tooltip } from '@chakra-ui/react';
|
||||||
|
|
||||||
import { isKeyEnter } from '../../../common/utils/keyEvent';
|
import { isKeyEnter } from '../../../common/utils/keyEvent';
|
||||||
import { cx } from '../../../common/utils/styleUtils';
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
import { PanelBaseProps, settingPanels, useSettingsStore } from '../settingsStore';
|
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||||
|
import { SettingsOption, SettingsOptionId, useAppSettingsMenu } from '../useAppSettingsMenu';
|
||||||
import useAppSettingsNavigation from '../useAppSettingsNavigation';
|
import useAppSettingsNavigation from '../useAppSettingsNavigation';
|
||||||
|
|
||||||
import style from './PanelList.module.scss';
|
import style from './PanelList.module.scss';
|
||||||
|
|
||||||
|
export interface PanelBaseProps {
|
||||||
|
location?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface PanelListProps extends PanelBaseProps {
|
interface PanelListProps extends PanelBaseProps {
|
||||||
selectedPanel: string;
|
selectedPanel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PanelList({ selectedPanel, location }: PanelListProps) {
|
export default function PanelList({ selectedPanel, location }: PanelListProps) {
|
||||||
const { setLocation } = useAppSettingsNavigation();
|
const { options } = useAppSettingsMenu();
|
||||||
const { hasUnsavedChanges } = useSettingsStore();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className={style.tabs}>
|
<ul className={style.tabs}>
|
||||||
{settingPanels.map((panel) => {
|
{options.map((panel) => {
|
||||||
const unsaved = hasUnsavedChanges(panel.id);
|
const isSelected = selectedPanel === panel.id;
|
||||||
|
if (panel.highlight) {
|
||||||
const classes = cx([
|
return (
|
||||||
style.primary,
|
<Tooltip key={panel.id} label={panel.highlight} openDelay={tooltipDelayFast} shouldWrapChildren>
|
||||||
selectedPanel === panel.id ? style.active : null,
|
<PanelListItem panel={panel} location={location} isSelected={isSelected} />
|
||||||
panel.split ? style.split : null,
|
</Tooltip>
|
||||||
unsaved ? style.unsaved : null,
|
);
|
||||||
]);
|
}
|
||||||
|
return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />;
|
||||||
return (
|
|
||||||
<Fragment key={panel.id}>
|
|
||||||
<li
|
|
||||||
key={panel.id}
|
|
||||||
onClick={() => setLocation(panel.id)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
isKeyEnter(event) && setLocation(panel.id);
|
|
||||||
}}
|
|
||||||
className={classes}
|
|
||||||
tabIndex={0}
|
|
||||||
role='button'
|
|
||||||
>
|
|
||||||
{panel.label}
|
|
||||||
</li>
|
|
||||||
{panel.secondary?.map((secondary) => {
|
|
||||||
const id = secondary.id.split('__')[1];
|
|
||||||
const secondaryClasses = cx([style.secondary, location === id ? style.active : null]);
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={secondary.id}
|
|
||||||
onClick={() => setLocation(secondary.id)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
isKeyEnter(event) && setLocation(secondary.id);
|
|
||||||
}}
|
|
||||||
className={secondaryClasses}
|
|
||||||
role='button'
|
|
||||||
>
|
|
||||||
{secondary.label}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PanelListItemProps {
|
||||||
|
panel: SettingsOption;
|
||||||
|
isSelected: boolean;
|
||||||
|
location?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelListItem(props: PanelListItemProps) {
|
||||||
|
const { panel, isSelected, location } = props;
|
||||||
|
const { setLocation } = useAppSettingsNavigation();
|
||||||
|
|
||||||
|
const classes = cx([
|
||||||
|
style.primary,
|
||||||
|
isSelected && style.active,
|
||||||
|
panel.split && style.split,
|
||||||
|
panel.highlight && style.highlight,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={panel.id}>
|
||||||
|
<li
|
||||||
|
key={panel.id}
|
||||||
|
onClick={() => setLocation(panel.id as SettingsOptionId)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
isKeyEnter(event) && setLocation(panel.id as SettingsOptionId);
|
||||||
|
}}
|
||||||
|
className={classes}
|
||||||
|
tabIndex={0}
|
||||||
|
role='button'
|
||||||
|
>
|
||||||
|
{panel.label}
|
||||||
|
</li>
|
||||||
|
{panel.secondary?.map((secondary) => {
|
||||||
|
const id = secondary.id.split('__')[1];
|
||||||
|
const secondaryClasses = cx([style.secondary, location === id ? style.active : null]);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={secondary.id}
|
||||||
|
onClick={() => setLocation(secondary.id as SettingsOptionId)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
isKeyEnter(event) && setLocation(secondary.id as SettingsOptionId);
|
||||||
|
}}
|
||||||
|
className={secondaryClasses}
|
||||||
|
role='button'
|
||||||
|
>
|
||||||
|
{secondary.label}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,6 +49,18 @@ $inner-padding: 1rem;
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.highlight {
|
||||||
|
font-size: calc(1rem - 2px);
|
||||||
|
color: $orange-500;
|
||||||
|
padding-inline: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blockquote {
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
border-left: 2px solid $gray-100;
|
||||||
|
background-color: $white-3;
|
||||||
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
font-size: $inner-section-text-size;
|
font-size: $inner-section-text-size;
|
||||||
display: block;
|
display: block;
|
||||||
@@ -2,7 +2,7 @@ import { HTMLAttributes, ReactNode } from 'react';
|
|||||||
|
|
||||||
import { cx } from '../../../common/utils/styleUtils';
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
|
|
||||||
import style from './Panel.module.scss';
|
import style from './PanelUtils.module.scss';
|
||||||
|
|
||||||
export function Header({ children }: { children: ReactNode }) {
|
export function Header({ children }: { children: ReactNode }) {
|
||||||
return <h2 className={style.header}>{children}</h2>;
|
return <h2 className={style.header}>{children}</h2>;
|
||||||
@@ -35,9 +35,9 @@ export function Paragraph({ children }: { children: ReactNode }) {
|
|||||||
return <p className={style.paragraph}>{children}</p>;
|
return <p className={style.paragraph}>{children}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Card({ children, ...props }: { children: ReactNode } & JSX.IntrinsicElements['div']) {
|
export function Card({ children, className, ...props }: { children: ReactNode } & JSX.IntrinsicElements['div']) {
|
||||||
return (
|
return (
|
||||||
<div className={style.card} {...props}>
|
<div className={cx([style.card, className])} {...props}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -74,6 +74,14 @@ export function Description({ children }: { children: ReactNode }) {
|
|||||||
return <div className={style.fieldDescription}>{children}</div>;
|
return <div className={style.fieldDescription}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function Highlight({ children }: { children: ReactNode }) {
|
||||||
|
return <code className={style.highlight}>{children}</code>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BlockQuote({ children }: { children: ReactNode }) {
|
||||||
|
return <blockquote className={style.blockquote}>{children}</blockquote>;
|
||||||
|
}
|
||||||
|
|
||||||
export function Error({ children }: { children: ReactNode }) {
|
export function Error({ children }: { children: ReactNode }) {
|
||||||
return <div className={style.fieldError}>{children}</div>;
|
return <div className={style.fieldError}>{children}</div>;
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { version } from '../../../../../package.json';
|
|
||||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||||
import { documentationUrl, githubUrl, websiteUrl } from '../../../../externals';
|
import { discordUrl, documentationUrl, githubUrl, websiteUrl } from '../../../../externals';
|
||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import CheckUpdatesButton from './CheckUpdatesButton';
|
import AppVersion from './AppVersion';
|
||||||
|
|
||||||
export default function AboutPanel() {
|
export default function AboutPanel() {
|
||||||
return (
|
return (
|
||||||
@@ -20,11 +19,11 @@ export default function AboutPanel() {
|
|||||||
<Panel.SubHeader>Links</Panel.SubHeader>
|
<Panel.SubHeader>Links</Panel.SubHeader>
|
||||||
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
|
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
|
||||||
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
|
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
|
||||||
|
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Panel.SubHeader>Current version</Panel.SubHeader>
|
<Panel.SubHeader>Current version</Panel.SubHeader>
|
||||||
<Panel.Paragraph>{`You are currently using Ontime ${version}`}</Panel.Paragraph>
|
<AppVersion />
|
||||||
<CheckUpdatesButton version={version} />
|
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||||
|
import useAppVersion from '../../../../common/hooks-query/useAppVersion';
|
||||||
|
import { appVersion } from '../../../../externals';
|
||||||
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
|
export default function AppVersion() {
|
||||||
|
const { data, isError } = useAppVersion();
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<Panel.Paragraph>
|
||||||
|
{`You are currently using Ontime version ${appVersion}`}
|
||||||
|
<Panel.Error>{`Could not fetch version information: ${isError}`}</Panel.Error>
|
||||||
|
</Panel.Paragraph>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.hasUpdates) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Panel.Paragraph>{`You are currently using Ontime version ${appVersion}.`}</Panel.Paragraph>
|
||||||
|
<Panel.Paragraph>
|
||||||
|
{`A new version ${data.version} is available.`}
|
||||||
|
<ExternalLink href={data.url}> Please visit the release page to download</ExternalLink>
|
||||||
|
</Panel.Paragraph>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Panel.Paragraph>{`You are currently using the latest version of Ontime: ${appVersion}`}</Panel.Paragraph>;
|
||||||
|
}
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Button } from '@chakra-ui/react';
|
|
||||||
|
|
||||||
import { getLatestVersion, HasUpdate } from '../../../../common/api/external';
|
|
||||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
|
||||||
|
|
||||||
import style from '../Panel.module.scss';
|
|
||||||
|
|
||||||
type CheckFail = {
|
|
||||||
error: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CheckIsLatest = {
|
|
||||||
latest: true;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CheckRemote = CheckFail | CheckIsLatest | HasUpdate;
|
|
||||||
|
|
||||||
interface CheckUpdatesButtonProps {
|
|
||||||
version: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CheckUpdatesButton(props: CheckUpdatesButtonProps) {
|
|
||||||
const { version } = props;
|
|
||||||
|
|
||||||
const [updateMessage, setUpdateMessage] = useState<CheckRemote | null>(null);
|
|
||||||
const [isFetching, setIsFetching] = useState(false);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles version comparison and returns component with message
|
|
||||||
*/
|
|
||||||
const versionCheck = async () => {
|
|
||||||
setIsFetching(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const latest = await getLatestVersion();
|
|
||||||
|
|
||||||
if (!latest.version.includes(version)) {
|
|
||||||
// new version, pass data to component
|
|
||||||
setUpdateMessage(latest);
|
|
||||||
} else {
|
|
||||||
setUpdateMessage({ latest: true });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setUpdateMessage({ error: 'Error reaching server' });
|
|
||||||
} finally {
|
|
||||||
setIsFetching(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const disableButton = Boolean(updateMessage && 'version' in updateMessage);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
onClick={versionCheck}
|
|
||||||
variant='ontime-filled'
|
|
||||||
isLoading={isFetching}
|
|
||||||
isDisabled={disableButton}
|
|
||||||
size='sm'
|
|
||||||
maxWidth='max-content'
|
|
||||||
>
|
|
||||||
Check for updates
|
|
||||||
</Button>
|
|
||||||
<ResolveUpdateMessage updateMessage={updateMessage} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResolveUpdateMessage(props: { updateMessage: CheckRemote | null }) {
|
|
||||||
const { updateMessage } = props;
|
|
||||||
|
|
||||||
if (updateMessage && 'error' in updateMessage) {
|
|
||||||
return <span className={style.error}>{updateMessage.error}</span>;
|
|
||||||
}
|
|
||||||
if (updateMessage && 'url' in updateMessage) {
|
|
||||||
return <ExternalLink href={updateMessage?.url}>{`New version available: ${updateMessage.version}`}</ExternalLink>;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import ClientList from './ClientList';
|
import ClientList from './ClientList';
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { RedirectClientModal } from '../../../../common/components/client-modal/
|
|||||||
import { RenameClientModal } from '../../../../common/components/client-modal/RenameClientModal';
|
import { RenameClientModal } from '../../../../common/components/client-modal/RenameClientModal';
|
||||||
import { setClientRemote } from '../../../../common/hooks/useSocket';
|
import { setClientRemote } from '../../../../common/hooks/useSocket';
|
||||||
import { useClientStore } from '../../../../common/stores/clientStore';
|
import { useClientStore } from '../../../../common/stores/clientStore';
|
||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import style from './ClientControlPanel.module.scss';
|
import style from './ClientControlPanel.module.scss';
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
.halfWidth {
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
.fullWidth {
|
.fullWidth {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -37,3 +41,9 @@
|
|||||||
.flex {
|
.flex {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.twoCols {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||||
import { PanelBaseProps } from '../../settingsStore';
|
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import CustomFields from './custom-fields/CustomFields';
|
import CustomFields from './custom-fields/CustomFields';
|
||||||
import UrlPresetsForm from './UrlPresetsForm';
|
import UrlPresetsForm from './UrlPresetsForm';
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import ExternalLink from '../../../../common/components/external-link/ExternalLi
|
|||||||
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
|
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
|
||||||
import { handleLinks } from '../../../../common/utils/linkUtils';
|
import { handleLinks } from '../../../../common/utils/linkUtils';
|
||||||
import { validateUrlPresetPath } from '../../../../common/utils/urlPresets';
|
import { validateUrlPresetPath } from '../../../../common/utils/urlPresets';
|
||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import style from './FeatureSettings.module.scss';
|
import style from './FeatureSettings.module.scss';
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ export default function UrlPresetsForm() {
|
|||||||
setError,
|
setError,
|
||||||
formState: { isSubmitting, isDirty, isValid, errors },
|
formState: { isSubmitting, isDirty, isValid, errors },
|
||||||
} = useForm<FormData>({
|
} = useForm<FormData>({
|
||||||
mode: 'onBlur',
|
mode: 'onChange',
|
||||||
defaultValues: { data },
|
defaultValues: { data },
|
||||||
values: { data },
|
values: { data },
|
||||||
resetOptions: {
|
resetOptions: {
|
||||||
@@ -78,7 +78,7 @@ export default function UrlPresetsForm() {
|
|||||||
|
|
||||||
const addNew = () => {
|
const addNew = () => {
|
||||||
prepend({
|
prepend({
|
||||||
enabled: false,
|
enabled: true,
|
||||||
alias: '',
|
alias: '',
|
||||||
pathAndParams: '',
|
pathAndParams: '',
|
||||||
});
|
});
|
||||||
@@ -105,12 +105,25 @@ export default function UrlPresetsForm() {
|
|||||||
<Alert status='info' variant='ontime-on-dark-info'>
|
<Alert status='info' variant='ontime-on-dark-info'>
|
||||||
<AlertIcon />
|
<AlertIcon />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
URL Presets
|
URL presets are user defined aliases to Ontime URLs
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
Custom presets allow providing a short name for any ontime URL. <br />
|
<b>Preset Name</b> <br />
|
||||||
- Providing dynamic URLs for automation or unattended screens <br />- Simplifying complex URLs
|
The alias for the URL. This will be the URL you will be calling. eg: <br />
|
||||||
|
<Panel.BlockQuote>
|
||||||
|
Preset name <Panel.Highlight>cam3</Panel.Highlight> called as{' '}
|
||||||
|
<Panel.Highlight>http://localhost:4001/cam3</Panel.Highlight>
|
||||||
|
</Panel.BlockQuote>
|
||||||
<br />
|
<br />
|
||||||
|
<b>URL Segment</b> <br />
|
||||||
|
The corresponding alias path and configuration parameters. eg: <br />
|
||||||
|
<Panel.BlockQuote>
|
||||||
|
URL segment <Panel.Highlight>backstage?hidePast=true&stopCycle=true</Panel.Highlight> corresponds to
|
||||||
|
complete URL
|
||||||
|
<Panel.Highlight>http://localhost:4001/backstage?hidePast=true&stopCycle=true</Panel.Highlight>
|
||||||
|
</Panel.BlockQuote>
|
||||||
|
<br />
|
||||||
|
You will need to save the changes before the presets are functional.
|
||||||
<br />
|
<br />
|
||||||
<ExternalLink href={urlPresetsDocs}>See the docs</ExternalLink>
|
<ExternalLink href={urlPresetsDocs}>See the docs</ExternalLink>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
@@ -130,8 +143,8 @@ export default function UrlPresetsForm() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className={style.fit}>Active</th>
|
<th className={style.fit}>Active</th>
|
||||||
<th className={style.aliasConstrain}>Preset</th>
|
<th className={style.aliasConstrain}>Preset name</th>
|
||||||
<th className={style.fullWidth}>URL</th>
|
<th className={style.fullWidth}>URL segment</th>
|
||||||
<th />
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
|
|||||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||||
import { CustomField, CustomFieldLabel } from 'ontime-types';
|
import { CustomField, CustomFieldLabel } from 'ontime-types';
|
||||||
|
|
||||||
|
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
|
||||||
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
|
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
|
||||||
|
|
||||||
import CustomFieldForm from './CustomFieldForm';
|
import CustomFieldForm from './CustomFieldForm';
|
||||||
@@ -36,6 +37,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
|||||||
onSubmit={handleEdit}
|
onSubmit={handleEdit}
|
||||||
initialColour={colour}
|
initialColour={colour}
|
||||||
initialLabel={label}
|
initialLabel={label}
|
||||||
|
initialKey={field}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -47,7 +49,12 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
|||||||
<td>
|
<td>
|
||||||
<Swatch color={colour} />
|
<Swatch color={colour} />
|
||||||
</td>
|
</td>
|
||||||
<td className={style.fullWidth}>{label}</td>
|
<td className={style.halfWidth}>{label}</td>
|
||||||
|
<td className={style.fullWidth}>
|
||||||
|
<CopyTag label='Copy key to use in integrations' copyValue={field}>
|
||||||
|
{field}
|
||||||
|
</CopyTag>
|
||||||
|
</td>
|
||||||
<td className={style.actions}>
|
<td className={style.actions}>
|
||||||
<IconButton
|
<IconButton
|
||||||
size='sm'
|
size='sm'
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { Button, Input } from '@chakra-ui/react';
|
import { Button, Input } from '@chakra-ui/react';
|
||||||
import { CustomField } from 'ontime-types';
|
import { CustomField } from 'ontime-types';
|
||||||
import { isAlphanumeric } from 'ontime-utils';
|
import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
|
||||||
|
|
||||||
import { maybeAxiosError } from '../../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../../common/api/utils';
|
||||||
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
|
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
|
||||||
import * as Panel from '../../PanelUtils';
|
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||||
|
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import style from '../FeatureSettings.module.scss';
|
import style from '../FeatureSettings.module.scss';
|
||||||
|
|
||||||
@@ -15,10 +16,13 @@ interface CustomFieldsFormProps {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
initialColour?: string;
|
initialColour?: string;
|
||||||
initialLabel?: string;
|
initialLabel?: string;
|
||||||
|
initialKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
||||||
const { onSubmit, onCancel, initialColour, initialLabel } = props;
|
const { onSubmit, onCancel, initialColour, initialLabel, initialKey } = props;
|
||||||
|
const { data } = useCustomFields();
|
||||||
|
|
||||||
// we use this to force an update
|
// we use this to force an update
|
||||||
const [_, setColour] = useState(initialColour || '');
|
const [_, setColour] = useState(initialColour || '');
|
||||||
|
|
||||||
@@ -31,7 +35,7 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
|||||||
getValues,
|
getValues,
|
||||||
formState: { errors, isSubmitting, isValid, isDirty },
|
formState: { errors, isSubmitting, isValid, isDirty },
|
||||||
} = useForm({
|
} = useForm({
|
||||||
defaultValues: { label: initialLabel || '', colour: initialColour || '' },
|
defaultValues: { label: initialLabel || '', colour: initialColour || '', key: initialKey || '' },
|
||||||
resetOptions: {
|
resetOptions: {
|
||||||
keepDirtyValues: true,
|
keepDirtyValues: true,
|
||||||
},
|
},
|
||||||
@@ -63,31 +67,45 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
|||||||
|
|
||||||
const colour = getValues('colour');
|
const colour = getValues('colour');
|
||||||
const canSubmit = isDirty && isValid;
|
const canSubmit = isDirty && isValid;
|
||||||
|
// if initial values are given, we can assume we are in edit mode
|
||||||
|
const isEditMode = initialKey !== undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}>
|
<form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}>
|
||||||
<div className={style.column}>
|
<div className={style.twoCols}>
|
||||||
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
|
<div>
|
||||||
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
|
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
|
||||||
<Input
|
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
|
||||||
{...register('label', {
|
<Input
|
||||||
required: { value: true, message: 'Required field' },
|
{...register('label', {
|
||||||
validate: (value) => {
|
required: { value: true, message: 'Required field' },
|
||||||
if (value.trim().length === 0) return 'Required field';
|
onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'),
|
||||||
if (!isAlphanumeric(value)) return 'Only alphanumeric characters are allowed';
|
validate: (value) => {
|
||||||
return true;
|
if (value.trim().length === 0) return 'Required field';
|
||||||
},
|
if (!isAlphanumericWithSpace(value)) return 'Only alphanumeric characters and space are allowed';
|
||||||
})}
|
if (!isEditMode) {
|
||||||
size='sm'
|
if (isEditMode && Object.keys(data).includes(value)) return 'Custom fields must be unique';
|
||||||
variant='ontime-filled'
|
}
|
||||||
autoComplete='off'
|
return true;
|
||||||
/>
|
},
|
||||||
|
})}
|
||||||
|
size='sm'
|
||||||
|
variant='ontime-filled'
|
||||||
|
autoComplete='off'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Panel.Description>Key (use in Integrations and API)</Panel.Description>
|
||||||
|
<Input {...register('key')} disabled size='sm' variant='ontime-filled' autoComplete='off' />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Panel.Description>Colour</Panel.Description>
|
<Panel.Description>Colour</Panel.Description>
|
||||||
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
|
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||||
<div className={style.buttonRow}>
|
<div className={style.buttonRow}>
|
||||||
<Button size='sm' variant='ontime-ghosted' onClick={onCancel}>
|
<Button size='sm' variant='ontime-ghosted' onClick={onCancel}>
|
||||||
|
|||||||
@@ -6,13 +6,12 @@ import { CustomField, CustomFieldLabel } from 'ontime-types';
|
|||||||
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
|
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
|
||||||
import ExternalLink from '../../../../../common/components/external-link/ExternalLink';
|
import ExternalLink from '../../../../../common/components/external-link/ExternalLink';
|
||||||
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||||
import * as Panel from '../../PanelUtils';
|
import { customFieldsDocsUrl } from '../../../../../externals';
|
||||||
|
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import CustomFieldEntry from './CustomFieldEntry';
|
import CustomFieldEntry from './CustomFieldEntry';
|
||||||
import CustomFieldForm from './CustomFieldForm';
|
import CustomFieldForm from './CustomFieldForm';
|
||||||
|
|
||||||
const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
|
|
||||||
|
|
||||||
export default function CustomFields() {
|
export default function CustomFields() {
|
||||||
const { data, refetch } = useCustomFields();
|
const { data, refetch } = useCustomFields();
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
@@ -59,9 +58,14 @@ export default function CustomFields() {
|
|||||||
<Alert status='info' variant='ontime-on-dark-info'>
|
<Alert status='info' variant='ontime-on-dark-info'>
|
||||||
<AlertIcon />
|
<AlertIcon />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Custom fields allow for additional information to be added to an event (eg. light, sound, camera). <br />
|
Custom fields allow for additional information to be added to an event.
|
||||||
<br />
|
<br />
|
||||||
This data is not used by Ontime.
|
<br />
|
||||||
|
This data is not used by Ontime, but provides place for cueing or department specific information (eg.
|
||||||
|
light, sound, camera).
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
Custom fields can be used width the Integrations feature using the generated key.
|
||||||
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
|
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -73,6 +77,7 @@ export default function CustomFields() {
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Colour</th>
|
<th>Colour</th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
|
<th>Key (used in Integrations)</th>
|
||||||
<th />
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||||
import { PanelBaseProps } from '../../settingsStore';
|
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||||
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
import EditorSettingsForm from '../interface-panel/EditorSettingsForm';
|
import EditorSettingsForm from '../interface-panel/EditorSettingsForm';
|
||||||
import * as Panel from '../PanelUtils';
|
|
||||||
|
|
||||||
import GeneralPanelForm from './GeneralPanelForm';
|
import GeneralPanelForm from './GeneralPanelForm';
|
||||||
import ViewSettingsForm from './ViewSettingsForm';
|
import ViewSettingsForm from './ViewSettingsForm';
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { postSettings } from '../../../../common/api/settings';
|
|||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import useSettings from '../../../../common/hooks-query/useSettings';
|
import useSettings from '../../../../common/hooks-query/useSettings';
|
||||||
import { isOnlyNumbers } from '../../../../common/utils/regex';
|
import { isOnlyNumbers } from '../../../../common/utils/regex';
|
||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import GeneralPinInput from './GeneralPinInput';
|
import GeneralPinInput from './GeneralPinInput';
|
||||||
|
|
||||||
@@ -154,7 +154,8 @@ export default function GeneralPanelForm() {
|
|||||||
<option value='pt'>Portuguese</option>
|
<option value='pt'>Portuguese</option>
|
||||||
<option value='es'>Spanish</option>
|
<option value='es'>Spanish</option>
|
||||||
<option value='sv'>Swedish</option>
|
<option value='sv'>Swedish</option>
|
||||||
<option value='pl'>Polish</option>
|
<option value='pl'>Polish</option>
|
||||||
|
<option value='zh'>Chinese (Simplified)</option>
|
||||||
</Select>
|
</Select>
|
||||||
</Panel.ListItem>
|
</Panel.ListItem>
|
||||||
</Panel.ListGroup>
|
</Panel.ListGroup>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import ExternalLink from '../../../../common/components/external-link/ExternalLi
|
|||||||
import { PopoverPickerRHF } from '../../../../common/components/input/popover-picker/PopoverPicker';
|
import { PopoverPickerRHF } from '../../../../common/components/input/popover-picker/PopoverPicker';
|
||||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||||
import useViewSettings from '../../../../common/hooks-query/useViewSettings';
|
import useViewSettings from '../../../../common/hooks-query/useViewSettings';
|
||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import style from './GeneralPanel.module.scss';
|
import style from './GeneralPanel.module.scss';
|
||||||
|
|
||||||
@@ -84,8 +84,8 @@ export default function ViewSettingsForm() {
|
|||||||
<Alert status='info' variant='ontime-on-dark-info'>
|
<Alert status='info' variant='ontime-on-dark-info'>
|
||||||
<AlertIcon />
|
<AlertIcon />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
You can override the styles of the viewers with a custom CSS file. <br />
|
You can the Ontime views or customise its styles by modifying the provided CSS file. <br />
|
||||||
{info?.cssOverride && `In your installation the file is at ${info?.cssOverride}`}
|
The CSS file is in the user directory at {`${info.publicDir}/user/styles/override.css`}
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
<ExternalLink href={cssOverrideDocsUrl}>See the docs</ExternalLink>
|
<ExternalLink href={cssOverrideDocsUrl}>See the docs</ExternalLink>
|
||||||
@@ -126,7 +126,7 @@ export default function ViewSettingsForm() {
|
|||||||
<Panel.ListItem>
|
<Panel.ListItem>
|
||||||
<Panel.Field
|
<Panel.Field
|
||||||
title='Freeze timer on end'
|
title='Freeze timer on end'
|
||||||
description='Timer in views will stop from going negative and instead stop at 00:00:00'
|
description='When a timer hits 00:00:00, it freezes instead of going negative. It invalidates the End Message.'
|
||||||
/>
|
/>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
@@ -139,7 +139,7 @@ export default function ViewSettingsForm() {
|
|||||||
<Panel.ListItem>
|
<Panel.ListItem>
|
||||||
<Panel.Field
|
<Panel.Field
|
||||||
title='End message'
|
title='End message'
|
||||||
description='Message to show on negative timers if not frozen. If not provided, timer will continue'
|
description='Message for negative timers; applies only if the timer isn`t frozen on End. If no message is provided, it continues into negative time'
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
size='sm'
|
size='sm'
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { maybeAxiosError } from '../../../../common/api/utils';
|
|||||||
import { useHttpSettings, usePostHttpSettings } from '../../../../common/hooks-query/useHttpSettings';
|
import { useHttpSettings, usePostHttpSettings } from '../../../../common/hooks-query/useHttpSettings';
|
||||||
import { isKeyEscape } from '../../../../common/utils/keyEvent';
|
import { isKeyEscape } from '../../../../common/utils/keyEvent';
|
||||||
import { startsWithHttp } from '../../../../common/utils/regex';
|
import { startsWithHttp } from '../../../../common/utils/regex';
|
||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import { cycles } from './integrationUtils';
|
import { cycles } from './integrationUtils';
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ export default function HttpIntegrations() {
|
|||||||
setError,
|
setError,
|
||||||
formState: { errors, isSubmitting, isDirty, isValid },
|
formState: { errors, isSubmitting, isDirty, isValid },
|
||||||
} = useForm<HttpSettings>({
|
} = useForm<HttpSettings>({
|
||||||
mode: 'onBlur',
|
mode: 'onChange',
|
||||||
defaultValues: data,
|
defaultValues: data,
|
||||||
values: data,
|
values: data,
|
||||||
resetOptions: {
|
resetOptions: {
|
||||||
@@ -60,7 +60,7 @@ export default function HttpIntegrations() {
|
|||||||
id: generateId(),
|
id: generateId(),
|
||||||
cycle: 'onLoad',
|
cycle: 'onLoad',
|
||||||
message: '',
|
message: '',
|
||||||
enabled: false,
|
enabled: true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
|
|||||||
|
|
||||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||||
import { PanelBaseProps } from '../../settingsStore';
|
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import HttpIntegrations from './HttpIntegrations';
|
import HttpIntegrations from './HttpIntegrations';
|
||||||
import OscIntegrations from './OscIntegrations';
|
import OscIntegrations from './OscIntegrations';
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { maybeAxiosError } from '../../../../common/api/utils';
|
|||||||
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
|
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
|
||||||
import { isKeyEscape } from '../../../../common/utils/keyEvent';
|
import { isKeyEscape } from '../../../../common/utils/keyEvent';
|
||||||
import { isASCII, isASCIIorEmpty, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
|
import { isASCII, isASCIIorEmpty, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
|
||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import { cycles } from './integrationUtils';
|
import { cycles } from './integrationUtils';
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ export default function OscIntegrations() {
|
|||||||
setError,
|
setError,
|
||||||
formState: { errors, isSubmitting, isDirty, isValid },
|
formState: { errors, isSubmitting, isDirty, isValid },
|
||||||
} = useForm<OSCSettings>({
|
} = useForm<OSCSettings>({
|
||||||
mode: 'onBlur',
|
mode: 'onChange',
|
||||||
defaultValues: data,
|
defaultValues: data,
|
||||||
values: data,
|
values: data,
|
||||||
resetOptions: {
|
resetOptions: {
|
||||||
@@ -75,7 +75,7 @@ export default function OscIntegrations() {
|
|||||||
cycle: 'onLoad',
|
cycle: 'onLoad',
|
||||||
address: '',
|
address: '',
|
||||||
payload: '',
|
payload: '',
|
||||||
enabled: false,
|
enabled: true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { Select, Switch } from '@chakra-ui/react';
|
import { Select, Switch } from '@chakra-ui/react';
|
||||||
import { EndAction, TimerType } from 'ontime-types';
|
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
|
||||||
import { parseUserTime } from 'ontime-utils';
|
import { parseUserTime } from 'ontime-utils';
|
||||||
|
|
||||||
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
||||||
import { editorSettingsDefaults, useEditorSettings } from '../../../../common/stores/editorSettings';
|
import { editorSettingsDefaults, useEditorSettings } from '../../../../common/stores/editorSettings';
|
||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
export default function EditorSettingsForm() {
|
export default function EditorSettingsForm() {
|
||||||
const {
|
const {
|
||||||
defaultDuration,
|
defaultDuration,
|
||||||
linkPrevious,
|
linkPrevious,
|
||||||
|
defaultTimeStrategy,
|
||||||
defaultWarnTime,
|
defaultWarnTime,
|
||||||
defaultDangerTime,
|
defaultDangerTime,
|
||||||
defaultPublic,
|
defaultPublic,
|
||||||
@@ -17,6 +18,7 @@ export default function EditorSettingsForm() {
|
|||||||
defaultEndAction,
|
defaultEndAction,
|
||||||
setDefaultDuration,
|
setDefaultDuration,
|
||||||
setLinkPrevious,
|
setLinkPrevious,
|
||||||
|
setTimeStrategy,
|
||||||
setWarnTime,
|
setWarnTime,
|
||||||
setDangerTime,
|
setDangerTime,
|
||||||
setDefaultPublic,
|
setDefaultPublic,
|
||||||
@@ -36,15 +38,6 @@ export default function EditorSettingsForm() {
|
|||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Panel.Title>Rundown defaults for new events</Panel.Title>
|
<Panel.Title>Rundown defaults for new events</Panel.Title>
|
||||||
<Panel.ListGroup>
|
<Panel.ListGroup>
|
||||||
<Panel.ListItem>
|
|
||||||
<Panel.Field title='Default duration' description='Default duration for new events' />
|
|
||||||
<TimeInput<'defaultDuration'>
|
|
||||||
name='defaultDuration'
|
|
||||||
submitHandler={(_field, value) => setDefaultDuration(value)}
|
|
||||||
time={durationInMs}
|
|
||||||
placeholder={editorSettingsDefaults.duration}
|
|
||||||
/>
|
|
||||||
</Panel.ListItem>
|
|
||||||
<Panel.ListItem>
|
<Panel.ListItem>
|
||||||
<Panel.Field
|
<Panel.Field
|
||||||
title='Link previous'
|
title='Link previous'
|
||||||
@@ -57,6 +50,33 @@ export default function EditorSettingsForm() {
|
|||||||
onChange={(event) => setLinkPrevious(event.target.checked)}
|
onChange={(event) => setLinkPrevious(event.target.checked)}
|
||||||
/>
|
/>
|
||||||
</Panel.ListItem>
|
</Panel.ListItem>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<Panel.Field
|
||||||
|
title='Timer strategy'
|
||||||
|
description='Which time should be maintained when event schedule is recalculated'
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
variant='ontime'
|
||||||
|
size='sm'
|
||||||
|
width='auto'
|
||||||
|
value={defaultTimeStrategy}
|
||||||
|
onChange={(event) => setTimeStrategy(event.target.value as TimeStrategy)}
|
||||||
|
>
|
||||||
|
<option value={TimeStrategy.LockDuration}>Duration</option>
|
||||||
|
<option value={TimeStrategy.LockEnd}>End Time</option>
|
||||||
|
</Select>
|
||||||
|
</Panel.ListItem>
|
||||||
|
</Panel.ListGroup>
|
||||||
|
<Panel.ListGroup>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<Panel.Field title='Default duration' description='Default duration for new events' />
|
||||||
|
<TimeInput<'defaultDuration'>
|
||||||
|
name='defaultDuration'
|
||||||
|
submitHandler={(_field, value) => setDefaultDuration(value)}
|
||||||
|
time={durationInMs}
|
||||||
|
placeholder={editorSettingsDefaults.duration}
|
||||||
|
/>
|
||||||
|
</Panel.ListItem>
|
||||||
<Panel.ListItem>
|
<Panel.ListItem>
|
||||||
<Panel.Field title='Timer type' description='Default type of timer for new events' />
|
<Panel.Field title='Timer type' description='Default type of timer for new events' />
|
||||||
<Select
|
<Select
|
||||||
@@ -70,6 +90,7 @@ export default function EditorSettingsForm() {
|
|||||||
<option value={TimerType.CountUp}>Count up</option>
|
<option value={TimerType.CountUp}>Count up</option>
|
||||||
<option value={TimerType.TimeToEnd}>Time to end</option>
|
<option value={TimerType.TimeToEnd}>Time to end</option>
|
||||||
<option value={TimerType.Clock}>Clock</option>
|
<option value={TimerType.Clock}>Clock</option>
|
||||||
|
<option value={TimerType.None}>None</option>
|
||||||
</Select>
|
</Select>
|
||||||
</Panel.ListItem>
|
</Panel.ListItem>
|
||||||
<Panel.ListItem>
|
<Panel.ListItem>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
|
import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
|
||||||
|
|
||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import EditorSettingsForm from './EditorSettingsForm';
|
import EditorSettingsForm from './EditorSettingsForm';
|
||||||
|
|
||||||
|
|||||||
@@ -4,3 +4,9 @@
|
|||||||
gap: $section-spacing;
|
gap: $section-spacing;
|
||||||
row-gap: $element-inner-spacing;
|
row-gap: $element-inner-spacing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.goIcon {
|
||||||
|
@include rotate-fourty-five;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,35 @@
|
|||||||
import { serverPort } from '../../../../common/api/constants';
|
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||||
import AppLink from '../../../../common/components/app-link/AppLink';
|
|
||||||
|
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
||||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||||
|
import { linkToOtherHost, openLink } from '../../../../common/utils/linkUtils';
|
||||||
|
import { isLocalhost } from '../../../../externals';
|
||||||
|
|
||||||
import style from './NetworkInterfaces.module.scss';
|
import style from './NetworkInterfaces.module.scss';
|
||||||
|
|
||||||
export default function InfoNif() {
|
export default function InfoNif() {
|
||||||
const { data } = useInfo();
|
const { data } = useInfo();
|
||||||
|
|
||||||
|
const handleClick = (address: string) => openLink(address);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.interfaces}>
|
<div className={style.interfaces}>
|
||||||
{data?.networkInterfaces?.map((nif) => (
|
{data.networkInterfaces.map((nif) => {
|
||||||
<AppLink key={nif.address} href={`http://${nif.address}:${serverPort}`}>
|
// interfaces outside localhost wont have access
|
||||||
{`${nif.name} - ${nif.address}`}
|
if (nif.name === 'localhost' && !isLocalhost) return null;
|
||||||
</AppLink>
|
const address = linkToOtherHost(nif.address);
|
||||||
))}
|
|
||||||
|
return (
|
||||||
|
<CopyTag
|
||||||
|
key={nif.name}
|
||||||
|
copyValue={address}
|
||||||
|
onClick={() => handleClick(address)}
|
||||||
|
label='Copy IP or navigate to address'
|
||||||
|
>
|
||||||
|
{`${nif.name} - ${nif.address}`} <IoArrowUp className={style.goIcon} />
|
||||||
|
</CopyTag>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
|||||||
|
|
||||||
import { handleLinks } from '../../../../common/utils/linkUtils';
|
import { handleLinks } from '../../../../common/utils/linkUtils';
|
||||||
import Log from '../../../log/Log';
|
import Log from '../../../log/Log';
|
||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import style from './NetworkLogExport.module.scss';
|
import style from './NetworkLogExport.module.scss';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||||
import { PanelBaseProps } from '../../settingsStore';
|
import { usePing } from '../../../../common/hooks/useSocket';
|
||||||
|
import { socketSendJson } from '../../../../common/utils/socket';
|
||||||
|
import { isDockerImage } from '../../../../externals';
|
||||||
|
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||||
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
import ClientControlPanel from '../client-control-panel/ClientControlPanel';
|
import ClientControlPanel from '../client-control-panel/ClientControlPanel';
|
||||||
import * as Panel from '../PanelUtils';
|
|
||||||
|
|
||||||
import InfoNif from './NetworkInterfaces';
|
import InfoNif from './NetworkInterfaces';
|
||||||
import LogExport from './NetworkLogExport';
|
import LogExport from './NetworkLogExport';
|
||||||
@@ -14,6 +19,7 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
|||||||
<>
|
<>
|
||||||
<Panel.Header>Network</Panel.Header>
|
<Panel.Header>Network</Panel.Header>
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
|
{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 />
|
||||||
@@ -26,3 +32,29 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function OntimeCloudStats() {
|
||||||
|
const { ping } = usePing();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send immediate ping request, and keep sending on an interval
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
socketSendJson('ping', new Date());
|
||||||
|
|
||||||
|
const doPing = setInterval(() => {
|
||||||
|
socketSendJson('ping', new Date());
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(doPing);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel.SubHeader>
|
||||||
|
Ontime cloud
|
||||||
|
<Panel.Description>Current ping: {ping}ms</Panel.Description>
|
||||||
|
</Panel.SubHeader>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { ChangeEvent, useRef, useState } from 'react';
|
import { ChangeEvent, useRef, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Button, Input } from '@chakra-ui/react';
|
import { Button, Input } from '@chakra-ui/react';
|
||||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||||
|
|
||||||
import { uploadProjectFile } from '../../../../common/api/db';
|
import { uploadProjectFile } from '../../../../common/api/db';
|
||||||
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
|
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import { validateProjectFile } from '../../../../common/utils/uploadUtils';
|
import { validateProjectFile } from '../../../../common/utils/uploadUtils';
|
||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import ProjectCreateForm from './ProjectCreateForm';
|
import ProjectCreateForm from './ProjectCreateForm';
|
||||||
import ProjectList from './ProjectList';
|
import ProjectList from './ProjectList';
|
||||||
@@ -13,14 +14,17 @@ import ProjectList from './ProjectList';
|
|||||||
import style from './ProjectPanel.module.scss';
|
import style from './ProjectPanel.module.scss';
|
||||||
|
|
||||||
export default function ManageProjects() {
|
export default function ManageProjects() {
|
||||||
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState<'import' | null>(null);
|
const [loading, setLoading] = useState<'import' | null>(null);
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const isCreatingProject = searchParams.get('new') === 'true';
|
||||||
|
|
||||||
const handleToggleCreate = () => {
|
const handleToggleCreate = () => {
|
||||||
setIsCreatingProject((prev) => !prev);
|
searchParams.set('new', isCreatingProject ? 'false' : 'true');
|
||||||
|
setSearchParams(searchParams);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectFile = () => {
|
const handleSelectFile = () => {
|
||||||
@@ -49,7 +53,8 @@ export default function ManageProjects() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseForm = () => {
|
const handleCloseForm = () => {
|
||||||
setIsCreatingProject(false);
|
searchParams.delete('new');
|
||||||
|
setSearchParams(searchParams);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { useQueryClient } from '@tanstack/react-query';
|
|||||||
import { PROJECT_LIST } from '../../../../common/api/constants';
|
import { PROJECT_LIST } from '../../../../common/api/constants';
|
||||||
import { createProject } from '../../../../common/api/db';
|
import { createProject } from '../../../../common/api/db';
|
||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import * as Panel from '../PanelUtils';
|
import { documentationUrl, websiteUrl } from '../../../../externals';
|
||||||
|
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')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import { useEffect } from 'react';
|
import { ChangeEvent, useEffect, useRef } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { Button, Input, Textarea } from '@chakra-ui/react';
|
import { Button, Input, Textarea } from '@chakra-ui/react';
|
||||||
|
import { IoDownloadOutline } from '@react-icons/all-files/io5/IoDownloadOutline';
|
||||||
|
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||||
import { type ProjectData } from 'ontime-types';
|
import { type ProjectData } from 'ontime-types';
|
||||||
|
|
||||||
import { postProjectData } from '../../../../common/api/project';
|
import { projectLogoPath } from '../../../../common/api/constants';
|
||||||
|
import { postProjectData, uploadProjectLogo } from '../../../../common/api/project';
|
||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import useProjectData from '../../../../common/hooks-query/useProjectData';
|
import useProjectData from '../../../../common/hooks-query/useProjectData';
|
||||||
import * as Panel from '../PanelUtils';
|
import { validateLogo } from '../../../../common/utils/uploadUtils';
|
||||||
|
import { documentationUrl, websiteUrl } from '../../../../externals';
|
||||||
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import style from './ProjectPanel.module.scss';
|
import style from './ProjectPanel.module.scss';
|
||||||
|
|
||||||
@@ -17,8 +22,10 @@ export default function ProjectData() {
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
register,
|
register,
|
||||||
reset,
|
reset,
|
||||||
formState: { isSubmitting, isValid, isDirty },
|
formState: { isSubmitting, isValid, isDirty, errors },
|
||||||
setError,
|
setError,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
} = useForm({
|
} = useForm({
|
||||||
defaultValues: data,
|
defaultValues: data,
|
||||||
values: data,
|
values: data,
|
||||||
@@ -34,6 +41,42 @@ export default function ProjectData() {
|
|||||||
}
|
}
|
||||||
}, [data, reset]);
|
}, [data, reset]);
|
||||||
|
|
||||||
|
const handleUploadProjectLogo = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateLogo(file);
|
||||||
|
const response = await uploadProjectLogo(file);
|
||||||
|
|
||||||
|
setValue('projectLogo', response.data.logoFilename, {
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = maybeAxiosError(error);
|
||||||
|
setError('projectLogo', { message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { ref, ...projectLogoRest } = register('projectLogo');
|
||||||
|
|
||||||
|
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const handleClickUpload = () => {
|
||||||
|
uploadInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteLogo = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setValue('projectLogo', null, {
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const onSubmit = async (formData: ProjectData) => {
|
const onSubmit = async (formData: ProjectData) => {
|
||||||
try {
|
try {
|
||||||
await postProjectData(formData);
|
await postProjectData(formData);
|
||||||
@@ -81,11 +124,58 @@ export default function ProjectData() {
|
|||||||
variant='ontime-filled'
|
variant='ontime-filled'
|
||||||
size='sm'
|
size='sm'
|
||||||
maxLength={50}
|
maxLength={50}
|
||||||
placeholder='Your project name'
|
placeholder='Project title is shown in production views'
|
||||||
autoComplete='off'
|
autoComplete='off'
|
||||||
{...register('title')}
|
{...register('title')}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<Panel.Section style={{ marginTop: 0 }}>
|
||||||
|
<label>
|
||||||
|
Project logo
|
||||||
|
<Input
|
||||||
|
variant='ontime-filled'
|
||||||
|
size='sm'
|
||||||
|
type='file'
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
accept='image/*'
|
||||||
|
{...projectLogoRest}
|
||||||
|
ref={(e) => {
|
||||||
|
ref(e);
|
||||||
|
uploadInputRef.current = e;
|
||||||
|
}}
|
||||||
|
onChange={handleUploadProjectLogo}
|
||||||
|
/>
|
||||||
|
<Panel.Card className={style.uploadLogoCard}>
|
||||||
|
{watch('projectLogo') ? (
|
||||||
|
<>
|
||||||
|
<img src={`${projectLogoPath}/${watch('projectLogo')}`} />
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant='ontime-filled'
|
||||||
|
isDisabled={isSubmitting || !watch('projectLogo')}
|
||||||
|
leftIcon={<IoTrash />}
|
||||||
|
onClick={handleDeleteLogo}
|
||||||
|
type='button'
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant='ontime-filled'
|
||||||
|
size='sm'
|
||||||
|
isDisabled={isSubmitting}
|
||||||
|
leftIcon={<IoDownloadOutline />}
|
||||||
|
onClick={handleClickUpload}
|
||||||
|
type='button'
|
||||||
|
>
|
||||||
|
Upload logo
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{errors?.projectLogo?.message && <Panel.Error>{errors.projectLogo.message}</Panel.Error>}
|
||||||
|
</Panel.Card>
|
||||||
|
</label>
|
||||||
|
</Panel.Section>
|
||||||
<label>
|
<label>
|
||||||
Project description
|
Project description
|
||||||
<Input
|
<Input
|
||||||
@@ -114,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')}
|
||||||
/>
|
/>
|
||||||
@@ -136,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,15 +1,14 @@
|
|||||||
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 '../PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import ProjectListItem, { EditMode } from './ProjectListItem';
|
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>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from '../../../../common/api/db';
|
} from '../../../../common/api/db';
|
||||||
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
|
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import { cx } from '../../../../common/utils/styleUtils';
|
import { cx } from '../../../../common/utils/styleUtils';
|
||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import ProjectForm, { ProjectFormValues } from './ProjectForm';
|
import ProjectForm, { ProjectFormValues } from './ProjectForm';
|
||||||
import ProjectMergeForm from './ProjectMergeForm';
|
import ProjectMergeForm from './ProjectMergeForm';
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { PROJECT_DATA } from '../../../../common/api/constants';
|
|||||||
import { getDb, patchData } from '../../../../common/api/db';
|
import { getDb, patchData } from '../../../../common/api/db';
|
||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import { cx } from '../../../../common/utils/styleUtils';
|
import { cx } from '../../../../common/utils/styleUtils';
|
||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import { makeProjectPatch } from './project.utils';
|
import { makeProjectPatch } from './project.utils';
|
||||||
|
|
||||||
|
|||||||
@@ -61,3 +61,26 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadLogoCard {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
background-color: $gray-1350;
|
||||||
|
border: 1px solid $white-10;
|
||||||
|
border-radius: 3px;
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 250px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||||
import { PanelBaseProps } from '../../settingsStore';
|
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
import QuickStart from '../../quick-start/QuickStart';
|
||||||
|
import type { SettingsOptionId } from '../../useAppSettingsMenu';
|
||||||
|
|
||||||
import ManageProjects from './ManageProjects';
|
import ManageProjects from './ManageProjects';
|
||||||
import ProjectData from './ProjectData';
|
import ProjectData from './ProjectData';
|
||||||
|
|
||||||
export default function ProjectPanel({ location }: PanelBaseProps) {
|
interface ProjectPanelProps extends PanelBaseProps {
|
||||||
|
setLocation: (location: SettingsOptionId) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectPanel({ location, setLocation }: ProjectPanelProps) {
|
||||||
const projectRef = useScrollIntoView<HTMLDivElement>('data', location);
|
const projectRef = useScrollIntoView<HTMLDivElement>('data', location);
|
||||||
const manageRef = useScrollIntoView<HTMLDivElement>('manage', location);
|
const manageRef = useScrollIntoView<HTMLDivElement>('manage', location);
|
||||||
|
|
||||||
|
const handleQuickClose = () => {
|
||||||
|
setLocation('project');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Panel.Header>Project</Panel.Header>
|
<Panel.Header>Project</Panel.Header>
|
||||||
|
<QuickStart isOpen={location === 'create'} onClose={handleQuickClose} />
|
||||||
<div ref={projectRef}>
|
<div ref={projectRef}>
|
||||||
<ProjectData />
|
<ProjectData />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import {
|
|||||||
useDisclosure,
|
useDisclosure,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
import { isLocalhost } from '../../../../common/api/constants';
|
import { useElectronEvent } from '../../../../common/hooks/useElectronEvent';
|
||||||
import useElectronEvent from '../../../../common/hooks/useElectronEvent';
|
import { isLocalhost } from '../../../../externals';
|
||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
export default function ShutdownPanel() {
|
export default function ShutdownPanel() {
|
||||||
const { isElectron, sendToElectron } = useElectronEvent();
|
const { isElectron, sendToElectron } = useElectronEvent();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { getWorksheetNames } from '../../../../common/api/sheets';
|
|||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
||||||
import { openLink } from '../../../../common/utils/linkUtils';
|
import { openLink } from '../../../../common/utils/linkUtils';
|
||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import useGoogleSheet from './useGoogleSheet';
|
import useGoogleSheet from './useGoogleSheet';
|
||||||
import { useSheetStore } from './useSheetStore';
|
import { useSheetStore } from './useSheetStore';
|
||||||
@@ -193,7 +193,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
|||||||
<Panel.ListGroup>
|
<Panel.ListGroup>
|
||||||
<div className={style.buttonRow}>
|
<div className={style.buttonRow}>
|
||||||
{isAuthenticating && <Spinner />}
|
{isAuthenticating && <Spinner />}
|
||||||
<CopyTag label='Google Auth Key' disabled={!canAuthenticate} size='sm'>
|
<CopyTag copyValue={authKey ?? ''} label='Google Auth Key' disabled={!canAuthenticate} size='sm'>
|
||||||
{authKey ? authKey : 'Upload files to generate Auth Key'}
|
{authKey ? authKey : 'Upload files to generate Auth Key'}
|
||||||
</CopyTag>
|
</CopyTag>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
|||||||
import { Button } from '@chakra-ui/react';
|
import { Button } from '@chakra-ui/react';
|
||||||
import { CustomFields, OntimeRundown } from 'ontime-types';
|
import { CustomFields, OntimeRundown } from 'ontime-types';
|
||||||
|
|
||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import PreviewSpreadsheet from './preview/PreviewRundown';
|
import PreviewSpreadsheet from './preview/PreviewRundown';
|
||||||
import useGoogleSheet from './useGoogleSheet';
|
import useGoogleSheet from './useGoogleSheet';
|
||||||
|
|||||||