Compare commits

...

66 Commits

Author SHA1 Message Date
Carlos Valente 3158311aa1 docs: align readme with documentation 2025-07-26 10:51:00 +02:00
Carlos Valente 6b4d96dddd feat: extra info in backstage 2025-07-26 10:51:00 +02:00
Carlos Valente dc3a32d2bc fix: custom project data 2025-07-26 10:51:00 +02:00
Carlos Valente bda01c886e fix: mirroring 2025-07-26 10:51:00 +02:00
Carlos Valente 93916653be refactor: tweaks to styles
- cuesheet action is ghosted
- mute index in cuesheet milestones
- clarify mirrored state in navigation menu
- align overview contents
2025-07-26 10:51:00 +02:00
Carlos Valente 40f8976350 fix: custom fields are last in order 2025-07-26 10:51:00 +02:00
Carlos Valente 78e905a735 fix: flag cannot be the current event 2025-07-26 10:51:00 +02:00
Carlos Valente 126ff61738 refactor: milestone actions in context menu 2025-07-26 10:51:00 +02:00
Carlos Valente 1e63238cac fix: allow empty string as a cue value 2025-07-26 10:51:00 +02:00
Carlos Valente 6ffe34a642 bump version to 4.0.0-alpha.1 2025-07-26 10:51:00 +02:00
Carlos Valente 69f276765b fix: delay indicator styling 2025-07-26 10:51:00 +02:00
Carlos Valente 75554071a7 refactor: improve params performance 2025-07-26 10:51:00 +02:00
Carlos Valente a1fab10bfd feat: improve preset building 2025-07-26 10:51:00 +02:00
Carlos Valente 5bc286145d refactor: extract regex utilities 2025-07-26 10:51:00 +02:00
Carlos Valente 44508ce8b4 refactor: block writing to base fields in run mode 2025-07-26 10:51:00 +02:00
Carlos Valente 90105a199a refactor: empty state for cuesheet 2025-07-26 10:51:00 +02:00
Carlos Valente 9618bec847 refactor: prevent reflow on visible rows 2025-07-26 10:51:00 +02:00
Carlos Valente 66a4e27fdd refactor: remove unused data 2025-07-26 10:51:00 +02:00
Carlos Valente ef906f43d7 docs: improve feature description 2025-07-26 10:51:00 +02:00
Carlos Valente 93b2a1aece refactor: index test alignment 2025-07-26 10:51:00 +02:00
Carlos Valente 050629d281 fix: stale column data 2025-07-26 10:51:00 +02:00
Carlos Valente abe9012f20 fix: keyboard shortcuts in rundown 2025-07-26 10:51:00 +02:00
Carlos Valente ad2ef3b53f refactor: small fixes and style tweaks
fix: prevent reflow on editing images

fix: schedule overview

fix: report on all events

refactor: over-under time colours

