mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-17 13:23:35 +00:00
Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3158311aa1 | |||
| 6b4d96dddd | |||
| dc3a32d2bc | |||
| bda01c886e | |||
| 93916653be | |||
| 40f8976350 | |||
| 78e905a735 | |||
| 126ff61738 | |||
| 1e63238cac | |||
| 6ffe34a642 | |||
| 69f276765b | |||
| 75554071a7 | |||
| a1fab10bfd | |||
| 5bc286145d | |||
| 44508ce8b4 | |||
| 90105a199a | |||
| 9618bec847 | |||
| 66a4e27fdd | |||
| ef906f43d7 | |||
| 93b2a1aece | |||
| 050629d281 | |||
| abe9012f20 | |||
| ad2ef3b53f | |||
| 1e1f547ce1 | |||
| 92a34cf135 | |||
| 1e5081f4a2 | |||
| 6f9e13948d | |||
| 55e34b5f9a | |||
| dd47d2b7e7 | |||
| 445cc9e845 | |||
| 637454f73d | |||
| e9dda817e5 | |||
| e811a82be2 | |||
| fd9bf0120c | |||
| e5f8a99bff | |||
| 7305edc398 | |||
| a69c5f354c | |||
| 0eaec1f88f | |||
| cdd021e76d | |||
| 05d01e0863 | |||
| 2aac4cb06d | |||
| 1c6adbd2fd | |||
| d24d5c1365 | |||
| 138c6523d2 | |||
| d8c1c38123 | |||
| 4d419c0877 | |||
| 50b603f668 | |||
| e1e8410ba4 | |||
| 4d359445a7 | |||
| 8d68a4554d | |||
| 6f34e1006b | |||
| 1fc6f9b3ea | |||
| a1ad1d47a4 | |||
| 9281c0b51d | |||
| 4365c19498 | |||
| 8728c1450b | |||
| a98c14406d | |||
| ab8bfbcd59 | |||
| f2913971db | |||
| a7ae8598db | |||
| 17a7d035bf | |||
| 6facd6a666 | |||
| 79bcc179ba | |||
| 3790681bec | |||
| c32203c759 | |||
| ce449408f0 |
+4
-4
@@ -45,7 +45,7 @@ Generally we have 2 types of tests.
|
|||||||
|
|
||||||
Unit tests are contained in mostly all the apps and packages (client, server and utils)
|
Unit tests are contained in mostly all the apps and packages (client, server and utils)
|
||||||
|
|
||||||
You can run unit tests by running turbo `turbo test:pipeline` from the project root.
|
You can run unit tests by running `turbo run test:pipeline` from the project root.
|
||||||
This will run all tests and close test runner.
|
This will run all tests and close test runner.
|
||||||
|
|
||||||
Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode
|
Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode
|
||||||
@@ -76,13 +76,13 @@ You can generate a distribution for your OS by running the following steps.
|
|||||||
From the project root, run the following commands
|
From the project root, run the following commands
|
||||||
|
|
||||||
- __Install the project dependencies__ by running `pnpm i`
|
- __Install the project dependencies__ by running `pnpm i`
|
||||||
- __Build the UI and server__ by running `turbo build:electron`
|
- __Build the UI and server__ by running `turbo run build:electron`
|
||||||
- __Create the package__ by running `turbo dist-win`, `turbo dist-mac` or `turbo dist-linux`
|
- __Create the package__ by running `turbo run dist-win`, `turbo run dist-mac` or `turbo run dist-linux`
|
||||||
|
|
||||||
The build distribution assets will be at `.apps/electron/dist`
|
The build distribution assets will be at `.apps/electron/dist`
|
||||||
|
|
||||||
Note: The MacOS build will only work in CI, locally it will fail due to notarisation issues.
|
Note: The MacOS build will only work in CI, locally it will fail due to notarisation issues.
|
||||||
Use the `turbo dist-mac:local` command to build a MacOS distribution locally.
|
Use the `turbo run dist-mac:local` command to build a MacOS distribution locally.
|
||||||
|
|
||||||
## DOCKER
|
## DOCKER
|
||||||
|
|
||||||
|
|||||||
@@ -43,12 +43,12 @@ Ontime is made by entertainment and broadcast engineers and used by
|
|||||||
- Houses of worship
|
- Houses of worship
|
||||||
|
|
||||||
## Main features
|
## Main features
|
||||||
- [x] **Multiplatform**: Available for Windows / MacOS, Linux. You can also self host with the docker image
|
- **Multiplatform**: Available as a Cloud service and for Windows, macOS, Linux, or self-hosted via Docker.
|
||||||
- [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...
|
- **In any device**: Ontime is available 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...
|
- **Team Collaboration**: Dedicated views for directors, operators, backstage, and signage.
|
||||||
- [x] **Delay workflows**: Manage and communicate runtime delays in real-time to your team
|
- **Real-Time Updates**: Manage and communicate runtime delays effortlessly.
|
||||||
- [x] **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock
|
- **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)
|
- **Flexible 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 ...
|
... and a lot more ...
|
||||||
|
|
||||||
@@ -79,77 +79,29 @@ Ontime is made by video engineers and entertainment technicians.
|
|||||||
|
|
||||||
## Using Ontime
|
## Using Ontime
|
||||||
|
|
||||||
Ontime can be started by downloading the latest release for your platform. \
|
### Getting started
|
||||||
Alternatively you can also use the docker image, available at [Docker Hub](https://hub.docker.com/r/getontime/ontime)
|
|
||||||
|
|
||||||
Once installed and running, any device in the network has access to Ontime.
|
The easiest way to start with Ontime is by leveraging our [Cloud service](https://getontime.no). \
|
||||||
|
This will give you immediate access to running instances of Ontime which are available to share with anyone with an internet connection.
|
||||||
|
|
||||||
Ontime provides different screens which allow for different types of interactions with the data. These are called
|
Alternatively, you can run Ontime locally for free by downloading the latest release for your platform or using the docker image, available at [Docker Hub](https://hub.docker.com/r/getontime/ontime)
|
||||||
views. \
|
|
||||||
Each view in Ontime focuses on empowering a specific role or achieving a particular task.
|
|
||||||
|
|
||||||
You can access the different views by reaching the ontime server, in your browser, at (_default port
|
Once installed and running, any device that shares the same network as Ontime will have access to Ontime.
|
||||||
4001_) `http://localhost:4001` or `http://192.168.1.3:4001`
|
|
||||||
|
|
||||||
```
|
|
||||||
For the backstage views
|
|
||||||
-------------------------------------------------------------
|
|
||||||
IP.ADDRESS:4001/timer > Presenter / Stage timer view
|
|
||||||
IP.ADDRESS:4001/minimal > Simple timer view
|
|
||||||
IP.ADDRESS:4001/clock > Simple clock view
|
|
||||||
IP.ADDRESS:4001/backstage > Stage Manager / Backstage view
|
|
||||||
IP.ADDRESS:4001/countdown > Countdown to anything
|
|
||||||
IP.ADDRESS:4001/studio > Studio Clock
|
|
||||||
IP.ADDRESS:4001/timeline > Timeline
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
For production views
|
|
||||||
-------------------------------------------------------------
|
|
||||||
IP.ADDRESS:4001/editor > the control interface, same as the app
|
|
||||||
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
|
|
||||||
IP.ADDRESS:4001/op > automated views for operators
|
|
||||||
```
|
|
||||||
|
|
||||||
More information is available [in our docs](https://docs.getontime.no)
|
More information is available [in our docs](https://docs.getontime.no)
|
||||||
|
|
||||||
## 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 improving 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)
|
||||||
|
|
||||||
### Issues
|
## Issues
|
||||||
|
|
||||||
We use Github's issue tracking for bug reporting and feature requests. \
|
We use Github's issue tracking for bug reporting and feature requests. \
|
||||||
Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new).
|
Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new).
|
||||||
|
|
||||||
#### Unsigned App
|
|
||||||
|
|
||||||
When installing the app you would see warning screens from the Operating System like:
|
|
||||||
|
|
||||||
in Windows
|
|
||||||
|
|
||||||
`Microsoft Defender SmartScreen prevented an unrecognised app from starting. Running this app might put your PC at risk.`
|
|
||||||
|
|
||||||
or in Linux
|
|
||||||
|
|
||||||
`Could Not Display "ontime-linux.AppImage`
|
|
||||||
|
|
||||||
We currently only sign MacOS releases. \
|
|
||||||
Purchasing the certificates for both Mac and Windows would mean a recurrent expense which we are not able to cover.
|
|
||||||
|
|
||||||
You can circumvent this by allowing the execution of the app manually.
|
|
||||||
|
|
||||||
- In Windows: click `more` -> `Run Anyway`
|
|
||||||
- In Linux: right-click the AppImage file: `Properties` -> `Permissions` -> `Allow Executing File as a Program`
|
|
||||||
|
|
||||||
If you have tips on how to improve this or would like to sponsor the code signing,
|
|
||||||
please [open an issue](https://github.com/cpvalente/ontime/issues/new)
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Looking to contribute? All types of help are appreciated, from coding to testing and feature specification.
|
Looking to contribute? All types of help are appreciated, from coding to testing and feature specification.
|
||||||
@@ -161,6 +113,7 @@ Information about the project setup can be found in the [development documentati
|
|||||||
## Links
|
## Links
|
||||||
- [Ontime website](https://getontime.no)
|
- [Ontime website](https://getontime.no)
|
||||||
- [Documentation](https://docs.getontime.no)
|
- [Documentation](https://docs.getontime.no)
|
||||||
|
- [Video tutorials](https://www.youtube.com/@ontimeapp)
|
||||||
- [Ontime discord server](https://discord.com/invite/eje3CSUEXm)
|
- [Ontime discord server](https://discord.com/invite/eje3CSUEXm)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "3.15.2",
|
"version": "4.0.0-alpha.1",
|
||||||
"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",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<style>
|
<style>
|
||||||
body,
|
body,
|
||||||
html {
|
html {
|
||||||
background-color: rgba(0, 0, 0, 0) !important;
|
background-color: #101010 !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "3.15.2",
|
"version": "4.0.0-alpha.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui-components/react": "1.0.0-beta.1",
|
"@base-ui-components/react": "1.0.0-beta.1",
|
||||||
"@chakra-ui/react": "^2.7.0",
|
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@emotion/is-prop-valid": "^1.3.1",
|
"@emotion/is-prop-valid": "^1.3.1",
|
||||||
"@emotion/react": "^11.10.6",
|
|
||||||
"@emotion/styled": "^11.10.6",
|
|
||||||
"@fontsource/open-sans": "^5.0.28",
|
"@fontsource/open-sans": "^5.0.28",
|
||||||
"@mantine/hooks": "^8.1.2",
|
"@mantine/hooks": "^8.1.2",
|
||||||
"@sentry/react": "^8.43.0",
|
"@sentry/react": "^8.43.0",
|
||||||
@@ -21,12 +18,12 @@
|
|||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"autosize": "^6.0.1",
|
"autosize": "^6.0.1",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
|
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
||||||
"csv-stringify": "^6.4.5",
|
"csv-stringify": "^6.4.5",
|
||||||
"framer-motion": "^10.10.0",
|
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
"react": "^18.3.1",
|
"react": "^19.1.0",
|
||||||
"react-colorful": "^5.6.1",
|
"react-colorful": "^5.6.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^19.1.0",
|
||||||
"react-fast-compare": "^3.2.2",
|
"react-fast-compare": "^3.2.2",
|
||||||
"react-hook-form": "^7.53.1",
|
"react-hook-form": "^7.53.1",
|
||||||
"react-icons": "5.4.0",
|
"react-icons": "5.4.0",
|
||||||
@@ -67,8 +64,8 @@
|
|||||||
"@sentry/vite-plugin": "^2.16.1",
|
"@sentry/vite-plugin": "^2.16.1",
|
||||||
"@tanstack/eslint-plugin-query": "^5.8.4",
|
"@tanstack/eslint-plugin-query": "^5.8.4",
|
||||||
"@types/prismjs": "^1.26.5",
|
"@types/prismjs": "^1.26.5",
|
||||||
"@types/react": "^18.0.26",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^18.0.10",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||||
"@typescript-eslint/parser": "catalog:",
|
"@typescript-eslint/parser": "catalog:",
|
||||||
"@vitejs/plugin-react": "4.5.1",
|
"@vitejs/plugin-react": "4.5.1",
|
||||||
@@ -78,16 +75,15 @@
|
|||||||
"eslint-plugin-prettier": "catalog:",
|
"eslint-plugin-prettier": "catalog:",
|
||||||
"eslint-plugin-react": "^7.32.0",
|
"eslint-plugin-react": "^7.32.0",
|
||||||
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-simple-import-sort": "^8.0.0",
|
"eslint-plugin-simple-import-sort": "^8.0.0",
|
||||||
"happy-dom": "^16.7.2",
|
|
||||||
"ontime-types": "workspace:*",
|
"ontime-types": "workspace:*",
|
||||||
"ontime-utils": "workspace:*",
|
"ontime-utils": "workspace:*",
|
||||||
"prettier": "catalog:",
|
"prettier": "catalog:",
|
||||||
"sass": "^1.57.1",
|
"sass": "^1.57.1",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "6.3.1",
|
"vite": "6.3.1",
|
||||||
"vite-plugin-compression2": "1.4.0",
|
"vite-plugin-compression2": "2.2.0",
|
||||||
"vite-plugin-svgr": "4.3.0",
|
"vite-plugin-svgr": "4.3.0",
|
||||||
"vite-tsconfig-paths": "5.1.4",
|
"vite-tsconfig-paths": "5.1.4",
|
||||||
"vitest": "catalog:"
|
"vitest": "catalog:"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { ChakraProvider } from '@chakra-ui/react';
|
import { Tooltip } from '@base-ui-components/react/tooltip';
|
||||||
import { QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
|
|
||||||
@@ -8,7 +8,6 @@ import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverla
|
|||||||
import { AppContextProvider } from './common/context/AppContext';
|
import { AppContextProvider } from './common/context/AppContext';
|
||||||
import { ontimeQueryClient } from './common/queryClient';
|
import { ontimeQueryClient } from './common/queryClient';
|
||||||
import { connectSocket } from './common/utils/socket';
|
import { connectSocket } from './common/utils/socket';
|
||||||
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';
|
import { baseURI } from './externals';
|
||||||
@@ -17,9 +16,9 @@ connectSocket();
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
|
<QueryClientProvider client={ontimeQueryClient}>
|
||||||
<QueryClientProvider client={ontimeQueryClient}>
|
<AppContextProvider>
|
||||||
<AppContextProvider>
|
<Tooltip.Provider>
|
||||||
<BrowserRouter basename={baseURI}>
|
<BrowserRouter basename={baseURI}>
|
||||||
<div className='App'>
|
<div className='App'>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
@@ -34,9 +33,9 @@ function App() {
|
|||||||
<div id='identify-portal' />
|
<div id='identify-portal' />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AppContextProvider>
|
</Tooltip.Provider>
|
||||||
</QueryClientProvider>
|
</AppContextProvider>
|
||||||
</ChakraProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +1,29 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import { Navigate, Route } 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 ViewLoader from './views/ViewLoader';
|
||||||
import { ONTIME_VERSION } from './ONTIME_VERSION';
|
import { initializeSentry } from './sentry.config';
|
||||||
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
|
|
||||||
|
|
||||||
const Editor = React.lazy(() => import('./views/editor/ProtectedEditor'));
|
const Editor = React.lazy(() => import('./views/editor/ProtectedEditor'));
|
||||||
const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
|
const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
|
||||||
const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
|
const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
|
||||||
|
|
||||||
const TimerView = React.lazy(() => import('./views/timer/Timer'));
|
const TimerView = React.lazy(() => import('./views/timer/Timer'));
|
||||||
const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
|
|
||||||
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
|
|
||||||
const Countdown = React.lazy(() => import('./views/countdown/Countdown'));
|
const Countdown = React.lazy(() => import('./views/countdown/Countdown'));
|
||||||
|
|
||||||
const Backstage = React.lazy(() => import('./views/backstage/Backstage'));
|
const Backstage = React.lazy(() => import('./views/backstage/Backstage'));
|
||||||
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
|
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
|
||||||
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
|
||||||
const StudioClock = React.lazy(() => import('./views/studio/Studio'));
|
const StudioClock = React.lazy(() => import('./views/studio/Studio'));
|
||||||
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
|
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 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 SProjectInfo = withPreset(ProjectInfo); // NOTE: ProjectInfo does not use the viewWrapper since it has no options
|
||||||
const SLowerThird = withPreset(withData(Lower));
|
|
||||||
const SStudio = withPreset(withData(StudioClock));
|
const SStudio = withPreset(withData(StudioClock));
|
||||||
const STimeline = withPreset(withData(Timeline));
|
const STimeline = withPreset(withData(Timeline));
|
||||||
const PCuesheet = withPreset(Cuesheet);
|
const PCuesheet = withPreset(Cuesheet);
|
||||||
@@ -50,25 +34,8 @@ const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport')
|
|||||||
const TimerControl = React.lazy(() => import('./features/control/playback/TimerControlExport'));
|
const TimerControl = React.lazy(() => import('./features/control/playback/TimerControlExport'));
|
||||||
const MessageControl = React.lazy(() => import('./features/control/message/MessageControlExport'));
|
const MessageControl = React.lazy(() => import('./features/control/message/MessageControlExport'));
|
||||||
|
|
||||||
Sentry.init({
|
// Initialize Sentry with our configuration
|
||||||
dsn: sentryDsn,
|
const SentryRouter = initializeSentry();
|
||||||
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
|
||||||
@@ -76,7 +43,7 @@ export default function AppRouter() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Suspense fallback={null}>
|
<React.Suspense fallback={null}>
|
||||||
<SentryRoutes>
|
<SentryRouter>
|
||||||
<Route path='/' element={<Navigate to='/timer' />} />
|
<Route path='/' element={<Navigate to='/timer' />} />
|
||||||
<Route
|
<Route
|
||||||
path='/timer'
|
path='/timer'
|
||||||
@@ -86,22 +53,6 @@ export default function AppRouter() {
|
|||||||
</ViewLoader>
|
</ViewLoader>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path='/minimal'
|
|
||||||
element={
|
|
||||||
<ViewLoader>
|
|
||||||
<SMinimalTimer />
|
|
||||||
</ViewLoader>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/clock'
|
|
||||||
element={
|
|
||||||
<ViewLoader>
|
|
||||||
<SClock />
|
|
||||||
</ViewLoader>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path='/countdown'
|
path='/countdown'
|
||||||
element={
|
element={
|
||||||
@@ -126,8 +77,6 @@ export default function AppRouter() {
|
|||||||
</ViewLoader>
|
</ViewLoader>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/*/!* Lower third cannot have a loading screen *!/*/}
|
|
||||||
<Route path='/lower' element={<SLowerThird />} />
|
|
||||||
<Route
|
<Route
|
||||||
path='/timeline'
|
path='/timeline'
|
||||||
element={
|
element={
|
||||||
@@ -192,7 +141,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>
|
</SentryRouter>
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { apiEntryUrl } from './constants';
|
|||||||
const urlPresetsPath = `${apiEntryUrl}/url-presets`;
|
const urlPresetsPath = `${apiEntryUrl}/url-presets`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to retrieve aliases
|
* HTTP request to retrieve all presets
|
||||||
*/
|
*/
|
||||||
export async function getUrlPresets(): Promise<URLPreset[]> {
|
export async function getUrlPresets(): Promise<URLPreset[]> {
|
||||||
const res = await axios.get(urlPresetsPath);
|
const res = await axios.get(urlPresetsPath);
|
||||||
@@ -14,8 +14,22 @@ export async function getUrlPresets(): Promise<URLPreset[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to mutate aliases
|
* HTTP request to add a preset
|
||||||
*/
|
*/
|
||||||
export async function postUrlPresets(data: URLPreset[]): Promise<URLPreset[]> {
|
export async function postUrlPreset(data: URLPreset): Promise<URLPreset[]> {
|
||||||
return axios.post(urlPresetsPath, data);
|
return (await axios.post(urlPresetsPath, data)).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to edit a preset
|
||||||
|
*/
|
||||||
|
export async function putUrlPreset(alias: string, data: URLPreset): Promise<URLPreset[]> {
|
||||||
|
return (await axios.put(`${urlPresetsPath}/${alias}`, data)).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to delete a preset
|
||||||
|
*/
|
||||||
|
export async function deleteUrlPreset(alias: string): Promise<URLPreset[]> {
|
||||||
|
return (await axios.delete(`${urlPresetsPath}/${alias}`)).data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,20 @@ export function maybeAxiosError(error: unknown) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility unwrap a an instance of Error
|
||||||
|
*/
|
||||||
|
export function unwrapError(error: unknown) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
} else {
|
||||||
|
if (typeof error !== 'string') {
|
||||||
|
return JSON.stringify(error);
|
||||||
|
}
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility unwraps a potential axios error and sends to logger
|
* Utility unwraps a potential axios error and sends to logger
|
||||||
* @param prepend
|
* @param prepend
|
||||||
|
|||||||
@@ -2,14 +2,23 @@ import axios from 'axios';
|
|||||||
import type { ViewSettings } from 'ontime-types';
|
import type { ViewSettings } from 'ontime-types';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
const viewSettingsPath = apiEntryUrl + '/view-settings';
|
|
||||||
|
|
||||||
export async function getViewSettings() {
|
const viewSettingsPath = `${apiEntryUrl}/view-settings`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to fetch view settings
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export async function getViewSettings(): Promise<ViewSettings> {
|
||||||
const res = await axios.get(viewSettingsPath);
|
const res = await axios.get(viewSettingsPath);
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postViewSettings(data: ViewSettings) {
|
/**
|
||||||
|
* HTTP request to update view settings
|
||||||
|
* needs to update entire objects, not just a patch
|
||||||
|
*/
|
||||||
|
export async function postViewSettings(data: ViewSettings): Promise<ViewSettings> {
|
||||||
const res = await axios.post(viewSettingsPath, data);
|
const res = await axios.post(viewSettingsPath, data);
|
||||||
return res.data as ViewSettings;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@use '../../../theme/viewerDefs' as *;
|
@use '@/theme/viewerDefs' as *;
|
||||||
@import './BaseButtonStyles.module.scss';
|
@import './BaseButtonStyles.module.scss';
|
||||||
|
|
||||||
.baseButton {
|
.baseButton {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@use '../../../theme/viewerDefs' as *;
|
@use '@/theme/viewerDefs' as *;
|
||||||
@import './BaseButtonStyles.module.scss';
|
@import './BaseButtonStyles.module.scss';
|
||||||
|
|
||||||
.baseIconButton {
|
.baseIconButton {
|
||||||
@@ -11,6 +11,11 @@
|
|||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid $blue-500;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ButtonHTMLAttributes } from 'react';
|
import { ButtonHTMLAttributes, forwardRef } from 'react';
|
||||||
|
|
||||||
import { cx } from '../../utils/styleUtils';
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
|
||||||
@@ -17,20 +17,21 @@ interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
size?: 'small' | 'medium' | 'large' | 'xlarge';
|
size?: 'small' | 'medium' | 'large' | 'xlarge';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function IconButton({
|
const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||||
className,
|
({ className, children, variant = 'subtle', size = 'medium', ...buttonProps }, ref) => {
|
||||||
children,
|
return (
|
||||||
variant = 'subtle',
|
<button
|
||||||
size = 'medium',
|
ref={ref}
|
||||||
...buttonProps
|
className={cx([style.baseIconButton, style[variant], style[size], className])}
|
||||||
}: IconButtonProps) {
|
type='button'
|
||||||
return (
|
{...buttonProps}
|
||||||
<button
|
>
|
||||||
className={cx([style.baseIconButton, style[variant], style[size], className])}
|
{children}
|
||||||
type='button'
|
</button>
|
||||||
{...buttonProps}
|
);
|
||||||
>
|
},
|
||||||
{children}
|
);
|
||||||
</button>
|
|
||||||
);
|
IconButton.displayName = 'IconButton';
|
||||||
}
|
|
||||||
|
export default IconButton;
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import { MouseEvent } from 'react';
|
|
||||||
import { IconButton, IconButtonProps, Tooltip } from '@chakra-ui/react';
|
|
||||||
|
|
||||||
interface TooltipActionBtnProps extends IconButtonProps {
|
|
||||||
clickHandler: (event: MouseEvent) => void | Promise<void>;
|
|
||||||
tooltip: string;
|
|
||||||
openDelay?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TooltipActionBtn(props: TooltipActionBtnProps) {
|
|
||||||
const { clickHandler, icon, size = 'xs', tooltip, openDelay = 0, className, ...rest } = props;
|
|
||||||
return (
|
|
||||||
<Tooltip label={tooltip} openDelay={openDelay}>
|
|
||||||
<IconButton {...rest} size={size} icon={icon} onClick={clickHandler} className={className} />
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -45,8 +45,8 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
|||||||
label: view.label,
|
label: view.label,
|
||||||
})),
|
})),
|
||||||
...enabledPresets.map((preset) => ({
|
...enabledPresets.map((preset) => ({
|
||||||
value: preset.pathAndParams,
|
value: preset.search,
|
||||||
label: `Preset: ${preset.alias}`,
|
label: `URL Preset: ${preset.alias}`,
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
|||||||
Either by selecting a URL Preset or entering a custom path.
|
Either by selecting a URL Preset or entering a custom path.
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
<AppLink search='settings=feature_settings__urlpresets'>Manage URL Presets</AppLink>
|
<AppLink search='settings=sharing__presets'>Manage URL Presets</AppLink>
|
||||||
</Info>
|
</Info>
|
||||||
<div>
|
<div>
|
||||||
<span className={style.label}>Select View or URL Preset</span>
|
<span className={style.label}>Select View or URL Preset</span>
|
||||||
@@ -91,12 +91,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
|||||||
<span className={style.label}>Enter custom path</span>
|
<span className={style.label}>Enter custom path</span>
|
||||||
<label className={style.textEntry}>
|
<label className={style.textEntry}>
|
||||||
{origin}
|
{origin}
|
||||||
<Input
|
<Input placeholder='eg. /timer' fluid value={path} onChange={(event) => setPath(event.target.value)} />
|
||||||
placeholder='eg. /minimal?key=0000ffff'
|
|
||||||
fluid
|
|
||||||
value={path}
|
|
||||||
onChange={(event) => setPath(event.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<Button
|
<Button
|
||||||
variant='primary'
|
variant='primary'
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
.contextMenuButton {
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contextMenuBackdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
// logic (with some modifications) culled from:
|
|
||||||
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
|
|
||||||
|
|
||||||
import { ReactElement } from 'react';
|
|
||||||
import { IconType } from 'react-icons';
|
|
||||||
import { Menu, MenuButton, MenuGroup, MenuList } from '@chakra-ui/react';
|
|
||||||
import { create } from 'zustand';
|
|
||||||
|
|
||||||
import { ContextMenuOption } from './ContextMenuOption';
|
|
||||||
|
|
||||||
import style from './ContextMenu.module.scss';
|
|
||||||
|
|
||||||
type ContextMenuCoords = {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OptionWithoutGroup = {
|
|
||||||
label: string;
|
|
||||||
isDisabled?: boolean;
|
|
||||||
icon: IconType;
|
|
||||||
onClick: () => void;
|
|
||||||
withDivider?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type OptionWithGroup = {
|
|
||||||
label: string;
|
|
||||||
group: Omit<OptionWithoutGroup, 'isGroup'>[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Option = OptionWithoutGroup | OptionWithGroup;
|
|
||||||
|
|
||||||
const isOptionWithGroup = (option: Option): option is OptionWithGroup => 'group' in option;
|
|
||||||
|
|
||||||
type ContextMenuStore = {
|
|
||||||
coords: ContextMenuCoords;
|
|
||||||
options: Option[];
|
|
||||||
isOpen: boolean;
|
|
||||||
setContextMenu: (coords: ContextMenuCoords, options: Option[]) => void;
|
|
||||||
setIsOpen: (newIsOpen: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useContextMenuStore = create<ContextMenuStore>((set) => ({
|
|
||||||
coords: { x: 0, y: 0 },
|
|
||||||
options: [],
|
|
||||||
isOpen: false,
|
|
||||||
setContextMenu: (coords, options) => set(() => ({ coords, options, isOpen: true })),
|
|
||||||
setIsOpen: (newIsOpen) => set(() => ({ isOpen: newIsOpen })),
|
|
||||||
}));
|
|
||||||
|
|
||||||
interface ContextMenuProps {
|
|
||||||
// ReactElement type required due to early `return` (line 51) returning {children}
|
|
||||||
children: ReactElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ContextMenu = ({ children }: ContextMenuProps) => {
|
|
||||||
const { coords, options, isOpen, setIsOpen } = useContextMenuStore();
|
|
||||||
|
|
||||||
const onClose = () => {
|
|
||||||
return setIsOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen) {
|
|
||||||
return children;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{children}
|
|
||||||
<div className={style.contextMenuBackdrop} />
|
|
||||||
<Menu isOpen size='sm' gutter={0} onClose={onClose} isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
|
|
||||||
<MenuButton
|
|
||||||
className={style.contextMenuButton}
|
|
||||||
aria-hidden
|
|
||||||
w={1}
|
|
||||||
h={1}
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
left: coords.x,
|
|
||||||
top: coords.y,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<MenuList>
|
|
||||||
{options.map((option) =>
|
|
||||||
isOptionWithGroup(option) ? (
|
|
||||||
<MenuGroup key={option.label} title={option.label}>
|
|
||||||
{option.group.map((groupOption) => (
|
|
||||||
<ContextMenuOption key={groupOption.label} {...groupOption} />
|
|
||||||
))}
|
|
||||||
</MenuGroup>
|
|
||||||
) : (
|
|
||||||
<ContextMenuOption key={option.label} {...option} />
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</MenuList>
|
|
||||||
</Menu>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { MenuDivider, MenuItem } from '@chakra-ui/react';
|
|
||||||
|
|
||||||
import { OptionWithoutGroup } from './ContextMenu';
|
|
||||||
|
|
||||||
export const ContextMenuOption = ({ label, onClick, isDisabled, icon: Icon, withDivider }: OptionWithoutGroup) => (
|
|
||||||
<>
|
|
||||||
{withDivider && <MenuDivider />}
|
|
||||||
<MenuItem icon={<Icon style={{ fontSize: '1rem' }} />} onClick={onClick} isDisabled={isDisabled}>
|
|
||||||
{label}
|
|
||||||
</MenuItem>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
.copytag {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action {
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
border-radius: 3px 0 0 3px;
|
||||||
|
border: 1px solid $gray-1200;
|
||||||
|
background-color: $white-3;
|
||||||
|
padding-inline: 0.5rem 1rem;
|
||||||
|
line-height: 1em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: $label-gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
height: 1.5rem;
|
||||||
|
font-size: calc(1rem - 3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.medium {
|
||||||
|
height: 2rem;
|
||||||
|
font-size: calc(1rem - 2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.large {
|
||||||
|
height: 2.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy {
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
}
|
||||||
@@ -1,22 +1,28 @@
|
|||||||
import { PropsWithChildren, useState } from 'react';
|
import { PropsWithChildren, useState } from 'react';
|
||||||
import { IoCheckmark } from 'react-icons/io5';
|
import { IoCheckmark } from 'react-icons/io5';
|
||||||
import { IoCopy } from 'react-icons/io5';
|
import { IoCopy } from 'react-icons/io5';
|
||||||
import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react';
|
|
||||||
|
|
||||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
|
||||||
import { Size } from '../../models/Util.type';
|
|
||||||
import copyToClipboard from '../../utils/copyToClipboard';
|
import copyToClipboard from '../../utils/copyToClipboard';
|
||||||
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
import Button from '../buttons/Button';
|
||||||
|
import IconButton from '../buttons/IconButton';
|
||||||
|
|
||||||
|
import style from './CopyTag.module.scss';
|
||||||
|
|
||||||
interface CopyTagProps {
|
interface CopyTagProps {
|
||||||
copyValue: string;
|
copyValue: string;
|
||||||
label: string;
|
|
||||||
size?: Size;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
size?: 'small' | 'medium' | 'large';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
|
export default function CopyTag({
|
||||||
const { copyValue, label, size = 'xs', disabled, children, onClick } = props;
|
copyValue,
|
||||||
|
disabled,
|
||||||
|
size = 'medium',
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
}: PropsWithChildren<CopyTagProps>) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
@@ -28,20 +34,24 @@ export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip label={label} openDelay={tooltipDelayFast}>
|
<div className={style.copytag}>
|
||||||
<ButtonGroup size={size} isAttached>
|
{onClick !== undefined ? (
|
||||||
<Button variant='ontime-subtle' tabIndex={-1} onClick={onClick} isDisabled={disabled}>
|
<Button className={style.action} size={size} tabIndex={-1} onClick={onClick} disabled={disabled}>
|
||||||
{children}
|
{children}
|
||||||
</Button>
|
</Button>
|
||||||
<IconButton
|
) : (
|
||||||
aria-label={label}
|
<div className={cx([style.label, style[size]])}>{children}</div>
|
||||||
icon={copied ? <IoCheckmark /> : <IoCopy />}
|
)}
|
||||||
variant='ontime-filled'
|
<IconButton
|
||||||
tabIndex={-1}
|
className={style.copy}
|
||||||
onClick={handleClick}
|
variant='primary'
|
||||||
isDisabled={disabled}
|
size={size}
|
||||||
/>
|
tabIndex={-1}
|
||||||
</ButtonGroup>
|
onClick={handleClick}
|
||||||
</Tooltip>
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{copied ? <IoCheckmark /> : <IoCopy />}
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
.delaySymbol {
|
.delaySymbol {
|
||||||
svg {
|
svg {
|
||||||
|
display: inline;
|
||||||
|
vertical-align: middle;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
color: $ontime-delay;
|
color: $ontime-delay;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { IoChevronDown, IoChevronUp } from 'react-icons/io5';
|
import { IoChevronDown, IoChevronUp } from 'react-icons/io5';
|
||||||
import { Tooltip } from '@chakra-ui/react';
|
|
||||||
|
|
||||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
|
||||||
import { millisToDelayString } from '../../utils/dateConfig';
|
import { millisToDelayString } from '../../utils/dateConfig';
|
||||||
|
import Tooltip from '../tooltip/Tooltip';
|
||||||
|
|
||||||
import style from './DelayIndicator.module.scss';
|
import style from './DelayIndicator.module.scss';
|
||||||
|
|
||||||
@@ -23,8 +22,8 @@ export default function DelayIndicator(props: DelayIndicatorProps) {
|
|||||||
: millisToDelayString(delayValue);
|
: millisToDelayString(delayValue);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip openDelay={tooltipDelayFast} label={delayString}>
|
<Tooltip text={delayString} render={<span />} className={style.delaySymbol}>
|
||||||
<span className={style.delaySymbol}>{delayValue < 0 ? <IoChevronDown /> : <IoChevronUp />}</span>
|
{delayValue < 0 ? <IoChevronDown /> : <IoChevronUp />}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
top: 10%;
|
top: 10%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
|
|
||||||
z-index: $zindex-dialog;
|
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
|
|
||||||
padding-inline: 1rem;
|
padding-inline: 1rem;
|
||||||
@@ -19,7 +18,6 @@
|
|||||||
.backdrop {
|
.backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: $zindex-backdrop;
|
|
||||||
background-color: $backdrop-color;
|
background-color: $backdrop-color;
|
||||||
transition: opacity 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
|
transition: opacity 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
.positioner {
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding-block: 0.25rem;
|
||||||
|
|
||||||
|
color: $ui-white;
|
||||||
|
background-color: $gray-1250;
|
||||||
|
font-size: calc(1rem - 2px);
|
||||||
|
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid $gray-1000;
|
||||||
|
box-shadow: $box-shadow-l1;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
transform-origin: var(--transform-origin);
|
||||||
|
transition:
|
||||||
|
transform 150ms,
|
||||||
|
opacity 150ms;
|
||||||
|
|
||||||
|
&[data-starting-style],
|
||||||
|
&[data-ending-style] {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.item {
|
||||||
|
outline: 0;
|
||||||
|
cursor: default;
|
||||||
|
padding-block: 0.5rem;
|
||||||
|
padding-inline: 1rem 2rem;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
line-height: 1em;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-disabled] {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-highlighted] {
|
||||||
|
z-index: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-highlighted]:not([data-disabled])::before {
|
||||||
|
content: '';
|
||||||
|
z-index: -1;
|
||||||
|
position: absolute;
|
||||||
|
inset-block: 0;
|
||||||
|
inset-inline: 0.25rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: $gray-1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
margin: 0.25rem 0.75rem;
|
||||||
|
height: 1px;
|
||||||
|
background-color: $white-7;
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { PropsWithChildren } from 'react';
|
||||||
|
import { IconType } from 'react-icons';
|
||||||
|
import { Menu as BaseMenu } from '@base-ui-components/react/menu';
|
||||||
|
|
||||||
|
import style from './DropdownMenu.module.scss';
|
||||||
|
|
||||||
|
type DropdownMenuItemDivider = { type: 'divider' };
|
||||||
|
type DropdownMenuItem = {
|
||||||
|
type: 'item';
|
||||||
|
label: string;
|
||||||
|
icon?: IconType;
|
||||||
|
disabled?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DropdownMenuOption = DropdownMenuItemDivider | DropdownMenuItem;
|
||||||
|
|
||||||
|
interface DropdownMenuProps extends BaseMenu.Trigger.Props {
|
||||||
|
items: DropdownMenuOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownMenu({ items, children, ...triggerProps }: PropsWithChildren<DropdownMenuProps>) {
|
||||||
|
return (
|
||||||
|
<BaseMenu.Root>
|
||||||
|
<BaseMenu.Trigger {...triggerProps}>{children}</BaseMenu.Trigger>
|
||||||
|
<BaseMenu.Portal>
|
||||||
|
<BaseMenu.Positioner className={style.positioner} align='start' sideOffset={8}>
|
||||||
|
<BaseMenu.Popup className={style.popup}>
|
||||||
|
{items.map((item, index) => {
|
||||||
|
if (item.type === 'divider') {
|
||||||
|
return <BaseMenu.Separator key={index} className={style.separator} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
|
||||||
|
{item.icon && <item.icon />}
|
||||||
|
{item.label}
|
||||||
|
</BaseMenu.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</BaseMenu.Popup>
|
||||||
|
</BaseMenu.Positioner>
|
||||||
|
</BaseMenu.Portal>
|
||||||
|
</BaseMenu.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PositionedDropdownMenuProps {
|
||||||
|
items: Array<DropdownMenuItemDivider | DropdownMenuItem>;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PositionedDropdownMenu({ items, isOpen, position, onClose }: PositionedDropdownMenuProps) {
|
||||||
|
return (
|
||||||
|
<BaseMenu.Root
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BaseMenu.Trigger style={{ position: 'fixed', left: position.x, top: position.y }} aria-hidden />
|
||||||
|
<BaseMenu.Portal>
|
||||||
|
<BaseMenu.Positioner className={style.positioner} align='start' sideOffset={8} alignOffset={8}>
|
||||||
|
<BaseMenu.Popup className={style.popup}>
|
||||||
|
{items.map((item, index) => {
|
||||||
|
if (item.type === 'divider') {
|
||||||
|
return <BaseMenu.Separator key={index} className={style.separator} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
|
||||||
|
{item.icon && <item.icon />}
|
||||||
|
{item.label}
|
||||||
|
</BaseMenu.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</BaseMenu.Popup>
|
||||||
|
</BaseMenu.Positioner>
|
||||||
|
</BaseMenu.Portal>
|
||||||
|
</BaseMenu.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,5 +33,5 @@ interface SeparatorProps extends HTMLAttributes<HTMLDivElement> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Separator({ className, orientation = 'vertical', ...elementProps }: SeparatorProps) {
|
export function Separator({ className, orientation = 'vertical', ...elementProps }: SeparatorProps) {
|
||||||
return <div className={cx([style.separator, style[orientation], className])} {...elementProps} />;
|
return <div className={cx([style.separator, style[orientation], className])} role='separator' {...elementProps} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,13 @@ interface FitTextProps extends HTMLAttributes<HTMLDivElement> {
|
|||||||
max?: number; // inclusive
|
max?: number; // inclusive
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FitText(props: PropsWithChildren<FitTextProps>) {
|
export function FitText({
|
||||||
const { children, mode = 'multi', min = 16, max = 256, ...elementProps } = props;
|
children,
|
||||||
|
mode = 'multi',
|
||||||
|
min = 16,
|
||||||
|
max = 256,
|
||||||
|
...elementProps
|
||||||
|
}: PropsWithChildren<FitTextProps>) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const isOverflown = useCallback(() => {
|
const isOverflown = useCallback(() => {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-content: center;
|
place-content: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
z-index: $zindex-modal;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { RefObject, useEffect } from 'react';
|
|
||||||
import { Textarea, TextareaProps } from '@chakra-ui/react';
|
|
||||||
// @ts-expect-error no types from library
|
|
||||||
import autosize from 'autosize/dist/autosize';
|
|
||||||
|
|
||||||
export const AutoTextArea = (props: TextareaProps & { inputref: RefObject<unknown> }) => {
|
|
||||||
const { value, inputref } = props;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const node = inputref.current;
|
|
||||||
autosize(inputref.current);
|
|
||||||
return () => {
|
|
||||||
autosize.destroy(node);
|
|
||||||
};
|
|
||||||
}, [inputref, value]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Textarea
|
|
||||||
overflow='hidden'
|
|
||||||
w='100%'
|
|
||||||
ref={inputref}
|
|
||||||
resize='none'
|
|
||||||
transition='height none'
|
|
||||||
variant='ontime-transparent'
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { RefObject, useEffect } from 'react';
|
||||||
|
// @ts-expect-error no types from library
|
||||||
|
import autosize from 'autosize/dist/autosize';
|
||||||
|
|
||||||
|
import Textarea, { type TextareaProps } from '../textarea/Textarea';
|
||||||
|
|
||||||
|
interface AutoTextAreaProps extends TextareaProps {
|
||||||
|
inputref: RefObject<HTMLTextAreaElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A textarea that automatically resizes based on its content
|
||||||
|
*/
|
||||||
|
export function AutoTextarea({ value, inputref, ...textAreaProps }: AutoTextAreaProps) {
|
||||||
|
// when the value changes, we use the ref to reapply autosize
|
||||||
|
useEffect(() => {
|
||||||
|
const node = inputref.current;
|
||||||
|
autosize(inputref.current);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
autosize.destroy(node);
|
||||||
|
};
|
||||||
|
}, [inputref, value]);
|
||||||
|
|
||||||
|
return <Textarea ref={inputref} value={value} {...textAreaProps} />;
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useCallback } from 'react';
|
|
||||||
import { useController, UseControllerProps } from 'react-hook-form';
|
import { useController, UseControllerProps } from 'react-hook-form';
|
||||||
import { IoEyedrop } from 'react-icons/io5';
|
import { IoEyedrop } from 'react-icons/io5';
|
||||||
|
import { useDebouncedCallback } from '@mantine/hooks';
|
||||||
import { ViewSettings } from 'ontime-types';
|
import { ViewSettings } from 'ontime-types';
|
||||||
|
|
||||||
import { debounce } from '../../../utils/debounce';
|
|
||||||
import { cx, getAccessibleColour } from '../../../utils/styleUtils';
|
import { cx, getAccessibleColour } from '../../../utils/styleUtils';
|
||||||
import PopoverPicker from '../popover-picker/PopoverPicker';
|
import PopoverPicker from '../popover-picker/PopoverPicker';
|
||||||
|
|
||||||
@@ -19,12 +18,9 @@ interface SwatchPickerProps {
|
|||||||
export default function SwatchPicker(props: SwatchPickerProps) {
|
export default function SwatchPicker(props: SwatchPickerProps) {
|
||||||
const { color, onChange, isSelected, alwaysDisplayColor } = props;
|
const { color, onChange, isSelected, alwaysDisplayColor } = props;
|
||||||
|
|
||||||
const debouncedOnChange = useCallback(
|
const debouncedOnChange = useDebouncedCallback((newValue: string) => {
|
||||||
debounce((newValue: string) => {
|
onChange(newValue);
|
||||||
onChange(newValue);
|
}, 100);
|
||||||
}, 500),
|
|
||||||
[onChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const displayColor = alwaysDisplayColor || isSelected ? color : '';
|
const displayColor = alwaysDisplayColor || isSelected ? color : '';
|
||||||
const { color: iconColor } = getAccessibleColour(displayColor);
|
const { color: iconColor } = getAccessibleColour(displayColor);
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
.radioGroup {
|
||||||
|
color: $gray-900;
|
||||||
|
font-size: calc(1rem - 3px);
|
||||||
|
color: $label-gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
line-height: 1.2em;
|
||||||
|
|
||||||
|
&:has([data-checked]) {
|
||||||
|
color: $ui-white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
width: 0.75rem;
|
||||||
|
height: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 100%;
|
||||||
|
outline: 0;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&[data-unchecked] {
|
||||||
|
background-color: $gray-1200;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-checked] {
|
||||||
|
background-color: $gray-1200;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $gray-1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid $blue-500;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator {
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
|
||||||
|
&[data-unchecked] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
border-radius: 100%;
|
||||||
|
width: 0.5em;
|
||||||
|
height: 0.5em;
|
||||||
|
background-color: $blue-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Radio } from '@base-ui-components/react/radio';
|
||||||
|
import { RadioGroup as BaseRadioGroup } from '@base-ui-components/react/radio-group';
|
||||||
|
|
||||||
|
import style from './BlockRadio.module.scss';
|
||||||
|
|
||||||
|
interface BlockRadioProps<T extends string | number | boolean> extends Omit<BaseRadioGroup.Props, 'onValueChange'> {
|
||||||
|
items: {
|
||||||
|
value: T;
|
||||||
|
label: string;
|
||||||
|
}[];
|
||||||
|
onValueChange?: (value: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BlockRadio<T extends string | number | boolean>({
|
||||||
|
items,
|
||||||
|
onValueChange,
|
||||||
|
...elementProps
|
||||||
|
}: BlockRadioProps<T>) {
|
||||||
|
return (
|
||||||
|
<BaseRadioGroup
|
||||||
|
onValueChange={(value) => onValueChange?.(value as T)}
|
||||||
|
className={style.radioGroup}
|
||||||
|
{...elementProps}
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
|
<label className={style.item} key={item.value.toString()}>
|
||||||
|
<Radio.Root value={item.value.toString()} className={style.radio}>
|
||||||
|
<Radio.Indicator className={style.indicator} />
|
||||||
|
</Radio.Root>
|
||||||
|
{item.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</BaseRadioGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,22 +1,12 @@
|
|||||||
$input-font-size: 15px;
|
|
||||||
|
|
||||||
.delayInput {
|
.delayInput {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: $element-spacing;
|
gap: $element-spacing;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: $text-body-size;
|
|
||||||
|
|
||||||
|
|
||||||
.inputField {
|
|
||||||
font-size: $input-font-size;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
max-width: 7em;
|
|
||||||
padding-left: 16px;
|
|
||||||
color: $ontime-delay-text
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.delayOptions {
|
.inputField {
|
||||||
display: flex;
|
text-align: center;
|
||||||
flex-direction: column;
|
letter-spacing: 1px;
|
||||||
|
max-width: 7em;
|
||||||
|
color: $ontime-delay-text
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { KeyboardEvent, useEffect, useRef, useState } from 'react';
|
import { KeyboardEvent, useEffect, useRef, useState } from 'react';
|
||||||
import { Input, Radio, RadioGroup } from '@chakra-ui/react';
|
|
||||||
import { millisToString, parseUserTime } from 'ontime-utils';
|
import { millisToString, parseUserTime } from 'ontime-utils';
|
||||||
|
|
||||||
import { useEntryActions } from '../../../hooks/useEntryAction';
|
import { useEntryActions } from '../../../hooks/useEntryAction';
|
||||||
|
import Input from '../input/Input';
|
||||||
|
|
||||||
|
import BlockRadio from './BlockRadio';
|
||||||
|
|
||||||
import style from './DelayInput.module.scss';
|
import style from './DelayInput.module.scss';
|
||||||
|
|
||||||
@@ -105,13 +107,10 @@ export default function DelayInput(props: DelayInputProps) {
|
|||||||
return (
|
return (
|
||||||
<div className={style.delayInput}>
|
<div className={style.delayInput}>
|
||||||
<Input
|
<Input
|
||||||
size='sm'
|
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
data-testid='delay-input'
|
data-testid='delay-input'
|
||||||
className={style.inputField}
|
className={style.inputField}
|
||||||
type='text'
|
|
||||||
placeholder='-'
|
placeholder='-'
|
||||||
variant='ontime-filled'
|
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onChange={(event) => setValue(event.target.value)}
|
onChange={(event) => setValue(event.target.value)}
|
||||||
onBlur={(event) => validateAndSubmit(event.target.value)}
|
onBlur={(event) => validateAndSubmit(event.target.value)}
|
||||||
@@ -119,16 +118,14 @@ export default function DelayInput(props: DelayInputProps) {
|
|||||||
value={value}
|
value={value}
|
||||||
maxLength={9}
|
maxLength={9}
|
||||||
/>
|
/>
|
||||||
<RadioGroup
|
<BlockRadio
|
||||||
className={style.delayOptions}
|
onValueChange={handleSlipChange}
|
||||||
onChange={handleSlipChange}
|
|
||||||
value={checkedOption}
|
value={checkedOption}
|
||||||
variant='ontime-block'
|
items={[
|
||||||
size='sm'
|
{ value: 'add', label: 'Add time' },
|
||||||
>
|
{ value: 'subtract', label: 'Subtract time' },
|
||||||
<Radio value='add'>Add time</Radio>
|
]}
|
||||||
<Radio value='subtract'>Subtract time</Radio>
|
/>
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 400;
|
font-weight: inherit;
|
||||||
color: $gray-200;
|
color: $gray-200;
|
||||||
border-radius: $component-border-radius-md;
|
border-radius: $component-border-radius-md;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
@@ -16,7 +16,8 @@
|
|||||||
|
|
||||||
&:focus:not(:read-only) {
|
&:focus:not(:read-only) {
|
||||||
background-color: $gray-1000;
|
background-color: $gray-1000;
|
||||||
border: 1px solid $blue-500;
|
outline: 2px solid $blue-500;
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
|
|||||||
@@ -1,20 +1,36 @@
|
|||||||
|
// styles from Input.module.scss
|
||||||
.input {
|
.input {
|
||||||
color: $gray-200;
|
width: 100%;
|
||||||
border: 1px solid transparent;
|
margin-top: 0.25rem;
|
||||||
border-radius: 0 0 8px 8px;
|
box-sizing: border-box;
|
||||||
background-color: $gray-1200;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: $gray-200;
|
||||||
|
border-radius: $component-border-radius-md;
|
||||||
|
background-color: $gray-1200;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
|
padding-inline: 0.5em;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
||||||
&:hover {
|
&:hover:not(:disabled) {
|
||||||
background-color: $gray-1100;
|
background-color: $gray-1100;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus:not(:read-only) {
|
||||||
background-color: $gray-1000;
|
background-color: $gray-1000;
|
||||||
color: $gray-50;
|
|
||||||
border: 1px solid $blue-500;
|
border: 1px solid $blue-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $gray-500;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
import { HexAlphaColorPicker, HexColorInput } from 'react-colorful';
|
import { HexAlphaColorPicker, HexColorInput } from 'react-colorful';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@chakra-ui/react';
|
import { Popover } from '@base-ui-components/react/popover';
|
||||||
|
|
||||||
|
import PopoverContents from '../../popover/Popover';
|
||||||
|
|
||||||
import style from './PopoverPicker.module.scss';
|
import style from './PopoverPicker.module.scss';
|
||||||
|
|
||||||
@@ -9,15 +11,14 @@ interface PopoverPickerProps {
|
|||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PopoverPicker(props: PropsWithChildren<PopoverPickerProps>) {
|
export default function PopoverPicker({ color, onChange, children }: PropsWithChildren<PopoverPickerProps>) {
|
||||||
const { color, onChange, children } = props;
|
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover.Root>
|
||||||
<PopoverTrigger>{children}</PopoverTrigger>
|
<Popover.Trigger>{children}</Popover.Trigger>
|
||||||
<PopoverContent className={style.small} style={{ borderRadius: '9px', width: 'auto' }}>
|
<PopoverContents>
|
||||||
<HexAlphaColorPicker color={color} onChange={onChange} />
|
<HexAlphaColorPicker color={color} onChange={onChange} />
|
||||||
<HexColorInput color={color} onChange={onChange} className={style.input} prefixed />
|
<HexColorInput color={color} onChange={onChange} className={style.input} prefixed />
|
||||||
</PopoverContent>
|
</PopoverContents>
|
||||||
</Popover>
|
</Popover.Root>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ interface UseReactiveTextInputReturn {
|
|||||||
export default function useReactiveTextInput(
|
export default function useReactiveTextInput(
|
||||||
initialText: string,
|
initialText: string,
|
||||||
submitCallback: (newValue: string) => void,
|
submitCallback: (newValue: string) => void,
|
||||||
ref: RefObject<HTMLInputElement>,
|
ref: RefObject<HTMLInputElement | HTMLTextAreaElement | null>,
|
||||||
options?: {
|
options?: {
|
||||||
submitOnEnter?: boolean;
|
submitOnEnter?: boolean;
|
||||||
submitOnCtrlEnter?: boolean;
|
submitOnCtrlEnter?: boolean;
|
||||||
@@ -101,25 +101,31 @@ export default function useReactiveTextInput(
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (options?.submitOnEnter) {
|
if (options?.submitOnEnter) {
|
||||||
hotKeys.push(['Enter', () => {
|
hotKeys.push([
|
||||||
isKeyboardSubmitting.current = true;
|
'Enter',
|
||||||
handleSubmit(text);
|
() => {
|
||||||
// clear flag after blur has been processed
|
isKeyboardSubmitting.current = true;
|
||||||
setTimeout(() => {
|
handleSubmit(text);
|
||||||
isKeyboardSubmitting.current = false;
|
// clear flag after blur has been processed
|
||||||
}, 0);
|
setTimeout(() => {
|
||||||
}]);
|
isKeyboardSubmitting.current = false;
|
||||||
|
}, 0);
|
||||||
|
},
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options?.submitOnCtrlEnter) {
|
if (options?.submitOnCtrlEnter) {
|
||||||
hotKeys.push(['mod + Enter', () => {
|
hotKeys.push([
|
||||||
isKeyboardSubmitting.current = true;
|
'mod + Enter',
|
||||||
handleSubmit(text);
|
() => {
|
||||||
// clear flag after blur has been processed
|
isKeyboardSubmitting.current = true;
|
||||||
setTimeout(() => {
|
handleSubmit(text);
|
||||||
isKeyboardSubmitting.current = false;
|
// clear flag after blur has been processed
|
||||||
}, 0);
|
setTimeout(() => {
|
||||||
}]);
|
isKeyboardSubmitting.current = false;
|
||||||
|
}, 0);
|
||||||
|
},
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hotKeyHandler = getHotkeyHandler(hotKeys);
|
const hotKeyHandler = getHotkeyHandler(hotKeys);
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
@use '../../../../theme/viewerDefs' as *;
|
|
||||||
|
|
||||||
.textarea {
|
.textarea {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
min-height: 2rem;
|
||||||
|
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 400;
|
font-weight: inherit;
|
||||||
color: $gray-200;
|
color: $gray-200;
|
||||||
border-radius: $component-border-radius-md;
|
border-radius: $component-border-radius-md;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
@@ -35,11 +34,13 @@
|
|||||||
|
|
||||||
.subtle {
|
.subtle {
|
||||||
background-color: $gray-1200;
|
background-color: $gray-1200;
|
||||||
|
padding-top: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ghosted {
|
.ghosted {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
padding-top: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fluid {
|
.fluid {
|
||||||
|
|||||||
@@ -18,8 +18,20 @@ interface NullableTimeInputProps<T extends string> {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NullableTimeInput<T extends string>(props: NullableTimeInputProps<T>) {
|
/**
|
||||||
const { id, name, submitHandler, time, emptyDisplay, placeholder, disabled, align = 'center', className } = props;
|
* Similar to TimeInput, but allows clearing the time value
|
||||||
|
*/
|
||||||
|
export default function NullableTimeInput<T extends string>({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
submitHandler,
|
||||||
|
time,
|
||||||
|
emptyDisplay,
|
||||||
|
placeholder,
|
||||||
|
disabled,
|
||||||
|
align = 'center',
|
||||||
|
className,
|
||||||
|
}: NullableTimeInputProps<T>) {
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [value, setValue] = useState<string>('');
|
const [value, setValue] = useState<string>('');
|
||||||
const ignoreChange = useRef(false);
|
const ignoreChange = useRef(false);
|
||||||
|
|||||||
@@ -3,4 +3,8 @@
|
|||||||
max-width: 7.5em;
|
max-width: 7.5em;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
|
||||||
|
&.delayed {
|
||||||
|
border: 1px solid $ontime-delay-text;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,21 @@ interface TimeInputProps<T extends string> {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
align?: 'left' | 'center';
|
align?: 'left' | 'center';
|
||||||
|
delayed?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
export default function TimeInput<T extends string>({
|
||||||
const { id, name, submitHandler, time, placeholder, disabled, align = 'center', className } = props;
|
id,
|
||||||
|
name,
|
||||||
|
submitHandler,
|
||||||
|
time,
|
||||||
|
placeholder,
|
||||||
|
disabled,
|
||||||
|
align = 'center',
|
||||||
|
delayed,
|
||||||
|
className,
|
||||||
|
}: TimeInputProps<T>) {
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [value, setValue] = useState<string>('');
|
const [value, setValue] = useState<string>('');
|
||||||
const ignoreChange = useRef(false);
|
const ignoreChange = useRef(false);
|
||||||
@@ -122,7 +132,7 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
data-testid={`time-input-${name}`}
|
data-testid={`time-input-${name}`}
|
||||||
className={cx([style.timeInput, className])}
|
className={cx([style.timeInput, delayed && style.delayed, className])}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onChange={(event) => setValue(event.target.value)}
|
onChange={(event) => setValue(event.target.value)}
|
||||||
@@ -130,7 +140,6 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
|||||||
onKeyDown={onKeyDownHandler}
|
onKeyDown={onKeyDownHandler}
|
||||||
value={value}
|
value={value}
|
||||||
maxLength={8}
|
maxLength={8}
|
||||||
autoComplete='off'
|
|
||||||
style={{
|
style={{
|
||||||
textAlign: align,
|
textAlign: align,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
$input-delayed-border-color: $ontime-delay-text;
|
|
||||||
|
|
||||||
.timeInput {
|
|
||||||
border: 1px solid transparent;
|
|
||||||
color: $label-gray;
|
|
||||||
|
|
||||||
&.delayed {
|
|
||||||
border: 1px solid $input-delayed-border-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { PropsWithChildren } from 'react';
|
|
||||||
import { InputGroup } from '@chakra-ui/react';
|
|
||||||
|
|
||||||
import { cx } from '../../../utils/styleUtils';
|
|
||||||
|
|
||||||
import TimeInput from './TimeInput';
|
|
||||||
|
|
||||||
import style from './TimeInputWithButton.module.scss';
|
|
||||||
|
|
||||||
interface TimeInputWithButtonProps<T extends string> {
|
|
||||||
name: T;
|
|
||||||
submitHandler: (field: T, value: string) => void;
|
|
||||||
time?: number;
|
|
||||||
hasDelay?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
placeholder: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TimeInputWithButton<T extends string>(props: PropsWithChildren<TimeInputWithButtonProps<T>>) {
|
|
||||||
const { name, submitHandler, time, hasDelay, placeholder, disabled, children } = props;
|
|
||||||
|
|
||||||
const inputClasses = cx([style.timeInput, hasDelay ? style.delayed : null]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<InputGroup size='sm' className={inputClasses} width='fit-content'>
|
|
||||||
<TimeInput<T>
|
|
||||||
name={name}
|
|
||||||
submitHandler={submitHandler}
|
|
||||||
time={time}
|
|
||||||
placeholder={placeholder}
|
|
||||||
align='left'
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
{children}
|
|
||||||
</InputGroup>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
transition-property: color;
|
transition-property: color;
|
||||||
transition-duration: $transition-time-action;
|
transition-duration: $transition-time-action;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
&.inline {
|
&.inline {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -15,4 +16,9 @@
|
|||||||
&:hover {
|
&:hover {
|
||||||
color: $ontime-color;
|
color: $ontime-color;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 1px 0 0 currentColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,13 +7,14 @@
|
|||||||
padding-inline: 1rem;
|
padding-inline: 1rem;
|
||||||
min-width: min(680px, 90vw);
|
min-width: min(680px, 90vw);
|
||||||
min-height: min(200px, 10vh);
|
min-height: min(200px, 10vh);
|
||||||
max-width: min(680px, 90vw);
|
max-width: min(800px, 90vw);
|
||||||
|
|
||||||
background-color: $gray-1250;
|
background-color: $gray-1250;
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
box-shadow: $box-shadow-l1;
|
box-shadow: $box-shadow-l1;
|
||||||
border: 1px solid $gray-1100;
|
border: 1px solid $gray-1100;
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.backdrop {
|
.backdrop {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@use '../../../theme/viewerDefs' as *;
|
@use '@/theme/viewerDefs' as *;
|
||||||
|
|
||||||
$progress-bar-size: 1.5rem;
|
$progress-bar-size: 1.5rem;
|
||||||
$progress-bar-br: 3px;
|
$progress-bar-br: 3px;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
.backdrop {
|
.backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: $zindex-backdrop;
|
|
||||||
background-color: $backdrop-color;
|
background-color: $backdrop-color;
|
||||||
transition: opacity 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
|
transition: opacity 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
|
||||||
|
|
||||||
@@ -21,7 +20,6 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
z-index: $zindex-dialog;
|
|
||||||
|
|
||||||
width: 22rem;
|
width: 22rem;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
@@ -66,3 +64,8 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
margin-left: auto;
|
||||||
|
color: $white-20;
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,9 +57,10 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
|||||||
Toggle Fullscreen
|
Toggle Fullscreen
|
||||||
{fullscreen ? <IoContract /> : <IoExpand />}
|
{fullscreen ? <IoContract /> : <IoExpand />}
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
<NavigationMenuItem active={mirror} onClick={toggleMirror}>
|
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
|
||||||
Flip Screen
|
Flip Screen
|
||||||
<IoSwapVertical />
|
<IoSwapVertical />
|
||||||
|
{mirror && <span className={style.note}>Active</span>}
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
<NavigationMenuItem onClick={handlers.open}>Rename Client</NavigationMenuItem>
|
<NavigationMenuItem onClick={handlers.open}>Rename Client</NavigationMenuItem>
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import useViewEditor from './useViewEditor';
|
|||||||
|
|
||||||
interface ViewNavigationMenuProps {
|
interface ViewNavigationMenuProps {
|
||||||
isLockable?: boolean;
|
isLockable?: boolean;
|
||||||
supressSettings?: boolean;
|
suppressSettings?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(ViewNavigationMenu);
|
export default memo(ViewNavigationMenu);
|
||||||
function ViewNavigationMenu({ isLockable, supressSettings }: ViewNavigationMenuProps) {
|
function ViewNavigationMenu({ isLockable, suppressSettings }: ViewNavigationMenuProps) {
|
||||||
const [isMenuOpen, menuHandler] = useDisclosure();
|
const [isMenuOpen, menuHandler] = useDisclosure();
|
||||||
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable });
|
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable });
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ function ViewNavigationMenu({ isLockable, supressSettings }: ViewNavigationMenuP
|
|||||||
[
|
[
|
||||||
'mod + ,',
|
'mod + ,',
|
||||||
() => {
|
() => {
|
||||||
if (isViewLocked || supressSettings) return;
|
if (isViewLocked || suppressSettings) return;
|
||||||
showEditFormDrawer();
|
showEditFormDrawer();
|
||||||
},
|
},
|
||||||
{ preventDefault: true },
|
{ preventDefault: true },
|
||||||
@@ -43,7 +43,7 @@ function ViewNavigationMenu({ isLockable, supressSettings }: ViewNavigationMenuP
|
|||||||
<>
|
<>
|
||||||
<FloatingNavigation
|
<FloatingNavigation
|
||||||
toggleMenu={menuHandler.toggle}
|
toggleMenu={menuHandler.toggle}
|
||||||
toggleSettings={supressSettings ? undefined : () => showEditFormDrawer()}
|
toggleSettings={suppressSettings ? undefined : () => showEditFormDrawer()}
|
||||||
/>
|
/>
|
||||||
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
|
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
+2
-3
@@ -25,9 +25,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: 2px solid $blue-500;
|
||||||
background-color: $gray-1300;
|
background-color: $gray-1350;
|
||||||
border: 2px solid $ui-white;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.current {
|
&.current {
|
||||||
|
|||||||
+1
-6
@@ -30,12 +30,7 @@ export default function OtherAddresses({ currentLocation }: OtherAddressesProps)
|
|||||||
const address = linkToOtherHost(nif.address, currentLocation);
|
const address = linkToOtherHost(nif.address, currentLocation);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CopyTag
|
<CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)}>
|
||||||
key={nif.name}
|
|
||||||
copyValue={address}
|
|
||||||
onClick={() => openLink(address)}
|
|
||||||
label='Copy IP or navigate to address'
|
|
||||||
>
|
|
||||||
{nif.address} <IoArrowUp className={style.goIcon} />
|
{nif.address} <IoArrowUp className={style.goIcon} />
|
||||||
</CopyTag>
|
</CopyTag>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||||
|
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
|
||||||
|
|
||||||
interface EditorVisibilityOptions {
|
interface EditorVisibilityOptions {
|
||||||
isLockable?: boolean;
|
isLockable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useViewEditor({ isLockable }: EditorVisibilityOptions) {
|
export default function useViewEditor({ isLockable }: EditorVisibilityOptions) {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
const { open: showEditFormDrawer } = useViewParamsEditorStore();
|
||||||
const showEditFormDrawer = useCallback(() => {
|
|
||||||
searchParams.set('edit', 'true');
|
|
||||||
setSearchParams(searchParams);
|
|
||||||
}, [searchParams, setSearchParams]);
|
|
||||||
|
|
||||||
const isViewLocked = useMemo(() => {
|
const isViewLocked = useMemo(() => {
|
||||||
if (!isLockable) {
|
if (!isLockable) {
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
.popup {
|
.popup {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
z-index: $zindex-dialog;
|
|
||||||
|
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
background-color: $gray-1250;
|
background-color: $gray-1250;
|
||||||
|
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
box-shadow: $box-shadow-l1;
|
box-shadow: $box-shadow-l1;
|
||||||
border: 2px solid $gray-1200;
|
border: 1px solid $gray-1000;
|
||||||
|
|
||||||
//width: 32rem;
|
|
||||||
max-width: 90vw;
|
max-width: 90vw;
|
||||||
|
|
||||||
transform-origin: var(--transform-origin);
|
transform-origin: var(--transform-origin);
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ export default function PopoverContents({
|
|||||||
<Popover.Positioner sideOffset={8} {...popoverProps}>
|
<Popover.Positioner sideOffset={8} {...popoverProps}>
|
||||||
<Popover.Popup className={style.popup}>
|
<Popover.Popup className={style.popup}>
|
||||||
{title && <Popover.Title className={style.title}>{title}</Popover.Title>}
|
{title && <Popover.Title className={style.title}>{title}</Popover.Title>}
|
||||||
<Popover.Description className={className}>{children}</Popover.Description>
|
<Popover.Description className={className} render={<div />}>
|
||||||
|
{children}
|
||||||
|
</Popover.Description>
|
||||||
</Popover.Popup>
|
</Popover.Popup>
|
||||||
</Popover.Positioner>
|
</Popover.Positioner>
|
||||||
</Popover.Portal>
|
</Popover.Portal>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@use '../../../theme/viewerDefs' as *;
|
@use '@/theme/viewerDefs' as *;
|
||||||
|
|
||||||
$progress-bar-size: 12px;
|
$progress-bar-size: 12px;
|
||||||
$progress-bar-br: 3px;
|
$progress-bar-br: 3px;
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
padding-top: 25vh;
|
||||||
|
|
||||||
|
background: $ui-black;
|
||||||
|
color: $ontime-color;
|
||||||
|
font-size: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
input {
|
||||||
|
font-size: 4rem;
|
||||||
|
height: 4rem;
|
||||||
|
width: 4em;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 0.25em;
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
height: 4rem;
|
||||||
|
width: 4rem;
|
||||||
|
font-size: 4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinFailed {
|
||||||
|
input {
|
||||||
|
animation: redFlash 1.5s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes redFlash {
|
||||||
|
from {
|
||||||
|
background: $red-500;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background: rgba($red-500, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,71 +1,55 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { PropsWithChildren, useState } from 'react';
|
||||||
import { IoCheckmark } from 'react-icons/io5';
|
import { IoCheckmark } from 'react-icons/io5';
|
||||||
import { IconButton, PinInput, PinInputField } from '@chakra-ui/react';
|
|
||||||
|
|
||||||
import style from './ProtectRoute.module.scss';
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
import IconButton from '../buttons/IconButton';
|
||||||
|
import Input from '../input/input/Input';
|
||||||
|
|
||||||
|
import style from './PinPage.module.scss';
|
||||||
|
|
||||||
interface PinPageProps {
|
interface PinPageProps {
|
||||||
permission: 'editor' | 'operator';
|
permission: 'editor' | 'operator';
|
||||||
handleValidation: (pin: string) => boolean;
|
handleValidation: (pin: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PinPage(props: PinPageProps) {
|
export default function PinPage({ permission, handleValidation }: PropsWithChildren<PinPageProps>) {
|
||||||
const { permission, handleValidation } = props;
|
|
||||||
const [pin, setPin] = useState('');
|
const [pin, setPin] = useState('');
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
const validate = useCallback(() => {
|
const validate = () => {
|
||||||
const isValid = handleValidation(pin);
|
const isValid = handleValidation(pin);
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
setFailed(true);
|
setFailed(true);
|
||||||
setPin('');
|
setPin('');
|
||||||
}
|
}
|
||||||
}, [handleValidation, pin]);
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const handleInputChange = (value: string) => {
|
||||||
const handleKeyPress = (event: KeyboardEvent) => {
|
setPin(value);
|
||||||
if (event.repeat) return;
|
if (failed) setFailed(false);
|
||||||
if (event.key === 'Enter') {
|
};
|
||||||
validate();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeyPress);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('keydown', handleKeyPress);
|
|
||||||
};
|
|
||||||
}, [validate]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.container}>
|
<div className={style.container}>
|
||||||
{`Ontime ${permission}`}
|
{`Ontime ${permission}`}
|
||||||
<div className={failed ? style.pin__failed : style.pin}>
|
<form
|
||||||
<PinInput
|
onSubmit={(event) => {
|
||||||
type='alphanumeric'
|
event.preventDefault();
|
||||||
size='lg'
|
validate();
|
||||||
mask
|
}}
|
||||||
autoFocus
|
className={cx([style.pin, failed && style.pinFailed])}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type='password'
|
||||||
|
maxLength={4}
|
||||||
|
height='large'
|
||||||
value={pin}
|
value={pin}
|
||||||
onChange={(value) => {
|
onChange={(e) => handleInputChange(e.target.value)}
|
||||||
setFailed(false);
|
|
||||||
setPin(value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PinInputField />
|
|
||||||
<PinInputField />
|
|
||||||
<PinInputField />
|
|
||||||
<PinInputField />
|
|
||||||
</PinInput>
|
|
||||||
<IconButton
|
|
||||||
variant='ontime-filled'
|
|
||||||
aria-label='Enter'
|
|
||||||
size='lg'
|
|
||||||
isRound
|
|
||||||
icon={<IoCheckmark />}
|
|
||||||
onClick={validate}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<IconButton type='submit' variant='primary' aria-label='Enter'>
|
||||||
|
<IoCheckmark />
|
||||||
|
</IconButton>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
.container {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding-top: 25vh;
|
|
||||||
|
|
||||||
background: $ui-black;
|
|
||||||
color: $ontime-color;
|
|
||||||
font-weight: 200;
|
|
||||||
font-size: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pin,
|
|
||||||
.pin__failed {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.125em;
|
|
||||||
padding-block: 0.5em;
|
|
||||||
|
|
||||||
input {
|
|
||||||
border-radius: 99px;
|
|
||||||
border-color: $gray-500;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: $blue-500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
margin-left: 1em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pin__failed {
|
|
||||||
input {
|
|
||||||
animation: colourFade 1.5s ease;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes colourFade {
|
|
||||||
from {
|
|
||||||
background: $red-500;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
background: rgba($red-500, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
.radioGroup {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
color: $gray-900;
|
||||||
|
font-size: calc(1rem - 2px);
|
||||||
|
color: $ui-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal {
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical {
|
||||||
|
align-items: start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
height: 2rem;
|
||||||
|
|
||||||
|
&:has([data-checked]) {
|
||||||
|
color: $ui-white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 100%;
|
||||||
|
outline: 0;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&[data-unchecked] {
|
||||||
|
border: 1px solid $gray-1200;
|
||||||
|
background-color: $gray-1200;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-checked] {
|
||||||
|
background-color: $ui-white;
|
||||||
|
&:hover {
|
||||||
|
border-color: $gray-1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid $blue-500;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator {
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
|
||||||
|
&[data-unchecked] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
border-radius: 100%;
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
background-color: $gray-1200;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Radio } from '@base-ui-components/react/radio';
|
||||||
|
import { RadioGroup as BaseRadioGroup } from '@base-ui-components/react/radio-group';
|
||||||
|
|
||||||
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
|
||||||
|
import style from './RadioGroup.module.scss';
|
||||||
|
|
||||||
|
interface RadioGroupProps<T extends string | number | boolean> extends Omit<BaseRadioGroup.Props, 'onValueChange'> {
|
||||||
|
items: {
|
||||||
|
value: T;
|
||||||
|
label: string;
|
||||||
|
}[];
|
||||||
|
onValueChange?: (value: T) => void;
|
||||||
|
orientation?: 'horizontal' | 'vertical';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RadioGroup<T extends string | number | boolean>({
|
||||||
|
items,
|
||||||
|
className,
|
||||||
|
orientation = 'vertical',
|
||||||
|
onValueChange,
|
||||||
|
...elementProps
|
||||||
|
}: RadioGroupProps<T>) {
|
||||||
|
return (
|
||||||
|
<BaseRadioGroup
|
||||||
|
onValueChange={(value) => onValueChange?.(value as T)}
|
||||||
|
className={cx([style.radioGroup, style[orientation], className])}
|
||||||
|
{...elementProps}
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
|
<label className={style.item} key={item.value.toString()}>
|
||||||
|
<Radio.Root value={item.value.toString()} className={style.radio}>
|
||||||
|
<Radio.Indicator className={style.indicator} />
|
||||||
|
</Radio.Root>
|
||||||
|
{item.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</BaseRadioGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,11 +34,24 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.fluid {
|
&:focus-visible {
|
||||||
width: 100%;
|
outline: 2px solid $blue-500;
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.medium {
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.large {
|
||||||
|
height: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fluid {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.selectIcon {
|
.selectIcon {
|
||||||
color: $gray-200;
|
color: $gray-200;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,19 +6,22 @@ import { cx } from '../../utils/styleUtils';
|
|||||||
|
|
||||||
import styles from './Select.module.scss';
|
import styles from './Select.module.scss';
|
||||||
|
|
||||||
|
export type SelectOption<T = string> = {
|
||||||
|
value: T;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
interface SelectProps<T> extends Omit<BaseSelect.Root.Props<T>, 'items'> {
|
interface SelectProps<T> extends Omit<BaseSelect.Root.Props<T>, 'items'> {
|
||||||
// overload items to not allow undefined values
|
options: SelectOption<T>[];
|
||||||
options: {
|
|
||||||
value: T;
|
|
||||||
label: string;
|
|
||||||
}[];
|
|
||||||
fluid?: boolean;
|
fluid?: boolean;
|
||||||
|
size?: 'medium' | 'large';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Select<T>({ options, fluid, ...selectRootProps }: SelectProps<T>) {
|
export default function Select<T>({ options, fluid, size = 'medium', ...selectRootProps }: SelectProps<T>) {
|
||||||
return (
|
return (
|
||||||
<BaseSelect.Root items={options} {...selectRootProps}>
|
<BaseSelect.Root items={options} {...selectRootProps}>
|
||||||
<BaseSelect.Trigger className={cx([styles.select, fluid && styles.fluid])}>
|
<BaseSelect.Trigger className={cx([styles.select, styles[size], fluid && styles.fluid])}>
|
||||||
<BaseSelect.Value />
|
<BaseSelect.Value />
|
||||||
<BaseSelect.Icon className={styles.selectIcon}>
|
<BaseSelect.Icon className={styles.selectIcon}>
|
||||||
<LuChevronsUpDown />
|
<LuChevronsUpDown />
|
||||||
@@ -28,8 +31,8 @@ export default function Select<T>({ options, fluid, ...selectRootProps }: Select
|
|||||||
<BaseSelect.Positioner side='bottom' align='start'>
|
<BaseSelect.Positioner side='bottom' align='start'>
|
||||||
<BaseSelect.ScrollUpArrow className={styles.scrollArrow} />
|
<BaseSelect.ScrollUpArrow className={styles.scrollArrow} />
|
||||||
<BaseSelect.Popup className={styles.popup}>
|
<BaseSelect.Popup className={styles.popup}>
|
||||||
{options.map(({ label, value }) => (
|
{options.map(({ disabled, label, value }) => (
|
||||||
<BaseSelect.Item key={String(value)} className={styles.item} value={value}>
|
<BaseSelect.Item key={String(value)} className={styles.item} value={value} disabled={disabled}>
|
||||||
<BaseSelect.ItemIndicator className={styles.itemIndicator}>
|
<BaseSelect.ItemIndicator className={styles.itemIndicator}>
|
||||||
<IoCheckmark className={styles.itemIndicatorIcon} />
|
<IoCheckmark className={styles.itemIndicatorIcon} />
|
||||||
</BaseSelect.ItemIndicator>
|
</BaseSelect.ItemIndicator>
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ interface EmptyProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Empty(props: EmptyProps) {
|
export default function Empty({ text, className, ...rest }: EmptyProps) {
|
||||||
const { text, className, ...rest } = props;
|
|
||||||
return (
|
return (
|
||||||
<div className={cx([style.emptyContainer, className])} {...rest}>
|
<div className={cx([style.emptyContainer, className])} {...rest}>
|
||||||
<EmptyImage className={style.empty} />
|
<EmptyImage className={style.empty} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@use '../../../theme/viewerDefs' as *;
|
@use '@/theme/viewerDefs' as *;
|
||||||
|
|
||||||
/* share the same style as a page layout */
|
/* share the same style as a page layout */
|
||||||
.page {
|
.page {
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
.emptyContainer {
|
||||||
|
width: 100%;
|
||||||
|
color: $white-10;
|
||||||
|
|
||||||
|
.emptyCell {
|
||||||
|
margin-inline: auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
width: 100%;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import EmptyImage from '../../../assets/images/empty.svg?react';
|
||||||
|
|
||||||
|
import style from './EmptyTableBody.module.scss';
|
||||||
|
|
||||||
|
interface EmptyTableBodyProps {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmptyTableBody({ text }: EmptyTableBodyProps) {
|
||||||
|
return (
|
||||||
|
<tbody className={style.emptyContainer}>
|
||||||
|
<tr>
|
||||||
|
<td colSpan={99} className={style.emptyCell}>
|
||||||
|
<EmptyImage className={style.empty} />
|
||||||
|
{text && <span className={style.text}>{text}</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,7 +19,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
border-color: $blue-500;
|
outline: 2px solid $blue-500;
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@use '../../../theme/viewerDefs' as *;
|
@use '@/theme/viewerDefs' as *;
|
||||||
|
|
||||||
.title-card {
|
.title-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
.tooltip {
|
||||||
|
font-size: calc(1rem - 3px);
|
||||||
|
background-color: $ui-white;
|
||||||
|
color: $ui-black;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
line-height: 1.25em;
|
||||||
|
max-width: 200px;
|
||||||
|
|
||||||
|
transform-origin: var(--transform-origin);
|
||||||
|
transition:
|
||||||
|
transform 150ms,
|
||||||
|
opacity 150ms;
|
||||||
|
|
||||||
|
&[data-starting-style],
|
||||||
|
&[data-ending-style] {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-instant] {
|
||||||
|
transition-duration: 0ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { PropsWithChildren } from 'react';
|
||||||
|
import { Tooltip as BaseTooltip } from '@base-ui-components/react/tooltip';
|
||||||
|
|
||||||
|
import style from './Tooltip.module.scss';
|
||||||
|
|
||||||
|
interface TooltipProps extends BaseTooltip.Trigger.Props {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Tooltip({ text, children, ...triggerProps }: PropsWithChildren<TooltipProps>) {
|
||||||
|
return (
|
||||||
|
<BaseTooltip.Root>
|
||||||
|
<BaseTooltip.Trigger {...triggerProps}>{children}</BaseTooltip.Trigger>
|
||||||
|
<BaseTooltip.Portal>
|
||||||
|
<BaseTooltip.Positioner side='bottom' sideOffset={4}>
|
||||||
|
<BaseTooltip.Popup className={style.tooltip}>
|
||||||
|
<BaseTooltip.Arrow />
|
||||||
|
{text}
|
||||||
|
</BaseTooltip.Popup>
|
||||||
|
</BaseTooltip.Positioner>
|
||||||
|
</BaseTooltip.Portal>
|
||||||
|
</BaseTooltip.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
.inline {
|
.inline {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
// attempt to match with ontimeTextInputs
|
// attempt to match with ontimeTextInputs
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
.inline {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// styles from subtle button
|
||||||
|
.toggleSelect {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding-inline: 0.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
background: $gray-1050;
|
||||||
|
color: $ui-white;
|
||||||
|
line-height: 1em;
|
||||||
|
border-radius: $component-border-radius-md;
|
||||||
|
|
||||||
|
&:hover:not(:disabled):not(:active) {
|
||||||
|
background: $gray-1000;
|
||||||
|
color: $blue-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
background: $gray-1100;
|
||||||
|
border-color: $gray-1250;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
width: 0.75em;
|
||||||
|
height: 0.75em;
|
||||||
|
background: var(--user-bg);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: $ui-white;
|
||||||
|
}
|
||||||
@@ -1,25 +1,17 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { IoChevronDown } from 'react-icons/io5';
|
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Input,
|
|
||||||
InputGroup,
|
|
||||||
InputLeftElement,
|
|
||||||
Menu,
|
|
||||||
MenuButton,
|
|
||||||
MenuItemOption,
|
|
||||||
MenuList,
|
|
||||||
MenuOptionGroup,
|
|
||||||
Select,
|
|
||||||
Switch,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
|
|
||||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||||
|
import Checkbox from '../checkbox/Checkbox';
|
||||||
|
import Input from '../input/input/Input';
|
||||||
|
import Select from '../select/Select';
|
||||||
|
import Switch from '../switch/Switch';
|
||||||
|
|
||||||
import InlineColourPicker from './InlineColourPicker';
|
import InlineColourPicker from './InlineColourPicker';
|
||||||
import { ParamField } from './viewParams.types';
|
import { ParamField } from './viewParams.types';
|
||||||
|
|
||||||
|
import style from './ParamInput.module.scss';
|
||||||
|
|
||||||
interface ParamInputProps {
|
interface ParamInputProps {
|
||||||
paramField: ParamField;
|
paramField: ParamField;
|
||||||
}
|
}
|
||||||
@@ -39,20 +31,11 @@ export default function ParamInput({ paramField }: ParamInputProps) {
|
|||||||
const optionFromParams = searchParams.get(id);
|
const optionFromParams = searchParams.get(id);
|
||||||
const defaultOptionValue = optionFromParams || defaultValue;
|
const defaultOptionValue = optionFromParams || defaultValue;
|
||||||
|
|
||||||
return (
|
if (paramField.values.length === 0) {
|
||||||
<Select
|
return <span className={style.empty}>No options available</span>;
|
||||||
placeholder={defaultValue ? undefined : 'Select an option'}
|
}
|
||||||
variant='ontime'
|
|
||||||
name={id}
|
return <Select size='large' name={id} defaultValue={defaultOptionValue} options={paramField.values} />;
|
||||||
defaultValue={defaultOptionValue}
|
|
||||||
>
|
|
||||||
{Object.entries(paramField.values).map(([key, value]) => (
|
|
||||||
<option key={key} value={key}>
|
|
||||||
{value}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'multi-option') {
|
if (type === 'multi-option') {
|
||||||
@@ -60,28 +43,22 @@ export default function ParamInput({ paramField }: ParamInputProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'boolean') {
|
if (type === 'boolean') {
|
||||||
const defaultCheckedValue = isStringBoolean(searchParams.get(id)) || defaultValue;
|
return <ControlledSwitch id={id} initialValue={isStringBoolean(searchParams.get(id)) || defaultValue} />;
|
||||||
|
|
||||||
// checked value should be 'true', so it can be captured by the form event
|
|
||||||
return <Switch variant='ontime' name={id} defaultChecked={defaultCheckedValue} value='true' />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'number') {
|
if (type === 'number') {
|
||||||
const { prefix, placeholder } = paramField;
|
const { placeholder } = paramField;
|
||||||
const defaultNumberValue = searchParams.get(id) ?? defaultValue;
|
const defaultNumberValue = searchParams.get(id) ?? defaultValue;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InputGroup variant='ontime-filled'>
|
<Input
|
||||||
{prefix && <InputLeftElement pointerEvents='none'>{prefix}</InputLeftElement>}
|
height='large'
|
||||||
<Input
|
type='number'
|
||||||
type='number'
|
step='any'
|
||||||
step='any'
|
name={id}
|
||||||
variant='ontime-filled'
|
defaultValue={defaultNumberValue}
|
||||||
name={id}
|
placeholder={placeholder}
|
||||||
defaultValue={defaultNumberValue}
|
/>
|
||||||
placeholder={placeholder}
|
|
||||||
/>
|
|
||||||
</InputGroup>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,52 +69,65 @@ export default function ParamInput({ paramField }: ParamInputProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaultStringValue = searchParams.get(id) ?? defaultValue;
|
const defaultStringValue = searchParams.get(id) ?? defaultValue;
|
||||||
const { prefix, placeholder } = paramField;
|
const { placeholder } = paramField;
|
||||||
|
|
||||||
return (
|
return <Input height='large' name={id} defaultValue={defaultStringValue} placeholder={placeholder} />;
|
||||||
<InputGroup variant='ontime-filled'>
|
|
||||||
{prefix && <InputLeftElement pointerEvents='none'>{prefix}</InputLeftElement>}
|
|
||||||
<Input name={id} defaultValue={defaultStringValue} placeholder={placeholder} />
|
|
||||||
</InputGroup>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EditFormMultiOptionProps {
|
interface EditFormMultiOptionProps {
|
||||||
paramField: ParamField & { type: 'multi-option' };
|
paramField: ParamField & { type: 'multi-option' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function MultiOption(props: EditFormMultiOptionProps) {
|
function MultiOption({ paramField }: EditFormMultiOptionProps) {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { paramField } = props;
|
const { id, values, defaultValue = [''] } = paramField;
|
||||||
const { id, defaultValue } = paramField;
|
|
||||||
|
|
||||||
const optionFromParams = searchParams.getAll(id);
|
const optionFromParams = searchParams.getAll(id);
|
||||||
const [paramState, setParamState] = useState<string[]>(optionFromParams || defaultValue || ['']);
|
const [paramState, setParamState] = useState<string[]>(optionFromParams || defaultValue);
|
||||||
|
|
||||||
|
const toggleValue = (value: string, checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setParamState((prev) => [...prev, value]);
|
||||||
|
} else {
|
||||||
|
setParamState((prev) => prev.filter((v) => v !== value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (values.length === 0) {
|
||||||
|
return <span className={style.empty}>No options available</span>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<input name={id} hidden readOnly value={paramState} />
|
<input name={id} hidden readOnly value={paramState.join(',')} />
|
||||||
<Menu isLazy closeOnSelect={false} variant='ontime-on-dark'>
|
<div className={style.inline}>
|
||||||
<MenuButton as={Button} variant='ontime-subtle-white' position='relative' width='fit-content' fontWeight={400}>
|
{values.map((option) => {
|
||||||
{paramField.title} <IoChevronDown style={{ display: 'inline' }} />
|
return (
|
||||||
</MenuButton>
|
<label
|
||||||
<MenuList overflow='auto' maxHeight='200px'>
|
key={option.value}
|
||||||
<MenuOptionGroup
|
className={style.toggleSelect}
|
||||||
type='checkbox'
|
style={{
|
||||||
value={paramState}
|
'--user-bg': option.colour,
|
||||||
onChange={(value) => setParamState(Array.isArray(value) ? value : [value])}
|
}}
|
||||||
>
|
>
|
||||||
{Object.values(paramField.values).map((option) => {
|
<Checkbox
|
||||||
const { value, label, colour } = option;
|
checked={paramState.includes(option.value)}
|
||||||
return (
|
onCheckedChange={(checked) => toggleValue(option.value, checked as boolean)}
|
||||||
<MenuItemOption value={value} key={value} style={{ borderRight: `8px solid ${colour}` }}>
|
/>
|
||||||
{label}
|
{option.label}
|
||||||
</MenuItemOption>
|
</label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</MenuOptionGroup>
|
</div>
|
||||||
</MenuList>
|
|
||||||
</Menu>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ControlledSwitchProps {
|
||||||
|
id: string;
|
||||||
|
initialValue: boolean;
|
||||||
|
}
|
||||||
|
function ControlledSwitch({ id, initialValue }: ControlledSwitchProps) {
|
||||||
|
const [checked, setChecked] = useState(initialValue);
|
||||||
|
return <Switch size='large' name={id} checked={checked} onCheckedChange={setChecked} />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
.backdrop {
|
.backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: $zindex-backdrop;
|
|
||||||
background-color: $backdrop-color;
|
background-color: $backdrop-color;
|
||||||
transition: opacity 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
|
transition: opacity 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
|
||||||
|
|
||||||
@@ -27,14 +26,13 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
z-index: $zindex-dialog;
|
|
||||||
|
|
||||||
width: 40rem;
|
width: 40rem;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 1rem 1.5rem;
|
padding-block: 1rem 1.5rem;
|
||||||
|
|
||||||
background-color: $gray-1250;
|
background-color: $gray-1250;
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
@@ -58,6 +56,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
|
padding-inline: 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -69,12 +68,14 @@
|
|||||||
|
|
||||||
.body {
|
.body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
padding-inline: 1rem;
|
||||||
padding-bottom: 10vh;
|
padding-bottom: 10vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
padding-inline: 1rem;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { FormEvent, memo, useEffect } from 'react';
|
import { FormEvent, memo } from 'react';
|
||||||
import { IoClose } from 'react-icons/io5';
|
import { IoClose } from 'react-icons/io5';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Dialog } from '@base-ui-components/react/dialog';
|
import { Dialog } from '@base-ui-components/react/dialog';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
|
||||||
|
|
||||||
import useViewSettings from '../../hooks-query/useViewSettings';
|
import useViewSettings from '../../hooks-query/useViewSettings';
|
||||||
import Button from '../buttons/Button';
|
import Button from '../buttons/Button';
|
||||||
@@ -11,6 +10,7 @@ import Info from '../info/Info';
|
|||||||
|
|
||||||
import { ViewOption } from './viewParams.types';
|
import { ViewOption } from './viewParams.types';
|
||||||
import { getURLSearchParamsFromObj } from './viewParams.utils';
|
import { getURLSearchParamsFromObj } from './viewParams.utils';
|
||||||
|
import { useViewParamsEditorStore } from './viewParamsEditor.store';
|
||||||
import ViewParamsSection from './ViewParamsSection';
|
import ViewParamsSection from './ViewParamsSection';
|
||||||
|
|
||||||
import style from './ViewParamsEditor.module.scss';
|
import style from './ViewParamsEditor.module.scss';
|
||||||
@@ -22,30 +22,16 @@ interface EditFormDrawerProps {
|
|||||||
export default memo(ViewParamsEditor);
|
export default memo(ViewParamsEditor);
|
||||||
|
|
||||||
function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [_, setSearchParams] = useSearchParams();
|
||||||
const { data: viewSettings } = useViewSettings();
|
const { data: viewSettings } = useViewSettings();
|
||||||
|
const { isOpen, close } = useViewParamsEditorStore();
|
||||||
const [isOpen, handlers] = useDisclosure(false);
|
|
||||||
|
|
||||||
// handle opening the drawer
|
|
||||||
useEffect(() => {
|
|
||||||
const isEditing = searchParams.get('edit');
|
|
||||||
|
|
||||||
if (isEditing === 'true') {
|
|
||||||
return handlers.open();
|
|
||||||
}
|
|
||||||
}, [searchParams, handlers]);
|
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
searchParams.delete('edit');
|
close();
|
||||||
setSearchParams(searchParams);
|
|
||||||
|
|
||||||
handlers.close();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetParams = () => {
|
const resetParams = () => {
|
||||||
setSearchParams();
|
setSearchParams();
|
||||||
handlers.close();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
|
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
import { IoChevronDown } from 'react-icons/io5';
|
import { IoChevronDown } from 'react-icons/io5';
|
||||||
import { useLocalStorage } from '@mantine/hooks';
|
import { useLocalStorage } from '@mantine/hooks';
|
||||||
|
|
||||||
@@ -15,7 +16,8 @@ interface ViewParamsSectionProps {
|
|||||||
options: ParamField[];
|
options: ParamField[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
|
export default memo(ViewParamsSection);
|
||||||
|
function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
|
||||||
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
|
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
|
||||||
|
|
||||||
const handleCollapse = () => {
|
const handleCollapse = () => {
|
||||||
|
|||||||
+58
-24
@@ -5,32 +5,32 @@ import { OptionTitle } from '../constants';
|
|||||||
import type { ViewOption } from '../viewParams.types';
|
import type { ViewOption } from '../viewParams.types';
|
||||||
import { getURLSearchParamsFromObj, makeOptionsFromCustomFields } from '../viewParams.utils';
|
import { getURLSearchParamsFromObj, makeOptionsFromCustomFields } from '../viewParams.utils';
|
||||||
|
|
||||||
describe('makeOptionsFromCustomFields', () => {
|
describe('makeOptionsFromCustomFields()', () => {
|
||||||
const testCustomFields: CustomFields = {
|
const testCustomFields: CustomFields = {
|
||||||
field1: { label: 'Field 1', colour: 'red', type: 'string' },
|
field1: { label: 'Field 1', colour: 'red', type: 'text' },
|
||||||
field2: { label: 'Field 2', colour: 'blue', type: 'string' },
|
field2: { label: 'Field 2', colour: 'blue', type: 'text' },
|
||||||
};
|
};
|
||||||
|
|
||||||
it('creates a record of keys for the given custom fields', () => {
|
it('creates an array of options to use in a select', () => {
|
||||||
const result = makeOptionsFromCustomFields(testCustomFields);
|
const result = makeOptionsFromCustomFields(testCustomFields);
|
||||||
expect(result).toStrictEqual({
|
expect(result).toStrictEqual([
|
||||||
'custom-field1': 'Custom: Field 1',
|
{ value: 'custom-field1', label: 'Custom: Field 1' },
|
||||||
'custom-field2': 'Custom: Field 2',
|
{ value: 'custom-field2', label: 'Custom: Field 2' },
|
||||||
});
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('appends additional data', () => {
|
it('appends additional data', () => {
|
||||||
const additionalData = {
|
const additionalData = [
|
||||||
test1: 'test1',
|
{ value: 'test1', label: 'Test 1' },
|
||||||
test2: 'test2',
|
{ value: 'test2', label: 'Test 2' },
|
||||||
};
|
];
|
||||||
const result = makeOptionsFromCustomFields(testCustomFields, additionalData);
|
const result = makeOptionsFromCustomFields(testCustomFields, additionalData);
|
||||||
expect(result).toStrictEqual({
|
expect(result).toStrictEqual([
|
||||||
'custom-field1': 'Custom: Field 1',
|
{ value: 'test1', label: 'Test 1' },
|
||||||
'custom-field2': 'Custom: Field 2',
|
{ value: 'test2', label: 'Test 2' },
|
||||||
test1: 'test1',
|
{ value: 'custom-field1', label: 'Custom: Field 1' },
|
||||||
test2: 'test2',
|
{ value: 'custom-field2', label: 'Custom: Field 2' },
|
||||||
});
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filtersImageTypes', () => {
|
it('filtersImageTypes', () => {
|
||||||
@@ -40,14 +40,14 @@ describe('makeOptionsFromCustomFields', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const result = makeOptionsFromCustomFields(customFieldsWIthImage);
|
const result = makeOptionsFromCustomFields(customFieldsWIthImage);
|
||||||
expect(result).toStrictEqual({
|
expect(result).toStrictEqual([
|
||||||
'custom-field1': 'Custom: Field 1',
|
{ value: 'custom-field1', label: 'Custom: Field 1' },
|
||||||
'custom-field2': 'Custom: Field 2',
|
{ value: 'custom-field2', label: 'Custom: Field 2' },
|
||||||
});
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getURLSearchParamsFromObj', () => {
|
describe('getURLSearchParamsFromObj()', () => {
|
||||||
// Mock view options for testing
|
// Mock view options for testing
|
||||||
const mockViewOptions: ViewOption[] = [
|
const mockViewOptions: ViewOption[] = [
|
||||||
{
|
{
|
||||||
@@ -73,7 +73,10 @@ describe('getURLSearchParamsFromObj', () => {
|
|||||||
title: 'Multi Select',
|
title: 'Multi Select',
|
||||||
description: 'A multi-select field',
|
description: 'A multi-select field',
|
||||||
type: 'option',
|
type: 'option',
|
||||||
values: { value1: 'Value 1', value2: 'Value 2' },
|
values: [
|
||||||
|
{ value: 'value1', label: 'Value 1' },
|
||||||
|
{ value: 'value2', label: 'Value 2' },
|
||||||
|
],
|
||||||
defaultValue: '',
|
defaultValue: '',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -201,4 +204,35 @@ describe('getURLSearchParamsFromObj', () => {
|
|||||||
// Should only include unique values while maintaining order
|
// Should only include unique values while maintaining order
|
||||||
expect(result.getAll('sub')).toEqual(['value1', 'value2', 'value3']);
|
expect(result.getAll('sub')).toEqual(['value1', 'value2', 'value3']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('converts on-off from toggle to boolean', () => {
|
||||||
|
const mockOptionsWithBooleans: ViewOption[] = [
|
||||||
|
{
|
||||||
|
title: OptionTitle.StyleOverride,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 'bool1',
|
||||||
|
title: 'bool1',
|
||||||
|
description: 'Bool1',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bool2',
|
||||||
|
title: 'bool2',
|
||||||
|
description: 'Bool2',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const params = {
|
||||||
|
bool1: 'off',
|
||||||
|
bool2: 'on',
|
||||||
|
};
|
||||||
|
const result = getURLSearchParamsFromObj(params, mockOptionsWithBooleans);
|
||||||
|
expect(result.get('bool1')).toBe('false');
|
||||||
|
expect(result.get('bool2')).toBe('true');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ export const getTimeOption = (timeFormat: string): ParamField => {
|
|||||||
const placeholder = `${timeFormat} (default)`;
|
const placeholder = `${timeFormat} (default)`;
|
||||||
return {
|
return {
|
||||||
id: 'timeformat',
|
id: 'timeformat',
|
||||||
title: 'Time format string, taken from the Application Settings',
|
title: 'Clock time format, defaults to value from the Settings',
|
||||||
description: 'Format for auxiliar time fields (not the running), eg. HH:mm:ss or hh:mm:ss a, see docs for help',
|
description: 'Format for the Time Now field, eg. HH:mm:ss or hh:mm:ss a, see docs for help',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
placeholder,
|
placeholder,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,20 +8,19 @@ type BaseField = {
|
|||||||
|
|
||||||
type OptionsField = {
|
type OptionsField = {
|
||||||
type: 'option';
|
type: 'option';
|
||||||
values: Record<string, string>;
|
values: { value: string; label: string }[];
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MultiselectOption = { value: string; label: string; colour: string };
|
export type MultiselectOption = { value: string; label: string; colour: string };
|
||||||
export type MultiselectOptions = Record<string, MultiselectOption>;
|
|
||||||
type MultiOptionsField = {
|
type MultiOptionsField = {
|
||||||
type: 'multi-option';
|
type: 'multi-option';
|
||||||
values: MultiselectOptions;
|
values: MultiselectOption[];
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
|
type StringField = { type: 'string'; defaultValue?: string; placeholder?: string };
|
||||||
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
|
type NumberField = { type: 'number'; defaultValue?: number; placeholder?: string };
|
||||||
type BooleanField = { type: 'boolean'; defaultValue: boolean };
|
type BooleanField = { type: 'boolean'; defaultValue: boolean };
|
||||||
type ColourField = { type: 'colour'; defaultValue: string; placeholder?: string };
|
type ColourField = { type: 'colour'; defaultValue: string; placeholder?: string };
|
||||||
type PersistedField = { type: 'persist'; defaultValue?: string[]; values: string[] };
|
type PersistedField = { type: 'persist'; defaultValue?: string[]; values: string[] };
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { CustomFields } from 'ontime-types';
|
import type { CustomFields, ProjectData } from 'ontime-types';
|
||||||
|
|
||||||
import type { MultiselectOptions, ViewOption } from './viewParams.types';
|
import type { SelectOption } from '../select/Select';
|
||||||
|
|
||||||
|
import type { MultiselectOption, ViewOption } from './viewParams.types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a list of custom fields for a select
|
* Creates a list of custom fields for a select
|
||||||
@@ -8,17 +10,23 @@ import type { MultiselectOptions, ViewOption } from './viewParams.types';
|
|||||||
*/
|
*/
|
||||||
export function makeOptionsFromCustomFields(
|
export function makeOptionsFromCustomFields(
|
||||||
customFields: CustomFields,
|
customFields: CustomFields,
|
||||||
additionalOptions: Readonly<Record<string, string>> = {},
|
additionalOptions: SelectOption[] = [],
|
||||||
filterImageType = true,
|
filterImageType = true,
|
||||||
): Record<string, string> {
|
): SelectOption[] {
|
||||||
const options = { ...additionalOptions };
|
const options: SelectOption[] = [...additionalOptions];
|
||||||
|
|
||||||
|
// Add custom fields first
|
||||||
for (const [key, value] of Object.entries(customFields)) {
|
for (const [key, value] of Object.entries(customFields)) {
|
||||||
if (filterImageType && value.type === 'image') {
|
if (filterImageType && value.type === 'image') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
options[`custom-${key}`] = `Custom: ${value.label}`;
|
options.push({
|
||||||
|
value: `custom-${key}`,
|
||||||
|
label: `Custom: ${value.label}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,17 +34,37 @@ export function makeOptionsFromCustomFields(
|
|||||||
* Creates data for a multiselect component from custom fields
|
* Creates data for a multiselect component from custom fields
|
||||||
* Filters out image type custom fields
|
* Filters out image type custom fields
|
||||||
*/
|
*/
|
||||||
export function makeCustomFieldSelectOptions(customFields: CustomFields, filterImageType = true): MultiselectOptions {
|
export function makeCustomFieldSelectOptions(customFields: CustomFields, filterImageType = true): MultiselectOption[] {
|
||||||
const options: MultiselectOptions = {};
|
const options: MultiselectOption[] = [];
|
||||||
|
|
||||||
|
// Add custom fields first
|
||||||
for (const [key, value] of Object.entries(customFields)) {
|
for (const [key, value] of Object.entries(customFields)) {
|
||||||
if (filterImageType && value.type === 'image') {
|
if (filterImageType && value.type === 'image') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
options[key] = { value: key, label: value.label, colour: value.colour };
|
|
||||||
|
options.push({
|
||||||
|
value: key,
|
||||||
|
label: value.label,
|
||||||
|
colour: value.colour || 'transparent',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates data for a select element that displays project custom data
|
||||||
|
*/
|
||||||
|
export function makeProjectDataOptions(projectData: ProjectData): SelectOption[] {
|
||||||
|
return projectData.custom.map((entry, index) => {
|
||||||
|
return {
|
||||||
|
value: `${index}-${entry.title}`,
|
||||||
|
label: entry.title,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
|
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,17 +80,22 @@ function sanitiseColour(colour: string) {
|
|||||||
type FieldMetadata = {
|
type FieldMetadata = {
|
||||||
defaultValues: Record<string, string>;
|
defaultValues: Record<string, string>;
|
||||||
colorFields: Set<string>;
|
colorFields: Set<string>;
|
||||||
|
booleanFields: Set<string>;
|
||||||
isPersistedField: Set<string>;
|
isPersistedField: Set<string>;
|
||||||
persistedValues: Record<string, string[]>;
|
persistedValues: Record<string, string[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility collects metadata about fields from view options
|
* Utility collects metadata about fields from view options
|
||||||
|
* - where are the default values
|
||||||
|
* - which fields are colours
|
||||||
|
* - which fields are persisted
|
||||||
*/
|
*/
|
||||||
function collectFieldMetadata(paramFields: ViewOption[]): FieldMetadata {
|
function collectFieldMetadata(paramFields: ViewOption[]): FieldMetadata {
|
||||||
const metadata: FieldMetadata = {
|
const metadata: FieldMetadata = {
|
||||||
defaultValues: {},
|
defaultValues: {},
|
||||||
colorFields: new Set(),
|
colorFields: new Set(),
|
||||||
|
booleanFields: new Set(),
|
||||||
isPersistedField: new Set(),
|
isPersistedField: new Set(),
|
||||||
persistedValues: {},
|
persistedValues: {},
|
||||||
};
|
};
|
||||||
@@ -80,6 +113,8 @@ function collectFieldMetadata(paramFields: ViewOption[]): FieldMetadata {
|
|||||||
|
|
||||||
if (option.type === 'colour') {
|
if (option.type === 'colour') {
|
||||||
metadata.colorFields.add(option.id);
|
metadata.colorFields.add(option.id);
|
||||||
|
} else if (option.type === 'boolean') {
|
||||||
|
metadata.booleanFields.add(option.id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -132,7 +167,16 @@ export function getURLSearchParamsFromObj(paramsObj: ViewParamsObj, paramFields:
|
|||||||
|
|
||||||
// Process and add new values
|
// Process and add new values
|
||||||
value.split(',').forEach((v) => {
|
value.split(',').forEach((v) => {
|
||||||
const processedValue = metadata.colorFields.has(id) ? sanitiseColour(v) : v;
|
// some field types need extra processing
|
||||||
|
const processedValue = (() => {
|
||||||
|
if (metadata.colorFields.has(id)) {
|
||||||
|
return sanitiseColour(v);
|
||||||
|
}
|
||||||
|
if (metadata.booleanFields.has(id)) {
|
||||||
|
return v === 'on' ? 'true' : 'false';
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
})();
|
||||||
if (metadata.isPersistedField.has(id) || metadata.defaultValues[id] !== processedValue) {
|
if (metadata.isPersistedField.has(id) || metadata.defaultValues[id] !== processedValue) {
|
||||||
addUniqueParam(id, processedValue);
|
addUniqueParam(id, processedValue);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface ViewParamsEditorStore {
|
||||||
|
isOpen: boolean;
|
||||||
|
open: () => void;
|
||||||
|
close: () => void;
|
||||||
|
toggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useViewParamsEditorStore = create<ViewParamsEditorStore>((set) => ({
|
||||||
|
isOpen: false,
|
||||||
|
open: () => set({ isOpen: true }),
|
||||||
|
close: () => set({ isOpen: false }),
|
||||||
|
toggle: () => set((state) => ({ isOpen: !state.isOpen })),
|
||||||
|
}));
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||||
import { PROJECT_DATA } from '../api/constants';
|
import { PROJECT_DATA } from '../api/constants';
|
||||||
import { getProjectData } from '../api/project';
|
import { getProjectData, postProjectData } from '../api/project';
|
||||||
import { projectDataPlaceholder } from '../models/ProjectData';
|
import { projectDataPlaceholder } from '../models/ProjectData';
|
||||||
|
|
||||||
export default function useProjectData() {
|
export default function useProjectData() {
|
||||||
@@ -10,11 +10,25 @@ export default function useProjectData() {
|
|||||||
queryKey: PROJECT_DATA,
|
queryKey: PROJECT_DATA,
|
||||||
queryFn: getProjectData,
|
queryFn: getProjectData,
|
||||||
placeholderData: (previousData, _previousQuery) => previousData,
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
retry: 5,
|
|
||||||
retryDelay: (attempt) => attempt * 2500,
|
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
networkMode: 'always',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { data: data ?? projectDataPlaceholder, status, isFetching, isError, refetch };
|
return { data: data ?? projectDataPlaceholder, status, isFetching, isError, refetch };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useUpdateProjectData() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const updateFn = useMutation({
|
||||||
|
mutationFn: postProjectData,
|
||||||
|
onSuccess: (newProjectData) => {
|
||||||
|
queryClient.setQueryData(PROJECT_DATA, newProjectData);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateProjectData: updateFn.mutateAsync,
|
||||||
|
isMutating: updateFn.isPending,
|
||||||
|
isMutatingError: updateFn.isError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { OntimeEntry, Rundown } from 'ontime-types';
|
import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
|
||||||
|
|
||||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||||
import { RUNDOWN } from '../api/constants';
|
import { RUNDOWN } from '../api/constants';
|
||||||
@@ -78,3 +78,18 @@ export function usePartialRundown(cb: (event: OntimeEntry) => boolean) {
|
|||||||
|
|
||||||
return { data: filteredData, status };
|
return { data: filteredData, status };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get a specific entry by ID from the rundown
|
||||||
|
*/
|
||||||
|
export function useEntry(entryId: EntryId | null): OntimeEntry | null {
|
||||||
|
const { data: rundown } = useRundown();
|
||||||
|
|
||||||
|
// track the specific entry we care about
|
||||||
|
const entry = useMemo(() => {
|
||||||
|
if (entryId === null) return null;
|
||||||
|
return rundown.entries[entryId];
|
||||||
|
}, [entryId, rundown.entries]);
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { URLPreset } from 'ontime-types';
|
||||||
|
|
||||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||||
import { URL_PRESETS } from '../api/constants';
|
import { URL_PRESETS } from '../api/constants';
|
||||||
import { getUrlPresets } from '../api/urlPresets';
|
import { deleteUrlPreset, getUrlPresets, postUrlPreset, putUrlPreset } from '../api/urlPresets';
|
||||||
|
|
||||||
interface FetchProps {
|
interface FetchProps {
|
||||||
skip?: boolean;
|
skip?: boolean;
|
||||||
@@ -13,12 +14,42 @@ export default function useUrlPresets({ skip = false }: FetchProps = {}) {
|
|||||||
queryKey: URL_PRESETS,
|
queryKey: URL_PRESETS,
|
||||||
queryFn: getUrlPresets,
|
queryFn: getUrlPresets,
|
||||||
placeholderData: (previousData, _previousQuery) => previousData,
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
retry: 5,
|
|
||||||
retryDelay: (attempt) => attempt * 2500,
|
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
networkMode: 'always',
|
|
||||||
enabled: !skip,
|
enabled: !skip,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { data: data ?? [], status, isError, refetch };
|
return { data: data ?? [], status, isError, refetch };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useUpdateUrlPreset() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const addFn = useMutation({
|
||||||
|
mutationFn: postUrlPreset,
|
||||||
|
onSuccess: (newPresets) => {
|
||||||
|
queryClient.setQueryData(URL_PRESETS, newPresets);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateFn = useMutation({
|
||||||
|
mutationFn: ({ alias, data }: { alias: string; data: URLPreset }) => putUrlPreset(alias, data),
|
||||||
|
onSuccess: (newPresets) => {
|
||||||
|
queryClient.setQueryData(URL_PRESETS, newPresets);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteFn = useMutation({
|
||||||
|
mutationFn: deleteUrlPreset,
|
||||||
|
onSuccess: (newPresets) => {
|
||||||
|
queryClient.setQueryData(URL_PRESETS, newPresets);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
addPreset: addFn.mutateAsync,
|
||||||
|
updatePreset: (alias: string, data: URLPreset) => updateFn.mutateAsync({ alias, data }),
|
||||||
|
deletePreset: deleteFn.mutateAsync,
|
||||||
|
isMutating: addFn.isPending || updateFn.isPending || deleteFn.isPending,
|
||||||
|
isMutationError: addFn.isError || updateFn.isError || deleteFn.isError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { MouseEvent } from 'react';
|
import { MouseEvent } from 'react';
|
||||||
|
|
||||||
import { Option, useContextMenuStore } from '../components/context-menu/ContextMenu';
|
import { useContextMenuStore } from '../../features/rundown/rundown-context-menu/RundownContextMenu';
|
||||||
|
import { DropdownMenuOption } from '../components/dropdown-menu/DropdownMenu';
|
||||||
|
|
||||||
export const useContextMenu = <T extends HTMLElement>(options: Option[]) => {
|
export const useContextMenu = <T extends HTMLElement>(options: DropdownMenuOption[]) => {
|
||||||
const { setContextMenu } = useContextMenuStore();
|
const setContextMenu = useContextMenuStore((state) => state.setContextMenu);
|
||||||
|
|
||||||
const localCreateContextMenu = (contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => {
|
const localCreateContextMenu = (contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => {
|
||||||
// prevent browser default context menu from showing up
|
// prevent browser default context menu from showing up
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { dayInMs, generateId, MILLIS_PER_SECOND, parseUserTime, swapEventData } from 'ontime-utils';
|
import { dayInMs, generateId, MILLIS_PER_SECOND, parseUserTime, swapEventData } from 'ontime-utils';
|
||||||
|
|
||||||
import { moveDown, moveUp } from '../../features/rundown/rundown.utils';
|
import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils';
|
||||||
import { RUNDOWN } from '../api/constants';
|
import { RUNDOWN } from '../api/constants';
|
||||||
import {
|
import {
|
||||||
deleteEntries,
|
deleteEntries,
|
||||||
@@ -89,25 +89,17 @@ export const useEntryActions = () => {
|
|||||||
|
|
||||||
// ************* CHECK OPTIONS specific to events
|
// ************* CHECK OPTIONS specific to events
|
||||||
if (isOntimeEvent(newEntry)) {
|
if (isOntimeEvent(newEntry)) {
|
||||||
// merge creation time options with event settings
|
if (options?.lastEventId) {
|
||||||
const applicationOptions = {
|
|
||||||
after: options?.after,
|
|
||||||
before: options?.before,
|
|
||||||
lastEventId: options?.lastEventId,
|
|
||||||
linkPrevious: options?.linkPrevious ?? linkPrevious,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (applicationOptions?.lastEventId) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
|
||||||
const rundownData = queryClient.getQueryData<Rundown>(RUNDOWN)!;
|
const rundownData = queryClient.getQueryData<Rundown>(RUNDOWN)!;
|
||||||
const previousEvent = rundownData.entries[applicationOptions.lastEventId];
|
const previousEvent = rundownData.entries[options?.lastEventId];
|
||||||
if (isOntimeEvent(previousEvent)) {
|
if (isOntimeEvent(previousEvent)) {
|
||||||
newEntry.timeStart = previousEvent.timeEnd;
|
newEntry.timeStart = previousEvent.timeEnd;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override event with options from editor settings
|
// Override event with options from editor settings
|
||||||
newEntry.linkStart = applicationOptions.linkPrevious;
|
newEntry.linkStart = options?.linkPrevious ?? linkPrevious;
|
||||||
|
|
||||||
if (newEntry.duration === undefined && newEntry.timeEnd === undefined) {
|
if (newEntry.duration === undefined && newEntry.timeEnd === undefined) {
|
||||||
newEntry.duration = parseUserTime(defaultDuration);
|
newEntry.duration = parseUserTime(defaultDuration);
|
||||||
@@ -232,9 +224,9 @@ export const useEntryActions = () => {
|
|||||||
* Updates existing entry
|
* Updates existing entry
|
||||||
*/
|
*/
|
||||||
const updateEntry = useCallback(
|
const updateEntry = useCallback(
|
||||||
async (event: Partial<OntimeEntry>) => {
|
async (entry: Partial<OntimeEntry>) => {
|
||||||
try {
|
try {
|
||||||
await updateEntryMutation(event);
|
await updateEntryMutation(entry);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error updating event', error);
|
logAxiosError('Error updating event', error);
|
||||||
}
|
}
|
||||||
@@ -589,7 +581,7 @@ export const useEntryActions = () => {
|
|||||||
try {
|
try {
|
||||||
await ungroupMutation(blockId);
|
await ungroupMutation(blockId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error dissolving block', error);
|
logAxiosError('Error dissolving group', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[ungroupMutation],
|
[ungroupMutation],
|
||||||
@@ -622,13 +614,22 @@ export const useEntryActions = () => {
|
|||||||
*/
|
*/
|
||||||
const groupEntries = useCallback(
|
const groupEntries = useCallback(
|
||||||
async (entryIds: EntryId[]) => {
|
async (entryIds: EntryId[]) => {
|
||||||
|
if (entryIds.length === 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await groupEntriesMutation(entryIds);
|
if (entryIds.length === 1) {
|
||||||
|
await groupEntriesMutation(entryIds);
|
||||||
|
} else {
|
||||||
|
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||||
|
if (!rundown) return;
|
||||||
|
const orderedIds = orderEntries(entryIds, rundown.flatOrder);
|
||||||
|
await groupEntriesMutation(orderedIds);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error grouping entries', error);
|
logAxiosError('Error grouping entries', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[groupEntriesMutation],
|
[groupEntriesMutation, queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -799,8 +800,12 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
|
|||||||
if (isOntimeBlock(entry) || !entry.parent) {
|
if (isOntimeBlock(entry) || !entry.parent) {
|
||||||
order = order.filter((id) => id !== entry.id);
|
order = order.filter((id) => id !== entry.id);
|
||||||
} else {
|
} else {
|
||||||
const parent = entries[entry.parent] as OntimeBlock;
|
const parent = entries[entry.parent];
|
||||||
parent.entries = parent.entries.filter((parentEntry) => parentEntry !== entry.id);
|
if ('parent' in entries) {
|
||||||
|
(parent as OntimeBlock).entries = (parent as OntimeBlock).entries.filter(
|
||||||
|
(parentEntry) => parentEntry !== entry.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
delete entries[entry.id];
|
delete entries[entry.id];
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { MutableRefObject, useCallback, useEffect, useRef } from 'react';
|
import { RefObject, useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import { useSelectedEventId } from './useSocket';
|
import { useSelectedEventId } from './useSocket';
|
||||||
|
|
||||||
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
|
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
|
||||||
componentRef: MutableRefObject<ComponentRef>,
|
componentRef: RefObject<ComponentRef>,
|
||||||
scrollRef: MutableRefObject<ScrollRef>,
|
scrollRef: RefObject<ScrollRef>,
|
||||||
topOffset: number,
|
topOffset: number,
|
||||||
) {
|
) {
|
||||||
if (!componentRef.current || !scrollRef.current) {
|
if (!componentRef.current || !scrollRef.current) {
|
||||||
@@ -19,8 +19,8 @@ function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends H
|
|||||||
}
|
}
|
||||||
|
|
||||||
function snapToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
|
function snapToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
|
||||||
componentRef: MutableRefObject<ComponentRef>,
|
componentRef: RefObject<ComponentRef>,
|
||||||
scrollRef: MutableRefObject<ScrollRef>,
|
scrollRef: RefObject<ScrollRef>,
|
||||||
topOffset: number,
|
topOffset: number,
|
||||||
) {
|
) {
|
||||||
if (!componentRef.current || !scrollRef.current) {
|
if (!componentRef.current || !scrollRef.current) {
|
||||||
@@ -36,8 +36,8 @@ function snapToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTM
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface UseFollowComponentProps {
|
interface UseFollowComponentProps {
|
||||||
followRef: MutableRefObject<HTMLElement | null>;
|
followRef: RefObject<HTMLElement | null>;
|
||||||
scrollRef: MutableRefObject<HTMLElement | null>;
|
scrollRef: RefObject<HTMLElement | null>;
|
||||||
doFollow: boolean;
|
doFollow: boolean;
|
||||||
topOffset?: number;
|
topOffset?: number;
|
||||||
setScrollFlag?: (newValue: boolean) => void;
|
setScrollFlag?: (newValue: boolean) => void;
|
||||||
@@ -56,11 +56,7 @@ export default function useFollowComponent(props: UseFollowComponentProps) {
|
|||||||
setScrollFlag?.(true);
|
setScrollFlag?.(true);
|
||||||
// Use requestAnimationFrame to ensure the component is fully loaded
|
// Use requestAnimationFrame to ensure the component is fully loaded
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
scrollToComponent(
|
scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, topOffset);
|
||||||
followRef as MutableRefObject<HTMLElement>,
|
|
||||||
scrollRef as MutableRefObject<HTMLElement>,
|
|
||||||
topOffset,
|
|
||||||
);
|
|
||||||
setScrollFlag?.(false);
|
setScrollFlag?.(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -97,8 +93,8 @@ export function useFollowSelected(doFollow: boolean, topOffset = 100) {
|
|||||||
// Use requestAnimationFrame to ensure the component is fully loaded
|
// Use requestAnimationFrame to ensure the component is fully loaded
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
snapToComponent(
|
snapToComponent(
|
||||||
{ current: selectedRef.current } as MutableRefObject<HTMLElement>,
|
{ current: selectedRef.current } as RefObject<HTMLElement>,
|
||||||
{ current: scrollRef.current } as MutableRefObject<HTMLElement>,
|
{ current: scrollRef.current } as RefObject<HTMLElement>,
|
||||||
topOffset,
|
topOffset,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { RefObject, useCallback, useEffect } from 'react';
|
||||||
|
import { EntryId } from 'ontime-types';
|
||||||
|
|
||||||
|
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
|
||||||
|
componentRef: RefObject<ComponentRef> | null,
|
||||||
|
scrollRef: RefObject<ScrollRef>,
|
||||||
|
leftOffset: number,
|
||||||
|
) {
|
||||||
|
if (!scrollRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!componentRef?.current) {
|
||||||
|
// If no target component, scroll to start
|
||||||
|
scrollRef.current.scrollTo({ left: 0, behavior: 'smooth' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentRect = componentRef.current.getBoundingClientRect();
|
||||||
|
const scrollRect = scrollRef.current.getBoundingClientRect();
|
||||||
|
const left = componentRect.left - scrollRect.left + scrollRef.current.scrollLeft - leftOffset;
|
||||||
|
|
||||||
|
scrollRef.current.scrollTo({ left, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseHorizontalFollowComponentOptions {
|
||||||
|
followRef: RefObject<HTMLElement | null>;
|
||||||
|
scrollRef: RefObject<HTMLElement | null>;
|
||||||
|
doFollow: boolean;
|
||||||
|
selectedEventId: EntryId | null;
|
||||||
|
leftOffset?: number;
|
||||||
|
setScrollFlag?: (newValue: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a copy of useFollowComponent, but for horizontal scrolling
|
||||||
|
* Designed with the timeline component in mind
|
||||||
|
*/
|
||||||
|
export default function useHorizontalFollowComponent({
|
||||||
|
followRef,
|
||||||
|
scrollRef,
|
||||||
|
doFollow,
|
||||||
|
selectedEventId,
|
||||||
|
leftOffset = 0,
|
||||||
|
setScrollFlag,
|
||||||
|
}: UseHorizontalFollowComponentOptions) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!doFollow || !scrollRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setScrollFlag?.(true);
|
||||||
|
// Use requestAnimationFrame to ensure the component is fully loaded
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
scrollToComponent(
|
||||||
|
selectedEventId !== null ? (followRef as RefObject<HTMLElement>) : null,
|
||||||
|
scrollRef as RefObject<HTMLElement>,
|
||||||
|
leftOffset,
|
||||||
|
);
|
||||||
|
setScrollFlag?.(false);
|
||||||
|
});
|
||||||
|
}, [followRef, scrollRef, doFollow, leftOffset, setScrollFlag, selectedEventId]);
|
||||||
|
|
||||||
|
const scrollToRefComponent = useCallback(
|
||||||
|
(componentRef = followRef, containerRef = scrollRef, offset = leftOffset) => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
scrollToComponent(
|
||||||
|
selectedEventId !== null ? (componentRef as RefObject<HTMLElement>) : null,
|
||||||
|
containerRef as RefObject<HTMLElement>,
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[followRef, scrollRef, leftOffset, selectedEventId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return scrollToRefComponent;
|
||||||
|
}
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { MouseEvent, SyntheticEvent, TouchEvent, useMemo, useRef } from 'react';
|
|
||||||
|
|
||||||
type LongPressOptions = {
|
|
||||||
threshold?: number;
|
|
||||||
onStart?: (e: SyntheticEvent) => void;
|
|
||||||
onFinish?: (e: SyntheticEvent) => void;
|
|
||||||
onCancel?: (e: SyntheticEvent) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type LongPressFns = {
|
|
||||||
onMouseDown: (e: MouseEvent) => void;
|
|
||||||
onMouseUp: (e: MouseEvent) => void;
|
|
||||||
onMouseLeave: (e: MouseEvent) => void;
|
|
||||||
onTouchStart: (e: TouchEvent) => void;
|
|
||||||
onTouchEnd: (e: TouchEvent) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function useLongPress(callback: () => void, options: LongPressOptions = {}): LongPressFns {
|
|
||||||
const { threshold = 400, onStart, onFinish, onCancel } = options;
|
|
||||||
const isLongPressActive = useRef(false);
|
|
||||||
const isPressed = useRef(false);
|
|
||||||
const timerId = useRef<NodeJS.Timeout>();
|
|
||||||
|
|
||||||
return useMemo(() => {
|
|
||||||
const start = (event: SyntheticEvent) => {
|
|
||||||
if (onStart) {
|
|
||||||
onStart(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
isPressed.current = true;
|
|
||||||
timerId.current = setTimeout(() => {
|
|
||||||
callback();
|
|
||||||
isLongPressActive.current = true;
|
|
||||||
}, threshold);
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancel = (event: SyntheticEvent) => {
|
|
||||||
if (isLongPressActive.current) {
|
|
||||||
if (onFinish) {
|
|
||||||
onFinish(event);
|
|
||||||
}
|
|
||||||
} else if (isPressed.current) {
|
|
||||||
if (onCancel) {
|
|
||||||
onCancel(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isLongPressActive.current = false;
|
|
||||||
isPressed.current = false;
|
|
||||||
|
|
||||||
if (timerId.current) {
|
|
||||||
clearTimeout(timerId.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
onMouseDown: start,
|
|
||||||
onMouseUp: cancel,
|
|
||||||
onMouseLeave: cancel,
|
|
||||||
onTouchStart: start,
|
|
||||||
onTouchEnd: cancel,
|
|
||||||
};
|
|
||||||
}, [callback, threshold, onCancel, onFinish, onStart]);
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,7 @@ export default function useMemoisedFn<T extends noop>(fn: T) {
|
|||||||
// https://github.com/alibaba/hooks/issues/728
|
// https://github.com/alibaba/hooks/issues/728
|
||||||
fnRef.current = useMemo(() => fn, [fn]);
|
fnRef.current = useMemo(() => fn, [fn]);
|
||||||
|
|
||||||
const memoizedFn = useRef<PickFunction<T>>();
|
const memoizedFn = useRef<PickFunction<T>>(undefined);
|
||||||
if (!memoizedFn.current) {
|
if (!memoizedFn.current) {
|
||||||
memoizedFn.current = function (this, ...args) {
|
memoizedFn.current = function (this, ...args) {
|
||||||
return fnRef.current.apply(this, args);
|
return fnRef.current.apply(this, args);
|
||||||
|
|||||||
@@ -150,6 +150,10 @@ export const useClock = createSelector((state: RuntimeStore) => ({
|
|||||||
clock: state.clock,
|
clock: state.clock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const useNextFlag = createSelector((state: RuntimeStore) => ({
|
||||||
|
nextFlag: state.nextFlag,
|
||||||
|
}));
|
||||||
|
|
||||||
/** Used by the progress bar components */
|
/** Used by the progress bar components */
|
||||||
export const useProgressData = createSelector((state: RuntimeStore) => ({
|
export const useProgressData = createSelector((state: RuntimeStore) => ({
|
||||||
current: state.timer.current,
|
current: state.timer.current,
|
||||||
@@ -174,6 +178,7 @@ export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) =
|
|||||||
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
|
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
|
||||||
|
|
||||||
blockStartedAt: state.blockNow?.startedAt ?? null,
|
blockStartedAt: state.blockNow?.startedAt ?? null,
|
||||||
|
blockExpectedEnd: state.blockNow?.expectedEnd ?? null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
|
export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { ProjectData } from 'ontime-types';
|
|||||||
export const projectDataPlaceholder: ProjectData = {
|
export const projectDataPlaceholder: ProjectData = {
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
backstageUrl: '',
|
url: '',
|
||||||
backstageInfo: '',
|
info: '',
|
||||||
projectLogo: null,
|
logo: null,
|
||||||
custom: [],
|
custom: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { ViewSettings } from 'ontime-types';
|
import { ViewSettings } from 'ontime-types';
|
||||||
|
|
||||||
export const viewsSettingsPlaceholder: ViewSettings = {
|
export const viewsSettingsPlaceholder: ViewSettings = {
|
||||||
dangerColor: '#ED3333',
|
dangerColor: '#ff7300',
|
||||||
endMessage: '',
|
|
||||||
freezeEnd: false,
|
|
||||||
normalColor: '#ffffffcc',
|
normalColor: '#ffffffcc',
|
||||||
overrideStyles: false,
|
overrideStyles: false,
|
||||||
warningColor: '#FFAB33',
|
warningColor: '#ffa528',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { create } from 'zustand';
|
|
||||||
|
|
||||||
export enum AppMode {
|
|
||||||
Run = 'run',
|
|
||||||
Edit = 'edit',
|
|
||||||
}
|
|
||||||
|
|
||||||
const appModeKey = 'ontime-app-mode';
|
|
||||||
|
|
||||||
function getModeFromSession() {
|
|
||||||
return sessionStorage.getItem(appModeKey) === AppMode.Run ? AppMode.Run : AppMode.Edit;
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistModeToSession(mode: AppMode) {
|
|
||||||
sessionStorage.setItem(appModeKey, mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
type AppModeStore = {
|
|
||||||
mode: AppMode;
|
|
||||||
setMode: (mode: AppMode) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useAppMode = create<AppModeStore>()((set) => ({
|
|
||||||
mode: getModeFromSession(),
|
|
||||||
setMode: (mode: AppMode) => {
|
|
||||||
persistModeToSession(mode);
|
|
||||||
|
|
||||||
return set(() => {
|
|
||||||
return { mode };
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -7,6 +7,7 @@ describe('cloneEvent()', () => {
|
|||||||
const original: OntimeEvent = {
|
const original: OntimeEvent = {
|
||||||
id: 'unique',
|
id: 'unique',
|
||||||
type: SupportedEntry.Event,
|
type: SupportedEntry.Event,
|
||||||
|
flag: false,
|
||||||
title: 'title',
|
title: 'title',
|
||||||
cue: 'cue',
|
cue: 'cue',
|
||||||
note: 'note',
|
note: 'note',
|
||||||
@@ -40,6 +41,7 @@ describe('cloneEvent()', () => {
|
|||||||
|
|
||||||
expect(cloned).toMatchObject({
|
expect(cloned).toMatchObject({
|
||||||
type: SupportedEntry.Event,
|
type: SupportedEntry.Event,
|
||||||
|
flag: original.flag,
|
||||||
title: original.title,
|
title: original.title,
|
||||||
note: original.note,
|
note: original.note,
|
||||||
timeStart: original.timeStart,
|
timeStart: original.timeStart,
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import { OffsetMode } from 'ontime-types';
|
import { formatTime, nowInMillis } from '../time';
|
||||||
import { dayInMs } from 'ontime-utils';
|
|
||||||
|
|
||||||
import { calculateTimeUntilStart, formatTime, nowInMillis } from '../time';
|
|
||||||
|
|
||||||
describe('nowInMillis()', () => {
|
describe('nowInMillis()', () => {
|
||||||
it('should return the current time in milliseconds', () => {
|
it('should return the current time in milliseconds', () => {
|
||||||
@@ -41,257 +38,3 @@ describe('formatTime()', () => {
|
|||||||
expect(time).toStrictEqual('-01:00');
|
expect(time).toStrictEqual('-01:00');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('calculateTimeUntilStart()', () => {
|
|
||||||
describe('Absolute offset mode', () => {
|
|
||||||
test('ontime', () => {
|
|
||||||
const test = {
|
|
||||||
timeStart: 100,
|
|
||||||
dayOffset: 0,
|
|
||||||
delay: 0,
|
|
||||||
currentDay: 0,
|
|
||||||
totalGap: 0,
|
|
||||||
clock: 90,
|
|
||||||
offset: 0,
|
|
||||||
offsetMode: OffsetMode.Absolute,
|
|
||||||
actualStart: null,
|
|
||||||
plannedStart: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(10);
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('running behind', () => {
|
|
||||||
const test = {
|
|
||||||
timeStart: 100,
|
|
||||||
dayOffset: 0,
|
|
||||||
delay: 0,
|
|
||||||
currentDay: 0,
|
|
||||||
totalGap: 0,
|
|
||||||
clock: 90,
|
|
||||||
offset: -20,
|
|
||||||
offsetMode: OffsetMode.Absolute,
|
|
||||||
actualStart: null,
|
|
||||||
plannedStart: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(30);
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(30);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('running ahead', () => {
|
|
||||||
const test = {
|
|
||||||
timeStart: 100,
|
|
||||||
dayOffset: 0,
|
|
||||||
delay: 0,
|
|
||||||
currentDay: 0,
|
|
||||||
totalGap: 0,
|
|
||||||
clock: 80,
|
|
||||||
offset: 10,
|
|
||||||
offsetMode: OffsetMode.Absolute,
|
|
||||||
actualStart: null,
|
|
||||||
plannedStart: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // <-- when running ahead the unlinked timer stays put
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('running behind with enough gaps', () => {
|
|
||||||
const test = {
|
|
||||||
timeStart: 100,
|
|
||||||
dayOffset: 0,
|
|
||||||
delay: 0,
|
|
||||||
currentDay: 0,
|
|
||||||
totalGap: 20,
|
|
||||||
clock: 50,
|
|
||||||
offset: -20,
|
|
||||||
offsetMode: OffsetMode.Absolute,
|
|
||||||
actualStart: null,
|
|
||||||
plannedStart: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(50); // <-- when gap is enough to compensate for the running behind
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
|
|
||||||
});
|
|
||||||
|
|
||||||
test('running behind with too little gaps', () => {
|
|
||||||
const test = {
|
|
||||||
timeStart: 100,
|
|
||||||
dayOffset: 0,
|
|
||||||
delay: 0,
|
|
||||||
currentDay: 0,
|
|
||||||
totalGap: 10,
|
|
||||||
clock: 50,
|
|
||||||
offset: -20,
|
|
||||||
offsetMode: OffsetMode.Absolute,
|
|
||||||
actualStart: 0,
|
|
||||||
plannedStart: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(60); // <-- when gap is not enough to compensate for the running behind it absorbs at much as possible
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Relative offset mode', () => {
|
|
||||||
test('basic function', () => {
|
|
||||||
const test = {
|
|
||||||
timeStart: 0,
|
|
||||||
dayOffset: 0,
|
|
||||||
delay: 0,
|
|
||||||
currentDay: 0,
|
|
||||||
totalGap: 0,
|
|
||||||
clock: 100,
|
|
||||||
actualStart: 100,
|
|
||||||
plannedStart: 0,
|
|
||||||
offset: 0,
|
|
||||||
offsetMode: OffsetMode.Relative,
|
|
||||||
};
|
|
||||||
|
|
||||||
const timeStartEvent2 = 10;
|
|
||||||
const timeStartEvent3 = 20;
|
|
||||||
|
|
||||||
//event 1 is the currently running event
|
|
||||||
|
|
||||||
//event 2
|
|
||||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(10);
|
|
||||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(10);
|
|
||||||
|
|
||||||
//event 3
|
|
||||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(20);
|
|
||||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(20);
|
|
||||||
|
|
||||||
// When clock advances by 5ms, time until start should decrease by 5ms
|
|
||||||
test.clock = 105;
|
|
||||||
|
|
||||||
//event 2
|
|
||||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(5);
|
|
||||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(5);
|
|
||||||
|
|
||||||
//event 3
|
|
||||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(15);
|
|
||||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(15);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gaps', () => {
|
|
||||||
const test = {
|
|
||||||
timeStart: 20,
|
|
||||||
dayOffset: 0,
|
|
||||||
delay: 0,
|
|
||||||
currentDay: 0,
|
|
||||||
totalGap: 10,
|
|
||||||
clock: 100,
|
|
||||||
actualStart: 100,
|
|
||||||
plannedStart: 0,
|
|
||||||
offset: 0,
|
|
||||||
offsetMode: OffsetMode.Relative,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
|
|
||||||
|
|
||||||
// When clock advances by 5ms, time until start should decrease by 5ms
|
|
||||||
test.clock = 105;
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(15);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('added/remove time', () => {
|
|
||||||
const test = {
|
|
||||||
timeStart: 20,
|
|
||||||
dayOffset: 0,
|
|
||||||
delay: 0,
|
|
||||||
currentDay: 0,
|
|
||||||
totalGap: 0,
|
|
||||||
clock: 100,
|
|
||||||
actualStart: 100,
|
|
||||||
plannedStart: 0,
|
|
||||||
offset: 0,
|
|
||||||
offsetMode: OffsetMode.Relative,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
|
|
||||||
|
|
||||||
test.offset = 5; // remove 5 with addtime - we are ahead of time
|
|
||||||
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // unlocked evets will stay on schedule
|
|
||||||
|
|
||||||
test.offset = -5; // add 5 with addtime - we are behind
|
|
||||||
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(25);
|
|
||||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(25);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('next day', () => {
|
|
||||||
const test = {
|
|
||||||
delay: 0,
|
|
||||||
currentDay: 0,
|
|
||||||
clock: 100,
|
|
||||||
actualStart: 100,
|
|
||||||
plannedStart: 0,
|
|
||||||
offset: 0,
|
|
||||||
offsetMode: OffsetMode.Relative,
|
|
||||||
};
|
|
||||||
|
|
||||||
// this event will start the current day
|
|
||||||
expect(
|
|
||||||
calculateTimeUntilStart({
|
|
||||||
...test,
|
|
||||||
timeStart: 10,
|
|
||||||
dayOffset: 0,
|
|
||||||
totalGap: 0,
|
|
||||||
isLinkedToLoaded: false,
|
|
||||||
}),
|
|
||||||
).toBe(10);
|
|
||||||
|
|
||||||
// this event will start the next day
|
|
||||||
// in absolute mode this would start in dayInMs - 100 since the gap would compensate
|
|
||||||
// but in relative mode with and actual start that is 100 offset it starts in dayInMs
|
|
||||||
expect(
|
|
||||||
calculateTimeUntilStart({
|
|
||||||
...test,
|
|
||||||
timeStart: 0,
|
|
||||||
dayOffset: 1,
|
|
||||||
totalGap: dayInMs - 20,
|
|
||||||
isLinkedToLoaded: false,
|
|
||||||
}),
|
|
||||||
).toBe(dayInMs);
|
|
||||||
|
|
||||||
// advancing 100ms
|
|
||||||
test.clock = 200;
|
|
||||||
|
|
||||||
expect(
|
|
||||||
calculateTimeUntilStart({
|
|
||||||
...test,
|
|
||||||
timeStart: 0,
|
|
||||||
dayOffset: 1,
|
|
||||||
totalGap: dayInMs - 20,
|
|
||||||
isLinkedToLoaded: false,
|
|
||||||
}),
|
|
||||||
).toBe(dayInMs - 100);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('overlap with negative total gap', () => {
|
|
||||||
const test = {
|
|
||||||
dayOffset: 0,
|
|
||||||
delay: 0,
|
|
||||||
currentDay: 0,
|
|
||||||
clock: 100,
|
|
||||||
actualStart: 100,
|
|
||||||
plannedStart: 0,
|
|
||||||
offset: 0,
|
|
||||||
offsetMode: OffsetMode.Relative,
|
|
||||||
isLinkedToLoaded: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// the overlap will be pushed out to the expected available time
|
|
||||||
expect(calculateTimeUntilStart({ ...test, timeStart: 5, totalGap: -5 })).toBe(10);
|
|
||||||
|
|
||||||
test.clock = 105;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { resolvePath } from 'react-router-dom';
|
import { resolvePath } from 'react-router-dom';
|
||||||
|
|
||||||
import { arePathsEquivalent, generatePathFromPreset, getRouteFromPreset, validateUrlPresetPath } from '../urlPresets';
|
import {
|
||||||
|
arePathsEquivalent,
|
||||||
|
generatePathFromPreset,
|
||||||
|
generateUrlPresetOptions,
|
||||||
|
getRouteFromPreset,
|
||||||
|
validateUrlPresetPath,
|
||||||
|
} from '../urlPresets';
|
||||||
|
|
||||||
describe('validateUrlPresetPaths()', () => {
|
describe('validateUrlPresetPaths()', () => {
|
||||||
test.each([
|
test.each([
|
||||||
@@ -27,7 +33,8 @@ describe('getRouteFromPreset()', () => {
|
|||||||
{
|
{
|
||||||
enabled: true,
|
enabled: true,
|
||||||
alias: 'demopage',
|
alias: 'demopage',
|
||||||
pathAndParams: '/timer?user=guest',
|
target: 'timer',
|
||||||
|
search: 'user=guest',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -76,14 +83,14 @@ describe('getRouteFromPreset()', () => {
|
|||||||
|
|
||||||
describe('generatePathFromPreset()', () => {
|
describe('generatePathFromPreset()', () => {
|
||||||
test.each([
|
test.each([
|
||||||
['timer?user=guest', 'demopage', 'timer?user=guest&alias=demopage'],
|
['timer', 'user=guest', 'demopage', 'timer?user=guest&alias=demopage'],
|
||||||
['timer?user=admin', 'demopage', 'timer?user=admin&alias=demopage'],
|
['timer', 'user=admin', 'demopage', 'timer?user=admin&alias=demopage'],
|
||||||
])('generates a path from a preset: %s', (path, alias, expected) => {
|
])('generates a path from a preset: %s', (target, path, alias, expected) => {
|
||||||
expect(generatePathFromPreset(path, alias, null, null)).toEqual(expected);
|
expect(generatePathFromPreset(target, path, alias, null, null)).toEqual(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('appends the feature params to the alias', () => {
|
test('appends the feature params to the alias', () => {
|
||||||
expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe(
|
expect(generatePathFromPreset('timer', 'user=guest', 'demopage', 'true', '123')).toBe(
|
||||||
'timer?user=guest&alias=demopage&locked=true&token=123',
|
'timer?user=guest&alias=demopage&locked=true&token=123',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -106,3 +113,62 @@ describe('arePathsEquivalent()', () => {
|
|||||||
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy();
|
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('generateUrlPresetOptions', () => {
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
'cloud URL without protocol',
|
||||||
|
'test',
|
||||||
|
'www.getontime.no/timer?param1=value1¶m2=value2',
|
||||||
|
{
|
||||||
|
alias: 'test',
|
||||||
|
target: 'timer',
|
||||||
|
search: 'param1=value1¶m2=value2',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'cloud URL',
|
||||||
|
'test',
|
||||||
|
'https://cloud.getontime.no/timer?param1=value1¶m2=value2',
|
||||||
|
{
|
||||||
|
alias: 'test',
|
||||||
|
target: 'timer',
|
||||||
|
search: 'param1=value1¶m2=value2',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'local URL',
|
||||||
|
'test',
|
||||||
|
'http://localhost:4001/timer?param1=value1¶m2=value2',
|
||||||
|
{
|
||||||
|
alias: 'test',
|
||||||
|
target: 'timer',
|
||||||
|
search: 'param1=value1¶m2=value2',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'IP-based URL',
|
||||||
|
'test',
|
||||||
|
'http://192.168.0.1:4001/timer?param1=value1¶m2=value2',
|
||||||
|
{
|
||||||
|
alias: 'test',
|
||||||
|
target: 'timer',
|
||||||
|
search: 'param1=value1¶m2=value2',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])('should generate URL preset options for %s', (_description, alias, url, expected) => {
|
||||||
|
expect(generateUrlPresetOptions(alias, url)).toStrictEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on invalid URL', () => {
|
||||||
|
expect(() => generateUrlPresetOptions('test', 'invalid-url')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on on invalid route', () => {
|
||||||
|
expect(() => generateUrlPresetOptions('test', 'www.getontime.no/somethingelse/')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>;
|
|||||||
export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
|
export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
|
||||||
return {
|
return {
|
||||||
type: SupportedEntry.Event,
|
type: SupportedEntry.Event,
|
||||||
|
flag: event.flag,
|
||||||
title: event.title,
|
title: event.title,
|
||||||
note: event.note,
|
note: event.note,
|
||||||
timeStart: event.timeStart,
|
timeStart: event.timeStart,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user