Compare commits

..

9 Commits

Author SHA1 Message Date
Carlos Valente 085e862be9 refactor: show electron dev tools 2024-10-06 21:32:29 +02:00
Carlos Valente 384a6298cf refactor: arrow used in new window navigation 2024-10-06 21:32:08 +02:00
Carlos Valente 0398db9945 refactor: navigate in electron 2024-10-06 21:21:16 +02:00
Carlos Valente 2560b0ca2a refactor: remove unused db 2024-10-06 20:34:58 +02:00
Carlos Valente 2e0c86c20a feat: open asset directories 2024-10-06 20:34:58 +02:00
Carlos Valente 61742d1549 refactor: collocate view navigation 2024-10-06 20:34:58 +02:00
Carlos Valente 220319be8a refactor: improve native menu links 2024-10-06 20:34:54 +02:00
Carlos Valente ea5de337cf feat: allow download of current project 2024-10-03 22:28:47 +02:00
Carlos Valente a08ea3fee9 refactor: create project form state from params 2024-10-03 22:28:47 +02:00
537 changed files with 10126 additions and 16766 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.TEAMID }} APPLE_TEAM_ID: ${{ secrets.TEAMID }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
CSC_LINK: ${{ secrets.CSC_LINK }} CSC_LINK: ${{ secrets.CSC_LINK }}
run: pnpm dist-mac --env-mode=loose run: pnpm dist-mac
timeout-minutes: 60 timeout-minutes: 60
- name: Release - name: Release
-2
View File
@@ -28,7 +28,6 @@ test-results
playwright-report playwright-report
/playwright/.cache/ /playwright/.cache/
automated-screenshots automated-screenshots
e2e/tests/fixtures/tmp/*
# production # production
build/ build/
@@ -40,7 +39,6 @@ dist/
# docker utils # docker utils
ontime-db ontime-db
ontime-external/ ontime-external/
ontime-data/
# versioning file # versioning file
**/ONTIME_VERSION.js **/ONTIME_VERSION.js
+1 -1
View File
@@ -91,7 +91,7 @@ While it should allow for a generic setup, it might need to be modified to fit y
From the project root, run the following commands From the project root, run the following commands
- __Build docker image from__ by running `docker build -t getontime/ontime .` - __Build docker image from__ by running `docker build -t getontime/ontime`
- __Run docker image from compose__ by running `docker-compose up -d` - __Run docker image from compose__ by running `docker-compose up -d`
Other useful commands Other useful commands
+1 -3
View File
@@ -23,8 +23,6 @@ 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/
COPY --from=builder /app/apps/server/src/html/ ./html/
# Export default ports # Export default ports
EXPOSE 4001/tcp 8888/udp 9999/udp EXPOSE 4001/tcp 8888/udp 9999/udp
@@ -34,4 +32,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:/data/ 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
-5
View File
@@ -2,7 +2,6 @@
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/cpvalente/ontime/total) ![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/cpvalente/ontime/total)
![Docker Pulls](https://img.shields.io/docker/pulls/getontime/ontime) ![Docker Pulls](https://img.shields.io/docker/pulls/getontime/ontime)
![NPM Downloads](https://img.shields.io/npm/dy/%40getontime%2Fcli) ![NPM Downloads](https://img.shields.io/npm/dy/%40getontime%2Fcli)
![Homebrew Cask Version](https://img.shields.io/homebrew/cask/v/ontime)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente) [![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente)
[![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente) [![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente)
@@ -13,11 +12,7 @@
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">MacOS Arm</a> - Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">MacOS Arm</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">MacOS Intel</a> - Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">MacOS Intel</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a> - Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
... or
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a> - Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
- Install from <a href="https://www.npmjs.com/package/ontime">NPM</a>
- Install from <a href="https://formulae.brew.sh/cask/ontime">Homebrew</a>
## Need help? ## Need help?
We do our best to have most topics covered by the documentation. However, if your question is not covered, you are welcome to [fill in a bug report in an issue](https://github.com/cpvalente/ontime/issues), [ask a question in GitHub discussions](https://github.com/cpvalente/ontime/discussions) or hop in the [discord server](https://discord.com/invite/eje3CSUEXm) for a chat. We do our best to have most topics covered by the documentation. However, if your question is not covered, you are welcome to [fill in a bug report in an issue](https://github.com/cpvalente/ontime/issues), [ask a question in GitHub discussions](https://github.com/cpvalente/ontime/discussions) or hop in the [discord server](https://discord.com/invite/eje3CSUEXm) for a chat.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "3.11.0", "version": "3.6.0",
"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
View File
@@ -15,6 +15,7 @@
], ],
"plugins": [ "plugins": [
"react", "react",
"testing-library",
"simple-import-sort", "simple-import-sort",
"@tanstack/query" "@tanstack/query"
], ],
+35 -26
View File
@@ -1,28 +1,37 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang='en'>
<head> <head>
<meta charset="utf-8" /> <meta charset='utf-8' />
<base href="/" /> <link rel='icon' href='/favicon.ico' />
<link rel="icon" href="/favicon.ico" /> <meta name='viewport' content='width=device-width, initial-scale=1' />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name='theme-color' content='#121212' />
<meta name="theme-color" content="#101010" /> <meta name='ontime' content='ontime - event timer manager' />
<meta name="ontime" content="ontime - time keeping for live events" /> <link rel='apple-touch-icon' href='/logo192.png' />
<link rel="apple-touch-icon" href="/ontime-logo.png" /> <link
<link rel="icon" type="image/png" href="/ontime-logo.png" /> rel='icon'
<link rel="manifest" href="/site.webmanifest" /> type='image/png'
<link rel="manifest" href="/manifest.json" /> sizes='32x32'
<meta name="robots" content="noindex" /> href='/favicon-32x32.png'
<title>ontime</title> />
<style> <link
body, rel='icon'
html { type='image/png'
background-color: rgba(0, 0, 0, 0) !important; sizes='16x16'
} href='/favicon-16x16.png'
</style> />
</head> <link rel='manifest' href='/site.webmanifest' />
<body> <link rel='manifest' href='/manifest.json' />
<noscript>You need to enable JavaScript to run this app.</noscript> <title>ontime</title>
<div id="root"></div> <style>
<script type="module" src="/src/index.tsx"></script> body,
</body> html {
background-color: rgba(0, 0, 0, 0) !important;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id='root'></div>
<script type='module' src='/src/index.tsx'></script>
</body>
</html> </html>
+16 -12
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "3.11.0", "version": "3.6.0",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
@@ -8,16 +8,15 @@
"@dnd-kit/core": "^6.1.0", "@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0", "@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@emotion/is-prop-valid": "^1.3.1",
"@emotion/react": "^11.10.6", "@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6", "@emotion/styled": "^11.10.6",
"@fontsource/open-sans": "^5.0.28", "@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^7.13.3", "@mantine/hooks": "^7.6.2",
"@react-icons/all-files": "^4.1.0", "@react-icons/all-files": "^4.1.0",
"@sentry/react": "^8.43.0", "@sentry/react": "^8.19.0",
"@tanstack/react-query": "^5.62.7", "@tanstack/react-query": "^5.17.9",
"@tanstack/react-query-devtools": "^5.62.7", "@tanstack/react-query-devtools": "^5.17.9",
"@tanstack/react-table": "^8.20.5", "@tanstack/react-table": "^8.11.3",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.2.0", "axios": "^1.2.0",
"color": "^4.2.3", "color": "^4.2.3",
@@ -27,7 +26,7 @@
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-fast-compare": "^3.2.2", "react-fast-compare": "^3.2.2",
"react-hook-form": "^7.53.1", "react-hook-form": "^7.49.2",
"react-qr-code": "^2.0.12", "react-qr-code": "^2.0.12",
"react-router-dom": "^6.3.0", "react-router-dom": "^6.3.0",
"web-vitals": "^3.1.1", "web-vitals": "^3.1.1",
@@ -40,7 +39,7 @@
"build": "vite build", "build": "vite build",
"build:local": "cross-env NODE_ENV=local vite build", "build:local": "cross-env NODE_ENV=local vite build",
"build:electron": "cross-env NODE_ENV=local vite build", "build:electron": "cross-env NODE_ENV=local vite build",
"build:docker": "cross-env VITE_IS_DOCKER=true vite build", "build:docker": "vite build",
"build:localdocker": "cross-env NODE_ENV=local vite build", "build:localdocker": "cross-env NODE_ENV=local vite build",
"lint": "eslint . --quiet", "lint": "eslint . --quiet",
"test": "vitest", "test": "vitest",
@@ -63,9 +62,13 @@
"devDependencies": { "devDependencies": {
"@sentry/vite-plugin": "^2.16.1", "@sentry/vite-plugin": "^2.16.1",
"@tanstack/eslint-plugin-query": "^5.8.4", "@tanstack/eslint-plugin-query": "^5.8.4",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.1.1",
"@testing-library/user-event": "^14.1.1",
"@types/color": "^3.0.3", "@types/color": "^3.0.3",
"@types/react": "^18.0.26", "@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10", "@types/react-dom": "^18.0.10",
"@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "catalog:", "@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:", "@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
@@ -76,16 +79,17 @@
"eslint-plugin-react": "^7.32.0", "eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^8.0.0", "eslint-plugin-simple-import-sort": "^8.0.0",
"happy-dom": "^16.7.2", "eslint-plugin-testing-library": "^5.9.1",
"jsdom": "^21.1.0",
"ontime-types": "workspace:*", "ontime-types": "workspace:*",
"ontime-utils": "workspace:*", "ontime-utils": "workspace:*",
"prettier": "catalog:", "prettier": "catalog:",
"sass": "^1.57.1", "sass": "^1.57.1",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "^5.2.11", "vite": "^5.2.11",
"vite-plugin-compression2": "^1.3.3", "vite-plugin-compression2": "^0.12.0",
"vite-plugin-svgr": "^4.2.0", "vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^4.3.1", "vite-tsconfig-paths": "^4.3.1",
"vitest": "catalog:" "vitest": "^1.6.0"
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

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

Before

Width:  |  Height:  |  Size: 19 KiB

+1 -10
View File
@@ -1,10 +1 @@
{ {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
"name": "",
"short_name": "",
"icons": [
{ "src": "/ontime-logo.png", "sizes": "295x295", "type": "image/png" }
],
"theme_color": "#2B5ABC",
"background_color": "#101010",
"display": "standalone"
}
+1 -2
View File
@@ -11,7 +11,6 @@ import { connectSocket } from './common/utils/socket';
import theme from './theme/theme'; import theme from './theme/theme';
import { TranslationProvider } from './translation/TranslationProvider'; import { TranslationProvider } from './translation/TranslationProvider';
import AppRouter from './AppRouter'; import AppRouter from './AppRouter';
import { baseURI } from './externals';
connectSocket(); connectSocket();
@@ -20,7 +19,7 @@ function App() {
<ChakraProvider disableGlobalStyle resetCSS theme={theme}> <ChakraProvider disableGlobalStyle resetCSS theme={theme}>
<QueryClientProvider client={ontimeQueryClient}> <QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider> <AppContextProvider>
<BrowserRouter basename={baseURI}> <BrowserRouter>
<div className='App'> <div className='App'>
<ErrorBoundary> <ErrorBoundary>
<TranslationProvider> <TranslationProvider>
+14 -83
View File
@@ -14,38 +14,33 @@ 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';
const Editor = React.lazy(() => import('./features/editors/ProtectedEditor')); const Editor = React.lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet')); const Cuesheet = React.lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
const Operator = React.lazy(() => import('./features/operator/OperatorExport')); const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
const TimerView = React.lazy(() => import('./views/timer/Timer')); const TimerView = React.lazy(() => import('./features/viewers/timer/Timer'));
const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer')); const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock')); 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('./views/timeline/TimelinePage')); const Timeline = React.lazy(() => import('./features/viewers/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));
const STimeline = withPreset(withData(Timeline)); const STimeline = withPreset(withData(Timeline));
const PCuesheet = withPreset(Cuesheet);
const POperator = withPreset(Operator);
const EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper')); const EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport')); const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport'));
@@ -80,85 +75,21 @@ 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 <Route path='/timer' element={<STimer />} />
path='/timer' <Route path='/public' element={<SPublic />} />
element={ <Route path='/minimal' element={<SMinimalTimer />} />
<ViewLoader> <Route path='/clock' element={<SClock />} />
<STimer />
</ViewLoader> <Route path='/countdown' element={<SCountdown />} />
} <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 <Route path='/timeline' element={<STimeline />} />
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 />} />
<Route path='/cuesheet' element={<PCuesheet />} /> <Route path='/cuesheet' element={<Cuesheet />} />
<Route path='/op' element={<POperator />} /> <Route path='/op' element={<Operator />} />
{/*/!* Protected Routes - Elements *!/*/} {/*/!* Protected Routes - Elements *!/*/}
<Route <Route
-85
View File
@@ -1,85 +0,0 @@
import axios from 'axios';
import type {
Automation,
AutomationDTO,
AutomationOutput,
AutomationSettings,
Trigger,
TriggerDTO,
} from 'ontime-types';
import { apiEntryUrl } from './constants';
const automationsPath = `${apiEntryUrl}/automations`;
/**
* HTTP request to get the automations settings
*/
export async function getAutomationSettings(): Promise<AutomationSettings> {
const res = await axios.get(automationsPath);
return res.data;
}
/**
* HTTP request to edit the automations settings
*/
export async function editAutomationSettings(
automationSettings: Partial<AutomationSettings>,
): Promise<AutomationSettings> {
const res = await axios.post(automationsPath, automationSettings);
return res.data;
}
/**
* HTTP request to create a new automation trigger
*/
export async function addTrigger(trigger: TriggerDTO): Promise<Trigger> {
const res = await axios.post(`${automationsPath}/trigger`, trigger);
return res.data;
}
/**
* HTTP request to update an automation trigger
*/
export async function editTrigger(id: string, trigger: Trigger): Promise<Trigger> {
const res = await axios.put(`${automationsPath}/trigger/${id}`, trigger);
return res.data;
}
/**
* HTTP request to delete an automation trigger
*/
export function deleteTrigger(id: string): Promise<void> {
return axios.delete(`${automationsPath}/trigger/${id}`);
}
/**
* HTTP request to create a new automation
*/
export async function addAutomation(automation: AutomationDTO): Promise<Automation> {
const res = await axios.post(`${automationsPath}/automation`, automation);
return res.data;
}
/**
* HTTP request to update a automation
*/
export async function editAutomation(id: string, automation: Automation): Promise<Automation> {
const res = await axios.put(`${automationsPath}/automation/${id}`, automation);
return res.data;
}
/**
* HTTP request to delete a automation
*/
export function deleteAutomation(id: string): Promise<void> {
return axios.delete(`${automationsPath}/automation/${id}`);
}
/**
* HTTP request to test automation output
* The return is irrelevant as we care for the resolution of the promise
*/
export function testOutput(output: AutomationOutput): Promise<void> {
return axios.post(`${automationsPath}/test`, output);
}
+17 -9
View File
@@ -1,11 +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 AUTOMATION = ['automation'];
export const CUSTOM_FIELDS = ['customFields']; export const CUSTOM_FIELDS = ['customFields'];
export const HTTP_SETTINGS = ['httpSettings'];
export const OSC_SETTINGS = ['oscSettings'];
export const PROJECT_DATA = ['project']; export const PROJECT_DATA = ['project'];
export const PROJECT_LIST = ['projectList']; export const PROJECT_LIST = ['projectList'];
export const RUNDOWN = ['rundown']; export const RUNDOWN = ['rundown'];
@@ -15,14 +13,24 @@ export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings']; export const VIEW_SETTINGS = ['viewSettings'];
export const CLIENT_LIST = ['clientList']; export const CLIENT_LIST = ['clientList'];
// API URLs // resolve location
const location = window.location;
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
export const isProduction = import.meta.env.MODE === 'production';
export const isDev = !isProduction;
export const isLocalhost = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
// resolve port
const STATIC_PORT = 4001;
export const serverPort = isProduction ? location.port : STATIC_PORT;
export const serverURL = `${location.protocol}//${location.hostname}:${serverPort}`;
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
export const apiEntryUrl = `${serverURL}/data`; export const apiEntryUrl = `${serverURL}/data`;
export const projectDataURL = `${serverURL}/project`; export const projectDataURL = `${serverURL}/project`;
export const rundownURL = `${serverURL}/events`; export const rundownURL = `${serverURL}/events`;
export const ontimeURL = `${serverURL}/ontime`; export const ontimeURL = `${serverURL}/ontime`;
export const userAssetsPath = 'user'; export const stylesPath = 'external/styles/override.css';
export const cssOverridePath = 'styles/override.css'; export const overrideStylesURL = `${serverURL}/${stylesPath}`;
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
+11 -19
View File
@@ -1,7 +1,7 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types'; import { DatabaseModel, GetInfo, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types';
import { makeCSV, makeTable } from '../../views/cuesheet/cuesheet.utils'; import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import { createBlob, downloadBlob } from './utils'; import { createBlob, downloadBlob } from './utils';
@@ -12,7 +12,7 @@ const dbPath = `${apiEntryUrl}/db`;
* HTTP request to the current DB * HTTP request to the current DB
*/ */
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> { export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
return axios.post(`${dbPath}/download`, { filename }); return axios.post(`${dbPath}/download/`, { filename });
} }
/** /**
@@ -86,14 +86,6 @@ export async function createProject(
return res.data; return res.data;
} }
/**
* HTTP request to create a project file
*/
export async function quickProject(data: QuickStartData): Promise<MessageResponse> {
const res = await axios.post(`${dbPath}/quick`, data);
return res.data;
}
/** /**
* HTTP request to get the list of available project files * HTTP request to get the list of available project files
*/ */
@@ -112,14 +104,6 @@ export async function loadProject(filename: string): Promise<MessageResponse> {
return res.data; return res.data;
} }
/**
* HTTP request to load the demo project file
*/
export async function loadDemo(): Promise<MessageResponse> {
const res = await axios.post(`${dbPath}/demo`);
return res.data;
}
/** /**
* HTTP request to duplicate a project file * HTTP request to duplicate a project file
*/ */
@@ -154,6 +138,14 @@ 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
+21
View File
@@ -0,0 +1,21 @@
import axios, { AxiosResponse } from 'axios';
import { HttpSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const httpPath = `${apiEntryUrl}/http`;
/**
* HTTP request to retrieve http settings
*/
export async function getHTTP(): Promise<HttpSettings> {
const res = await axios.get(httpPath);
return res.data;
}
/**
* HTTP request to mutate http settings
*/
export async function postHTTP(data: HttpSettings): Promise<AxiosResponse<HttpSettings>> {
return axios.post(httpPath, data);
}
+21
View File
@@ -0,0 +1,21 @@
import axios, { AxiosResponse } from 'axios';
import { OSCSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const oscPath = `${apiEntryUrl}/osc`;
/**
* HTTP request to retrieve osc settings
*/
export async function getOSC(): Promise<OSCSettings> {
const res = await axios.get(oscPath);
return res.data;
}
/**
* HTTP request to mutate osc settings
*/
export async function postOSC(data: OSCSettings): Promise<AxiosResponse<OSCSettings>> {
return axios.post(oscPath, data);
}
+1 -16
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { ProjectData, ProjectLogoResponse } from 'ontime-types'; import { ProjectData } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
@@ -19,18 +19,3 @@ export async function getProjectData(): Promise<ProjectData> {
export async function postProjectData(data: ProjectData): Promise<AxiosResponse<ProjectData>> { export async function postProjectData(data: ProjectData): Promise<AxiosResponse<ProjectData>> {
return axios.post(projectPath, data); return axios.post(projectPath, data);
} }
/**
* HTTP request to upload a project logo
*/
export async function uploadProjectLogo(file: File): Promise<AxiosResponse<ProjectLogoResponse>> {
const formData = new FormData();
formData.append('image', file);
const response = await axios.post(`${projectPath}/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached, TransientEventPayload } from 'ontime-types'; import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
@@ -16,7 +16,7 @@ export async function fetchNormalisedRundown(): Promise<RundownCached> {
/** /**
* HTTP request to post new event * HTTP request to post new event
*/ */
export async function requestPostEvent(data: TransientEventPayload): Promise<AxiosResponse<OntimeRundownEntry>> { export async function requestPostEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> {
return axios.post(rundownPath, data); return axios.post(rundownPath, data);
} }
-27
View File
@@ -1,27 +0,0 @@
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;
}
/**
* HTTP request to get a pre-authenticated URL
*/
export async function generateUrl(
baseUrl: string,
path: string,
lock: boolean,
authenticate: boolean,
): Promise<string> {
const res = await axios.post(`${sessionPath}/url`, { baseUrl, path, lock, authenticate });
return res.data.url;
}
-7
View File
@@ -19,10 +19,3 @@ export async function getSettings(): Promise<Settings> {
export async function postSettings(data: Settings): Promise<AxiosResponse<Settings>> { export async function postSettings(data: Settings): Promise<AxiosResponse<Settings>> {
return axios.post(settingsPath, data); return axios.post(settingsPath, data);
} }
/**
* Allows setting the welcome modal dialog state from the clients
*/
export async function postShowWelcomeDialog(show: boolean) {
axios.post(`${settingsPath}/welcomedialog`, { show });
}
-2
View File
@@ -23,8 +23,6 @@ export function maybeAxiosError(error: unknown) {
} }
} }
return `${statusText}: ${data}`; return `${statusText}: ${data}`;
} else if (error instanceof Error) {
return error.message;
} else { } else {
if (typeof error !== 'string') { if (typeof error !== 'string') {
return JSON.stringify(error); return JSON.stringify(error);
@@ -8,5 +8,5 @@
.linkIcon { .linkIcon {
margin-left: $element-inner-spacing; margin-left: $element-inner-spacing;
display: inline-block; display: inline-block;
@include rotate-fourty-five; transform: rotate(45deg);
} }
@@ -0,0 +1,6 @@
.buttonSection {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $element-spacing;
margin-top: -$element-spacing;
}
@@ -15,6 +15,8 @@ import {
import { setClientRemote } from '../../hooks/useSocket'; import { setClientRemote } from '../../hooks/useSocket';
import style from './ClientModal.module.scss';
interface RedirectClientModalProps { interface RedirectClientModalProps {
id: string; id: string;
name?: string; name?: string;
@@ -52,12 +54,14 @@ export function RedirectClientModal(props: RedirectClientModalProps) {
</InputGroup> </InputGroup>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button size='md' variant='ontime-subtle' onClick={onClose}> <div className={style.buttonSection}>
Cancel <Button size='md' variant='ontime-subtle' onClick={onClose}>
</Button> Cancel
<Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}> </Button>
Submit <Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}>
</Button> Submit
</Button>
</div>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>
</Modal> </Modal>
@@ -13,6 +13,8 @@ import {
import { setClientRemote } from '../../hooks/useSocket'; import { setClientRemote } from '../../hooks/useSocket';
import style from './ClientModal.module.scss';
interface RenameClientModalProps { interface RenameClientModalProps {
id: string; id: string;
name?: string; name?: string;
@@ -51,12 +53,14 @@ export function RenameClientModal(props: RenameClientModalProps) {
/> />
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button size='md' variant='ontime-subtle' onClick={onClose}> <div className={style.buttonSection}>
Cancel <Button size='md' variant='ontime-subtle' onClick={onClose}>
</Button> Cancel
<Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}> </Button>
Submit <Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}>
</Button> Submit
</Button>
</div>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>
</Modal> </Modal>
@@ -1,6 +1,5 @@
import { PropsWithChildren, useState } from 'react'; import { PropsWithChildren } 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';
@@ -8,34 +7,26 @@ 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 { copyValue, label, size = 'xs', disabled, children, onClick } = props; const { label, className, size = 'xs', disabled, children } = props;
const [copied, setCopied] = useState(false);
const handleClick = () => { const handleClick = () => copyToClipboard(children as string);
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> <ButtonGroup size={size} isAttached className={className}>
<Button variant='ontime-subtle' tabIndex={-1} onClick={onClick} isDisabled={disabled}> <Button variant='ontime-subtle' tabIndex={-1} isDisabled={disabled}>
{children} {children}
</Button> </Button>
<IconButton <IconButton
aria-label={label} aria-label={label}
icon={copied ? <IoCheckmark /> : <IoCopy />} icon={<IoCopy />}
variant='ontime-filled' variant='ontime-filled'
tabIndex={-1} tabIndex={-1}
onClick={handleClick} onClick={handleClick}
@@ -9,23 +9,32 @@ import style from './DelayIndicator.module.scss';
interface DelayIndicatorProps { interface DelayIndicatorProps {
delayValue?: number; delayValue?: number;
tooltipPrefix?: string;
} }
export default function DelayIndicator(props: DelayIndicatorProps) { export default function DelayIndicator(props: DelayIndicatorProps) {
const { delayValue, tooltipPrefix } = props; const { delayValue } = props;
if (typeof delayValue !== 'number' || delayValue === 0) { if (typeof delayValue === 'number') {
return null; if (delayValue < 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronDown />
</span>
</Tooltip>
);
}
if (delayValue > 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronUp />
</span>
</Tooltip>
);
}
} }
const delayString = tooltipPrefix return null;
? `${tooltipPrefix} ${millisToDelayString(delayValue)}`
: millisToDelayString(delayValue);
return (
<Tooltip openDelay={tooltipDelayFast} label={delayString}>
<span className={style.delaySymbol}>{delayValue < 0 ? <IoChevronDown /> : <IoChevronUp />}</span>
</Tooltip>
);
} }
@@ -1,16 +0,0 @@
.infoLabel {
display: flex;
align-items: center;
gap: $element-spacing;
padding: 1rem;
margin-bottom: 1rem;
background-color: $gray-1100;
border-radius: 2px;
font-size: $inner-section-text-size;
svg {
font-size: 1.5rem;
color: $info-blue;
}
}
@@ -1,13 +0,0 @@
import { PropsWithChildren } from 'react';
import { IoAlertCircle } from '@react-icons/all-files/io5/IoAlertCircle';
import style from './Info.module.scss';
export default function Info({ children }: PropsWithChildren) {
return (
<div className={style.infoLabel}>
<IoAlertCircle />
{children}
</div>
);
}
@@ -16,11 +16,11 @@ export default function Swatch(props: SwatchProps) {
const handleClick = () => { const handleClick = () => {
onClick?.(color); onClick?.(color);
}; };
const classes = cx([style.swatch, isSelected && style.selected, onClick && style.selectable]); const classes = cx([style.swatch, isSelected ? style.selected : null, onClick ? style.selectable : null]);
if (!color) { if (!color) {
return ( return (
<div className={classes} onClick={handleClick}> <div className={`${classes} ${style.center}`} onClick={handleClick}>
<IoBan /> <IoBan />
</div> </div>
); );
@@ -1,60 +0,0 @@
import { useCallback } from 'react';
import { useController, UseControllerProps } from 'react-hook-form';
import { IoEyedrop } from '@react-icons/all-files/io5/IoEyedrop';
import { ViewSettings } from 'ontime-types';
import PopoverPicker from '../../../../common/components/input/popover-picker/PopoverPicker';
import { debounce } from '../../../../common/utils/debounce';
import { cx, getAccessibleColour } from '../../../utils/styleUtils';
import style from './SwatchSelect.module.scss';
interface SwatchPickerProps {
color: string;
isSelected?: boolean;
onChange: (name: string) => void;
alwaysDisplayColor?: boolean;
}
export default function SwatchPicker(props: SwatchPickerProps) {
const { color, onChange, isSelected, alwaysDisplayColor } = props;
const debouncedOnChange = useCallback(
debounce((newValue: string) => {
onChange(newValue);
}, 500),
[onChange],
);
const displayColor = alwaysDisplayColor || isSelected ? color : '';
const { color: iconColor } = getAccessibleColour(displayColor);
return (
<PopoverPicker color={displayColor} onChange={debouncedOnChange}>
<div
className={cx([style.swatch, isSelected && style.selected, style.selectable])}
style={{ backgroundColor: displayColor }}
>
<IoEyedrop color={iconColor} />
</div>
</PopoverPicker>
);
}
export function SwatchPickerRHF(props: UseControllerProps<ViewSettings>) {
const { name, control } = props;
const {
field: { onChange, value },
} = useController({ control, name });
const displayColor = typeof value === 'string' ? value : '';
const { color: iconColor } = getAccessibleColour(displayColor);
return (
<PopoverPicker color={value as string} onChange={onChange}>
<div className={cx([style.swatch, style.selectable])} style={{ backgroundColor: displayColor }}>
<IoEyedrop color={iconColor} />
</div>
</PopoverPicker>
);
}
@@ -1,18 +1,15 @@
.list { .list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem
} }
.swatch { .swatch {
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
aspect-ratio: 1;
border-radius: 99px; border-radius: 99px;
border: 2px solid $gray-1200; border: 2px solid $gray-1200;
color: $ui-white;
display: grid;
place-content: center;
&.selected { &.selected {
border: 2px solid $blue-500; border: 2px solid $blue-500;
@@ -20,9 +17,11 @@
&.selectable { &.selectable {
cursor: pointer; cursor: pointer;
&:hover {
border: 2px solid $blue-300;
}
} }
} }
.center {
display: grid;
place-content: center;
color: $ui-white;
}
@@ -1,7 +1,6 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import Swatch from './Swatch'; import Swatch from './Swatch';
import SwatchPicker from './SwatchPicker';
import style from './SwatchSelect.module.scss'; import style from './SwatchSelect.module.scss';
@@ -44,7 +43,6 @@ export default function SwatchSelect(props: ColourInputProps) {
{colours.map((colour) => ( {colours.map((colour) => (
<Swatch key={colour} color={colour} onClick={setColour} isSelected={value === colour} /> <Swatch key={colour} color={colour} onClick={setColour} isSelected={value === colour} />
))} ))}
<SwatchPicker color={value} onChange={setColour} isSelected={!colours.includes(value)} />
</div> </div>
); );
} }
@@ -1,20 +1,16 @@
.input { .swatch {
color: $gray-200; width: 24px;
border: 1px solid transparent; height: 24px;
border-radius: 0 0 8px 8px; border-radius: 12px;
background-color: $gray-1200; border: 1px solid white;
padding: 0 0.5rem; box-shadow: 0 0 0 1px $gray-300;
font-size: 1rem; cursor: pointer;
height: 2rem;
outline: none;
&:hover { transition-property: box-shadow;
background-color: $gray-1100; transition-duration: $transition-time-action;
}
&:focus {
background-color: $gray-1000;
color: $gray-50;
border: 1px solid $blue-500;
}
} }
.swatch:hover {
box-shadow: 0 0 0 1px $blue-300;
}
@@ -1,22 +1,34 @@
import { PropsWithChildren } from 'react'; import { HexAlphaColorPicker } from 'react-colorful';
import { HexAlphaColorPicker, HexColorInput } from 'react-colorful'; import { useController, UseControllerProps } from 'react-hook-form';
import { Popover, PopoverContent, PopoverTrigger } from '@chakra-ui/react'; import { Popover, PopoverContent, PopoverTrigger } from '@chakra-ui/react';
import { ViewSettings } from 'ontime-types';
import style from './PopoverPicker.module.scss'; import style from './PopoverPicker.module.scss';
export function PopoverPickerRHF(props: UseControllerProps<ViewSettings>) {
const { name, control } = props;
const {
field: { onChange, value },
} = useController({ control, name });
return <PopoverPicker color={value as string} onChange={onChange} />;
}
interface PopoverPickerProps { interface PopoverPickerProps {
color: string; color: string;
onChange: (color: string) => void; onChange: (color: string) => void;
} }
export default function PopoverPicker(props: PropsWithChildren<PopoverPickerProps>) { export default function PopoverPicker(props: PopoverPickerProps) {
const { color, onChange, children } = props; const { color, onChange } = props;
return ( return (
<Popover> <Popover>
<PopoverTrigger>{children}</PopoverTrigger> <PopoverTrigger>
<PopoverContent className={style.small} style={{ borderRadius: '9px', width: 'auto' }}> <div className={style.swatch} style={{ backgroundColor: color }} />
</PopoverTrigger>
<PopoverContent style={{ width: 'auto' }}>
<HexAlphaColorPicker color={color} onChange={onChange} /> <HexAlphaColorPicker color={color} onChange={onChange} />
<HexColorInput color={color} onChange={onChange} className={style.input} prefixed />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );
@@ -15,8 +15,6 @@ export default function useReactiveTextInput(
options?: { options?: {
submitOnEnter?: boolean; submitOnEnter?: boolean;
submitOnCtrlEnter?: boolean; submitOnCtrlEnter?: boolean;
onCancelUpdate?: () => void;
allowSubmitSameValue?: boolean;
}, },
): UseReactiveTextInputReturn { ): UseReactiveTextInputReturn {
const [text, setText] = useState<string>(initialText); const [text, setText] = useState<string>(initialText);
@@ -49,9 +47,7 @@ export default function useReactiveTextInput(
const handleSubmit = useCallback( const handleSubmit = useCallback(
(valueToSubmit: string) => { (valueToSubmit: string) => {
// No need to update if it hasn't changed // No need to update if it hasn't changed
if (valueToSubmit === initialText && !options?.allowSubmitSameValue) { if (valueToSubmit !== initialText) {
options?.onCancelUpdate?.();
} else {
const cleanVal = valueToSubmit.trim(); const cleanVal = valueToSubmit.trim();
submitCallback(cleanVal); submitCallback(cleanVal);
if (cleanVal !== valueToSubmit) { if (cleanVal !== valueToSubmit) {
@@ -60,7 +56,7 @@ export default function useReactiveTextInput(
} }
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
}, },
[initialText, options, ref, submitCallback], [initialText, ref, submitCallback],
); );
/** /**
@@ -70,9 +66,8 @@ export default function useReactiveTextInput(
const handleEscape = useCallback(() => { const handleEscape = useCallback(() => {
// No need to update if it hasn't changed // No need to update if it hasn't changed
setText(initialText); setText(initialText);
options?.onCancelUpdate?.();
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
}, [initialText, options, ref]); }, [initialText, ref]);
const keyHandler = useMemo(() => { const keyHandler = useMemo(() => {
const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]]; const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]];
@@ -5,7 +5,6 @@ import { millisToString, parseUserTime } from 'ontime-utils';
import { useEmitLog } from '../../../stores/logger'; import { useEmitLog } from '../../../stores/logger';
interface TimeInputProps<T extends string> { interface TimeInputProps<T extends string> {
id?: T;
name: T; name: T;
submitHandler: (field: T, value: string) => void; submitHandler: (field: T, value: string) => void;
time?: number; time?: number;
@@ -16,7 +15,7 @@ interface TimeInputProps<T extends string> {
} }
export default function TimeInput<T extends string>(props: TimeInputProps<T>) { export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
const { id, name, submitHandler, time = 0, placeholder, disabled, align = 'center', className } = props; const { name, submitHandler, time = 0, placeholder, disabled, align = 'center', className } = props;
const { emitError } = useEmitLog(); const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>(''); const [value, setValue] = useState<string>('');
@@ -55,7 +54,6 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
// we dont know the values in the rundown, escalate to handler // we dont know the values in the rundown, escalate to handler
if (newValue.startsWith('p') || newValue.startsWith('+')) { if (newValue.startsWith('p') || newValue.startsWith('+')) {
submitHandler(name, newValue); submitHandler(name, newValue);
return true;
} }
const valueInMillis = parseUserTime(newValue); const valueInMillis = parseUserTime(newValue);
@@ -119,7 +117,6 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
return ( return (
<Input <Input
id={id}
disabled={disabled} disabled={disabled}
size='sm' size='sm'
ref={inputRef} ref={inputRef}
@@ -2,8 +2,6 @@ $input-delayed-border-color: $ontime-delay-text;
.timeInput { .timeInput {
border: 1px solid transparent; border: 1px solid transparent;
color: $label-gray;
&.delayed { &.delayed {
border: 1px solid $input-delayed-border-color; border: 1px solid $input-delayed-border-color;
} }
@@ -1,6 +1,6 @@
@use '../../../theme/viewerDefs' as *; @use '../../../theme/viewerDefs' as *;
$progress-bar-size: 1.5rem; $progress-bar-size: 12px;
$progress-bar-br: 3px; $progress-bar-br: 3px;
.multiprogress-bar { .multiprogress-bar {
@@ -53,6 +53,3 @@ $progress-bar-br: 3px;
.multiprogress-bar__bg-danger { .multiprogress-bar__bg-danger {
flex-shrink: 0; flex-shrink: 0;
} }
.multiprogress-bar__bg-danger--overtime {
background: var(--timer-overtime-color-override, $timer-finished-color);
}
@@ -1,7 +1,6 @@
import { MaybeNumber } from 'ontime-types'; import { MaybeNumber } from 'ontime-types';
import { getProgress } from '../../utils/getProgress'; import { getProgress } from '../../utils/getProgress';
import { cx } from '../../utils/styleUtils';
import './MultiPartProgressBar.scss'; import './MultiPartProgressBar.scss';
@@ -14,7 +13,6 @@ interface MultiPartProgressBar {
danger?: MaybeNumber; danger?: MaybeNumber;
dangerColor: string; dangerColor: string;
hidden?: boolean; hidden?: boolean;
hideOvertime?: boolean;
ignoreCssOverride?: boolean; ignoreCssOverride?: boolean;
className?: string; className?: string;
} }
@@ -29,7 +27,6 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
danger, danger,
dangerColor, dangerColor,
hidden, hidden,
hideOvertime,
ignoreCssOverride, ignoreCssOverride,
className = '', className = '',
} = props; } = props;
@@ -37,17 +34,12 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
const percentRemaining = 100 - getProgress(now, complete); const percentRemaining = 100 - getProgress(now, complete);
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0; const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0; const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
const isOvertime = now !== null && now < 0;
const showOvertime = isOvertime && !hideOvertime;
return ( return (
<div <div
className={cx([ className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${
'multiprogress-bar', ignoreCssOverride ? 'multiprogress-bar--ignore-css-override' : ''
hidden && 'multiprogress-bar--hidden', } ${className}`}
ignoreCssOverride && 'multiprogress-bar--ignore-css-override',
className,
])}
> >
{now !== null && ( {now !== null && (
<> <>
@@ -58,8 +50,8 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }} style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
/> />
<div <div
className={cx(['multiprogress-bar__bg-danger', showOvertime && 'multiprogress-bar__bg-danger--overtime'])} className='multiprogress-bar__bg-danger'
style={{ width: `${dangerWidth}%`, backgroundColor: showOvertime ? undefined : dangerColor }} style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
/> />
</div> </div>
<div className='multiprogress-bar__indicator'> <div className='multiprogress-bar__indicator'>
@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react';
import { IoApps } from '@react-icons/all-files/io5/IoApps'; import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline'; import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity'; import { debounce } from '../../utils/debounce';
import { cx } from '../../utils/styleUtils';
import style from './NavigationMenu.module.scss'; import style from './NavigationMenu.module.scss';
@@ -13,10 +13,32 @@ interface FloatingNavigationProps {
export default function FloatingNavigation(props: FloatingNavigationProps) { export default function FloatingNavigation(props: FloatingNavigationProps) {
const { toggleMenu, toggleSettings } = props; const { toggleMenu, toggleSettings } = props;
const isButtonShown = useFadeOutOnInactivity(); const [showButton, setShowButton] = useState(false);
// show on mouse move
useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null;
const setShowMenuTrue = () => {
setShowButton(true);
if (fadeOut) {
clearTimeout(fadeOut);
}
fadeOut = setTimeout(() => setShowButton(false), 3000);
};
const debouncedShowMenu = debounce(setShowMenuTrue, 1000);
document.addEventListener('mousemove', debouncedShowMenu);
return () => {
document.removeEventListener('mousemove', debouncedShowMenu);
if (fadeOut) {
clearTimeout(fadeOut);
}
};
}, []);
return ( return (
<div className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}> <div className={`${style.buttonContainer} ${!showButton ? style.hidden : ''}`}>
<button <button
onClick={toggleMenu} onClick={toggleMenu}
aria-label='toggle menu' aria-label='toggle menu'
@@ -7,38 +7,24 @@ $icon-color: $ui-white;
$button-bg: $gray-1050; $button-bg: $gray-1050;
$button-size: 3rem; $button-size: 3rem;
.fadeable {
opacity: 1;
transition-property: opacity;
transition-duration: 0.3s;
&.hidden {
opacity: 0;
}
}
.lockIcon {
font-size: 1.5rem;
color: $active-indicator;
padding: 0.5em;
position: fixed;
left: 0;
top: 0;
z-index: 12;
}
.buttonContainer { .buttonContainer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
row-gap: 1rem; row-gap: 1rem;
padding: 0.5em; padding: 0.5em;
transition-property: opacity;
transition-duration: 0.3s;
opacity: 1;
position: fixed; position: fixed;
left: 0; left: 0;
top: 0; top: 0;
z-index: 12; z-index: 12;
&.hidden {
opacity: 0;
}
} }
.button { .button {
@@ -86,33 +72,9 @@ $button-size: 3rem;
.linkIcon { .linkIcon {
margin-left: auto; margin-left: auto;
@include rotate-fourty-five; transform: rotate(45deg);
} }
.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;
}
@@ -1,4 +1,4 @@
import { memo, PropsWithChildren } from 'react'; import { memo, PropsWithChildren, useRef } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { import {
@@ -17,17 +17,15 @@ import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline'; import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical'; import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { isLocalhost } from '../../../externals';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import { useElectronEvent } from '../../hooks/useElectronEvent'; import useClickOutside from '../../hooks/useClickOutside';
import useInfo from '../../hooks-query/useInfo'; import useElectronEvent from '../../hooks/useElectronEvent';
import { useClientStore } from '../../stores/clientStore'; import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
import { isKeyEnter } from '../../utils/keyEvent'; import { isKeyEnter } from '../../utils/keyEvent';
import { handleLinks, linkToOtherHost, openLink } from '../../utils/linkUtils'; import { handleLinks } 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';
@@ -44,15 +42,19 @@ function NavigationMenu(props: NavigationMenuProps) {
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure(); const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
const { fullscreen, toggle } = useFullscreen(); const { fullscreen, toggle } = useFullscreen();
const { mirror, toggleMirror } = useViewOptionsStore(); const { toggleMirror } = useViewOptionsStore();
const location = useLocation(); const location = useLocation();
const menuRef = useRef<HTMLDivElement | null>(null);
useClickOutside(menuRef, () => onClose);
return createPortal( return createPortal(
<div id='navigation-menu-portal'> <div id='navigation-menu-portal' ref={menuRef}>
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} /> <RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'> <Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
<DrawerOverlay /> <DrawerOverlay />
<DrawerContent maxWidth='22rem'> <DrawerContent>
<DrawerHeader> <DrawerHeader>
<DrawerCloseButton size='lg' /> <DrawerCloseButton size='lg' />
Ontime Ontime
@@ -60,7 +62,7 @@ function NavigationMenu(props: NavigationMenuProps) {
<DrawerBody padding={0}> <DrawerBody padding={0}>
<div className={style.buttonsContainer}> <div className={style.buttonsContainer}>
<div <div
className={cx([style.link, fullscreen && style.current])} className={style.link}
tabIndex={0} tabIndex={0}
role='button' role='button'
onClick={toggle} onClick={toggle}
@@ -72,7 +74,7 @@ function NavigationMenu(props: NavigationMenuProps) {
{fullscreen ? <IoContract /> : <IoExpand />} {fullscreen ? <IoContract /> : <IoExpand />}
</div> </div>
<div <div
className={cx([style.link, mirror && style.current])} className={style.link}
tabIndex={0} tabIndex={0}
role='button' role='button'
onClick={() => toggleMirror()} onClick={() => toggleMirror()}
@@ -118,7 +120,6 @@ function NavigationMenu(props: NavigationMenuProps) {
{route.label} {route.label}
</ClientLink> </ClientLink>
))} ))}
{isLocalhost && <OtherAddresses currentLocation={location.pathname} />}
</DrawerBody> </DrawerBody>
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
@@ -127,46 +128,6 @@ 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;
@@ -1,16 +0,0 @@
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
import { cx } from '../../utils/styleUtils';
import style from './NavigationMenu.module.scss';
export default function ViewLockedIcon() {
const isLockIconShown = useFadeOutOnInactivity();
return (
<div className={cx([style.fadeable, style.lockIcon, !isLockIconShown && style.hidden])}>
<IoLockClosedOutline />
</div>
);
}
@@ -1,27 +1,21 @@
import { memo } from 'react'; import { memo, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useDisclosure } from '@chakra-ui/react'; import { useDisclosure } from '@chakra-ui/react';
import FloatingNavigation from './FloatingNavigation'; import FloatingNavigation from './FloatingNavigation';
import NavigationMenu from './NavigationMenu'; import NavigationMenu from './NavigationMenu';
import useViewEditor from './useViewEditor';
import ViewLockedIcon from './ViewLockedIcon';
interface ViewNavigationMenuProps {
isLockable?: boolean;
}
function ViewNavigationMenu(props: ViewNavigationMenuProps) {
const { isLockable } = props;
function ViewNavigationMenu() {
const [searchParams, setSearchParams] = useSearchParams();
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure(); const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable });
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
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,26 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
interface EditorVisibilityOptions {
isLockable?: boolean;
}
export default function useViewEditor({ isLockable }: EditorVisibilityOptions) {
const [searchParams, setSearchParams] = useSearchParams();
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
const isViewLocked = useMemo(() => {
if (!isLockable) {
return false;
}
return isStringBoolean(searchParams.get('locked'));
}, [isLockable, searchParams]);
return { showEditFormDrawer, isViewLocked };
}
@@ -1,13 +1,9 @@
.container { .container {
width: 100%;
height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding-top: 25vh; padding-top: 25vh;
background: $ui-black;
color: $ontime-color; color: $ontime-color;
font-weight: 200; font-weight: 200;
font-size: 3rem; font-size: 3rem;
@@ -46,4 +42,4 @@
to { to {
background: rgba($red-500, 0); background: rgba($red-500, 0);
} }
} }
@@ -1,6 +1,5 @@
import { PropsWithChildren, useCallback, useContext } from 'react'; import { PropsWithChildren, useCallback, useContext } from 'react';
import { isLocalhost } from '../../../externals';
import { AppContext } from '../../context/AppContext'; import { AppContext } from '../../context/AppContext';
import PinPage from './PinPage'; import PinPage from './PinPage';
@@ -10,6 +9,7 @@ interface ProtectRouteProps {
} }
export default function ProtectRoute({ permission, children }: PropsWithChildren<ProtectRouteProps>) { export default function ProtectRoute({ permission, children }: PropsWithChildren<ProtectRouteProps>) {
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
const { editorAuth, operatorAuth, validate } = useContext(AppContext); const { editorAuth, operatorAuth, validate } = useContext(AppContext);
const handleValidation = useCallback( const handleValidation = useCallback(
@@ -29,7 +29,7 @@ export default function ProtectRoute({ permission, children }: PropsWithChildren
return false; return false;
}; };
if (isLocalhost || hasRelevantAuth()) { if (isLocal || hasRelevantAuth()) {
// eslint-disable-next-line react/jsx-no-useless-fragment -- trying to make typescript happy // eslint-disable-next-line react/jsx-no-useless-fragment -- trying to make typescript happy
return <>{children}</>; return <>{children}</>;
} }
@@ -1,20 +0,0 @@
@use '../../../theme/viewerDefs' as *;
/* share the same style as a page layout */
.page {
margin: 0;
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100vh;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color);
color: var(--color-override, $viewer-color);
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
display: flex;
flex-direction: column;
align-items: center;
padding-top: 5rem;
}
@@ -1,20 +0,0 @@
import { CSSProperties } from 'react';
import Empty from './Empty';
import style from './EmptyPage.module.scss';
interface EmptyPageProps {
text?: string;
style?: CSSProperties;
}
export default function EmptyPage(props: EmptyPageProps) {
const { text, ...rest } = props;
return (
<div className={style.page}>
<Empty text={text} {...rest} />
</div>
);
}
@@ -1,12 +1,10 @@
@use '../../../theme/viewerDefs' as *; @use '../../../theme/viewerDefs' as *;
$title-primary-size: clamp(1.5rem, 3vw, 3rem);
$title-secondary-size: clamp(1rem, 2vw, 2.25rem);
.title-card { .title-card {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem;
} }
.title-card__title, .title-card__title,
@@ -18,13 +16,13 @@ $title-secondary-size: clamp(1rem, 2vw, 2.25rem);
.title-card__title { .title-card__title {
font-weight: 600; font-weight: 600;
font-size: $title-primary-size; font-size: clamp(1.5rem, 3vw, 3rem);
color: var(--color-override, $viewer-color); color: var(--color-override, $viewer-color);
line-height: 1.2em; line-height: 1.2em;
} }
.title-card__secondary { .title-card__secondary {
font-size: $title-secondary-size; font-size: clamp(1rem, 2vw, 2.25rem);
color: var(--secondary-color-override, $viewer-secondary-color); color: var(--secondary-color-override, $viewer-secondary-color);
line-height: 1.2em; line-height: 1.2em;
} }
@@ -33,7 +31,7 @@ $title-secondary-size: clamp(1rem, 2vw, 2.25rem);
position: absolute; position: absolute;
right: 1rem; right: 1rem;
top: 0.5rem; top: 0.5rem;
font-size: $timer-label-size; font-size: clamp(1rem, 1.5vw, 1.5rem);
color: var(--secondary-color-override, $viewer-secondary-color); color: var(--secondary-color-override, $viewer-secondary-color);
margin-left: auto; margin-left: auto;
text-transform: uppercase; text-transform: uppercase;
@@ -5,7 +5,7 @@ import { useTranslation } from '../../../translation/TranslationProvider';
import './TitleCard.scss'; import './TitleCard.scss';
interface TitleCardProps { interface TitleCardProps {
title?: string; title: string;
label?: 'now' | 'next'; label?: 'now' | 'next';
secondary?: string; secondary?: string;
className?: string; className?: string;
@@ -1,11 +0,0 @@
import { projectLogoPath } from '../../api/constants';
interface ViewLogoProps {
name: string;
className: string;
}
export default function ViewLogo(props: ViewLogoProps) {
const { name, className } = props;
return <img src={`${projectLogoPath}/${name}`} className={className} />;
}
@@ -1,27 +0,0 @@
.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;
}
}
@@ -1,30 +0,0 @@
import { useState } from 'react';
import SwatchPicker from '../input/colour-input/SwatchPicker';
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));
return (
<div className={style.inline}>
<SwatchPicker color={colour} onChange={setColour} alwaysDisplayColor />
<span>{colour}</span>
<input type='hidden' name={name} value={colour} />
</div>
);
}
@@ -13,11 +13,9 @@ 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 {
@@ -25,8 +23,8 @@ interface EditFormInputProps {
} }
export default function ParamInput(props: EditFormInputProps) { export default function ParamInput(props: EditFormInputProps) {
const { paramField } = props;
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { paramField } = props;
const { id, type, defaultValue } = paramField; const { id, type, defaultValue } = paramField;
if (type === 'persist') { if (type === 'persist') {
@@ -83,12 +81,6 @@ 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;
@@ -109,21 +101,25 @@ function MultiOption(props: EditFormMultiOptionProps) {
const { paramField } = props; const { paramField } = props;
const { id, defaultValue } = paramField; const { id, defaultValue } = paramField;
const optionFromParams = searchParams.getAll(id); const optionFromParams = (searchParams.get(id) ?? '').toLocaleLowerCase();
const [paramState, setParamState] = useState<string[]>(optionFromParams || defaultValue || ['']); const defaultOptionValue = optionFromParams || defaultValue?.toLocaleLowerCase() || '';
const [paramState, setParamState] = useState<string>(defaultOptionValue);
return ( return (
<> <>
<input name={id} hidden readOnly value={paramState} /> <input name={id} hidden readOnly value={paramState} />
<Menu isLazy closeOnSelect={false} variant='ontime-on-dark'> <Menu isLazy closeOnSelect={false} variant='ontime-on-dark'>
<MenuButton as={Button} variant='ontime-subtle-white' position='relative' width='fit-content' fontWeight={400}> <MenuButton as={Button} variant='ontime-subtle-white' position='relative' width='fit-content' fontWeight={400}>
{paramField.title} <IoChevronDown style={{ display: 'inline' }} /> {paramField.title}
</MenuButton> </MenuButton>
<MenuList overflow='auto' maxHeight='200px'> <MenuList>
<MenuOptionGroup <MenuOptionGroup
type='checkbox' type='checkbox'
value={paramState} value={paramState.split('_')}
onChange={(value) => setParamState(Array.isArray(value) ? value : [value])} onChange={(value) => {
setParamState(typeof value === 'object' ? value.filter((v) => v !== '').join('_') : value);
}}
> >
{Object.values(paramField.values).map((option) => { {Object.values(paramField.values).map((option) => {
const { value, label } = option; const { value, label } = option;
@@ -8,11 +8,39 @@
} }
} }
.sectionList { .label {
font-size: $inner-section-text-size;
color: $label-gray;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100%; gap: 0.25rem;
gap: 2rem; }
overflow-y: scroll;
padding-right: 0.5rem; .section {
color: $ui-white;
font-size: 1rem;
&:not(:first-child) {
margin-top: 2rem;
}
}
.fieldSet {
display: flex;
padding: $section-spacing 0;
flex-direction: column;
gap: $element-spacing;
margin-left: 0.5rem;
}
.title {
font-size: $inner-section-text-size;
display: block;
width: 100%;
}
.description {
font-size: $inner-section-text-size;
display: block;
color: $modal-note-color;
} }
@@ -12,26 +12,13 @@ import {
useDisclosure, useDisclosure,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import useViewSettings from '../../../common/hooks-query/useViewSettings'; import ParamInput from './ParamInput';
import Info from '../info/Info'; import { isSection, ViewOption } from './types';
import { ViewOption } from './types';
import ViewParamsSection from './ViewParamsSection';
import style from './ViewParamsEditor.module.scss'; 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,46 +27,23 @@ const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOp
// Convert paramFields to an object that contains default values // Convert paramFields to an object that contains default values
const defaultValues: Record<string, string> = {}; const defaultValues: Record<string, string> = {};
paramFields.forEach((section) => { paramFields.forEach((option) => {
section.options.forEach((option) => { if (!isSection(option)) {
defaultValues[option.id] = String(option.defaultValue); defaultValues[option.id] = String(option.defaultValue);
}
// extract persisted values // extract persisted values
if ('type' in option && option.type === 'persist') { if ('type' in option && option.type === 'persist') {
newSearchParams.set(option.id, option.value); newSearchParams.set(option.id, option.value);
} }
});
}); });
// 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) { if (typeof value === 'string' && value.length && defaultValues[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); newSearchParams.set(id, value);
} }
} });
return newSearchParams; return newSearchParams;
}; };
@@ -90,8 +54,6 @@ interface EditFormDrawerProps {
// TODO: this is a good candidate for memoisation, but needs the paramFields to be stable // TODO: this is a good candidate for memoisation, but needs the paramFields to be stable
export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) { export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings();
const { isOpen, onClose, onOpen } = useDisclosure(); const { isOpen, onClose, onOpen } = useDisclosure();
useEffect(() => { useEffect(() => {
@@ -120,6 +82,8 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget)); const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions); const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
setSearchParams(newSearchParams); setSearchParams(newSearchParams);
onClose();
}; };
return ( return (
@@ -132,16 +96,30 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
</DrawerHeader> </DrawerHeader>
<DrawerBody> <DrawerBody>
{viewSettings.overrideStyles && <Info>This view style is being modified by a custom CSS file.</Info>} <form id='edit-params-form' onSubmit={onParamsFormSubmit}>
<form id='edit-params-form' onSubmit={onParamsFormSubmit} className={style.sectionList}> {viewOptions.map((option) => {
{viewOptions.map((section) => ( if (isSection(option)) {
<ViewParamsSection return (
key={section.title} <div key={option.section} className={style.section}>
title={section.title} {option.section}
collapsible={section.collapsible} </div>
options={section.options} );
/> }
))}
if (option.type === 'persist') {
return null;
}
return (
<div key={option.title} className={style.fieldSet}>
<label className={style.label}>
<span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span>
<ParamInput key={option.title} paramField={option} />
</label>
</div>
);
})}
</form> </form>
</DrawerBody> </DrawerBody>
@@ -1,50 +0,0 @@
.section {
color: $ui-white;
font-size: 1rem;
}
.sectionHeader {
display: flex;
justify-content: space-between;
align-items: center;
height: 2rem;
&.collapsible {
cursor: pointer;
&:hover {
border-bottom: 1px solid $white-10;
}
}
}
.label {
margin-top: $section-spacing;
font-size: $inner-section-text-size;
color: $label-gray;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.title {
font-size: $inner-section-text-size;
display: block;
width: 100%;
}
.description {
font-size: $inner-section-text-size;
display: block;
color: $modal-note-color;
}
.closed {
transition: rotate $transition-time-feedback;
rotate: 0deg;
}
.open {
transition: rotate $transition-time-feedback;
rotate: 180deg;
}
@@ -1,54 +0,0 @@
import { useLocalStorage } from '@mantine/hooks';
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
import { cx } from '../../utils/styleUtils';
import ParamInput from './ParamInput';
import { type ParamField } from './types';
import style from './ViewParamsSection.module.scss';
interface ViewParamsSectionProps {
title: string;
collapsible?: boolean;
options: ParamField[];
}
export default function ViewParamsSection(props: ViewParamsSectionProps) {
const { title, collapsible, options } = props;
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
const handleCollapse = () => {
if (collapsible) {
setCollapsed((prev) => !prev);
}
};
return (
<section className={style.section}>
<div className={cx([style.sectionHeader, collapsible && style.collapsible])} onClick={handleCollapse}>
{title}
{collapsible && <IoChevronDown className={cx([collapsed ? style.closed : style.open])} />}
</div>
{!collapsed && (
<>
{options.map((option) => {
if (option.type === 'persist') {
return null;
}
return (
<label key={option.title} className={style.label}>
<span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span>
<ParamInput paramField={option} />
</label>
);
})}
</>
)}
</section>
);
}
@@ -38,14 +38,3 @@ export const showLeadingZeros: ParamField = {
type: 'boolean', type: 'boolean',
defaultValue: false, defaultValue: false,
}; };
export enum OptionTitle {
ClockOptions = 'Clock Options',
TimerOptions = 'Timer Options',
DataSources = 'Data sources',
ElementVisibility = 'Element visibility',
BehaviourOptions = 'View behaviour',
StyleOverride = 'View style override',
Animation = 'View animation',
Schedule = 'Schedule options',
}
@@ -1,4 +1,6 @@
import { OptionTitle } from './constants'; type ParamSection = {
section: string;
};
type BaseField = { type BaseField = {
id: string; id: string;
@@ -21,14 +23,15 @@ 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 &
(OptionsField | MultiOptionsField | StringField | NumberField | BooleanField | ColourField | PersistedField); (StringField | BooleanField | NumberField | OptionsField | MultiOptionsField | PersistedField);
export type ViewOption = ParamSection | ParamField;
export type ViewOption = { /**
title: OptionTitle; * Type assertion utility checks whether an entry is a section separator
options: ParamField[]; */
collapsible?: boolean; export function isSection(entry: ViewOption): entry is ParamSection {
}; return 'section' in entry;
}
@@ -1,34 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { dayInMs } from 'ontime-utils';
import { version } from '../../../../../package.json';
import { isLocalhost } from '../../externals';
import { APP_VERSION } from '../api/constants';
import { getLatestVersion, HasUpdate } from '../api/external';
const placeholder: HasUpdate & { hasUpdates: boolean } = { url: '', version: '', hasUpdates: false };
export default function useAppVersion() {
const {
data: fetchData,
status,
isFetching,
isError,
refetch,
} = useQuery({
queryKey: APP_VERSION,
queryFn: getLatestVersion,
placeholderData: (previousData, _previousQuery) => previousData,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
staleTime: dayInMs,
enabled: isLocalhost,
});
const hasUpdates = fetchData?.version && !fetchData.version.includes(version);
const data = fetchData ? { ...fetchData, hasUpdates } : placeholder;
return { data, placeholder, status, isFetching, isError, refetch };
}
@@ -1,34 +0,0 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { editAutomationSettings, getAutomationSettings } from '../api/automation';
import { AUTOMATION } from '../api/constants';
import { logAxiosError } from '../api/utils';
import { automationPlaceholderSettings } from '../models/AutomationSettings';
import { ontimeQueryClient } from '../queryClient';
export default function useAutomationSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: AUTOMATION,
queryFn: getAutomationSettings,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch };
}
export function useAutomationSettingsMutation() {
const { isPending, mutateAsync } = useMutation({
mutationFn: editAutomationSettings,
onError: (error) => logAxiosError('Error saving Automation settings', error),
onSuccess: (data) => {
ontimeQueryClient.setQueryData(AUTOMATION, data);
},
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: AUTOMATION }),
});
return { isPending, mutateAsync };
}
@@ -0,0 +1,34 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { HTTP_SETTINGS } from '../api/constants';
import { getHTTP, postHTTP } from '../api/http';
import { logAxiosError } from '../api/utils';
import { httpPlaceholder } from '../models/Http';
import { ontimeQueryClient } from '../queryClient';
export function useHttpSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: HTTP_SETTINGS,
queryFn: getHTTP,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? httpPlaceholder, status, isFetching, isError, refetch };
}
export function usePostHttpSettings() {
const { isPending, mutateAsync } = useMutation({
mutationFn: postHTTP,
onError: (error) => logAxiosError('Error saving HTTP settings', error),
onSuccess: (res) => {
ontimeQueryClient.setQueryData(HTTP_SETTINGS, res.data);
},
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: HTTP_SETTINGS }),
});
return { isPending, mutateAsync };
}
@@ -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/session'; import { getInfo } from '../api/db';
import { ontimePlaceholderInfo } from '../models/Info'; import { ontimePlaceholderInfo } from '../models/Info';
export default function useInfo() { export default function useInfo() {
@@ -0,0 +1,34 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { OSC_SETTINGS } from '../api/constants';
import { getOSC, postOSC } from '../api/osc';
import { logAxiosError } from '../api/utils';
import { oscPlaceholderSettings } from '../models/OscSettings';
import { ontimeQueryClient } from '../queryClient';
export default function useOscSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: OSC_SETTINGS,
queryFn: getOSC,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? oscPlaceholderSettings, status, isFetching, isError, refetch };
}
export function useOscSettingsMutation() {
const { isPending, mutateAsync } = useMutation({
mutationFn: postOSC,
onError: (error) => logAxiosError('Error saving OSC settings', error),
onSuccess: (res) => {
ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data);
},
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
});
return { isPending, mutateAsync };
}
@@ -1,4 +1,3 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { ProjectFileListResponse } from 'ontime-types'; import { ProjectFileListResponse } from 'ontime-types';
@@ -23,23 +22,3 @@ export function useProjectList() {
}); });
return { data: data ?? placeholderProjectList, status, refetch }; return { data: data ?? placeholderProjectList, status, refetch };
} }
export function useOrderedProjectList() {
const response = useProjectList();
const { files, lastLoadedProject } = response.data;
const reorderedProjectFiles = useMemo(() => {
if (!files.length) return [];
const currentlyLoadedIndex = files.findIndex((project) => project.filename === lastLoadedProject);
if (currentlyLoadedIndex === -1) return files;
const projectFiles = [...files];
const current = projectFiles.splice(currentlyLoadedIndex, 1)[0];
return [current, ...projectFiles];
}, [files, lastLoadedProject]);
return { ...response, data: { reorderedProjectFiles, lastLoadedProject: response.data.lastLoadedProject } };
}
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types'; import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
@@ -6,8 +6,6 @@ import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { RUNDOWN } from '../api/constants'; import { RUNDOWN } from '../api/constants';
import { fetchNormalisedRundown } from '../api/rundown'; import { fetchNormalisedRundown } from '../api/rundown';
import useProjectData from './useProjectData';
// revision is -1 so that the remote revision is higher // revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 }; const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 };
@@ -26,9 +24,7 @@ export default function useRundown() {
export function useFlatRundown() { export function useFlatRundown() {
const { data, status } = useRundown(); const { data, status } = useRundown();
const { data: projectData } = useProjectData();
const loadedProject = useRef<string>('');
const [prevRevision, setPrevRevision] = useState<number>(-1); const [prevRevision, setPrevRevision] = useState<number>(-1);
const [flatRunDown, setFlatRunDown] = useState<OntimeRundown>([]); const [flatRunDown, setFlatRunDown] = useState<OntimeRundown>([]);
@@ -41,14 +37,5 @@ export function useFlatRundown() {
} }
}, [data.order, data.revision, data.rundown, prevRevision]); }, [data.order, data.revision, data.rundown, prevRevision]);
// TODO: should we have a project id field?
// invalidate current version if project changes
useEffect(() => {
if (projectData?.title !== loadedProject.current) {
setPrevRevision(-1);
loadedProject.current = projectData?.title ?? '';
}
}, [projectData]);
return { data: flatRunDown, status }; return { data: flatRunDown, status };
} }
@@ -0,0 +1,46 @@
import { act, renderHook } from '@testing-library/react';
import useClickOutside from '../useClickOutside';
describe('useClickOutside', () => {
let target: HTMLElement;
let anotherElement: HTMLElement;
beforeAll(() => {
target = global.document.createElement('div');
global.document.body.appendChild(target);
anotherElement = global.document.createElement('div');
global.document.body.appendChild(anotherElement);
});
it('should trigger clicking outside', () => {
const ref = { current: target };
const callback = vi.fn();
renderHook(() => useClickOutside(ref, callback));
act(() => {
global.document.dispatchEvent(new Event('click'));
});
expect(callback).toHaveBeenCalled();
act(() => {
anotherElement.click();
});
expect(callback).toHaveBeenCalledTimes(2);
});
it('should not trigger clicking inside', () => {
const ref = { current: target };
const callback = vi.fn();
renderHook(() => useClickOutside(ref, callback));
act(() => {
target.click();
});
expect(callback).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,25 @@
import { RefObject, useEffect } from 'react';
type ClickOutsideEventHandler = (event: MouseEvent) => void;
export default function useClickOutside<T extends HTMLElement = HTMLElement>(
ref: RefObject<T>,
callback: ClickOutsideEventHandler,
) {
useEffect(() => {
function handleClick(event: MouseEvent) {
const element = ref?.current;
// Do nothing if clicking ref's element or descendent element
if (!element || element.contains(event.target as Node)) {
return;
}
callback(event);
}
document.addEventListener('click', handleClick);
return () => {
document.removeEventListener('click', handleClick);
};
}, [ref, callback]);
}
+4 -11
View File
@@ -4,23 +4,16 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { useClientStore } from '../stores/clientStore'; import { useClientStore } from '../stores/clientStore';
import { socketSendJson } from '../utils/socket'; import { socketSendJson } from '../utils/socket';
import { useIsOnline } from './useSocket';
export const useClientPath = () => { export const useClientPath = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { pathname, search } = useLocation(); const { pathname, search } = useLocation();
const { redirect, setRedirect } = useClientStore((store) => ({ const redirect = useClientStore((store) => store.redirect);
redirect: store.redirect, const setRedirect = useClientStore((store) => store.setRedirect);
setRedirect: store.setRedirect,
}));
const isOnline = useIsOnline();
// notify of client path changes // notify of client path changes
useEffect(() => { useEffect(() => {
if (!isOnline) return;
socketSendJson('set-client-path', pathname + search); socketSendJson('set-client-path', pathname + search);
}, [pathname, search, isOnline]); }, [pathname, search]);
// navigate to new path when received from server // navigate to new path when received from server
useEffect(() => { useEffect(() => {
@@ -33,7 +26,7 @@ export const useClientPath = () => {
// navigate if there is a path change // navigate if there is a path change
if (redirect !== pathname + search) { if (redirect !== pathname + search) {
navigate(redirect, { replace: true }); navigate(redirect);
} }
}, [navigate, pathname, redirect, search, setRedirect]); }, [navigate, pathname, redirect, search, setRedirect]);
}; };
@@ -1,42 +1,31 @@
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { addDialog } from '../stores/dialogStore';
const isElectron = window.process?.type === 'renderer'; const isElectron = window.process?.type === 'renderer';
const ipcRenderer = isElectron ? window.require('electron').ipcRenderer : null; const ipcRenderer = isElectron ? window.require('electron').ipcRenderer : null;
export function useElectronEvent() { export default function useElectronEvent() {
const navigate = useNavigate();
const sendToElectron = useCallback((channel: string, args?: string | Record<string, unknown>) => { const sendToElectron = useCallback((channel: string, args?: string | Record<string, unknown>) => {
if (isElectron && ipcRenderer) { if (isElectron && ipcRenderer) {
ipcRenderer.send(channel, args); ipcRenderer.send(channel, args);
} }
}, []); }, []);
return { isElectron, sendToElectron };
}
export function useElectronListener() {
const navigate = useNavigate();
const { isElectron } = useElectronEvent();
// listen to requests to change the editor location // listen to requests to change the editor location
useEffect(() => { useEffect(() => {
if (isElectron) { if (isElectron) {
ipcRenderer.on('request-editor-location', (_event: unknown, location: string) => { ipcRenderer.on('request-editor-location', (_event: unknown, location: string) => {
navigate(location, { relative: 'route' }); navigate(location, { relative: 'route' });
}); });
ipcRenderer.on('dialog', (_event: unknown, dialog: string) => {
if (dialog === 'welcome') {
addDialog('welcome');
}
});
} }
// Clean the listener after the component is dismounted // Clean the listener after the component is dismounted
return () => { return () => {
ipcRenderer?.removeAllListeners(); ipcRenderer?.removeAllListeners();
}; };
}, [isElectron, navigate]); }, [navigate]);
return { isElectron, sendToElectron };
} }
+99 -103
View File
@@ -1,17 +1,17 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { import {
isOntimeEvent, dayInMs,
OntimeBlock, getLinkedTimes,
OntimeDelay, getPreviousEventNormal,
OntimeEvent, MILLIS_PER_SECOND,
OntimeRundownEntry, parseUserTime,
RundownCached, reorderArray,
TimeField, swapEventData,
TimeStrategy, validateEndAction,
TransientEventPayload, validateTimerType,
} from 'ontime-types'; } from 'ontime-utils';
import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils';
import { RUNDOWN } from '../api/constants'; import { RUNDOWN } from '../api/constants';
import { import {
@@ -29,16 +29,6 @@ import {
import { logAxiosError } from '../api/utils'; import { logAxiosError } from '../api/utils';
import { useEditorSettings } from '../stores/editorSettings'; import { useEditorSettings } from '../stores/editorSettings';
export type EventOptions = Partial<{
// options to any new block (event / delay / block)
after: string;
before: string;
// options to blocks of type OntimeEvent
defaultPublic: boolean;
linkPrevious: boolean;
lastEventId: string;
}>;
/** /**
* @description Set of utilities for events //TODO: should this be called useEntryAction and so on * @description Set of utilities for events //TODO: should this be called useEntryAction and so on
*/ */
@@ -47,7 +37,6 @@ export const useEventAction = () => {
const { const {
defaultPublic, defaultPublic,
linkPrevious, linkPrevious,
defaultTimeStrategy,
defaultDuration, defaultDuration,
defaultWarnTime, defaultWarnTime,
defaultDangerTime, defaultDangerTime,
@@ -67,19 +56,31 @@ export const useEventAction = () => {
networkMode: 'always', networkMode: 'always',
}); });
// options to any new block (event / delay / block)
type BaseOptions = {
after?: string;
};
// options to blocks of type OntimeEvent
type EventOptions = BaseOptions &
Partial<{
defaultPublic: boolean;
linkPrevious: boolean;
lastEventId: string;
}>;
/** /**
* Adds an event to rundown * Adds an event to rundown
*/ */
const addEvent = useCallback( const addEvent = useCallback(
async (event: Partial<OntimeEvent | OntimeDelay | OntimeBlock>, options?: EventOptions) => { async (event: Partial<OntimeRundownEntry>, options?: EventOptions) => {
const newEvent: TransientEventPayload = { ...event }; const newEvent: Partial<OntimeRundownEntry> = { ...event };
// ************* CHECK OPTIONS specific to events // ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEvent)) { if (isOntimeEvent(newEvent)) {
// merge creation time options with event settings // merge creation time options with event settings
const applicationOptions = { const applicationOptions = {
after: options?.after, after: options?.after,
before: options?.before,
defaultPublic: options?.defaultPublic ?? defaultPublic, defaultPublic: options?.defaultPublic ?? defaultPublic,
lastEventId: options?.lastEventId, lastEventId: options?.lastEventId,
linkPrevious: options?.linkPrevious ?? linkPrevious, linkPrevious: options?.linkPrevious ?? linkPrevious,
@@ -115,30 +116,21 @@ export const useEventAction = () => {
} }
if (newEvent.timerType === undefined) { if (newEvent.timerType === undefined) {
newEvent.timerType = defaultTimerType; newEvent.timerType = validateTimerType(defaultTimerType);
} }
if (newEvent.endAction === undefined) { if (newEvent.endAction === undefined) {
newEvent.endAction = defaultEndAction; newEvent.endAction = validateEndAction(defaultEndAction);
}
if (newEvent.timeStrategy === undefined) {
newEvent.timeStrategy = defaultTimeStrategy;
} }
} }
// handle adding options that concern all event type // handle adding options that concern all event type
if (options?.after) { if (options?.after) {
// @ts-expect-error -- not sure how to type this, <after> is a transient property
newEvent.after = options.after; newEvent.after = options.after;
} }
if (options?.before) {
// @ts-expect-error -- not sure how to type this, <before> is a transient property
newEvent.before = options.before;
}
try { try {
await _addEventMutation.mutateAsync(newEvent as TransientEventPayload); await _addEventMutation.mutateAsync(newEvent);
} catch (error) { } catch (error) {
logAxiosError('Failed adding event', error); logAxiosError('Failed adding event', error);
} }
@@ -150,7 +142,6 @@ export const useEventAction = () => {
defaultEndAction, defaultEndAction,
defaultPublic, defaultPublic,
defaultTimerType, defaultTimerType,
defaultTimeStrategy,
defaultWarnTime, defaultWarnTime,
linkPrevious, linkPrevious,
queryClient, queryClient,
@@ -216,75 +207,13 @@ export const useEventAction = () => {
[updateEvent], [updateEvent],
); );
type TimeField = 'timeStart' | 'timeEnd' | 'duration';
/** /**
* Updates time of existing event * Updates time of existing event
* @param eventId {string} - id of the event
* @param field {TimeField} - field to update
* @param value {string} - new value string to be parsed
* @param lockOnUpdate {boolean} - whether we will apply the lock / release on update
*/ */
const updateTimer = useCallback( const updateTimer = useCallback(
async (eventId: string, field: TimeField, value: string, lockOnUpdate?: boolean) => { async (eventId: string, field: TimeField, value: string) => {
// an empty value with no lock has no domain validity const getPreviousEnd = (): number => {
if (!lockOnUpdate && value === '') {
return;
}
const newEvent: Partial<OntimeEvent> = {
id: eventId,
};
// check if we should lock the field
if (lockOnUpdate) {
if (field === 'timeEnd') {
// an empty value indicates that we should unlock the field
newEvent.timeStrategy = value === '' ? TimeStrategy.LockDuration : TimeStrategy.LockEnd;
newEvent.timeEnd = value === '' ? undefined : calculateNewValue();
} else if (field === 'duration') {
// an empty value indicates that we should unlock the field
newEvent.timeStrategy = value === '' ? TimeStrategy.LockEnd : TimeStrategy.LockDuration;
newEvent.duration = value === '' ? undefined : calculateNewValue();
} else if (field === 'timeStart') {
// an empty values means we should link to the previous
newEvent.linkStart = value === '' ? 'true' : null;
newEvent.timeStart = value === '' ? undefined : calculateNewValue();
}
} else {
newEvent[field] = calculateNewValue();
}
try {
await _updateEventMutation.mutateAsync(newEvent);
} catch (error) {
logAxiosError('Error updating event', error);
}
/**
* Utility function to calculate the new time value
*/
function calculateNewValue(): number {
let newValMillis = 0;
// check for previous keyword
if (value === 'p' || value === 'prev' || value === 'previous') {
newValMillis = getPreviousEnd();
// check for adding time keyword
} else if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) {
// TODO: is this logic solid?
const remainingString = value.substring(1);
newValMillis = getPreviousEnd() + parseUserTime(remainingString);
} else {
newValMillis = parseUserTime(value);
}
// dont allow timer values over 23:59:59
return Math.min(newValMillis, dayInMs - MILLIS_PER_SECOND);
}
/**
* Utility function to get the previous event end time
*/
function getPreviousEnd(): number {
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN); const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (!cachedRundown?.order || !cachedRundown?.rundown) { if (!cachedRundown?.order || !cachedRundown?.rundown) {
@@ -304,6 +233,72 @@ export const useEventAction = () => {
} }
} }
return previousEnd; return previousEnd;
};
let newValMillis = 0;
// check for previous keyword
if (value === 'p' || value === 'prev' || value === 'previous') {
newValMillis = getPreviousEnd();
// check for adding time keyword
} else if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) {
// TODO: is this logic solid?
const remainingString = value.substring(1);
newValMillis = getPreviousEnd() + parseUserTime(remainingString);
} else {
newValMillis = parseUserTime(value);
}
// dont allow timer values over 23:59:59
const cappedMillis = Math.min(newValMillis, dayInMs - MILLIS_PER_SECOND);
const newEvent = {
id: eventId,
[field]: cappedMillis,
};
try {
await _updateEventMutation.mutateAsync(newEvent);
} catch (error) {
logAxiosError('Error updating event', error);
}
},
[_updateEventMutation, queryClient],
);
/**
* Toggles link of an event to the previous
*/
const linkTimer = useCallback(
async (eventId: string, linkStart: MaybeString) => {
let newEvent: Partial<OntimeEvent> = { id: eventId };
if (!linkStart) {
newEvent.linkStart = null;
} else {
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (!cachedRundown) {
return;
}
const currentEvent = cachedRundown.rundown[eventId] as OntimeEvent;
if (!isOntimeEvent(currentEvent)) {
return;
}
const { previousEvent } = getPreviousEventNormal(cachedRundown.rundown, cachedRundown.order, eventId);
if (!previousEvent) {
newEvent.linkStart = null;
} else {
newEvent.linkStart = previousEvent.id;
const timePatch = getLinkedTimes(currentEvent, previousEvent);
newEvent = { ...newEvent, ...timePatch };
}
}
try {
await _updateEventMutation.mutateAsync(newEvent);
} catch (error) {
logAxiosError('Error updating event', error);
} }
}, },
[_updateEventMutation, queryClient], [_updateEventMutation, queryClient],
@@ -615,6 +610,7 @@ export const useEventAction = () => {
batchUpdateEvents, batchUpdateEvents,
deleteEvent, deleteEvent,
deleteAllEvents, deleteAllEvents,
linkTimer,
reorderEvent, reorderEvent,
swapEvents, swapEvents,
updateEvent, updateEvent,
@@ -1,31 +0,0 @@
import { useEffect, useState } from 'react';
import { throttle } from '../../common/utils/throttle';
export const useFadeOutOnInactivity = () => {
const [isMouseMoved, setIsMouseMoved] = useState(false);
// show on mouse move
useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null;
const setShowMenuTrue = () => {
setIsMouseMoved(true);
if (fadeOut) {
clearTimeout(fadeOut);
}
fadeOut = setTimeout(() => setIsMouseMoved(false), 3000);
};
const throttledShowMenu = throttle(setShowMenuTrue, 1000);
document.addEventListener('mousemove', throttledShowMenu);
return () => {
document.removeEventListener('mousemove', throttledShowMenu);
if (fadeOut) {
clearTimeout(fadeOut);
}
};
}, []);
return isMouseMoved;
};
@@ -6,7 +6,7 @@
import { useMemo, useRef } from 'react'; import { useMemo, useRef } from 'react';
import { isDev } from '../../externals'; import { isDev } from '../api/constants';
type noop = (this: any, ...args: any[]) => any; type noop = (this: any, ...args: any[]) => any;
+7 -27
View File
@@ -65,7 +65,6 @@ export const useMessagePreview = () => {
showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external), showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external),
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text), showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
timerType: state.eventNow?.timerType ?? null, timerType: state.eventNow?.timerType ?? null,
countToEnd: state.eventNow?.countToEnd ?? false,
}); });
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
@@ -149,18 +148,16 @@ export const setAuxTimer = {
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }), setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
}; };
export const useSelectedEventId = () => { export const useCuesheet = () => {
const featureSelector = (state: RuntimeStore) => ({
selectedEventId: state.eventNow?.id ?? null,
});
return useRuntimeStore(featureSelector);
};
export const useCurrentBlockId = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
currentBlockId: state.currentBlock.block?.id ?? null, currentBlockId: state.currentBlock.block?.id ?? null,
selectedEventId: state.eventNow?.id ?? null,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
titleNow: state.eventNow?.title || '',
}); });
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
}; };
@@ -233,20 +230,3 @@ export const useTimelineStatus = () => {
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
}; };
export const usePing = () => {
const featureSelector = (state: RuntimeStore) => ({
ping: state.ping,
});
return useRuntimeStore(featureSelector);
};
/** convert ping into a derived value which changes less often */
export const useIsOnline = () => {
const featureSelector = (state: RuntimeStore) => ({
isOnline: state.ping > 0,
});
return useRuntimeStore(featureSelector);
};
@@ -1,9 +0,0 @@
import { AutomationSettings } from 'ontime-types';
export const automationPlaceholderSettings: AutomationSettings = {
enabledAutomations: false,
enabledOscIn: false,
oscPortIn: 8888,
triggers: [],
automations: {},
};
+6
View File
@@ -0,0 +1,6 @@
import { HttpSettings } from 'ontime-types';
export const httpPlaceholder: HttpSettings = {
enabledOut: false,
subscriptions: [],
};
+4 -1
View File
@@ -1,8 +1,11 @@
import { GetInfo } from 'ontime-types'; import { GetInfo } from 'ontime-types';
import { oscPlaceholderSettings } from './OscSettings';
export const ontimePlaceholderInfo: GetInfo = { export const ontimePlaceholderInfo: GetInfo = {
networkInterfaces: [], networkInterfaces: [],
version: '2.0.0', version: '2.0.0',
serverPort: 4001, serverPort: 4001,
publicDir: '', osc: oscPlaceholderSettings,
cssOverride: '',
}; };
@@ -0,0 +1,10 @@
import { OSCSettings } from 'ontime-types';
export const oscPlaceholderSettings: OSCSettings = {
portIn: 8888,
portOut: 9999,
targetIP: '127.0.0.1',
enabledIn: false,
enabledOut: false,
subscriptions: [],
};
@@ -7,5 +7,4 @@ export const projectDataPlaceholder: ProjectData = {
publicInfo: '', publicInfo: '',
backstageUrl: '', backstageUrl: '',
backstageInfo: '', backstageInfo: '',
projectLogo: null,
}; };
@@ -15,5 +15,4 @@ export type ViewExtendedTimer = {
clock: number; clock: number;
timerType: TimerType; timerType: TimerType;
countToEnd: boolean;
}; };
@@ -15,5 +15,4 @@ export type OverridableOptions = {
language?: string; language?: string;
showProgressBar?: boolean; showProgressBar?: boolean;
hideTimerSeconds?: boolean; hideTimerSeconds?: boolean;
removeLeadingZeros?: boolean;
}; };
@@ -1,24 +0,0 @@
import { MaybeString } from 'ontime-types';
import { create } from 'zustand';
interface DialogStore {
showDialog: MaybeString;
setDialog: (name: string) => void;
clearDialog: () => void;
}
/**
* Used to allow the server to show a dialog in the client
*/
export const useDialogStore = create<DialogStore>((set) => ({
showDialog: null,
clearDialog: () => set({ showDialog: null }),
setDialog: (name) => set({ showDialog: name }),
}));
/**
* Allows setting a dialog to show (outside react)
*/
export function addDialog(name: string): void {
useDialogStore.getState().setDialog(name);
}
@@ -1,5 +1,5 @@
import { EndAction, TimerType, TimeStrategy } from 'ontime-types'; import { EndAction, TimerType } from 'ontime-types';
import { validateEndAction, validateTimerType, validateTimeStrategy } from 'ontime-utils'; import { validateEndAction, validateTimerType } from 'ontime-utils';
import { create } from 'zustand'; import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage'; import { booleanFromLocalStorage } from '../utils/localStorage';
@@ -7,7 +7,6 @@ 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;
@@ -15,7 +14,6 @@ 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;
@@ -26,7 +24,6 @@ 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,
@@ -37,7 +34,6 @@ 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',
@@ -49,10 +45,6 @@ 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),
@@ -76,12 +68,6 @@ 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));
+55 -22
View File
@@ -1,7 +1,58 @@
import isEqual from 'react-fast-compare'; import isEqual from 'react-fast-compare';
import { RuntimeStore, runtimeStorePlaceholder } from 'ontime-types'; import { Playback, RuntimeStore, SimpleDirection, SimplePlayback, TimerPhase } 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>(
@@ -14,31 +65,13 @@ export const runtimeStore = createWithEqualityFn<RuntimeStore>(
export const useRuntimeStore = <T>(selector: (state: RuntimeStore) => T) => export const useRuntimeStore = <T>(selector: (state: RuntimeStore) => T) =>
useStoreWithEqualityFn(runtimeStore, selector, deepCompare); useStoreWithEqualityFn(runtimeStore, selector, deepCompare);
let batchStore: Partial<RuntimeStore> = {};
export function addToBatchUpdates<K extends keyof RuntimeStore>(key: K, value: RuntimeStore[K]) {
batchStore[key] = value;
}
export function flushBatchUpdates() {
const state = runtimeStore.getState();
runtimeStore.setState({ ...state, ...batchStore });
batchStore = {};
}
/** /**
* Allows patching a property of the runtime store * Allows patching a property of the runtime store
* @param key
* @param value
*/ */
export function patchRuntimeProperty<K extends keyof RuntimeStore>(key: K, value: RuntimeStore[K]) { export function patchRuntime<K extends keyof RuntimeStore>(key: K, value: RuntimeStore[K]): void {
const state = runtimeStore.getState(); const state = runtimeStore.getState();
state[key] = value; state[key] = value;
runtimeStore.setState({ ...state }); runtimeStore.setState({ ...state });
} }
/**
* Allows patching the entire runtime store
*/
export function patchRuntime(patch: Partial<RuntimeStore>) {
const state = runtimeStore.getState();
runtimeStore.setState({ ...state, ...patch });
}
@@ -1,17 +0,0 @@
import { linkToOtherHost } from '../linkUtils';
describe('linkToOTherHost', () => {
it('should handle electron links', () => {
const serverUrl = 'http://localhost:4001';
const baseUri = '';
const destination = linkToOtherHost('192.168.10.166', 'path', serverUrl, baseUri);
expect(destination).toBe('http://192.168.10.166:4001/path');
});
it('should handle ontime cloud links', () => {
const serverUrl = 'https://cloud.getontime.no/user-hash';
const baseUri = 'user-hash';
const destination = linkToOtherHost('cloud.getontime.no', 'path', serverUrl, baseUri);
expect(destination).toBe('https://cloud.getontime.no/user-hash/path');
});
});
@@ -26,8 +26,8 @@ describe('simple tests for regex', () => {
}); });
test('startsWithHttp', () => { test('startsWithHttp', () => {
const right = ['https://test', 'http://test']; const right = ['http://test'];
const wrong = ['testing', '123.0.1']; const wrong = ['https://test', 'testing', '123.0.1'];
right.forEach((t) => { right.forEach((t) => {
expect(startsWithHttp.test(t)).toBe(true); expect(startsWithHttp.test(t)).toBe(true);

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