fix: prevent edit on delete
2025-07-14 16:08:40 +02:00
Carlos Valente 1e1f547ce1 breaking: convert offset > over-under
change offset direction and relabel to over-under
2025-07-14 11:39:16 +02:00
Carlos Valente 92a34cf135 fix: group expected end 2025-07-14 11:39:16 +02:00
Carlos Valente 1e5081f4a2 fix: parse nested events in rundown 2025-07-14 11:39:16 +02:00
Carlos Valente 6f9e13948d refactor: timeline design review 2025-07-14 11:39:16 +02:00
Carlos Valente 55e34b5f9a fix: guard corrupted parent on delete 2025-07-14 11:39:16 +02:00
Carlos Valente dd47d2b7e7 refactor: always reload on changes 2025-07-14 11:39:16 +02:00
Carlos Valente 445cc9e845 feat: time to next flag 2025-07-14 11:39:16 +02:00
Carlos Valente 637454f73d feat: add flag property 2025-07-14 11:38:27 +02:00
Alex Christoffer Rasmussen e9dda817e5 block end calculation (#1683) 2025-07-14 11:38:27 +02:00
Carlos Valente e811a82be2 refactor: make minimal and clock as presets 2025-07-14 11:38:27 +02:00
Carlos Valente fd9bf0120c refactor: disable react compiler 2025-07-14 11:38:27 +02:00
Carlos Valente e5f8a99bff refactor: extract sentry 2025-07-13 12:33:50 +02:00
Carlos Valente 7305edc398 refactor: review timer styles 2025-07-13 12:33:50 +02:00
Carlos Valente a69c5f354c react 19 type migration 2025-07-13 12:33:50 +02:00
Carlos Valente 0eaec1f88f refactor: timeline style tweaks 2025-07-13 12:33:50 +02:00
google-labs-jules[bot] cdd021e76d Docs: Update DEVELOPMENT.MD with explicit turbo run commands
Updated DEVELOPMENT.MD to use `turbo run <task>` instead of `turbo <task>` for clarity and consistency when referring to Turborepo tasks that don't have a direct pnpm script alias.

Also, added `dependsOn: ["build"]` to `dist-*` tasks in `turbo.json` to ensure correct build order before packaging.
2025-07-12 22:12:17 +02:00
Carlos Valente 05d01e0863 refactor: timer design review 2025-07-12 18:41:17 +02:00
Carlos Valente 2aac4cb06d refactor: remove deprecated views 2025-07-12 18:41:17 +02:00
Carlos Valente 1c6adbd2fd refactor: upgrade react and add compiler 2025-07-12 18:41:17 +02:00
Carlos Valente d24d5c1365 refactor: freeze is view option 2025-07-12 18:41:17 +02:00
Carlos Valente 138c6523d2 feat: implement custom project data image 2025-07-12 18:41:17 +02:00
Carlos Valente d8c1c38123 refactor: fixes to custom field 2025-07-12 18:41:17 +02:00
Carlos Valente 4d419c0877 refactor: style tweaks and bug fixes
- italic and bold for rundown metadata entries
- allow deleting groups
- make groups in order
- correct style for milestones
- open playing groups
- style tweaks
2025-07-12 18:41:17 +02:00
Carlos Valente 50b603f668 create release 4.0.0-alpha.0 2025-07-11 19:46:25 +02:00
Carlos Valente e1e8410ba4 refactor: migrate UI components and remove chakra
migrate pin page
migrate copy tag
migrate pin input
migrate dropdown menus
migrate radio buttons
migrate switch
migrate select
migrate textarea
migrate colour picker
migrate select
migrate context menu
migrate viewparams
remove chakra
2025-07-11 08:43:38 +02:00
Alex Christoffer Rasmussen 4d359445a7 Fix ontime aux actions (#1684)
* switch to base ui

* ensure all action types is part of the valitation

* update test

* atempt to do it in the base-ui way

* fix fixing

* allow undefide visible on set timer message

* fix set time not showing

* correct time data for aux set automation

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2025-07-09 13:14:23 +02:00
Carlos Valente 8d68a4554d refactor: deprecate languages 2025-07-08 20:14:49 +02:00
Carlos Valente 6f34e1006b refactor: migrate project data 2025-07-08 20:14:49 +02:00
Carlos Valente 1fc6f9b3ea refactor: inline quick add 2025-07-08 16:34:23 +02:00
Carlos Valente a1ad1d47a4 refactor: review action menu 2025-07-08 16:34:23 +02:00
Carlos Valente 9281c0b51d refactor: update app menu links 2025-07-08 16:34:23 +02:00
Carlos Valente 4365c19498 refactor: review event entry 2025-07-08 16:34:23 +02:00
Carlos Valente 8728c1450b refactor: rename block > group in UI elements 2025-07-08 16:34:23 +02:00
Carlos Valente a98c14406d refactor: remove isNextDay block property 2025-07-08 16:34:23 +02:00
Alex Christoffer Rasmussen ab8bfbcd59 handle rearange in block (#1682) 2025-07-07 23:15:37 +02:00
Carlos Valente f2913971db feat: milestones 2025-07-07 18:22:10 +02:00
Carlos Valente a7ae8598db feat: add group metadata to overview 2025-07-07 18:22:10 +02:00
Carlos Valente 17a7d035bf refactor: style cleanups and migrations
fix: close modal with button
refactor: small type improvements
refactor: migrate time inputs
refactor: migrate tooltips
refactor: prevent component resizing
2025-07-07 18:22:10 +02:00
Carlos Valente 6facd6a666 refactor: improve editor and cuesheet headers
- consistent behaviour for app mode
- improve navigation
2025-07-07 18:22:10 +02:00
Carlos Valente 79bcc179ba refactor: style tweaks and ux improvements 2025-07-07 18:22:10 +02:00
Carlos Valente 3790681bec fix: reordering out of a block 2025-07-07 18:22:10 +02:00
Carlos Valente c32203c759 fix: search params editor conflicts with alias 2025-07-07 18:22:10 +02:00
Carlos Valente ce449408f0 Chore/remove got dependency (#1667)
* Refactor: Remove GOT dependency, use simplified fetch wrapper

Replaced GOT with a lightweight, functional, fetch-based utility (`appClient.ts`) for handling JSON POST requests in `SheetService.ts`.

- Created `appClient.ts` to encapsulate fetch logic. It mimics GOT's behavior for sending JSON payloads and parsing JSON responses.
- Error handling in `appClient.ts` uses standard `Error` objects with descriptive messages, including status and error body details, instead of custom error types.
- Updated `SheetService.ts` to use the new `appClient.ts`.
- Removed `got` from server dependencies.

* cleanup

* dont throw when we know the error

* rearange logging

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: arc-alex <ac@omnivox.dk>
2025-07-07 15:54:26 +02:00
434 changed files with 7497 additions and 7216 deletions
+4 -4
View File
@@ -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
+14 -61
View File
@@ -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 -1
View File
@@ -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",
+1 -1
View File
@@ -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>
+8 -12
View File
@@ -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:"
+7 -8
View File
@@ -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>
); );
} }
+7 -58
View File
@@ -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>
); );
} }
+18 -4
View File
@@ -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;
} }
+14
View File
@@ -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
+13 -4
View File
@@ -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} />
</> </>
@@ -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 {
@@ -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 = () => {
@@ -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
+24 -19
View File
@@ -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 -3
View File
@@ -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&param2=value2',
{
alias: 'test',
target: 'timer',
search: 'param1=value1&param2=value2',
enabled: true,
},
],
[
'cloud URL',
'test',
'https://cloud.getontime.no/timer?param1=value1&param2=value2',
{
alias: 'test',
target: 'timer',
search: 'param1=value1&param2=value2',
enabled: true,
},
],
[
'local URL',
'test',
'http://localhost:4001/timer?param1=value1&param2=value2',
{
alias: 'test',
target: 'timer',
search: 'param1=value1&param2=value2',
enabled: true,
},
],
[
'IP-based URL',
'test',
'http://192.168.0.1:4001/timer?param1=value1&param2=value2',
{
alias: 'test',
target: 'timer',
search: 'param1=value1&param2=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();
});
});
+1
View File
@@ -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