Compare commits

..

5 Commits

Author SHA1 Message Date
arc-alex 71317f5e40 bump to v3.4.2 2024-08-19 17:26:15 +02:00
Alex Christoffer Rasmussen cc14445252 swap shouldThrottle check
Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2024-08-19 17:24:51 +02:00
arc-alex 31316a6661 remove async from parseProperty 2024-08-19 17:24:42 +02:00
arc-alex a8bc4eb098 bump to version 3.4.1 2024-07-29 12:52:58 +02:00
arc-alex 807698aee1 restore rundownGetAll 2024-07-29 12:51:44 +02:00
484 changed files with 9164 additions and 14957 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
+3 -1
View File
@@ -39,7 +39,9 @@ dist/
# docker utils # docker utils
ontime-db ontime-db
ontime-external/ ontime-external/
ontime-data/
# working database
apps/server/src/preloaded-db/db.json
# versioning file # versioning file
**/ONTIME_VERSION.js **/ONTIME_VERSION.js
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm lint-staged
+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
+2 -2
View File
@@ -22,8 +22,8 @@ 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 ./demo-db/ ./preloaded-db/
COPY --from=builder /app/apps/server/src/external/ ./external/ COPY --from=builder /app/apps/server/src/external/ ./external/
COPY --from=builder /app/apps/server/src/user/ ./user/
# Export default ports # Export default ports
EXPOSE 4001/tcp 8888/udp 9999/udp EXPOSE 4001/tcp 8888/udp 9999/udp
@@ -33,4 +33,4 @@ CMD ["node", "server/docker.cjs"]
# Build and run commands # Build and run commands
# !!! Note that this command needs pre-build versions of the UI and server apps # !!! Note that this command needs pre-build versions of the UI and server apps
# docker buildx build . -t getontime/ontime # docker buildx build . -t getontime/ontime
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/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
+18 -19
View File
@@ -14,10 +14,8 @@
- 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>
- 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>
## 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.
## Using Ontime? ## Using Ontime?
Let us know! Let us know!
Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback. Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback.
@@ -37,16 +35,6 @@ Ontime is made by entertainment and broadcast engineers and used by
- Theatres and opera houses - Theatres and opera houses
- Houses of worship - Houses of worship
## Main features
- [x] **Multiplatform**: Available for Windows / MacOS, Linux. You can also self host with the docker image
- [x] **In any device**: Ontime is available in the local network to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers...
- [x] **Made for teams**: Ontime caters to different roles in your production team: directors, operators, backstage and front of house signage...
- [x] **Delay workflows**: Manage and communicate runtime delays in real-time to your team
- [x] **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock
- [x] **Focus on integrations**: Use one of the APIs provided (OSC, HTTP, Websocket) or the available [Companion module](https://bitfocus.io/connections/getontime-ontime) to integrate into your workflow (vMix, disguise, Qlab, OBS)
... and a lot more ...
### For live environments ### For live environments
Ontime is designed for use in live environments. \ Ontime is designed for use in live environments. \
@@ -95,7 +83,6 @@ IP.ADDRESS:4001/clock > Simple clock view
IP.ADDRESS:4001/backstage > Stage Manager / Backstage view IP.ADDRESS:4001/backstage > Stage Manager / Backstage view
IP.ADDRESS:4001/countdown > Countdown to anything IP.ADDRESS:4001/countdown > Countdown to anything
IP.ADDRESS:4001/studio > Studio Clock IP.ADDRESS:4001/studio > Studio Clock
IP.ADDRESS:4001/timeline > Timeline
``` ```
``` ```
@@ -113,13 +100,26 @@ IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
IP.ADDRESS:4001/operator > automated views for operators IP.ADDRESS:4001/operator > automated views for operators
``` ```
More information is available [in our docs](https://docs.getontime.no) More documentation is available [in our docs](https://docs.getontime.no)
## Main features
- [x] Distribute data over network and render it in the browser
- [x] Collaborative
- [x] Extendable
- [x] Send messages to different screen types
- [x] Differentiate between backstage and public data
- [x] Workflow for managing delays
- [x] Rich protocol integrations for Control and Feedback
- [x] For servers: use docker to run Ontime in in a server, configure from a browser anywhere
- [x] Multi-platform (available on Windows, MacOS and Linux)
- [x] Companion integration [follow link](https://bitfocus.io/connections/getontime-ontime)
## Roadmap ## Roadmap
### Continued development ### Continued development
Ontime is under active development. We continue adding and improving features in collaboration with users. Ontime is under active development. We continue adding and tweaking features in collaboration with users.
Have an idea? Reach out via [email](mail@getontime.no) Have an idea? Reach out via [email](mail@getontime.no)
or [open an issue](https://github.com/cpvalente/ontime/issues/new) or [open an issue](https://github.com/cpvalente/ontime/issues/new)
@@ -161,9 +161,8 @@ If you are a developer and would like to contribute with code, please open an is
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md) Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
## Links ## Links
- [Ontime website](https://getontime.no)
- [Documentation](https://docs.getontime.no) See the [Ontime website](https://getontime.no) here and the link to the [documentation](https://docs.getontime.no)
- [Ontime discord server](https://discord.com/invite/eje3CSUEXm)
## License ## License
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "3.10.2", "version": "3.4.2",
"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",
@@ -20,8 +20,8 @@
"server" "server"
], ],
"devDependencies": { "devDependencies": {
"eslint": "catalog:", "eslint": "^8.53.0",
"eslint-config-prettier": "catalog:", "eslint-config-prettier": "^9.0.0",
"prettier": "catalog:" "prettier": "^3.0.3"
} }
} }
+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>
+20 -19
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "3.10.2", "version": "3.4.2",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
@@ -11,22 +11,22 @@
"@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": "^7.92.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",
"csv-stringify": "^6.4.5", "csv-stringify": "^6.4.5",
"framer-motion": "^10.10.0", "framer-motion": "^10.10.0",
"react": "^18.3.1", "react": "^18.2.0",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-dom": "^18.3.1", "react-dom": "^18.2.0",
"react-fast-compare": "^3.2.2", "react-fast-compare": "^3.2.2",
"react-hook-form": "^7.53.1", "react-hook-form": "^7.49.2",
"react-qr-code": "^2.0.12", "react-qr-code": "^2.0.12",
"react-router-dom": "^6.3.0", "react-router-dom": "^6.3.0",
"web-vitals": "^3.1.1", "web-vitals": "^3.1.1",
@@ -39,9 +39,10 @@
"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",
"lint-staged": "eslint",
"test": "vitest", "test": "vitest",
"test:pipeline": "vitest run", "test:pipeline": "vitest run",
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build", "cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build",
@@ -69,13 +70,13 @@
"@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", "@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "catalog:", "@typescript-eslint/eslint-plugin": "^v7.16.1",
"@typescript-eslint/parser": "catalog:", "@typescript-eslint/parser": "^7.16.1",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"eslint": "catalog:", "eslint": "^8.56.0",
"eslint-config-prettier": "catalog:", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-jest": "^28.6.0", "eslint-plugin-jest": "^28.6.0",
"eslint-plugin-prettier": "catalog:", "eslint-plugin-prettier": "^5.1.3",
"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",
@@ -83,13 +84,13 @@
"jsdom": "^21.1.0", "jsdom": "^21.1.0",
"ontime-types": "workspace:*", "ontime-types": "workspace:*",
"ontime-utils": "workspace:*", "ontime-utils": "workspace:*",
"prettier": "catalog:", "prettier": "^3.3.1",
"sass": "^1.57.1", "sass": "^1.57.1",
"typescript": "catalog:", "typescript": "^5.5.3",
"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

Before

Width:  |  Height:  |  Size: 47 KiB

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>
+37 -131
View File
@@ -1,162 +1,68 @@
import React from 'react'; import { lazy, Suspense } from 'react';
import { import { Navigate, Route, Routes } from 'react-router-dom';
createRoutesFromChildren,
matchRoutes,
Navigate,
Route,
Routes,
useLocation,
useNavigationType,
} from 'react-router-dom';
import * as Sentry from '@sentry/react';
import { useClientPath } from './common/hooks/useClientPath'; 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 { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
const Editor = React.lazy(() => import('./features/editors/ProtectedEditor')); const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet')); const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
const Operator = React.lazy(() => import('./features/operator/OperatorExport')); const Operator = lazy(() => import('./features/operator/OperatorExport'));
const TimerView = React.lazy(() => import('./features/viewers/timer/Timer')); const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer')); const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock')); const ClockView = lazy(() => import('./features/viewers/clock/Clock'));
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown')); const Countdown = lazy(() => import('./features/viewers/countdown/Countdown'));
const Backstage = React.lazy(() => import('./features/viewers/backstage/Backstage')); const Backstage = lazy(() => import('./features/viewers/backstage/Backstage'));
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage')); const Public = lazy(() => import('./features/viewers/public/Public'));
const Public = React.lazy(() => import('./features/viewers/public/Public')); const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird')); const StudioClock = 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 EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper')); const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport')); const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
const TimerControl = React.lazy(() => import('./features/control/playback/TimerControlExport')); const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport'));
const MessageControl = React.lazy(() => import('./features/control/message/MessageControlExport')); const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
Sentry.init({
dsn: sentryDsn,
integrations: [
Sentry.reactRouterV6BrowserTracingIntegration({
useEffect: React.useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
}),
],
tracesSampleRate: 0.3,
release: ONTIME_VERSION,
enabled: import.meta.env.PROD,
ignoreErrors: [...sentryRecommendedIgnore, /Unable to preload CSS/i, /dynamically imported module/i],
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
});
const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes);
export default function AppRouter() { export default function AppRouter() {
// handle client path changes // handle client path changes
useClientPath(); useClientPath();
return ( return (
<React.Suspense fallback={null}> <Suspense fallback={null}>
<SentryRoutes> <Routes>
<Route path='/' element={<Navigate to='/timer' />} /> <Route path='/' element={<Navigate to='/timer' />} />
<Route <Route path='/timer' element={<STimer />} />
path='/timer'
element={ <Route path='/minimal' element={<SMinimalTimer />} />
<ViewLoader>
<STimer /> <Route path='/clock' element={<SClock />} />
</ViewLoader>
} <Route path='/countdown' element={<SCountdown />} />
/>
<Route <Route path='/backstage' element={<SBackstage />} />
path='/public'
element={ <Route path='/public' element={<SPublic />} />
<ViewLoader>
<SPublic /> <Route path='/studio' element={<SStudio />} />
</ViewLoader>
}
/>
<Route
path='/minimal'
element={
<ViewLoader>
<SMinimalTimer />
</ViewLoader>
}
/>
<Route
path='/clock'
element={
<ViewLoader>
<SClock />
</ViewLoader>
}
/>
<Route
path='/countdown'
element={
<ViewLoader>
<SCountdown />
</ViewLoader>
}
/>
<Route
path='/backstage'
element={
<ViewLoader>
<SBackstage />
</ViewLoader>
}
/>
<Route
path='/studio'
element={
<ViewLoader>
<SStudio />
</ViewLoader>
}
/>
{/*/!* Lower third cannot have a loading screen *!/*/}
<Route path='/lower' element={<SLowerThird />} /> <Route path='/lower' element={<SLowerThird />} />
<Route
path='/timeline' <Route path='/op' element={<Operator />} />
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={<Cuesheet />} /> <Route path='/cuesheet' element={<Cuesheet />} />
<Route path='/op' element={<Operator />} />
{/*/!* Protected Routes - Elements *!/*/} {/*/!* Protected Routes - Elements *!/*/}
<Route <Route
@@ -193,7 +99,7 @@ export default function AppRouter() {
/> />
{/*/!* Send to default if nothing found *!/*/} {/*/!* Send to default if nothing found *!/*/}
<Route path='*' element={<STimer />} /> <Route path='*' element={<STimer />} />
</SentryRoutes> </Routes>
</React.Suspense> </Suspense>
); );
} }
+14 -8
View File
@@ -1,9 +1,6 @@
import { serverURL } from '../../externals';
// keys in tanstack store // keys in tanstack store
export const APP_INFO = ['appinfo']; export const APP_INFO = ['appinfo'];
export const APP_SETTINGS = ['appSettings']; export const APP_SETTINGS = ['appSettings'];
export const APP_VERSION = ['appVersion'];
export const CUSTOM_FIELDS = ['customFields']; export const CUSTOM_FIELDS = ['customFields'];
export const HTTP_SETTINGS = ['httpSettings']; export const HTTP_SETTINGS = ['httpSettings'];
export const OSC_SETTINGS = ['oscSettings']; export const OSC_SETTINGS = ['oscSettings'];
@@ -16,14 +13,23 @@ 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;
// 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';
@@ -11,7 +11,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>> { async 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
+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);
} }
-14
View File
@@ -1,14 +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;
}
-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;
@@ -36,7 +38,7 @@ export function RedirectClientModal(props: RedirectClientModalProps) {
onClose(); onClose();
}; };
const host = window.location.origin; const host = `${window.location.origin}/`;
const canSubmit = path !== currentPath && path !== ''; const canSubmit = path !== currentPath && path !== '';
return ( return (
@@ -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>
);
} }
@@ -28,11 +28,9 @@ class ErrorBoundary extends React.Component {
}); });
Sentry.withScope((scope) => { Sentry.withScope((scope) => {
scope.setExtras({ scope.setExtras('error', error);
error, scope.setExtras('store', runtimeStore.getState());
store: runtimeStore.getState(), scope.setExtras('hasSocket', { hasConnected, shouldReconnect, reconnectAttempts });
hasSocket: { hasConnected, shouldReconnect, reconnectAttempts },
});
const eventId = Sentry.captureException(error); const eventId = Sentry.captureException(error);
this.setState({ eventId, info }); this.setState({ eventId, info });
}); });
@@ -1,63 +0,0 @@
/**
* Copied from
* https://github.com/namhong2001/react-textfit/blob/master/index.tsx
*/
import { HTMLAttributes, PropsWithChildren, useCallback, useEffect, useRef } from 'react';
import { bsearch } from './fitText.utils';
interface FitTextProps extends HTMLAttributes<HTMLDivElement> {
mode?: 'single' | 'multi';
min?: number; // inclusive
max?: number; // inclusive
}
export function FitText(props: PropsWithChildren<FitTextProps>) {
const { children, mode = 'multi', min = 16, max = 256, ...elementProps } = props;
const ref = useRef<HTMLDivElement>(null);
const isOverflown = useCallback(() => {
const el = ref.current;
if (!el) return false;
return el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth;
}, []);
const setFontSize = useCallback(() => {
const el = ref.current;
if (!el) return;
const originVisibility = el.style.visibility;
el.style.visibility = 'hidden';
const fontSize = bsearch(min, max + 1, (mid) => {
el.style.fontSize = `${mid}px`;
return !isOverflown();
});
el.style.fontSize = `${fontSize}px`;
el.style.visibility = originVisibility;
}, [isOverflown, min, max]);
useEffect(() => {
const el = ref.current;
if (!el) return;
setFontSize();
const observer = new ResizeObserver(setFontSize);
observer.observe(el);
return () => observer.disconnect();
}, [children, mode, setFontSize]);
return (
<div
ref={ref}
style={{
whiteSpace: mode === 'single' ? 'nowrap' : 'normal',
}}
{...elementProps}
>
{children}
</div>
);
}
@@ -1,18 +0,0 @@
/**
* @param low inclusive, must be true on predicate function
* @param high exclusive,
* @param predicate predicate function
*/
export const bsearch = (low: number, high: number, predicate: (mid: number) => boolean): number => {
while (low < high) {
const mid = Math.floor((low + high) / 2);
if (mid === low) break;
if (predicate(mid)) {
low = mid;
} else {
high = mid;
}
}
return low;
};
@@ -54,6 +54,7 @@ function Overlay() {
}; };
}, [showOverlay, id, setIdentify, handleClose]); }, [showOverlay, id, setIdentify, handleClose]);
console.log('here2');
return ( return (
<div className={style.overlay} data-testid='identify-overlay' onClick={handleClose}> <div className={style.overlay} data-testid='identify-overlay' onClick={handleClose}>
<div className={style.name}>{name}</div> <div className={style.name}>{name}</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,12 +1,12 @@
import { MaybeNumber } from 'ontime-types'; import { MaybeNumber } from 'ontime-types';
import { getProgress } from '../../utils/getProgress'; import { clamp } from '../../utils/math';
import './MultiPartProgressBar.scss'; import './MultiPartProgressBar.scss';
interface MultiPartProgressBar { interface MultiPartProgressBar {
now: MaybeNumber; now: MaybeNumber;
complete: MaybeNumber; complete: number;
normalColor: string; normalColor: string;
warning?: MaybeNumber; warning?: MaybeNumber;
warningColor: string; warningColor: string;
@@ -31,9 +31,10 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
className = '', className = '',
} = props; } = props;
const percentRemaining = 100 - getProgress(now, complete); const percentRemaining = complete === 0 ? 0 : 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0; const dangerWidth = danger ? clamp((danger / complete) * 100, 0, 100) : 0;
const warningWidth = warning ? clamp((warning / complete) * 100 - dangerWidth, 0, 100) : 0;
return ( return (
<div <div
@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react';
import { IoApps } from '@react-icons/all-files/io5/IoApps'; import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline'; import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity'; import { debounce } from '../../utils/debounce';
import style from './NavigationMenu.module.scss'; import style from './NavigationMenu.module.scss';
@@ -12,10 +13,32 @@ interface FloatingNavigationProps {
export default function FloatingNavigation(props: FloatingNavigationProps) { export default function FloatingNavigation(props: FloatingNavigationProps) {
const { toggleMenu, toggleSettings } = props; const { toggleMenu, toggleSettings } = props;
const isButtonShown = useFadeOutOnInactivity(); const [showButton, setShowButton] = useState(false);
// show on mouse move
useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null;
const setShowMenuTrue = () => {
setShowButton(true);
if (fadeOut) {
clearTimeout(fadeOut);
}
fadeOut = setTimeout(() => setShowButton(false), 3000);
};
const debouncedShowMenu = debounce(setShowMenuTrue, 1000);
document.addEventListener('mousemove', debouncedShowMenu);
return () => {
document.removeEventListener('mousemove', debouncedShowMenu);
if (fadeOut) {
clearTimeout(fadeOut);
}
};
}, []);
return ( return (
<div className={`${style.buttonContainer} ${!isButtonShown ? style.hidden : ''}`}> <div className={`${style.buttonContainer} ${!showButton ? style.hidden : ''}`}>
<button <button
onClick={toggleMenu} onClick={toggleMenu}
aria-label='toggle menu' aria-label='toggle menu'
@@ -38,16 +38,6 @@ $button-size: 3rem;
border-radius: 3px; border-radius: 3px;
} }
.lockIcon {
font-size: 1.5rem;
display: grid;
place-content: center;
border-radius: 3px;
background-color: transparent;
color: $active-indicator;
cursor: default;
}
.navButton { .navButton {
@extend .button; @extend .button;
z-index: 3; z-index: 3;
@@ -57,8 +47,6 @@ $button-size: 3rem;
@include action-link; @include action-link;
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
gap: 0.5rem; gap: 0.5rem;
width: 100%;
cursor: pointer;
&:hover { &:hover {
background-color: $menu-hover-bg; background-color: $menu-hover-bg;
@@ -82,33 +70,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,6 +1,6 @@
import { memo, PropsWithChildren, useRef } from 'react'; import { memo, useRef } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Link, useLocation } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { import {
Drawer, Drawer,
DrawerBody, DrawerBody,
@@ -17,18 +17,12 @@ import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline'; import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical'; import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { isLocalhost } from '../../../externals';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import useClickOutside from '../../hooks/useClickOutside'; import useClickOutside from '../../hooks/useClickOutside';
import { useElectronEvent } from '../../hooks/useElectronEvent';
import useInfo from '../../hooks-query/useInfo';
import { useClientStore } from '../../stores/clientStore'; import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
import { isKeyEnter } from '../../utils/keyEvent'; import { isKeyEnter } from '../../utils/keyEvent';
import { handleLinks, linkToOtherHost, openLink } from '../../utils/linkUtils';
import { 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';
@@ -45,8 +39,7 @@ function NavigationMenu(props: NavigationMenuProps) {
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure(); const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
const { fullscreen, toggle } = useFullscreen(); const { fullscreen, toggle } = useFullscreen();
const { mirror, toggleMirror } = useViewOptionsStore(); const { toggleMirror } = useViewOptionsStore();
const location = useLocation();
const menuRef = useRef<HTMLDivElement | null>(null); const menuRef = useRef<HTMLDivElement | null>(null);
@@ -57,7 +50,7 @@ function NavigationMenu(props: NavigationMenuProps) {
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} /> <RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'> <Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
<DrawerOverlay /> <DrawerOverlay />
<DrawerContent maxWidth='22rem'> <DrawerContent>
<DrawerHeader> <DrawerHeader>
<DrawerCloseButton size='lg' /> <DrawerCloseButton size='lg' />
Ontime Ontime
@@ -65,7 +58,7 @@ function NavigationMenu(props: NavigationMenuProps) {
<DrawerBody padding={0}> <DrawerBody padding={0}>
<div className={style.buttonsContainer}> <div className={style.buttonsContainer}>
<div <div
className={cx([style.link, fullscreen && style.current])} className={style.link}
tabIndex={0} tabIndex={0}
role='button' role='button'
onClick={toggle} onClick={toggle}
@@ -77,7 +70,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()}
@@ -103,27 +96,39 @@ function NavigationMenu(props: NavigationMenuProps) {
<hr className={style.separator} /> <hr className={style.separator} />
<Link <Link
to='/editor' to='/editor'
className={`${style.link} ${location.pathname === '/editor' ? style.current : ''}`}
tabIndex={0} tabIndex={0}
className={`${style.link} ${location.pathname === '/editor' && style.current}`}
> >
<IoLockClosedOutline /> <IoLockClosedOutline />
Editor Editor
<IoArrowUp className={style.linkIcon} />
</Link> </Link>
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}> <Link
to='/cuesheet'
className={`${style.link} ${location.pathname === '/cuesheet' ? style.current : ''}`}
tabIndex={0}
>
<IoLockClosedOutline /> <IoLockClosedOutline />
Cuesheet Cuesheet
</ClientLink> <IoArrowUp className={style.linkIcon} />
<ClientLink to='op' current={location.pathname === '/op'}> </Link>
<Link to='/op' className={`${style.link} ${location.pathname === '/op' ? style.current : ''}`} tabIndex={0}>
<IoLockClosedOutline /> <IoLockClosedOutline />
Operator Operator
</ClientLink> <IoArrowUp className={style.linkIcon} />
</Link>
<hr className={style.separator} /> <hr className={style.separator} />
{navigatorConstants.map((route) => ( {navigatorConstants.map((route) => (
<ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}> <Link
key={route.url}
to={route.url}
className={`${style.link} ${route.url === location.pathname ? style.current : undefined}`}
tabIndex={0}
>
{route.label} {route.label}
</ClientLink> <IoArrowUp className={style.linkIcon} />
</Link>
))} ))}
{isLocalhost && <OtherAddresses currentLocation={location.pathname} />}
</DrawerBody> </DrawerBody>
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
@@ -132,71 +137,4 @@ 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 {
current: boolean;
to: string;
}
function ClientLink(props: PropsWithChildren<ClientLinkProps>) {
const { current, to, children } = props;
const { isElectron } = useElectronEvent();
const classes = cx([style.link, current && style.current]);
if (isElectron) {
return (
<button className={classes} tabIndex={0} onClick={(event) => handleLinks(event, to)}>
{children}
<IoArrowUp className={style.linkIcon} />
</button>
);
}
return (
<Link to={`/${to}`} className={classes} tabIndex={0}>
{children}
</Link>
);
}
export default memo(NavigationMenu); export default memo(NavigationMenu);
@@ -1,15 +0,0 @@
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
import style from './NavigationMenu.module.scss';
export default function ViewLockedIcon() {
const isLockIconShown = useFadeOutOnInactivity();
return (
<div className={`${style.buttonContainer} ${!isLockIconShown ? style.hidden : ''}`}>
<IoLockClosedOutline className={style.lockIcon} />
</div>
);
}
@@ -2,18 +2,13 @@ import { memo, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { useDisclosure } from '@chakra-ui/react'; import { useDisclosure } from '@chakra-ui/react';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import FloatingNavigation from './FloatingNavigation'; import FloatingNavigation from './FloatingNavigation';
import NavigationMenu from './NavigationMenu'; import NavigationMenu from './NavigationMenu';
import ViewLockedIcon from './ViewLockedIcon';
function ViewNavigationMenu() { function ViewNavigationMenu() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure(); const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
const isViewLocked = isStringBoolean(searchParams.get('locked'));
const showEditFormDrawer = useCallback(() => { const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true'); searchParams.set('edit', 'true');
setSearchParams(searchParams); setSearchParams(searchParams);
@@ -21,10 +16,6 @@ function ViewNavigationMenu() {
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen()); const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
if (isViewLocked) {
return <ViewLockedIcon />;
}
return ( return (
<> <>
<FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} /> <FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
@@ -1,23 +1,22 @@
import { MaybeNumber } from 'ontime-types'; import { clamp } from '../../utils/math';
import { getProgress } from '../../utils/getProgress';
import './ProgressBar.scss'; import './ProgressBar.scss';
interface ProgressBarProps { interface ProgressBarProps {
current: MaybeNumber; now?: number;
duration: MaybeNumber; complete?: number;
hidden?: boolean; hidden?: boolean;
className?: string; className?: string;
} }
export default function ProgressBar(props: ProgressBarProps) { export default function ProgressBar(props: ProgressBarProps) {
const { current, duration, hidden, className = '' } = props; const { now = 0, complete = 100, hidden, className = '' } = props;
const progress = getProgress(current, duration);
const percentComplete = clamp(100 - (Math.max(now, 0) * 100) / complete, 0, 100);
return ( return (
<div className={`progress-bar__bg ${hidden ? 'progress-bar__bg--hidden' : ''} ${className}`}> <div className={`progress-bar__bg ${hidden ? 'progress-bar__bg--hidden' : ''} ${className}`}>
<div className='progress-bar__indicator' style={{ width: `${progress}%` }} /> <div className='progress-bar__indicator' style={{ width: `${percentComplete}%` }} />
</div> </div>
); );
} }
@@ -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;
@@ -1,6 +1,5 @@
import { PropsWithChildren, useCallback, useContext } from 'react'; import { PropsWithChildren, useCallback, useContext } from 'react';
import { isLocalhost } from '../../../externals';
import { AppContext } from '../../context/AppContext'; import { AppContext } from '../../context/AppContext';
import PinPage from './PinPage'; import PinPage from './PinPage';
@@ -10,6 +9,7 @@ interface ProtectRouteProps {
} }
export default function ProtectRoute({ permission, children }: PropsWithChildren<ProtectRouteProps>) { export default function ProtectRoute({ permission, children }: PropsWithChildren<ProtectRouteProps>) {
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
const { editorAuth, operatorAuth, validate } = useContext(AppContext); const { editorAuth, operatorAuth, validate } = useContext(AppContext);
const handleValidation = useCallback( const handleValidation = useCallback(
@@ -29,7 +29,7 @@ export default function ProtectRoute({ permission, children }: PropsWithChildren
return false; return false;
}; };
if (isLocalhost || hasRelevantAuth()) { if (isLocal || hasRelevantAuth()) {
// eslint-disable-next-line react/jsx-no-useless-fragment -- trying to make typescript happy // eslint-disable-next-line react/jsx-no-useless-fragment -- trying to make typescript happy
return <>{children}</>; return <>{children}</>;
} }
@@ -1,20 +0,0 @@
@use '../../../theme/viewerDefs' as *;
/* share the same style as a page layout */
.page {
margin: 0;
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100vh;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color);
color: var(--color-override, $viewer-color);
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
display: flex;
flex-direction: column;
align-items: center;
padding-top: 5rem;
}
@@ -1,20 +0,0 @@
import { CSSProperties } from 'react';
import Empty from './Empty';
import style from './EmptyPage.module.scss';
interface EmptyPageProps {
text?: string;
style?: CSSProperties;
}
export default function EmptyPage(props: EmptyPageProps) {
const { text, ...rest } = props;
return (
<div className={style.page}>
<Empty text={text} {...rest} />
</div>
);
}
@@ -1,11 +0,0 @@
import { projectLogoPath } from '../../api/constants';
interface ViewLogoProps {
name: string;
className: string;
}
export default function ViewLogo(props: ViewLogoProps) {
const { name, className } = props;
return <img src={`${projectLogoPath}/${name}`} className={className} />;
}
@@ -1,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> <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,23 +8,6 @@
} }
} }
.infoLabel {
display: flex;
align-items: center;
gap: $element-spacing;
padding: 1rem;
margin-bottom: 1rem;
background-color: $gray-1100;
border-radius: 2px;
font-size: $inner-section-text-size;
svg {
font-size: 1.5rem;
color: $info-blue;
}
}
.label { .label {
font-size: $inner-section-text-size; font-size: $inner-section-text-size;
color: $label-gray; color: $label-gray;
@@ -11,9 +11,6 @@ import {
DrawerOverlay, DrawerOverlay,
useDisclosure, useDisclosure,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { IoAlertCircle } from '@react-icons/all-files/io5/IoAlertCircle';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
import ParamInput from './ParamInput'; import ParamInput from './ParamInput';
import { isSection, ViewOption } from './types'; import { isSection, ViewOption } from './types';
@@ -22,16 +19,6 @@ 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
*/ */
@@ -53,33 +40,10 @@ const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOp
// compare which values are different from the default values // compare which values are different from the default values
Object.entries(paramsObj).forEach(([id, value]) => { Object.entries(paramsObj).forEach(([id, value]) => {
if (typeof value === 'string' && value.length) { 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;
}; };
@@ -87,11 +51,8 @@ interface EditFormDrawerProps {
viewOptions: ViewOption[]; viewOptions: ViewOption[];
} }
// 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 +81,8 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget)); const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions); const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
setSearchParams(newSearchParams); setSearchParams(newSearchParams);
onClose();
}; };
return ( return (
@@ -132,12 +95,6 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
</DrawerHeader> </DrawerHeader>
<DrawerBody> <DrawerBody>
{viewSettings.overrideStyles && (
<div className={style.infoLabel}>
<IoAlertCircle />
This view style is being modified by a custom CSS file. <br />
</div>
)}
<form id='edit-params-form' onSubmit={onParamsFormSubmit}> <form id='edit-params-form' onSubmit={onParamsFormSubmit}>
{viewOptions.map((option) => { {viewOptions.map((option) => {
if (isSection(option)) { if (isSection(option)) {
@@ -23,11 +23,10 @@ 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 = ParamSection | ParamField;
/** /**
@@ -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,7 +1,7 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { CustomFields } from 'ontime-types'; import { CustomFields } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchInterval } from '../../ontimeConfig';
import { CUSTOM_FIELDS } from '../api/constants'; import { CUSTOM_FIELDS } from '../api/constants';
import { getCustomFields } from '../api/customFields'; import { getCustomFields } from '../api/customFields';
@@ -14,7 +14,7 @@ export default function useCustomFields() {
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchInterval,
networkMode: 'always', networkMode: 'always',
}); });
@@ -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() {
@@ -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,13 +1,11 @@
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';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchInterval } 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 };
@@ -18,7 +16,7 @@ export default function useRundown() {
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchInterval,
networkMode: 'always', networkMode: 'always',
}); });
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching }; return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
@@ -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 };
} }
+7 -12
View File
@@ -4,23 +4,18 @@ 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; //remove leading '/' from path
const fullPath = (pathname.startsWith('/') ? pathname.slice(1) : pathname) + search;
socketSendJson('set-client-path', pathname + search); socketSendJson('set-client-path', fullPath);
}, [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 +28,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,11 @@
import { useCallback, useEffect } from 'react'; export default function useElectronEvent() {
import { useNavigate } from 'react-router-dom'; const isElectron = window?.process?.type === 'renderer';
import { addDialog } from '../stores/dialogStore'; const sendToElectron = (channel: string, args?: string | Record<string, unknown>) => {
if (isElectron) {
const isElectron = window.process?.type === 'renderer'; window?.ipcRenderer.send(channel, args);
const ipcRenderer = isElectron ? window.require('electron').ipcRenderer : null;
export function useElectronEvent() {
const sendToElectron = useCallback((channel: string, args?: string | Record<string, unknown>) => {
if (isElectron && ipcRenderer) {
ipcRenderer.send(channel, args);
} }
}, []); };
return { isElectron, sendToElectron }; return { isElectron, sendToElectron };
} }
export function useElectronListener() {
const navigate = useNavigate();
const { isElectron } = useElectronEvent();
// listen to requests to change the editor location
useEffect(() => {
if (isElectron) {
ipcRenderer.on('request-editor-location', (_event: unknown, location: string) => {
navigate(location, { relative: 'route' });
});
ipcRenderer.on('dialog', (_event: unknown, dialog: string) => {
if (dialog === 'welcome') {
addDialog('welcome');
}
});
}
// Clean the listener after the component is dismounted
return () => {
ipcRenderer?.removeAllListeners();
};
}, [isElectron, navigate]);
}
+99 -131
View File
@@ -1,17 +1,15 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { import {
isOntimeEvent, dayInMs,
OntimeBlock, getLinkedTimes,
OntimeDelay, getPreviousEventNormal,
OntimeEvent, MILLIS_PER_SECOND,
OntimeRundownEntry, parseUserTime,
RundownCached, reorderArray,
TimeField, swapEventData,
TimeStrategy, } from 'ontime-utils';
TransientEventPayload,
} from 'ontime-types';
import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils';
import { RUNDOWN } from '../api/constants'; import { RUNDOWN } from '../api/constants';
import { import {
@@ -29,31 +27,12 @@ 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
*/ */
export const useEventAction = () => { export const useEventAction = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { const { defaultPublic, linkPrevious, defaultDuration, defaultWarnTime, defaultDangerTime } = useEditorSettings();
defaultPublic,
linkPrevious,
defaultTimeStrategy,
defaultDuration,
defaultWarnTime,
defaultDangerTime,
defaultTimerType,
defaultEndAction,
} = useEditorSettings();
/** /**
* Calls mutation to add new event * Calls mutation to add new event
@@ -67,22 +46,35 @@ export const useEventAction = () => {
networkMode: 'always', networkMode: 'always',
}); });
type BaseOptions = {
after?: string;
};
type EventOptions = BaseOptions &
Partial<{
defaultPublic: boolean;
linkPrevious: boolean;
lastEventId: string;
defaultWarnTime: number;
defaultDangerTime: number;
}>;
/** /**
* 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
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,
defaultWarnTime,
defaultDangerTime,
}; };
if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) { if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) {
@@ -97,7 +89,6 @@ export const useEventAction = () => {
} }
} }
// Override event with options from editor settings
if (applicationOptions.defaultPublic) { if (applicationOptions.defaultPublic) {
newEvent.isPublic = true; newEvent.isPublic = true;
} }
@@ -113,48 +104,20 @@ export const useEventAction = () => {
if (newEvent.timeWarning === undefined) { if (newEvent.timeWarning === undefined) {
newEvent.timeWarning = parseUserTime(defaultWarnTime); newEvent.timeWarning = parseUserTime(defaultWarnTime);
} }
if (newEvent.timerType === undefined) {
newEvent.timerType = defaultTimerType;
}
if (newEvent.endAction === undefined) {
newEvent.endAction = 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);
} }
}, },
[ [_addEventMutation, defaultDangerTime, defaultDuration, defaultPublic, defaultWarnTime, linkPrevious, queryClient],
_addEventMutation,
defaultDangerTime,
defaultDuration,
defaultEndAction,
defaultPublic,
defaultTimerType,
defaultTimeStrategy,
defaultWarnTime,
linkPrevious,
queryClient,
],
); );
/** /**
@@ -216,75 +179,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 +205,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 +582,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;
};
+159
View File
@@ -0,0 +1,159 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
export type TLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'none';
export type TOptions = {
logLevel?: TLogLevel;
maxFontSize?: number;
minFontSize?: number;
onFinish?: (fontSize: number) => void;
onStart?: () => void;
resolution?: number;
};
const LOG_LEVEL: Record<TLogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40,
none: 100,
};
const useFitText = ({
logLevel: logLevelOption = 'info',
maxFontSize = 100,
minFontSize = 20,
onFinish,
onStart,
resolution = 5,
}: TOptions = {}) => {
const logLevel = LOG_LEVEL[logLevelOption];
const initState = useCallback(() => {
return {
calcKey: 0,
fontSize: maxFontSize,
fontSizePrev: minFontSize,
fontSizeMax: maxFontSize,
fontSizeMin: minFontSize,
};
}, [maxFontSize, minFontSize]);
const ref = useRef<HTMLDivElement>(null);
const innerHtmlPrevRef = useRef<string | null>();
const isCalculatingRef = useRef(false);
const [state, setState] = useState(initState);
const { calcKey, fontSize, fontSizeMax, fontSizeMin, fontSizePrev } = state;
// Monitor div size changes and recalculate on resize
let animationFrameId: number | null = null;
const [ro] = useState(
() =>
new ResizeObserver(() => {
animationFrameId = window.requestAnimationFrame(() => {
if (isCalculatingRef.current) {
return;
}
onStart && onStart();
isCalculatingRef.current = true;
// `calcKey` is used in the dependencies array of
// `useIsoLayoutEffect` below. It is incremented so that the font size
// will be recalculated even if the previous state didn't change (e.g.
// when the text fit initially).
setState({
...initState(),
calcKey: calcKey + 1,
});
});
}),
);
useEffect(() => {
if (ref.current) {
ro.observe(ref.current);
}
return () => {
animationFrameId && window.cancelAnimationFrame(animationFrameId);
ro.disconnect();
};
}, [animationFrameId, ro]);
// Recalculate when the div contents change
const innerHtml = ref.current && ref.current.innerHTML;
useEffect(() => {
if (calcKey === 0 || isCalculatingRef.current) {
return;
}
if (innerHtml !== innerHtmlPrevRef.current) {
onStart && onStart();
setState({
...initState(),
calcKey: calcKey + 1,
});
}
innerHtmlPrevRef.current = innerHtml;
}, [calcKey, initState, innerHtml, onStart]);
// Check overflow and resize font
useLayoutEffect(() => {
// Don't start calculating font size until the `resizeKey` is incremented
// above in the `ResizeObserver` callback. This avoids an extra resize
// on initialization.
if (calcKey === 0) {
return;
}
const isWithinResolution = Math.abs(fontSize - fontSizePrev) <= resolution;
const isOverflow =
!!ref.current &&
(ref.current.scrollHeight > ref.current.offsetHeight || ref.current.scrollWidth > ref.current.offsetWidth);
const isFailed = isOverflow && fontSize === fontSizePrev;
const isAsc = fontSize > fontSizePrev;
// Return if the font size has been adjusted "enough" (change within `resolution`)
// reduce font size by one increment if it's overflowing.
if (isWithinResolution) {
if (isFailed) {
isCalculatingRef.current = false;
if (logLevel <= LOG_LEVEL.info) {
console.info(`[use-fit-text] reached \`minFontSize = ${minFontSize}\` without fitting text`);
}
} else if (isOverflow) {
setState({
fontSize: isAsc ? fontSizePrev : fontSizeMin,
fontSizeMax,
fontSizeMin,
fontSizePrev,
calcKey,
});
} else {
isCalculatingRef.current = false;
onFinish && onFinish(fontSize);
}
return;
}
// Binary search to adjust font size
let delta: number;
let newMax = fontSizeMax;
let newMin = fontSizeMin;
if (isOverflow) {
delta = isAsc ? fontSizePrev - fontSize : fontSizeMin - fontSize;
newMax = Math.min(fontSizeMax, fontSize);
} else {
delta = isAsc ? fontSizeMax - fontSize : fontSizePrev - fontSize;
newMin = Math.max(fontSizeMin, fontSize);
}
setState({
calcKey,
fontSize: fontSize + delta / 2,
fontSizeMax: newMax,
fontSizeMin: newMin,
fontSizePrev: fontSize,
});
}, [calcKey, fontSize, fontSizeMax, fontSizeMin, fontSizePrev, onFinish, ref, resolution]);
return { fontSize: `${fontSize}%`, ref };
};
export default useFitText;
@@ -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;
+11 -78
View File
@@ -1,4 +1,4 @@
import { RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types'; import { RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
import { useRuntimeStore } from '../stores/runtime'; import { useRuntimeStore } from '../stores/runtime';
import { socketSendJson } from '../utils/socket'; import { socketSendJson } from '../utils/socket';
@@ -28,44 +28,11 @@ export const useOperator = () => {
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
}; };
export const useTimerViewControl = () => { export const useMessageControl = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
blackout: state.message.timer.blackout, timer: state.message.timer,
blink: state.message.timer.blink, external: state.message.external,
secondarySource: state.message.timer.secondarySource, onAir: state.onAir,
});
return useRuntimeStore(featureSelector);
};
export const useTimerMessageInput = () => {
const featureSelector = (state: RuntimeStore) => ({
text: state.message.timer.text,
visible: state.message.timer.visible,
});
return useRuntimeStore(featureSelector);
};
export const useExternalMessageInput = () => {
const featureSelector = (state: RuntimeStore) => ({
text: state.message.external,
visible: state.message.timer.secondarySource === 'external',
});
return useRuntimeStore(featureSelector);
};
export const useMessagePreview = () => {
const featureSelector = (state: RuntimeStore) => ({
blink: state.message.timer.blink,
blackout: state.message.timer.blackout,
phase: state.timer.phase,
showAuxTimer: state.message.timer.secondarySource === 'aux',
showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external),
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
timerType: state.eventNow?.timerType ?? null,
countToEnd: state.eventNow?.countToEnd ?? false,
}); });
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
@@ -74,11 +41,8 @@ export const useMessagePreview = () => {
export const setMessage = { export const setMessage = {
timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }), timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }),
timerVisible: (payload: boolean) => socketSendJson('message', { timer: { visible: payload } }), timerVisible: (payload: boolean) => socketSendJson('message', { timer: { visible: payload } }),
externalText: (payload: string) => socketSendJson('message', { external: payload }),
timerBlink: (payload: boolean) => socketSendJson('message', { timer: { blink: payload } }), timerBlink: (payload: boolean) => socketSendJson('message', { timer: { blink: payload } }),
timerBlackout: (payload: boolean) => socketSendJson('message', { timer: { blackout: payload } }), timerBlackout: (payload: boolean) => socketSendJson('message', { timer: { blackout: payload } }),
timerSecondary: (payload: TimerMessage['secondarySource']) =>
socketSendJson('message', { timer: { secondarySource: payload } }),
}; };
export const usePlaybackControl = () => { export const usePlaybackControl = () => {
@@ -86,7 +50,6 @@ export const usePlaybackControl = () => {
playback: state.timer.playback, playback: state.timer.playback,
selectedEventIndex: state.runtime.selectedEventIndex, selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents, numEvents: state.runtime.numEvents,
timerPhase: state.timer.phase,
}); });
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
@@ -149,21 +112,18 @@ 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) => ({ const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null, selectedEventId: state.eventNow?.id ?? null,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
titleNow: state.eventNow?.title || '',
}); });
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
}; };
export const useCurrentBlockId = () => {
const featureSelector = (state: RuntimeStore) => ({
currentBlockId: state.currentBlock.block?.id ?? null,
});
return useRuntimeStore(featureSelector);
};
export const setEventPlayback = { export const setEventPlayback = {
loadEvent: (id: string) => socketSendJson('load', { id }), loadEvent: (id: string) => socketSendJson('load', { id }),
startEvent: (id: string) => socketSendJson('start', { id }), startEvent: (id: string) => socketSendJson('start', { id }),
@@ -189,6 +149,7 @@ export const useClock = () => {
/** Used by the progress bar components */ /** Used by the progress bar components */
export const useProgressData = () => { export const useProgressData = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
addedTime: state.timer.addedTime,
current: state.timer.current, current: state.timer.current,
duration: state.timer.duration, duration: state.timer.duration,
timeWarning: state.eventNow?.timeWarning ?? null, timeWarning: state.eventNow?.timeWarning ?? null,
@@ -218,34 +179,6 @@ export const useRuntimePlaybackOverview = () => {
numEvents: state.runtime.numEvents, numEvents: state.runtime.numEvents,
selectedEventIndex: state.runtime.selectedEventIndex, selectedEventIndex: state.runtime.selectedEventIndex,
offset: state.runtime.offset, offset: state.runtime.offset,
currentBlock: state.currentBlock,
});
return useRuntimeStore(featureSelector);
};
export const useTimelineStatus = () => {
const featureSelector = (state: RuntimeStore) => ({
clock: state.clock,
offset: state.runtime.offset,
});
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); return useRuntimeStore(featureSelector);
+1 -1
View File
@@ -7,5 +7,5 @@ export const ontimePlaceholderInfo: GetInfo = {
version: '2.0.0', version: '2.0.0',
serverPort: 4001, serverPort: 4001,
osc: oscPlaceholderSettings, osc: oscPlaceholderSettings,
publicDir: '', cssOverride: '',
}; };
@@ -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,3 @@
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
import { validateEndAction, validateTimerType, validateTimeStrategy } from 'ontime-utils';
import { create } from 'zustand'; import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage'; import { booleanFromLocalStorage } from '../utils/localStorage';
@@ -7,63 +5,39 @@ 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;
defaultTimerType: TimerType;
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;
setDefaultTimerType: (defaultTimerType: TimerType) => void;
setDefaultEndAction: (defaultEndAction: EndAction) => void;
}; };
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,
timerType: TimerType.CountDown,
endAction: EndAction.None,
}; };
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',
DefaultTimerType = 'ontime-default-timer-type',
DefaultEndAction = 'ontime-default-end-action',
} }
export const useEditorSettings = create<EditorSettingsStore>((set) => { 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),
defaultTimerType: validateTimerType(
localStorage.getItem(EditorSettingsKeys.DefaultTimerType),
editorSettingsDefaults.timerType,
),
defaultEndAction: validateEndAction(
localStorage.getItem(EditorSettingsKeys.DefaultEndAction),
editorSettingsDefaults.endAction,
),
setDefaultDuration: (defaultDuration) => setDefaultDuration: (defaultDuration) =>
set(() => { set(() => {
@@ -76,12 +50,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));
@@ -97,15 +65,5 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic)); localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
return { defaultPublic }; return { defaultPublic };
}), }),
setDefaultTimerType: (defaultTimerType) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.DefaultTimerType, String(defaultTimerType));
return { defaultTimerType };
}),
setDefaultEndAction: (defaultEndAction) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.DefaultEndAction, String(defaultEndAction));
return { defaultEndAction };
}),
}; };
}); });
+52 -10
View File
@@ -1,7 +1,55 @@
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,
},
external: {
text: '',
visible: false,
},
},
runtime: {
selectedEventIndex: null,
numEvents: 0,
offset: 0,
plannedStart: 0,
plannedEnd: 0,
actualStart: null,
expectedEnd: null,
},
eventNow: null,
eventNext: null,
publicEventNow: null,
publicEventNext: null,
auxtimer1: {
current: 0,
direction: SimpleDirection.CountUp,
duration: 0,
playback: SimplePlayback.Stop,
},
};
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>(
@@ -16,17 +64,11 @@ export const useRuntimeStore = <T>(selector: (state: RuntimeStore) => T) =>
/** /**
* Allows patching a property of the runtime store * Allows patching a property of the runtime store
* @param key
* @param value
*/ */
export function patchRuntimeProperty<K extends keyof RuntimeStore>(key: K, value: RuntimeStore[K]) { export function patchRuntime<K extends keyof RuntimeStore>(key: K, value: RuntimeStore[K]): void {
const state = runtimeStore.getState(); const state = runtimeStore.getState();
state[key] = value; state[key] = value;
runtimeStore.setState({ ...state }); runtimeStore.setState({ ...state });
} }
/**
* Allows patching the entire runtime store
*/
export function patchRuntime(patch: Partial<RuntimeStore>) {
const state = runtimeStore.getState();
runtimeStore.setState({ ...state, ...patch });
}
+4 -4
View File
@@ -1,12 +1,12 @@
export function debounce<T extends any[]>(callback: (...args: T) => void, wait: number) { export function debounce(callback: () => void, wait: number) {
let timeout: NodeJS.Timeout | null; let timeout: NodeJS.Timeout | null;
return (...args: T) => { return () => {
if (timeout) { if (timeout) {
clearTimeout(timeout); return;
} }
timeout = setTimeout(() => { timeout = setTimeout(() => {
timeout = null; timeout = null;
callback(...args); callback();
}, wait); }, wait);
}; };
} }
@@ -7,7 +7,7 @@ import { OntimeEvent, SupportedEvent } from 'ontime-types';
* @return {OntimeEvent} clean event * @return {OntimeEvent} clean event
*/ */
type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>; type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>;
export const cloneEvent = (event: OntimeEvent): ClonedEvent => { export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
return { return {
type: SupportedEvent.Event, type: SupportedEvent.Event,
title: event.title, title: event.title,
@@ -17,14 +17,13 @@ export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
timeEnd: event.timeEnd, timeEnd: event.timeEnd,
timerType: event.timerType, timerType: event.timerType,
timeStrategy: event.timeStrategy, timeStrategy: event.timeStrategy,
countToEnd: event.countToEnd,
linkStart: event.linkStart, linkStart: event.linkStart,
endAction: event.endAction, endAction: event.endAction,
isPublic: event.isPublic, isPublic: event.isPublic,
skip: event.skip, skip: event.skip,
colour: event.colour, colour: event.colour,
after,
revision: 0, revision: 0,
delay: 0,
timeWarning: event.timeWarning, timeWarning: event.timeWarning,
timeDanger: event.timeDanger, timeDanger: event.timeDanger,
custom: {}, custom: {},
@@ -1,23 +0,0 @@
import { MaybeNumber } from 'ontime-types';
import { clamp } from './math';
/**
* Returns completion percentage of a progress bar
* This code assumes the current time and duration have addedTime already applied
*/
export function getProgress(current: MaybeNumber, duration: MaybeNumber) {
if (current === null || duration === null) {
return 0;
}
if (current <= 0) {
return 100;
}
if (current >= duration) {
return 0;
}
return clamp(((duration - current) / duration) * 100, 0, 100);
}
-8
View File
@@ -7,11 +7,3 @@ export function isKeyEnter<T>(event: KeyboardEvent<T>): boolean {
export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean { export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean {
return event.key === 'Escape'; return event.key === 'Escape';
} }
export function preventEscape<T>(event: KeyboardEvent<T>, callback?: () => void): void {
if (isKeyEscape(event)) {
event.preventDefault();
event.stopPropagation();
callback?.();
}
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Returns hostname
* @type {string}
*/
export const host = window?.location?.host;
/**
* Open an external URLs: specifically for a electron / browser case
* If electron: ask main process to call a new browser window
* If browser: open in new tab
* @param url
*/
export function openLink(url) {
if (window.process?.type === 'renderer') {
window.ipcRenderer.send('send-to-link', url);
} else {
window.open(url);
}
}
/**
* Handles opening external links
* @param event
* @param location
*/
export function handleLinks(event, location) {
// we handle the link manually
event.preventDefault();
openLink(`http://${host}/${location}`);
}
-40
View File
@@ -1,40 +0,0 @@
import type { MouseEvent } from 'react';
import { baseURI, serverURL } from '../../externals';
/**
* Open an external URLs: specifically for a electron / browser case
* If electron: ask main process to call a new browser window
* If browser: open in new tab
* @param url
*/
export function openLink(url: string) {
if (window.process?.type === 'renderer') {
window.ipcRenderer.send('send-to-link', url);
} else {
window.open(url);
}
}
/**
* Handles opening external links
* @param event
* @param location
*/
export function handleLinks(event: MouseEvent, location: string) {
// we handle the link manually
event.preventDefault();
const destination = new URL(serverURL);
destination.pathname = baseURI ? `${baseURI}/${location}` : location;
openLink(destination.toString());
}
export function linkToOtherHost(host: string, path?: string) {
const destination = new URL(serverURL);
destination.hostname = host;
if (path) {
destination.pathname = baseURI ? `${baseURI}/${path}` : path;
}
return destination.toString();
}
+18 -67
View File
@@ -1,8 +1,6 @@
import { Log, RundownCached, RuntimeStore } from 'ontime-types'; import { Log, RuntimeStore } from 'ontime-types';
import { isProduction, websocketUrl } from '../../externals'; import { CLIENT_LIST, isProduction, RUNTIME, websocketUrl } from '../api/constants';
import { CLIENT_LIST, CUSTOM_FIELDS, RUNDOWN, RUNTIME } from '../api/constants';
import { invalidateAllCaches } from '../api/utils';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
import { import {
getClientId, getClientId,
@@ -12,9 +10,8 @@ import {
setClientRedirect, setClientRedirect,
setClients, setClients,
} from '../stores/clientStore'; } from '../stores/clientStore';
import { addDialog } from '../stores/dialogStore';
import { addLog } from '../stores/logger'; import { addLog } from '../stores/logger';
import { patchRuntime, patchRuntimeProperty } from '../stores/runtime'; import { patchRuntime, runtimeStore } from '../stores/runtime';
export let websocket: WebSocket | null = null; export let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null; let reconnectTimeout: NodeJS.Timeout | null = null;
@@ -39,13 +36,11 @@ export const connectSocket = () => {
} }
socketSendJson('set-client-type', 'ontime'); socketSendJson('set-client-type', 'ontime');
setOnlineStatus(true); socketSendJson('set-client-path', location.pathname);
}; };
websocket.onclose = () => { websocket.onclose = () => {
console.warn('WebSocket disconnected'); console.warn('WebSocket disconnected');
setOnlineStatus(false);
if (shouldReconnect) { if (shouldReconnect) {
reconnectTimeout = setTimeout(() => { reconnectTimeout = setTimeout(() => {
console.warn('WebSocket: attempting reconnect'); console.warn('WebSocket: attempting reconnect');
@@ -72,12 +67,6 @@ export const connectSocket = () => {
} }
switch (type) { switch (type) {
case 'pong': {
const offset = (new Date().getTime() - new Date(payload).getTime()) * 0.5;
patchRuntimeProperty('ping', offset);
updateDevTools({ ping: offset });
break;
}
case 'client-id': { case 'client-id': {
if (typeof payload === 'string') { if (typeof payload === 'string') {
setClientId(payload); setClientId(payload);
@@ -120,93 +109,67 @@ export const connectSocket = () => {
break; break;
} }
case 'dialog': {
if (payload.dialog === 'welcome') {
addDialog('welcome');
}
break;
}
case 'ontime-log': { case 'ontime-log': {
addLog(payload as Log); addLog(payload as Log);
break; break;
} }
case 'ontime': { case 'ontime': {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- removing the key from the payload runtimeStore.setState(payload as RuntimeStore);
const { ping, ...serverPayload } = payload as Partial<RuntimeStore>; if (!isProduction) {
ontimeQueryClient.setQueryData(RUNTIME, data.payload);
patchRuntime(serverPayload); }
updateDevTools(serverPayload);
break; break;
} }
case 'ontime-clock': { case 'ontime-clock': {
patchRuntimeProperty('clock', payload); patchRuntime('clock', payload);
updateDevTools({ clock: payload }); updateDevTools({ clock: payload });
break; break;
} }
case 'ontime-timer': { case 'ontime-timer': {
patchRuntimeProperty('timer', payload); patchRuntime('timer', payload);
updateDevTools({ timer: payload }); updateDevTools({ timer: payload });
break; break;
} }
case 'ontime-onAir': { case 'ontime-onAir': {
patchRuntimeProperty('onAir', payload); patchRuntime('onAir', payload);
updateDevTools({ onAir: payload }); updateDevTools({ onAir: payload });
break; break;
} }
case 'ontime-message': { case 'ontime-message': {
patchRuntimeProperty('message', payload); patchRuntime('message', payload);
updateDevTools({ message: payload }); updateDevTools({ message: payload });
break; break;
} }
case 'ontime-runtime': { case 'ontime-runtime': {
patchRuntimeProperty('runtime', payload); patchRuntime('runtime', payload);
updateDevTools({ runtime: payload }); updateDevTools({ runtime: payload });
break; break;
} }
case 'ontime-eventNow': { case 'ontime-eventNow': {
patchRuntimeProperty('eventNow', payload); patchRuntime('eventNow', payload);
updateDevTools({ eventNow: payload }); updateDevTools({ eventNow: payload });
break; break;
} }
case 'ontime-currentBlock': {
patchRuntimeProperty('currentBlock', payload);
updateDevTools({ currentBlock: payload });
break;
}
case 'ontime-publicEventNow': { case 'ontime-publicEventNow': {
patchRuntimeProperty('publicEventNow', payload); patchRuntime('publicEventNow', payload);
updateDevTools({ publicEventNow: payload }); updateDevTools({ publicEventNow: payload });
break; break;
} }
case 'ontime-eventNext': { case 'ontime-eventNext': {
patchRuntimeProperty('eventNext', payload); patchRuntime('eventNext', payload);
updateDevTools({ eventNext: payload }); updateDevTools({ eventNext: payload });
break; break;
} }
case 'ontime-publicEventNext': { case 'ontime-publicEventNext': {
patchRuntimeProperty('publicEventNext', payload); patchRuntime('publicEventNext', payload);
updateDevTools({ publicEventNext: payload }); updateDevTools({ publicEventNext: payload });
break; break;
} }
case 'ontime-auxtimer1': { case 'ontime-auxtimer1': {
patchRuntimeProperty('auxtimer1', payload); patchRuntime('auxtimer1', payload);
updateDevTools({ auxtimer1: payload }); updateDevTools({ auxtimer1: payload });
break; break;
} }
case 'ontime-refetch': {
// the refetch message signals that the rundown has changed in the server side
const { revision, reload } = payload;
const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1;
if (reload) {
invalidateAllCaches();
} else if (revision > currentRevision) {
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
}
break;
}
} }
} catch (_) { } catch (_) {
// ignore unhandled // ignore unhandled
@@ -242,15 +205,3 @@ function updateDevTools(newData: Partial<RuntimeStore>) {
})); }));
} }
} }
/**
* Allows setting the status of the client
* We leverage the ping as an indication of the client's online status
* @example ping < 0 - client is offline
* @example ping > 0 -> client is online
*/
function setOnlineStatus(status: boolean) {
const derivedPing = status ? 1 : -1;
patchRuntimeProperty('ping', derivedPing);
updateDevTools({ ping: derivedPing });
}
@@ -34,16 +34,3 @@ export const enDash = '';
export const timerPlaceholder = '––:––:––'; export const timerPlaceholder = '––:––:––';
export const timerPlaceholderMin = '––:––'; export const timerPlaceholderMin = '––:––';
/**
* Adds opacity to a given colour if possible
*/
export function alpha(colour: string, amount: number): string {
try {
const withAlpha = Color(colour).alpha(amount).hexa();
return withAlpha;
} catch (_error) {
/* we do not handle errors here */
}
return colour;
}
-12
View File
@@ -1,12 +0,0 @@
export function throttle<T extends any[]>(callback: (...args: T) => void, wait: number) {
let timeout: NodeJS.Timeout | null;
return (...args: T) => {
if (timeout) {
return;
}
timeout = setTimeout(() => {
timeout = null;
callback(...args);
}, wait);
};
}
+7 -37
View File
@@ -1,25 +1,25 @@
import { MaybeNumber, Settings, TimeFormat } from 'ontime-types'; import { MaybeNumber, Settings, TimeFormat } from 'ontime-types';
import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils'; import { formatFromMillis } from 'ontime-utils';
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig'; import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
import { APP_SETTINGS } from '../api/constants'; import { APP_SETTINGS } from '../api/constants';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
/** /**
* Returns current time in milliseconds from midnight * Returns current time in milliseconds
* @returns {number} * @returns {number}
*/ */
export function nowInMillis(): number { export const nowInMillis = () => {
const now = new Date(); const now = new Date();
// extract milliseconds since midnight // extract milliseconds since midnight
let elapsed = now.getHours() * MILLIS_PER_HOUR; let elapsed = now.getHours() * 3600000;
elapsed += now.getMinutes() * MILLIS_PER_MINUTE; elapsed += now.getMinutes() * 60000;
elapsed += now.getSeconds() * MILLIS_PER_SECOND; elapsed += now.getSeconds() * 1000;
elapsed += now.getMilliseconds(); elapsed += now.getMilliseconds();
return elapsed; return elapsed;
} };
/** /**
* @description Resolves format from url and store * @description Resolves format from url and store
@@ -95,33 +95,3 @@ export const formatTime = (
const isNegative = milliseconds < 0; const isNegative = milliseconds < 0;
return `${isNegative ? '-' : ''}${display}`; return `${isNegative ? '-' : ''}${display}`;
}; };
/**
* Handles case for formatting a duration time
* @param duration
* @returns
*/
export function formatDuration(duration: number, hideSeconds = true): string {
// durations should never be negative, we handle it here to flag if there is an issue in future
if (duration <= 0) {
return '0h 0m';
}
const hours = Math.floor(duration / MILLIS_PER_HOUR);
const minutes = Math.floor((duration % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE);
let result = '';
if (hours > 0) {
result += `${hours}h`;
}
if (minutes > 0) {
result += `${minutes}m`;
}
if (!hideSeconds) {
const seconds = Math.floor((duration % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND);
if (seconds > 0) {
result += `${seconds}s`;
}
}
return result;
}
+1 -17
View File
@@ -34,7 +34,7 @@ export function validateProjectFile(file: File) {
// Limit file size of a project file to around 1MB // Limit file size of a project file to around 1MB
if (file.size > 1_000_000) { if (file.size > 1_000_000) {
throw new Error('File size limit (1MB) exceeded'); throw new Error('File size limit (10MB) exceeded');
} }
} }
@@ -45,19 +45,3 @@ export function isExcelFile(file: File | null) {
export function isOntimeFile(file: File | null) { export function isOntimeFile(file: File | null) {
return file?.name.endsWith('.json'); return file?.name.endsWith('.json');
} }
/**
* Collection of rules for pre-validating a project file
* @param file
*/
export function validateLogo(file: File) {
// Check if file is empty
if (file.size === 0) {
throw new Error('File is empty');
}
// Limit file size of a project file to around 1MB
if (file.size > 1_000_000) {
throw new Error('File size limit (1MB) exceeded');
}
}
-21
View File
@@ -6,9 +6,6 @@ declare module '*.scss' {
type ListenerType = (event: 'string', args: unknown[]) => void; type ListenerType = (event: 'string', args: unknown[]) => void;
declare global { declare global {
/**
* Register the electron properties
*/
interface Window { interface Window {
ipcRenderer: { ipcRenderer: {
send: (channel: string, args?: string | object) => void; send: (channel: string, args?: string | object) => void;
@@ -20,24 +17,6 @@ declare global {
} }
} }
/**
* We pass a custom property to the table meta to allow field update
*/
declare module '@tanstack/react-table' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface TableMeta<TData extends RowData> {
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom: boolean) => void;
handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => void;
options: {
showDelayedTimes: boolean;
hideTableSeconds: boolean;
};
}
}
/**
* Allow passing CSS Properties
*/
declare module 'react' { declare module 'react' {
interface CSSProperties { interface CSSProperties {
[key: `--${string}`]: string | number; [key: `--${string}`]: string | number;
-70
View File
@@ -1,75 +1,5 @@
/**
* This file contains a list of constants that may need to be resolved at runtime
*/
import { version } from '../../../package.json';
export const githubUrl = 'https://www.github.com/cpvalente/ontime'; export const githubUrl = 'https://www.github.com/cpvalente/ontime';
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest'; export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
export const websiteUrl = 'https://www.getontime.no'; export const websiteUrl = 'https://www.getontime.no';
export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
export const documentationUrl = 'https://docs.getontime.no'; export const documentationUrl = 'https://docs.getontime.no';
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
// resolve environment
export const appVersion = version;
export const isProduction = import.meta.env.MODE === 'production';
export const isDev = !isProduction;
export const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
export const isDockerImage = Boolean(import.meta.env.VITE_IS_DOCKER);
export const isOntimeCloud = window.location.hostname.includes('cloud.getontime.no');
// resolve entrypoint URLs
/**
* Resolve base
* @example '' for electron
* @example '/client-hash' for cloud
*/
export const baseURI = resolveBaseURI();
export const serverURL = resolveUrl('http', '');
export const websocketUrl = resolveUrl('ws', 'ws');
function resolveUrl(protocol: 'http' | 'ws', path: string) {
const url = new URL(window.location.origin);
// generate ws url
if (protocol === 'ws') {
// ensure we remain in a secure context
const isSecure = window.location.protocol === 'https:';
url.protocol = isSecure ? 'wss' : 'ws';
}
// make path name relative to the base URI
url.pathname = baseURI ? `${baseURI}/${path}` : path;
// in development mode, we use the React port for UI, but need the requests to target the server
if (isDev) {
// this is used as a fallback port for development
url.port = '4001';
}
const result = url.toString();
// prevent trailing slash
return result.endsWith('/') ? result.slice(0, -1) : result;
}
/**
* Resolves a base URI for a client that is not at the root segment
* ie: https://cloud.getontime.com/client-hash/timer
* This is necessary for ontime cloud and should otherwise not affect the client
*/
function resolveBaseURI(): string {
// in ontime cloud, the base tag is set by the server
const baseHref = document.querySelector('base')?.getAttribute('href');
const base = baseHref ?? '';
// prevent a trailing slash from either an empty base or a base with a trailing slash
if (base.endsWith('/')) {
return base.slice(0, -1);
}
return base;
}
@@ -18,7 +18,7 @@ import useAppSettingsNavigation from './useAppSettingsNavigation';
import style from './AppSettings.module.scss'; import style from './AppSettings.module.scss';
export default function AppSettings() { export default function AppSettings() {
const { close, panel, location, setLocation } = useAppSettingsNavigation(); const { close, panel, location } = useAppSettingsNavigation();
useKeyDown(close, 'Escape'); useKeyDown(close, 'Escape');
return ( return (
@@ -26,7 +26,7 @@ export default function AppSettings() {
<ErrorBoundary> <ErrorBoundary>
<PanelList selectedPanel={panel} location={location} /> <PanelList selectedPanel={panel} location={location} />
<PanelContent onClose={close}> <PanelContent onClose={close}>
{panel === 'project' && <ProjectPanel location={location} setLocation={setLocation} />} {panel === 'project' && <ProjectPanel location={location} />}
{panel === 'general' && <GeneralPanel location={location} />} {panel === 'general' && <GeneralPanel location={location} />}
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />} {panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
{panel === 'sources' && <SourcesPanel />} {panel === 'sources' && <SourcesPanel />}
@@ -40,16 +40,12 @@ ul {
background-color: $gray-1100; background-color: $gray-1100;
} }
&.highlight { &.unsaved::before {
color: $red-400; content: '';
width: 6px;
&::before { height: 6px;
content: ''; border-radius: 3px;
width: 0.5em; background-color: $blue-400;
height: 0.5em;
border-radius: 99px;
background-color: $red-400;
}
} }
&.split { &.split {
@@ -1,90 +1,66 @@
import { Fragment } from 'react'; import { Fragment } from 'react';
import { Tooltip } from '@chakra-ui/react';
import { isKeyEnter } from '../../../common/utils/keyEvent'; import { isKeyEnter } from '../../../common/utils/keyEvent';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayFast } from '../../../ontimeConfig'; import { PanelBaseProps, settingPanels, useSettingsStore } from '../settingsStore';
import { SettingsOption, SettingsOptionId, useAppSettingsMenu } from '../useAppSettingsMenu';
import useAppSettingsNavigation from '../useAppSettingsNavigation'; import useAppSettingsNavigation from '../useAppSettingsNavigation';
import style from './PanelList.module.scss'; import style from './PanelList.module.scss';
export interface PanelBaseProps {
location?: string;
}
interface PanelListProps extends PanelBaseProps { interface PanelListProps extends PanelBaseProps {
selectedPanel: string; selectedPanel: string;
} }
export default function PanelList({ selectedPanel, location }: PanelListProps) { export default function PanelList({ selectedPanel, location }: PanelListProps) {
const { options } = useAppSettingsMenu(); const { setLocation } = useAppSettingsNavigation();
const { hasUnsavedChanges } = useSettingsStore();
return ( return (
<ul className={style.tabs}> <ul className={style.tabs}>
{options.map((panel) => { {settingPanels.map((panel) => {
const isSelected = selectedPanel === panel.id; const unsaved = hasUnsavedChanges(panel.id);
if (panel.highlight) {
return ( const classes = cx([
<Tooltip key={panel.id} label={panel.highlight} openDelay={tooltipDelayFast} shouldWrapChildren> style.primary,
<PanelListItem panel={panel} location={location} isSelected={isSelected} /> selectedPanel === panel.id ? style.active : null,
</Tooltip> panel.split ? style.split : null,
); unsaved ? style.unsaved : null,
} ]);
return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />;
return (
<Fragment key={panel.id}>
<li
key={panel.id}
onClick={() => setLocation(panel.id)}
onKeyDown={(event) => {
isKeyEnter(event) && setLocation(panel.id);
}}
className={classes}
tabIndex={0}
role='button'
>
{panel.label}
</li>
{panel.secondary?.map((secondary) => {
const id = secondary.id.split('__')[1];
const secondaryClasses = cx([style.secondary, location === id ? style.active : null]);
return (
<li
key={secondary.id}
onClick={() => setLocation(secondary.id)}
onKeyDown={(event) => {
isKeyEnter(event) && setLocation(secondary.id);
}}
className={secondaryClasses}
role='button'
>
{secondary.label}
</li>
);
})}
</Fragment>
);
})} })}
</ul> </ul>
); );
} }
interface PanelListItemProps {
panel: SettingsOption;
isSelected: boolean;
location?: string;
}
function PanelListItem(props: PanelListItemProps) {
const { panel, isSelected, location } = props;
const { setLocation } = useAppSettingsNavigation();
const classes = cx([
style.primary,
isSelected && style.active,
panel.split && style.split,
panel.highlight && style.highlight,
]);
return (
<Fragment key={panel.id}>
<li
key={panel.id}
onClick={() => setLocation(panel.id as SettingsOptionId)}
onKeyDown={(event) => {
isKeyEnter(event) && setLocation(panel.id as SettingsOptionId);
}}
className={classes}
tabIndex={0}
role='button'
>
{panel.label}
</li>
{panel.secondary?.map((secondary) => {
const id = secondary.id.split('__')[1];
const secondaryClasses = cx([style.secondary, isSelected && location === id ? style.active : null]);
return (
<li
key={secondary.id}
onClick={() => setLocation(secondary.id as SettingsOptionId)}
onKeyDown={(event) => {
isKeyEnter(event) && setLocation(secondary.id as SettingsOptionId);
}}
className={secondaryClasses}
role='button'
>
{secondary.label}
</li>
);
})}
</Fragment>
);
}

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