mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-04 15:08:01 +00:00
Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87df656470 | |||
| 54f07c4330 | |||
| ae061cb69a | |||
| ba529def34 | |||
| 210a85c463 | |||
| 3ad3ad088f | |||
| 617da68ab5 | |||
| efceb930e5 | |||
| 836fa52757 | |||
| 902edf4a85 | |||
| 0e770655dc | |||
| db922a67e2 | |||
| 048a9c96ea | |||
| ed93cf313a | |||
| 97a0e17192 | |||
| 022a58e142 | |||
| e0149c1cbf | |||
| 4665f9c2f7 | |||
| 9ab6b30aea | |||
| e5d2457717 | |||
| 486d89ecf4 | |||
| 94b1f63c7c | |||
| 0299463f62 | |||
| f7ccb6bb51 | |||
| 6c6f5c2c0f | |||
| 1695b4dc68 | |||
| 1ed08df198 | |||
| c300fc18e1 | |||
| bea7e2bb2b | |||
| ad7f658f15 | |||
| f23adfa45c | |||
| 20ee391525 | |||
| 78184b23b7 | |||
| e204f671be | |||
| 8c90f5043f | |||
| 7a322406da | |||
| 307f0ffd34 | |||
| 5f8a5c4995 | |||
| 3747dbc1a8 | |||
| 9ebb3decdb | |||
| 35347e8d63 | |||
| e37d2ce50f | |||
| 49e923026e | |||
| f1a8db9649 | |||
| dddeefd544 | |||
| b4aaa8966f | |||
| ad9db4edd3 | |||
| fef9ed4fcc | |||
| 9ec47eda6e | |||
| 32edf3404c | |||
| bcf80f9d77 | |||
| df137402cf | |||
| daddb7f7b7 | |||
| 82e8b9d66a | |||
| 765d5d97cf | |||
| ed4d3ba265 | |||
| a358ec0f17 | |||
| 524721002a | |||
| 9ab54edbf5 | |||
| 8a7f8c8ebc | |||
| 3ce539af2a | |||
| 3158311aa1 | |||
| 6b4d96dddd | |||
| dc3a32d2bc | |||
| bda01c886e | |||
| 93916653be | |||
| 40f8976350 | |||
| 78e905a735 | |||
| 126ff61738 | |||
| 1e63238cac | |||
| 6ffe34a642 | |||
| 69f276765b | |||
| 75554071a7 | |||
| a1fab10bfd | |||
| 5bc286145d | |||
| 44508ce8b4 | |||
| 90105a199a | |||
| 9618bec847 | |||
| 66a4e27fdd | |||
| ef906f43d7 | |||
| 93b2a1aece | |||
| 050629d281 | |||
| abe9012f20 |
@@ -43,12 +43,12 @@ Ontime is made by entertainment and broadcast engineers and used by
|
||||
- Houses of worship
|
||||
|
||||
## Main features
|
||||
- [x] **Multiplatform**: Available for Windows / MacOS, Linux. You can also self host with the docker image
|
||||
- [x] **In any device**: Ontime is available in the local network to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers...
|
||||
- [x] **Made for teams**: Ontime caters to different roles in your production team: directors, operators, backstage and front of house signage...
|
||||
- [x] **Delay workflows**: Manage and communicate runtime delays in real-time to your team
|
||||
- [x] **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock
|
||||
- [x] **Focus on integrations**: Use one of the APIs provided (OSC, HTTP, Websocket) or the available [Companion module](https://bitfocus.io/connections/getontime-ontime) to integrate into your workflow (vMix, disguise, Qlab, OBS)
|
||||
- **Multiplatform**: Available as a Cloud service and for Windows, macOS, Linux, or self-hosted via Docker.
|
||||
- **In any device**: Ontime is available to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers...
|
||||
- **Team Collaboration**: Dedicated views for directors, operators, backstage, and signage.
|
||||
- **Real-Time Updates**: Manage and communicate runtime delays effortlessly.
|
||||
- **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock
|
||||
- **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 ...
|
||||
|
||||
@@ -79,75 +79,29 @@ Ontime is made by video engineers and entertainment technicians.
|
||||
|
||||
## Using Ontime
|
||||
|
||||
Ontime can be started by downloading the latest release for your platform. \
|
||||
Alternatively you can also use the docker image, available at [Docker Hub](https://hub.docker.com/r/getontime/ontime)
|
||||
### Getting started
|
||||
|
||||
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
|
||||
views. \
|
||||
Each view in Ontime focuses on empowering a specific role or achieving a particular task.
|
||||
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)
|
||||
|
||||
You can access the different views by reaching the ontime server, in your browser, at (_default port
|
||||
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/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
|
||||
```
|
||||
Once installed and running, any device that shares the same network as Ontime will have access to Ontime.
|
||||
|
||||
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.
|
||||
|
||||
Have an idea? Reach out via [email](mail@getontime.no)
|
||||
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. \
|
||||
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
|
||||
|
||||
Looking to contribute? All types of help are appreciated, from coding to testing and feature specification.
|
||||
@@ -159,6 +113,7 @@ Information about the project setup can be found in the [development documentati
|
||||
## Links
|
||||
- [Ontime website](https://getontime.no)
|
||||
- [Documentation](https://docs.getontime.no)
|
||||
- [Video tutorials](https://www.youtube.com/@ontimeapp)
|
||||
- [Ontime discord server](https://discord.com/invite/eje3CSUEXm)
|
||||
|
||||
## License
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "4.0.0-alpha.0",
|
||||
"version": "4.0.0-alpha.3",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
+17
-18
@@ -1,37 +1,36 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "4.0.0-alpha.0",
|
||||
"version": "4.0.0-alpha.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@base-ui-components/react": "1.0.0-beta.1",
|
||||
"@base-ui-components/react": "1.0.0-beta.2",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/is-prop-valid": "^1.3.1",
|
||||
"@fontsource/open-sans": "^5.0.28",
|
||||
"@mantine/hooks": "^8.1.2",
|
||||
"@sentry/react": "^8.43.0",
|
||||
"@mantine/hooks": "^8.2.4",
|
||||
"@sentry/react": "^10.2.0",
|
||||
"@table-nav/react": "^0.0.7",
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"@tanstack/react-query-devtools": "^5.62.7",
|
||||
"@tanstack/react-query": "^5.84.1",
|
||||
"@tanstack/react-query-devtools": "^5.84.1",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.9.0",
|
||||
"axios": "^1.11.0",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"csv-stringify": "^6.4.5",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^19.1.0",
|
||||
"csv-stringify": "^6.6.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^19.1.1",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-fast-compare": "^3.2.2",
|
||||
"react-hook-form": "^7.53.1",
|
||||
"react-icons": "5.4.0",
|
||||
"react-qr-code": "^2.0.12",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-hook-form": "^7.62.0",
|
||||
"react-icons": "5.5.0",
|
||||
"react-qr-code": "^2.0.18",
|
||||
"react-router": "^7.8.0",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"web-vitals": "^3.1.1",
|
||||
"zustand": "^5.0.3"
|
||||
"web-vitals": "^5.1.0",
|
||||
"zustand": "^5.0.7"
|
||||
},
|
||||
"scripts": {
|
||||
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { BrowserRouter } from 'react-router';
|
||||
import { Tooltip } from '@base-ui-components/react/tooltip';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
|
||||
+156
-53
@@ -1,38 +1,34 @@
|
||||
import React from 'react';
|
||||
import { Navigate, Route } from 'react-router-dom';
|
||||
import { ComponentType, lazy, Suspense, useEffect, useMemo } from 'react';
|
||||
import { Navigate, Route, useLocation, useNavigate, useParams } from 'react-router';
|
||||
import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types';
|
||||
|
||||
import ViewNavigationMenu from './common/components/navigation-menu/ViewNavigationMenu';
|
||||
import { PresetContext } from './common/context/PresetContext';
|
||||
import { useClientPath } from './common/hooks/useClientPath';
|
||||
import useUrlPresets from './common/hooks-query/useUrlPresets';
|
||||
import { getRouteFromPreset } from './common/utils/urlPresets';
|
||||
import Log from './features/log/Log';
|
||||
import withPreset from './features/PresetWrapper';
|
||||
import withData from './features/viewers/ViewWrapper';
|
||||
import Loader from './views/common/loader/Loader';
|
||||
import NotFound from './views/common/not-found/NotFound';
|
||||
import ViewLoader from './views/ViewLoader';
|
||||
import { getIsNavigationLocked, sessionScope } from './externals';
|
||||
import { initializeSentry } from './sentry.config';
|
||||
|
||||
const Editor = React.lazy(() => import('./views/editor/ProtectedEditor'));
|
||||
const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
|
||||
const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
|
||||
const Timer = lazy(() => import('./views/timer/Timer'));
|
||||
const Countdown = lazy(() => import('./views/countdown/Countdown'));
|
||||
const Backstage = lazy(() => import('./views/backstage/Backstage'));
|
||||
const StudioClock = lazy(() => import('./views/studio/Studio'));
|
||||
const Timeline = lazy(() => import('./views/timeline/TimelinePage'));
|
||||
const ProjectInfo = lazy(() => import('./views/project-info/ProjectInfo'));
|
||||
|
||||
const TimerView = React.lazy(() => import('./views/timer/Timer'));
|
||||
const Countdown = React.lazy(() => import('./views/countdown/Countdown'));
|
||||
const Editor = lazy(() => import('./views/editor/ProtectedEditor'));
|
||||
const Cuesheet = lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
|
||||
const Operator = lazy(() => import('./features/operator/OperatorExport'));
|
||||
|
||||
const Backstage = React.lazy(() => import('./views/backstage/Backstage'));
|
||||
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
|
||||
const StudioClock = React.lazy(() => import('./views/studio/Studio'));
|
||||
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
|
||||
|
||||
const STimer = withPreset(withData(TimerView));
|
||||
const SCountdown = withPreset(withData(Countdown));
|
||||
const SBackstage = withPreset(withData(Backstage));
|
||||
const SProjectInfo = withPreset(ProjectInfo); // NOTE: ProjectInfo does not use the viewWrapper since it has no options
|
||||
const SStudio = withPreset(withData(StudioClock));
|
||||
const STimeline = withPreset(withData(Timeline));
|
||||
const PCuesheet = withPreset(Cuesheet);
|
||||
const POperator = withPreset(Operator);
|
||||
|
||||
const EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper'));
|
||||
const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport'));
|
||||
const TimerControl = React.lazy(() => import('./features/control/playback/TimerControlExport'));
|
||||
const MessageControl = React.lazy(() => import('./features/control/message/MessageControlExport'));
|
||||
const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'));
|
||||
const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
|
||||
const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport'));
|
||||
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
|
||||
|
||||
// Initialize Sentry with our configuration
|
||||
const SentryRouter = initializeSentry();
|
||||
@@ -42,73 +38,77 @@ export default function AppRouter() {
|
||||
useClientPath();
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={null}>
|
||||
<Suspense fallback={<Loader />}>
|
||||
<SentryRouter>
|
||||
<Route path='/' element={<Navigate to='/timer' />} />
|
||||
<Route
|
||||
path='/timer'
|
||||
path='timer'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<STimer />
|
||||
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
|
||||
<Timer />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/countdown'
|
||||
path='countdown'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SCountdown />
|
||||
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
|
||||
<Countdown />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/backstage'
|
||||
path='backstage'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SBackstage />
|
||||
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
|
||||
<Backstage />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/studio'
|
||||
path='studio'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SStudio />
|
||||
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
|
||||
<StudioClock />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/timeline'
|
||||
path='timeline'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<STimeline />
|
||||
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
|
||||
<Timeline />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/info'
|
||||
path='info'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SProjectInfo />
|
||||
<ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />
|
||||
<ProjectInfo />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
|
||||
{/*/!* Protected Routes *!/*/}
|
||||
<Route path='/editor' element={<Editor />} />
|
||||
<Route path='/cuesheet' element={<PCuesheet />} />
|
||||
<Route path='editor' element={<Editor />} />
|
||||
<Route path='cuesheet' element={<Cuesheet />} />
|
||||
<Route
|
||||
path='/op'
|
||||
path='op'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<POperator />
|
||||
<Operator />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
|
||||
{/*/!* Protected Routes - Elements *!/*/}
|
||||
<Route
|
||||
path='/rundown'
|
||||
path='rundown'
|
||||
element={
|
||||
<EditorFeatureWrapper>
|
||||
<RundownPanel />
|
||||
@@ -116,7 +116,7 @@ export default function AppRouter() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/timercontrol'
|
||||
path='timercontrol'
|
||||
element={
|
||||
<EditorFeatureWrapper>
|
||||
<TimerControl />
|
||||
@@ -124,7 +124,7 @@ export default function AppRouter() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/messagecontrol'
|
||||
path='messagecontrol'
|
||||
element={
|
||||
<EditorFeatureWrapper>
|
||||
<MessageControl />
|
||||
@@ -132,16 +132,119 @@ export default function AppRouter() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/log'
|
||||
path='log'
|
||||
element={
|
||||
<EditorFeatureWrapper>
|
||||
<Log />
|
||||
</EditorFeatureWrapper>
|
||||
}
|
||||
/>
|
||||
{/*/!* Send to default if nothing found *!/*/}
|
||||
<Route path='*' element={<STimer />} />
|
||||
{/**
|
||||
* If the views are prefixed with the "preset" path, we are in a locked preset
|
||||
* Locked presets do not expose their parameters
|
||||
*/}
|
||||
<Route path='preset/:alias' element={<PresetView />} />
|
||||
|
||||
{/**
|
||||
* If we havent matched any views or presets, we may be in an unlocked preset
|
||||
* Unlocked presets are unwrapped to expose their target and parameters
|
||||
*/}
|
||||
<Route path='*' element={<RedirectPreset />} />
|
||||
</SentryRouter>
|
||||
</React.Suspense>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const PresetViewMap: Record<OntimeViewPresettable, ComponentType> = {
|
||||
[OntimeView.Cuesheet]: Cuesheet,
|
||||
[OntimeView.Operator]: Operator,
|
||||
[OntimeView.Timer]: Timer,
|
||||
[OntimeView.Backstage]: Backstage,
|
||||
[OntimeView.Timeline]: Timeline,
|
||||
[OntimeView.StudioClock]: StudioClock,
|
||||
[OntimeView.Countdown]: Countdown,
|
||||
[OntimeView.ProjectInfo]: ProjectInfo,
|
||||
};
|
||||
|
||||
/**
|
||||
* This view will mask a configured canonical route
|
||||
* and inject the preset search parameters to context
|
||||
* User are not able to configure the parameters locked presets
|
||||
*/
|
||||
function PresetView() {
|
||||
const { data, status } = useUrlPresets();
|
||||
const { alias } = useParams();
|
||||
|
||||
const preset: URLPreset | undefined = useMemo(() => {
|
||||
if (status === 'pending' || !alias) return;
|
||||
return data.find((p) => p.alias === alias && p.enabled);
|
||||
}, [data, status, alias]);
|
||||
|
||||
if (status === 'pending') {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
/**
|
||||
* We need to check the session scope to determine if the user can navigate
|
||||
* If the user has a global scope, they can navigate freely
|
||||
* Otherwise, they are locked to the preset view
|
||||
*/
|
||||
const showNav = sessionScope === 'rw';
|
||||
|
||||
/**
|
||||
* If we are in a preset path but cannot find a preset, we will need to show a not found page
|
||||
* This can happen if the preset was deleted or disabled
|
||||
*/
|
||||
if (!preset) {
|
||||
return (
|
||||
<>
|
||||
<ViewNavigationMenu isNavigationLocked={!showNav} suppressSettings />
|
||||
<NotFound />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Locked presets do not allow configuration changes
|
||||
* Whether the user can navigate is determined by the locked param
|
||||
*/
|
||||
const Component = PresetViewMap[preset.target as OntimeViewPresettable];
|
||||
return (
|
||||
<PresetContext value={preset}>
|
||||
{preset.target !== OntimeView.Cuesheet && (
|
||||
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
|
||||
)}
|
||||
{Component ? <Component /> : <NotFound />}
|
||||
</PresetContext>
|
||||
);
|
||||
}
|
||||
|
||||
function RedirectPreset() {
|
||||
const { data, status } = useUrlPresets();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// checks if we are in a preset path and resolves a destination URL
|
||||
const destination = useMemo(() => {
|
||||
if (status === 'pending') return null;
|
||||
return getRouteFromPreset(location, data);
|
||||
}, [data, location, status]);
|
||||
|
||||
// if we have a destination, we will navigate to it
|
||||
useEffect(() => {
|
||||
if (destination) {
|
||||
navigate(`/${destination}`, { replace: true });
|
||||
}
|
||||
}, [destination, navigate]);
|
||||
|
||||
if (status === 'pending') {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
|
||||
<NotFound />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,14 +95,14 @@ export async function postCloneEntry(entryId: EntryId): Promise<AxiosResponse<Ru
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request for dissolving of a block
|
||||
* HTTP request for dissolving of a group
|
||||
*/
|
||||
export async function requestUngroup(blockId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.post(`${rundownPath}/ungroup/${blockId}`);
|
||||
export async function requestUngroup(groupId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.post(`${rundownPath}/ungroup/${groupId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request for grouping a list of entries into a block
|
||||
* HTTP request for grouping a list of entries into a group
|
||||
*/
|
||||
export async function requestGroupEntries(entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.post(`${rundownPath}/group`, { ids: entryIds });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import { GetInfo } from 'ontime-types';
|
||||
import { GetInfo, LinkOptions } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
@@ -16,12 +16,7 @@ export async function getInfo(): Promise<GetInfo> {
|
||||
/**
|
||||
* HTTP request to get a pre-authenticated URL
|
||||
*/
|
||||
export async function generateUrl(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
lock: boolean,
|
||||
authenticate: boolean,
|
||||
): Promise<string> {
|
||||
const res = await axios.post(`${sessionPath}/url`, { baseUrl, path, lock, authenticate });
|
||||
export async function generateUrl(options: LinkOptions & { baseUrl: string; path: string }): Promise<string> {
|
||||
const res = await axios.post(`${sessionPath}/url`, options);
|
||||
return res.data.url;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { apiEntryUrl } from './constants';
|
||||
const urlPresetsPath = `${apiEntryUrl}/url-presets`;
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve aliases
|
||||
* HTTP request to retrieve all presets
|
||||
*/
|
||||
export async function getUrlPresets(): Promise<URLPreset[]> {
|
||||
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[]> {
|
||||
return axios.post(urlPresetsPath, data);
|
||||
export async function postUrlPreset(data: URLPreset): Promise<URLPreset[]> {
|
||||
return (await axios.post(urlPresetsPath, data)).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to edit a preset
|
||||
*/
|
||||
export async function putUrlPreset(alias: string, data: URLPreset): Promise<URLPreset[]> {
|
||||
return (await axios.put(`${urlPresetsPath}/${alias}`, data)).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to delete a preset
|
||||
*/
|
||||
export async function deleteUrlPreset(alias: string): Promise<URLPreset[]> {
|
||||
return (await axios.delete(`${urlPresetsPath}/${alias}`)).data;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,20 @@ export function maybeAxiosError(error: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility unwrap a an instance of Error
|
||||
*/
|
||||
export function unwrapError(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
} else {
|
||||
if (typeof error !== 'string') {
|
||||
return JSON.stringify(error);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility unwraps a potential axios error and sends to logger
|
||||
* @param prepend
|
||||
|
||||
@@ -45,8 +45,8 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
||||
label: view.label,
|
||||
})),
|
||||
...enabledPresets.map((preset) => ({
|
||||
value: preset.pathAndParams,
|
||||
label: `Preset: ${preset.alias}`,
|
||||
value: preset.search,
|
||||
label: `URL Preset: ${preset.alias}`,
|
||||
})),
|
||||
];
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { PropsWithChildren, useRef, useState } from 'react';
|
||||
import { IoCheckmark } from 'react-icons/io5';
|
||||
import { IoCopy } from 'react-icons/io5';
|
||||
|
||||
@@ -24,32 +24,29 @@ export default function CopyTag({
|
||||
onClick,
|
||||
}: PropsWithChildren<CopyTagProps>) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleClick = () => {
|
||||
copyToClipboard(copyValue);
|
||||
setCopied(true);
|
||||
|
||||
// reset copied state
|
||||
setTimeout(() => setCopied(false), 1000);
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.copytag}>
|
||||
{onClick !== undefined ? (
|
||||
<Button className={style.action} size={size} tabIndex={-1} onClick={onClick} disabled={disabled}>
|
||||
<Button className={style.action} size={size} onClick={onClick} disabled={disabled}>
|
||||
{children}
|
||||
</Button>
|
||||
) : (
|
||||
<div className={cx([style.label, style[size]])}>{children}</div>
|
||||
)}
|
||||
<IconButton
|
||||
className={style.copy}
|
||||
variant='primary'
|
||||
size={size}
|
||||
tabIndex={-1}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<IconButton className={style.copy} variant='primary' size={size} onClick={handleClick} disabled={disabled}>
|
||||
{copied ? <IoCheckmark /> : <IoCopy />}
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
.delaySymbol {
|
||||
svg {
|
||||
display: inline;
|
||||
vertical-align: middle;
|
||||
font-size: 1.5rem;
|
||||
color: $ontime-delay;
|
||||
margin: 0 auto;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
border-radius: 3px;
|
||||
box-shadow: $box-shadow-l1;
|
||||
border: 1px solid $gray-1100;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
|
||||
@@ -31,11 +31,7 @@
|
||||
}
|
||||
|
||||
&[data-checked] {
|
||||
background-color: $gray-1200;
|
||||
|
||||
&:hover {
|
||||
border-color: $gray-1000;
|
||||
}
|
||||
background-color: $gray-700;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@@ -57,6 +53,6 @@
|
||||
border-radius: 100%;
|
||||
width: 0.5em;
|
||||
height: 0.5em;
|
||||
background-color: $blue-500;
|
||||
background-color: $ui-white;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,13 +10,19 @@
|
||||
padding-inline: 0.5em;
|
||||
outline: none;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
&::placeholder {
|
||||
color: $gray-500;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled):not(:read-only) {
|
||||
background-color: $gray-1100;
|
||||
}
|
||||
|
||||
&:focus:not(:read-only) {
|
||||
background-color: $gray-1000;
|
||||
border: 1px solid $blue-500;
|
||||
outline: 2px solid $blue-500;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@@ -24,9 +30,8 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: $gray-500;
|
||||
letter-spacing: 0;
|
||||
&:read-only::placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,13 @@
|
||||
border-radius: $component-border-radius-md;
|
||||
border: 1px solid transparent;
|
||||
|
||||
padding-inline: 0.5em;
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
color: $gray-500;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: $gray-1100;
|
||||
}
|
||||
@@ -26,21 +30,21 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: $gray-500;
|
||||
letter-spacing: 0;
|
||||
&:read-only::placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.subtle {
|
||||
background-color: $gray-1200;
|
||||
padding-top: 0.5em;
|
||||
padding-inline: 0.5em;
|
||||
padding-top: 0.25em;
|
||||
}
|
||||
|
||||
.ghosted {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
padding-top: 0.5em;
|
||||
padding-top: 0.25em;
|
||||
}
|
||||
|
||||
.fluid {
|
||||
|
||||
@@ -9,7 +9,7 @@ import style from './TimeInput.module.scss';
|
||||
interface NullableTimeInputProps<T extends string> {
|
||||
id?: T;
|
||||
name: T;
|
||||
submitHandler: (field: T, value: string) => void;
|
||||
submitHandler: (field: T, value: number) => void;
|
||||
time?: number | null;
|
||||
emptyDisplay: string;
|
||||
placeholder?: string;
|
||||
@@ -71,7 +71,7 @@ export default function NullableTimeInput<T extends string>({
|
||||
return false;
|
||||
}
|
||||
|
||||
submitHandler(name, newValue);
|
||||
submitHandler(name, valueInMillis);
|
||||
return true;
|
||||
},
|
||||
[name, submitHandler, time],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type PropsWithChildren } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import style from './AppLink.module.scss';
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
padding-inline: 1rem;
|
||||
min-width: min(680px, 90vw);
|
||||
min-height: min(200px, 10vh);
|
||||
max-width: min(800px, 90vw);
|
||||
max-width: min(900px, 90vw);
|
||||
|
||||
background-color: $gray-1250;
|
||||
color: $ui-white;
|
||||
|
||||
@@ -64,3 +64,8 @@
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-left: auto;
|
||||
color: $white-20;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { memo } from 'react';
|
||||
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useLocation } from 'react-router';
|
||||
import { Dialog } from '@base-ui-components/react/dialog';
|
||||
import { useDisclosure, useFullscreen } from '@mantine/hooks';
|
||||
|
||||
@@ -57,9 +57,10 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
||||
Toggle Fullscreen
|
||||
{fullscreen ? <IoContract /> : <IoExpand />}
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem active={mirror} onClick={toggleMirror}>
|
||||
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
|
||||
Flip Screen
|
||||
<IoSwapVertical />
|
||||
{mirror && <span className={style.note}>Active</span>}
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem onClick={handlers.open}>Rename Client</NavigationMenuItem>
|
||||
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
import { memo } from 'react';
|
||||
import { useDisclosure, useHotkeys } from '@mantine/hooks';
|
||||
|
||||
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
|
||||
|
||||
import FloatingNavigation from './floating-navigation/FloatingNavigation';
|
||||
import ViewLockedIcon from './view-locked-icon/ViewLockedIcon';
|
||||
import NavigationMenu from './NavigationMenu';
|
||||
import useViewEditor from './useViewEditor';
|
||||
|
||||
interface ViewNavigationMenuProps {
|
||||
isLockable?: boolean;
|
||||
/** prevent navigation */
|
||||
isNavigationLocked?: boolean;
|
||||
/** prevent showing settings */
|
||||
suppressSettings?: boolean;
|
||||
}
|
||||
|
||||
export default memo(ViewNavigationMenu);
|
||||
function ViewNavigationMenu({ isLockable, suppressSettings }: ViewNavigationMenuProps) {
|
||||
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
|
||||
const [isMenuOpen, menuHandler] = useDisclosure();
|
||||
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable });
|
||||
const { open: showEditFormDrawer } = useViewParamsEditorStore();
|
||||
|
||||
useHotkeys([
|
||||
[
|
||||
'Space',
|
||||
() => {
|
||||
if (isViewLocked) return;
|
||||
if (isNavigationLocked) return;
|
||||
menuHandler.toggle();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
@@ -28,24 +31,24 @@ function ViewNavigationMenu({ isLockable, suppressSettings }: ViewNavigationMenu
|
||||
[
|
||||
'mod + ,',
|
||||
() => {
|
||||
if (isViewLocked || suppressSettings) return;
|
||||
if (suppressSettings) return;
|
||||
showEditFormDrawer();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
],
|
||||
]);
|
||||
|
||||
if (isViewLocked) {
|
||||
if (isNavigationLocked && suppressSettings) {
|
||||
return <ViewLockedIcon />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FloatingNavigation
|
||||
toggleMenu={menuHandler.toggle}
|
||||
toggleSettings={suppressSettings ? undefined : () => showEditFormDrawer()}
|
||||
toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle}
|
||||
toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
|
||||
/>
|
||||
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
|
||||
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { IoArrowUp } from 'react-icons/io5';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { useElectronEvent } from '../../../hooks/useElectronEvent';
|
||||
import { handleLinks } from '../../../utils/linkUtils';
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { IoLockClosedOutline } from 'react-icons/io5';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { useIsSmallDevice } from '../../../hooks/useIsSmallDevice';
|
||||
import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
|
||||
|
||||
+12
-10
@@ -8,7 +8,7 @@ import IconButton from '../../buttons/IconButton';
|
||||
import style from './FloatingNavigation.module.scss';
|
||||
|
||||
interface FloatingNavigationProps {
|
||||
toggleMenu: () => void;
|
||||
toggleMenu?: () => void;
|
||||
toggleSettings?: () => void;
|
||||
}
|
||||
|
||||
@@ -20,15 +20,17 @@ export default function FloatingNavigation({ toggleMenu, toggleSettings }: Float
|
||||
id='fadeable-navigation'
|
||||
className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}
|
||||
>
|
||||
<IconButton
|
||||
variant='subtle-white'
|
||||
size='xlarge'
|
||||
onClick={toggleMenu}
|
||||
aria-label='toggle menu'
|
||||
data-testid='navigation__toggle-menu'
|
||||
>
|
||||
<IoApps />
|
||||
</IconButton>
|
||||
{toggleMenu && (
|
||||
<IconButton
|
||||
variant='subtle-white'
|
||||
size='xlarge'
|
||||
onClick={toggleMenu}
|
||||
aria-label='toggle menu'
|
||||
data-testid='navigation__toggle-menu'
|
||||
>
|
||||
<IoApps />
|
||||
</IconButton>
|
||||
)}
|
||||
{toggleSettings && (
|
||||
<IconButton
|
||||
variant='subtle-white'
|
||||
|
||||
+6
-1
@@ -5,12 +5,17 @@
|
||||
}
|
||||
|
||||
.interfaces {
|
||||
padding: 0.5rem 1rem;
|
||||
padding: 0.5rem 0.5rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.interfaceCopy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.goIcon {
|
||||
@include rotate-fourty-five;
|
||||
margin-left: 0.25rem;
|
||||
|
||||
+4
-2
@@ -30,8 +30,10 @@ export default function OtherAddresses({ currentLocation }: OtherAddressesProps)
|
||||
const address = linkToOtherHost(nif.address, currentLocation);
|
||||
|
||||
return (
|
||||
<CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)}>
|
||||
{nif.address} <IoArrowUp className={style.goIcon} />
|
||||
<CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)} size='small'>
|
||||
<span className={style.interfaceCopy}>
|
||||
{nif.address} <IoArrowUp className={style.goIcon} />
|
||||
</span>
|
||||
</CopyTag>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
|
||||
|
||||
interface EditorVisibilityOptions {
|
||||
isLockable?: boolean;
|
||||
}
|
||||
|
||||
export default function useViewEditor({ isLockable }: EditorVisibilityOptions) {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { open: showEditFormDrawer } = useViewParamsEditorStore();
|
||||
|
||||
const isViewLocked = useMemo(() => {
|
||||
if (!isLockable) {
|
||||
return false;
|
||||
}
|
||||
return isStringBoolean(searchParams.get('locked'));
|
||||
}, [isLockable, searchParams]);
|
||||
|
||||
return { showEditFormDrawer, isViewLocked };
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
.radioGroup {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
color: $gray-900;
|
||||
font-size: calc(1rem - 2px);
|
||||
color: $ui-white;
|
||||
|
||||
&[data-disabled] {
|
||||
.item {
|
||||
opacity: $opacity-disabled;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.horizontal {
|
||||
@@ -32,8 +38,8 @@
|
||||
.radio {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
width: 16px; // use absolute units to avoid subpixel rendering issues
|
||||
height: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 100%;
|
||||
@@ -46,10 +52,7 @@
|
||||
}
|
||||
|
||||
&[data-checked] {
|
||||
background-color: $ui-white;
|
||||
&:hover {
|
||||
border-color: $gray-1000;
|
||||
}
|
||||
background-color: $blue-700;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@@ -69,8 +72,9 @@
|
||||
&::before {
|
||||
content: '';
|
||||
border-radius: 100%;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
background-color: $gray-1200;
|
||||
width: 6px; // half of the radio size
|
||||
height: 6px;
|
||||
aspect-ratio: 1;
|
||||
background-color: $ui-white;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
border: 1px solid transparent;
|
||||
|
||||
padding-inline: 0.5rem;
|
||||
font-size: calc(1rem - 2px);
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover:not([data-disabled]) {
|
||||
@@ -57,7 +56,7 @@
|
||||
}
|
||||
|
||||
.popup {
|
||||
font-size: calc(1rem - 2px);
|
||||
font-size: 1rem;
|
||||
background-color: $gray-1200;
|
||||
box-sizing: border-box;
|
||||
padding: 2px;
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
}
|
||||
|
||||
.text {
|
||||
display: block;
|
||||
margin-inline: auto;;
|
||||
font-weight: 600;
|
||||
font-size: 2em;
|
||||
max-width: 600px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,13 @@ import style from './Empty.module.scss';
|
||||
|
||||
interface EmptyProps {
|
||||
text?: string;
|
||||
style?: CSSProperties;
|
||||
injectedStyles?: CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Empty(props: EmptyProps) {
|
||||
const { text, className, ...rest } = props;
|
||||
export default function Empty({ text, className, injectedStyles }: EmptyProps) {
|
||||
return (
|
||||
<div className={cx([style.emptyContainer, className])} {...rest}>
|
||||
<div className={cx([style.emptyContainer, className])} style={injectedStyles}>
|
||||
<EmptyImage className={style.empty} />
|
||||
{text && <span className={style.text}>{text}</span>}
|
||||
</div>
|
||||
|
||||
@@ -6,15 +6,13 @@ import style from './EmptyPage.module.scss';
|
||||
|
||||
interface EmptyPageProps {
|
||||
text?: string;
|
||||
style?: CSSProperties;
|
||||
injectedStyles?: CSSProperties;
|
||||
}
|
||||
|
||||
export default function EmptyPage(props: EmptyPageProps) {
|
||||
const { text, ...rest } = props;
|
||||
|
||||
export default function EmptyPage({ text, injectedStyles }: EmptyPageProps) {
|
||||
return (
|
||||
<div className={style.page}>
|
||||
<Empty text={text} {...rest} />
|
||||
<Empty text={text} injectedStyles={injectedStyles} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
.emptyContainer {
|
||||
width: 100%;
|
||||
color: $white-10;
|
||||
|
||||
.emptyCell {
|
||||
margin-inline: auto;
|
||||
text-align: center;
|
||||
padding-top: 10vh;
|
||||
}
|
||||
|
||||
.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>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,11 @@
|
||||
outline: 2px solid $blue-500;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
opacity: $opacity-disabled;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.medium {
|
||||
@@ -41,7 +46,7 @@
|
||||
}
|
||||
|
||||
.thumb {
|
||||
aspect-ratio: 1 / 1;
|
||||
aspect-ratio: 1;
|
||||
height: 100%;
|
||||
border-radius: 99px;
|
||||
background-color: $ui-white;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
import Checkbox from '../checkbox/Checkbox';
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { OntimeView, URLPreset } from 'ontime-types';
|
||||
|
||||
import { useViewUrlPresets } from '../../hooks-query/useUrlPresets';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import Button from '../buttons/Button';
|
||||
|
||||
import style from './ViewParamsShare.module.scss';
|
||||
|
||||
/**
|
||||
* Shows a list of presets for the current view
|
||||
*/
|
||||
export function ViewParamsShare({ target }: { target: OntimeView }) {
|
||||
const { viewPresets } = useViewUrlPresets(target);
|
||||
const [_, setSearchParams] = useSearchParams();
|
||||
|
||||
const handleRecall = (preset: URLPreset) => {
|
||||
setSearchParams(`${preset.search}&alias=${preset.alias}`);
|
||||
};
|
||||
|
||||
if (viewPresets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.presetSection}>
|
||||
{viewPresets.map((preset) => {
|
||||
const active = window.location.search.includes(`alias=${preset.alias}`);
|
||||
return (
|
||||
<div key={preset.alias} className={cx([style.preset, active && style.active])}>
|
||||
<div>{preset.alias}</div>
|
||||
<Button
|
||||
variant='subtle-white'
|
||||
onClick={() => handleRecall(preset)}
|
||||
disabled={active}
|
||||
className={style.presetActions}
|
||||
>
|
||||
{active ? 'Applied' : 'Apply'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { FormEvent, memo } from 'react';
|
||||
import { FormEvent, memo, useReducer } from 'react';
|
||||
import { IoClose } from 'react-icons/io5';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { Dialog } from '@base-ui-components/react/dialog';
|
||||
import { OntimeView } from 'ontime-types';
|
||||
|
||||
import useViewSettings from '../../hooks-query/useViewSettings';
|
||||
import Button from '../buttons/Button';
|
||||
@@ -11,20 +12,24 @@ import Info from '../info/Info';
|
||||
import { ViewOption } from './viewParams.types';
|
||||
import { getURLSearchParamsFromObj } from './viewParams.utils';
|
||||
import { useViewParamsEditorStore } from './viewParamsEditor.store';
|
||||
import { ViewParamsShare } from './ViewParamShare';
|
||||
import ViewParamsSection from './ViewParamsSection';
|
||||
|
||||
import style from './ViewParamsEditor.module.scss';
|
||||
|
||||
interface EditFormDrawerProps {
|
||||
target: OntimeView;
|
||||
viewOptions: ViewOption[];
|
||||
}
|
||||
|
||||
export default memo(ViewParamsEditor);
|
||||
|
||||
function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
||||
function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
||||
// TODO: can we ensure that the options update when the user loads an alias?
|
||||
const [_, setSearchParams] = useSearchParams();
|
||||
const { data: viewSettings } = useViewSettings();
|
||||
const { isOpen, close } = useViewParamsEditorStore();
|
||||
// TODO: we dont want this as a permanent option
|
||||
const forceRender = useReducer((x) => x + 1, 0)[1];
|
||||
|
||||
const handleClose = () => {
|
||||
close();
|
||||
@@ -32,6 +37,7 @@ function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
||||
|
||||
const resetParams = () => {
|
||||
setSearchParams();
|
||||
forceRender();
|
||||
};
|
||||
|
||||
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
|
||||
@@ -40,6 +46,7 @@ function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
||||
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
|
||||
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
|
||||
setSearchParams(newSearchParams);
|
||||
forceRender();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -56,7 +63,7 @@ function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
||||
<Dialog.Popup className={style.drawer}>
|
||||
<div className={style.header}>
|
||||
<Dialog.Title>Customise</Dialog.Title>
|
||||
<IconButton variant='subtle-white' size='large' onClick={handleClose}>
|
||||
<IconButton variant='subtle-white' size='large' data-testid='close-view-params' onClick={handleClose}>
|
||||
<IoClose />
|
||||
</IconButton>
|
||||
</div>
|
||||
@@ -64,6 +71,7 @@ function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
||||
{viewSettings.overrideStyles && (
|
||||
<Info className={style.info}>This view style is being modified by a custom CSS file.</Info>
|
||||
)}
|
||||
<ViewParamsShare target={target} />
|
||||
<form id='edit-params-form' onSubmit={onParamsFormSubmit} className={style.sectionList}>
|
||||
{viewOptions.map((section) => (
|
||||
<ViewParamsSection
|
||||
@@ -79,8 +87,14 @@ function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
||||
<Button variant='subtle' size='large' onClick={resetParams} type='reset'>
|
||||
Reset to default
|
||||
</Button>
|
||||
<Button variant='primary' size='large' form='edit-params-form' type='submit'>
|
||||
Save
|
||||
<Button
|
||||
variant='primary'
|
||||
size='large'
|
||||
form='edit-params-form'
|
||||
type='submit'
|
||||
data-testid='apply-view-params'
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Popup>
|
||||
|
||||
@@ -36,11 +36,11 @@
|
||||
}
|
||||
|
||||
.closed {
|
||||
transition: rotate $transition-time-feedback;
|
||||
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
|
||||
rotate: 0deg;
|
||||
}
|
||||
|
||||
.open {
|
||||
transition: rotate $transition-time-feedback;
|
||||
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
|
||||
rotate: 180deg;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { memo } from 'react';
|
||||
import { IoChevronDown } from 'react-icons/io5';
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
|
||||
@@ -16,8 +15,7 @@ interface ViewParamsSectionProps {
|
||||
options: ParamField[];
|
||||
}
|
||||
|
||||
export default memo(ViewParamsSection);
|
||||
function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
|
||||
export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
|
||||
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
|
||||
|
||||
const handleCollapse = () => {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
.presetSection {
|
||||
background-color: $gray-1350;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 1rem 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.preset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
&.active {
|
||||
color: $blue-500;
|
||||
}
|
||||
}
|
||||
|
||||
.presetActions {
|
||||
margin-left: auto;
|
||||
}
|
||||
-1
@@ -232,7 +232,6 @@ describe('getURLSearchParamsFromObj()', () => {
|
||||
bool2: 'on',
|
||||
};
|
||||
const result = getURLSearchParamsFromObj(params, mockOptionsWithBooleans);
|
||||
console.log('Result:', result.toString());
|
||||
expect(result.get('bool1')).toBe('false');
|
||||
expect(result.get('bool2')).toBe('true');
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CustomFields } from 'ontime-types';
|
||||
import type { CustomFields, ProjectData } from 'ontime-types';
|
||||
|
||||
import type { SelectOption } from '../select/Select';
|
||||
|
||||
@@ -53,6 +53,18 @@ export function makeCustomFieldSelectOptions(customFields: CustomFields, filterI
|
||||
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 };
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createContext } from 'react';
|
||||
import { URLPreset } from 'ontime-types';
|
||||
|
||||
export const PresetContext = createContext<URLPreset | undefined>(undefined);
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { PROJECT_DATA } from '../api/constants';
|
||||
import { getProjectData } from '../api/project';
|
||||
import { getProjectData, postProjectData } from '../api/project';
|
||||
import { projectDataPlaceholder } from '../models/ProjectData';
|
||||
|
||||
export default function useProjectData() {
|
||||
@@ -10,11 +10,25 @@ export default function useProjectData() {
|
||||
queryKey: PROJECT_DATA,
|
||||
queryFn: getProjectData,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ export function useFlatRundown() {
|
||||
}, [data.entries, data.flatOrder, data.revision, prevRevision]);
|
||||
|
||||
// TODO: should we have a project id field?
|
||||
// TODO(v4): cleanup as part of load multiple rundowns
|
||||
// invalidate current version if project changes
|
||||
useEffect(() => {
|
||||
if (projectData?.title !== loadedProject.current) {
|
||||
@@ -64,7 +65,7 @@ export function useFlatRundown() {
|
||||
}
|
||||
}, [projectData]);
|
||||
|
||||
return { data: flatRundown, status };
|
||||
return { data: flatRundown, rundownId: data.id, status };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { OntimeView, URLPreset } from 'ontime-types';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { URL_PRESETS } from '../api/constants';
|
||||
import { getUrlPresets } from '../api/urlPresets';
|
||||
import { deleteUrlPreset, getUrlPresets, postUrlPreset, putUrlPreset } from '../api/urlPresets';
|
||||
|
||||
interface FetchProps {
|
||||
skip?: boolean;
|
||||
@@ -13,12 +15,50 @@ export default function useUrlPresets({ skip = false }: FetchProps = {}) {
|
||||
queryKey: URL_PRESETS,
|
||||
queryFn: getUrlPresets,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
enabled: !skip,
|
||||
});
|
||||
|
||||
return { data: data ?? [], status, isError, refetch };
|
||||
}
|
||||
|
||||
export function useViewUrlPresets(view: OntimeView) {
|
||||
const { data } = useUrlPresets();
|
||||
|
||||
const viewPresets = useMemo(() => data.filter((preset) => preset.target === view), [data, view]);
|
||||
|
||||
return { viewPresets };
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { VIEW_SETTINGS } from '../api/constants';
|
||||
import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
|
||||
|
||||
export default function useViewSettings() {
|
||||
const { data, isPending } = useQuery({
|
||||
const { data, status } = useQuery({
|
||||
queryKey: VIEW_SETTINGS,
|
||||
queryFn: getViewSettings,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
@@ -24,5 +24,5 @@ export default function useViewSettings() {
|
||||
},
|
||||
});
|
||||
|
||||
return { data: data ?? viewsSettingsPlaceholder, mutateAsync, isPending };
|
||||
return { data: data ?? viewsSettingsPlaceholder, status, mutateAsync };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
import { MessageTag } from 'ontime-types';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { addDialog } from '../stores/dialogStore';
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@ import { useCallback } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
EntryId,
|
||||
isOntimeBlock,
|
||||
isOntimeEvent,
|
||||
isOntimeGroup,
|
||||
MaybeString,
|
||||
OntimeBlock,
|
||||
OntimeEntry,
|
||||
OntimeEvent,
|
||||
Rundown,
|
||||
SupportedEntry,
|
||||
TimeField,
|
||||
TimeStrategy,
|
||||
TransientEventPayload,
|
||||
@@ -36,7 +36,7 @@ import { logAxiosError } from '../api/utils';
|
||||
import { useEditorSettings } from '../stores/editorSettings';
|
||||
|
||||
export type EventOptions = Partial<{
|
||||
// options of any new entries (event / delay / block)
|
||||
// options of any new entries (event / delay / group)
|
||||
after: MaybeString;
|
||||
before: MaybeString;
|
||||
// options of entries of type OntimeEvent
|
||||
@@ -89,25 +89,17 @@ export const useEntryActions = () => {
|
||||
|
||||
// ************* CHECK OPTIONS specific to events
|
||||
if (isOntimeEvent(newEntry)) {
|
||||
// merge creation time options with event settings
|
||||
const applicationOptions = {
|
||||
after: options?.after,
|
||||
before: options?.before,
|
||||
lastEventId: options?.lastEventId,
|
||||
linkPrevious: options?.linkPrevious ?? linkPrevious,
|
||||
};
|
||||
|
||||
if (applicationOptions?.lastEventId) {
|
||||
if (options?.lastEventId) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
|
||||
const rundownData = queryClient.getQueryData<Rundown>(RUNDOWN)!;
|
||||
const previousEvent = rundownData.entries[applicationOptions.lastEventId];
|
||||
const previousEvent = rundownData.entries[options?.lastEventId];
|
||||
if (isOntimeEvent(previousEvent)) {
|
||||
newEntry.timeStart = previousEvent.timeEnd;
|
||||
}
|
||||
}
|
||||
|
||||
// Override event with options from editor settings
|
||||
newEntry.linkStart = applicationOptions.linkPrevious;
|
||||
newEntry.linkStart = options?.linkPrevious ?? linkPrevious;
|
||||
|
||||
if (newEntry.duration === undefined && newEntry.timeEnd === undefined) {
|
||||
newEntry.duration = parseUserTime(defaultDuration);
|
||||
@@ -560,7 +552,7 @@ export const useEntryActions = () => {
|
||||
);
|
||||
|
||||
/**
|
||||
* Calls mutation to dissolve a block
|
||||
* Calls mutation to dissolve a group
|
||||
* @private
|
||||
*/
|
||||
const { mutateAsync: ungroupMutation } = useMutation({
|
||||
@@ -582,12 +574,12 @@ export const useEntryActions = () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Deletes a block and moves its events to the top level
|
||||
* Deletes a group and moves its events to the top level
|
||||
*/
|
||||
const ungroup = useCallback(
|
||||
async (blockId: EntryId) => {
|
||||
async (groupId: EntryId) => {
|
||||
try {
|
||||
await ungroupMutation(blockId);
|
||||
await ungroupMutation(groupId);
|
||||
} catch (error) {
|
||||
logAxiosError('Error dissolving group', error);
|
||||
}
|
||||
@@ -596,7 +588,7 @@ export const useEntryActions = () => {
|
||||
);
|
||||
|
||||
/**
|
||||
* Calls mutation to create a block with a selection
|
||||
* Calls mutation to create a group with a selection
|
||||
* @private
|
||||
*/
|
||||
const { mutateAsync: groupEntriesMutation } = useMutation({
|
||||
@@ -618,7 +610,7 @@ export const useEntryActions = () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a block with a selection
|
||||
* Create a group with a selection
|
||||
*/
|
||||
const groupEntries = useCallback(
|
||||
async (entryIds: EntryId[]) => {
|
||||
@@ -682,8 +674,8 @@ export const useEntryActions = () => {
|
||||
} catch (error) {
|
||||
logAxiosError('Error re-ordering event', error);
|
||||
}
|
||||
// the rundown needs to know whether we moved into a block
|
||||
return rundown.entries[destinationId]?.type === 'block' ? destinationId : undefined;
|
||||
// the rundown needs to know whether we moved into a group
|
||||
return rundown.entries[destinationId]?.type === SupportedEntry.Group ? destinationId : undefined;
|
||||
},
|
||||
[queryClient, reorderEntryMutation],
|
||||
);
|
||||
@@ -701,6 +693,7 @@ export const useEntryActions = () => {
|
||||
await reorderEntryMutation(reorderObject);
|
||||
} catch (error) {
|
||||
logAxiosError('Error re-ordering event', error);
|
||||
throw error; // rethrow to handle in the component
|
||||
}
|
||||
},
|
||||
[reorderEntryMutation],
|
||||
@@ -805,14 +798,12 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
|
||||
}
|
||||
|
||||
function deleteEntry(entry: OntimeEntry) {
|
||||
if (isOntimeBlock(entry) || !entry.parent) {
|
||||
if (isOntimeGroup(entry) || !entry.parent) {
|
||||
order = order.filter((id) => id !== entry.id);
|
||||
} else {
|
||||
const parent = entries[entry.parent];
|
||||
if ('parent' in entries) {
|
||||
(parent as OntimeBlock).entries = (parent as OntimeBlock).entries.filter(
|
||||
(parentEntry) => parentEntry !== entry.id,
|
||||
);
|
||||
if (parent && isOntimeGroup(parent)) {
|
||||
parent.entries = parent.entries.filter((parentEntry) => parentEntry !== entry.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
|
||||
import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage, TimerType } from 'ontime-types';
|
||||
|
||||
import { useRuntimeStore } from '../stores/runtime';
|
||||
import { sendSocket } from '../utils/socket';
|
||||
@@ -17,7 +17,6 @@ export const setClientRemote = {
|
||||
export const useRundownEditor = createSelector((state: RuntimeStore) => ({
|
||||
playback: state.timer.playback,
|
||||
selectedEventId: state.eventNow?.id ?? null,
|
||||
selectedBlockId: state.blockNow?.id ?? null,
|
||||
nextEventId: state.eventNext?.id ?? null,
|
||||
}));
|
||||
|
||||
@@ -59,8 +58,8 @@ export const setMessage = {
|
||||
|
||||
export const usePlaybackControl = createSelector((state: RuntimeStore) => ({
|
||||
playback: state.timer.playback,
|
||||
selectedEventIndex: state.runtime.selectedEventIndex,
|
||||
numEvents: state.runtime.numEvents,
|
||||
selectedEventIndex: state.rundown.selectedEventIndex,
|
||||
numEvents: state.rundown.numEvents,
|
||||
timerPhase: state.timer.phase,
|
||||
}));
|
||||
|
||||
@@ -131,8 +130,8 @@ export const useSelectedEventId = createSelector((state: RuntimeStore) => ({
|
||||
selectedEventId: state.eventNow?.id ?? null,
|
||||
}));
|
||||
|
||||
export const useCurrentBlockId = createSelector((state: RuntimeStore) => ({
|
||||
currentBlockId: state.blockNow?.id ?? null,
|
||||
export const useCurrentGroupId = createSelector((state: RuntimeStore) => ({
|
||||
currentGroupId: state.groupNow?.id ?? null,
|
||||
}));
|
||||
|
||||
export const setEventPlayback = {
|
||||
@@ -151,7 +150,8 @@ export const useClock = createSelector((state: RuntimeStore) => ({
|
||||
}));
|
||||
|
||||
export const useNextFlag = createSelector((state: RuntimeStore) => ({
|
||||
nextFlag: state.nextFlag,
|
||||
id: state.eventFlag?.id ?? null,
|
||||
expectedStart: state.offset.expectedFlagStart,
|
||||
}));
|
||||
|
||||
/** Used by the progress bar components */
|
||||
@@ -162,37 +162,36 @@ export const useProgressData = createSelector((state: RuntimeStore) => ({
|
||||
timeDanger: state.eventNow?.timeDanger ?? null,
|
||||
}));
|
||||
|
||||
export const useRuntimeOverview = createSelector((state: RuntimeStore) => ({
|
||||
plannedStart: state.runtime.plannedStart,
|
||||
actualStart: state.runtime.actualStart,
|
||||
plannedEnd: state.runtime.plannedEnd,
|
||||
expectedEnd: state.runtime.expectedEnd,
|
||||
export const useRundownOverview = createSelector((state: RuntimeStore) => ({
|
||||
plannedStart: state.rundown.plannedStart,
|
||||
actualStart: state.rundown.actualStart,
|
||||
plannedEnd: state.rundown.plannedEnd,
|
||||
expectedEnd: state.offset.expectedRundownEnd,
|
||||
}));
|
||||
|
||||
export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) => ({
|
||||
playback: state.timer.playback,
|
||||
clock: state.clock,
|
||||
|
||||
numEvents: state.runtime.numEvents,
|
||||
selectedEventIndex: state.runtime.selectedEventIndex,
|
||||
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
|
||||
numEvents: state.rundown.numEvents,
|
||||
selectedEventIndex: state.rundown.selectedEventIndex,
|
||||
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
|
||||
|
||||
blockStartedAt: state.blockNow?.startedAt ?? null,
|
||||
blockExpectedEnd: state.blockNow?.expectedEnd ?? null,
|
||||
groupExpectedEnd: state.offset.expectedGroupEnd,
|
||||
}));
|
||||
|
||||
export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
offset: state.runtime.offset,
|
||||
offset: state.offset.absolute,
|
||||
}));
|
||||
|
||||
export const useTimeUntilData = createSelector((state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
|
||||
offsetMode: state.runtime.offsetMode,
|
||||
export const useExpectedStartData = createSelector((state: RuntimeStore) => ({
|
||||
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
|
||||
mode: state.offset.mode,
|
||||
currentDay: state.eventNow?.dayOffset ?? 0,
|
||||
actualStart: state.runtime.actualStart,
|
||||
plannedStart: state.runtime.plannedStart,
|
||||
actualStart: state.rundown.actualStart,
|
||||
plannedStart: state.rundown.plannedStart,
|
||||
clock: state.clock,
|
||||
}));
|
||||
|
||||
export const useCurrentDay = createSelector((state: RuntimeStore) => ({
|
||||
@@ -200,7 +199,7 @@ export const useCurrentDay = createSelector((state: RuntimeStore) => ({
|
||||
}));
|
||||
|
||||
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
|
||||
offset: state.runtime.offset,
|
||||
offset: state.offset.absolute,
|
||||
}));
|
||||
|
||||
export const usePing = createSelector((state: RuntimeStore) => ({
|
||||
@@ -213,7 +212,7 @@ export const useIsOnline = createSelector((state: RuntimeStore) => ({
|
||||
}));
|
||||
|
||||
export const useOffsetMode = createSelector((state: RuntimeStore) => ({
|
||||
offsetMode: state.runtime.offsetMode,
|
||||
offsetMode: state.offset.mode,
|
||||
}));
|
||||
|
||||
export const setOffsetMode = (payload: OffsetMode) => sendSocket('offsetmode', payload);
|
||||
@@ -225,3 +224,53 @@ export const usePlayback = () => {
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
|
||||
/* ======================= View specific subscriptions ======================= */
|
||||
|
||||
export const useTimerSocket = createSelector((state: RuntimeStore) => ({
|
||||
eventNext: state.eventNext,
|
||||
eventNow: state.eventNow,
|
||||
message: state.message,
|
||||
time: state.timer,
|
||||
clock: state.clock,
|
||||
timerTypeNow: state.eventNow?.timerType ?? TimerType.CountDown,
|
||||
countToEndNow: state.eventNow?.countToEnd ?? false,
|
||||
auxTimer: {
|
||||
aux1: state.auxtimer1.current,
|
||||
aux2: state.auxtimer2.current,
|
||||
aux3: state.auxtimer3.current,
|
||||
},
|
||||
}));
|
||||
|
||||
export const useCountdownSocket = createSelector((state: RuntimeStore) => ({
|
||||
playback: state.timer.playback,
|
||||
current: state.timer.current,
|
||||
clock: state.clock,
|
||||
}));
|
||||
|
||||
export const useBackstageSocket = createSelector((state: RuntimeStore) => ({
|
||||
eventNext: state.eventNext,
|
||||
eventNow: state.eventNow,
|
||||
rundown: state.rundown,
|
||||
selectedEventId: state.eventNow?.id ?? null,
|
||||
time: state.timer,
|
||||
}));
|
||||
|
||||
export const useStudioClockSocket = createSelector((state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
playback: state.timer.playback,
|
||||
}));
|
||||
|
||||
export const useStudioTimersSocket = createSelector((state: RuntimeStore) => ({
|
||||
eventNext: state.eventNext,
|
||||
eventNow: state.eventNow,
|
||||
message: state.message,
|
||||
time: state.timer,
|
||||
offset: state.offset,
|
||||
rundown: state.rundown,
|
||||
}));
|
||||
|
||||
export const useTimelineSocket = createSelector((state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
offset: state.offset.absolute,
|
||||
}));
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { MaybeNumber, Playback, TimerPhase, TimerType } from 'ontime-types';
|
||||
|
||||
// first set extends TimerState
|
||||
export type ViewExtendedTimer = {
|
||||
addedTime: number;
|
||||
current: MaybeNumber;
|
||||
duration: MaybeNumber;
|
||||
elapsed: MaybeNumber;
|
||||
expectedFinish: MaybeNumber;
|
||||
finishedAt: MaybeNumber;
|
||||
phase: TimerPhase;
|
||||
playback: Playback;
|
||||
secondaryTimer: MaybeNumber;
|
||||
startedAt: MaybeNumber;
|
||||
|
||||
clock: number;
|
||||
timerType: TimerType;
|
||||
countToEnd: boolean;
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ClientList } from 'ontime-types';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { makeStageKey } from '../utils/localStorage';
|
||||
|
||||
interface ClientStore {
|
||||
name?: string;
|
||||
setName: (newValue: string) => void;
|
||||
@@ -15,7 +17,7 @@ interface ClientStore {
|
||||
setClients: (clients: ClientList) => void;
|
||||
}
|
||||
|
||||
const clientNameKey = 'ontime-client-name';
|
||||
const clientNameKey = makeStageKey('client-name');
|
||||
|
||||
function persistNameInStorage(newValue: string) {
|
||||
localStorage.setItem(clientNameKey, newValue);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
type Target = 'cuesheet' | 'timer' | 'clock' | 'countdown' | 'backstage' | 'studio';
|
||||
|
||||
interface SelectionState {
|
||||
[key: string]: boolean;
|
||||
}
|
||||
|
||||
interface ColumnPermissions {
|
||||
read: string[];
|
||||
write: string[];
|
||||
}
|
||||
|
||||
interface CuesheetLinksState {
|
||||
target: Target | null;
|
||||
readSelected: SelectionState;
|
||||
writeSelected: SelectionState;
|
||||
setTarget: (target: Target | null) => void;
|
||||
setField: (field: 'read' | 'write', key: string, value: boolean) => void;
|
||||
toggleField: (field: 'read' | 'write', key: string) => void;
|
||||
selectAll: (field: 'read' | 'write', keys: string[]) => void;
|
||||
clearAll: (field: 'read' | 'write', keys: string[]) => void;
|
||||
// Returns arrays of column keys that have read/write permissions if target is 'cuesheet'
|
||||
getSelections: () => ColumnPermissions | null;
|
||||
}
|
||||
|
||||
export const useCuesheetLinksStore = create<CuesheetLinksState>((set, get) => ({
|
||||
target: null,
|
||||
readSelected: {},
|
||||
writeSelected: {},
|
||||
setTarget: (target) => set({ target }),
|
||||
setField: (field, key, value) =>
|
||||
set((state) => ({
|
||||
...(field === 'read'
|
||||
? { readSelected: { ...state.readSelected, [key]: value } }
|
||||
: { writeSelected: { ...state.writeSelected, [key]: value } }),
|
||||
})),
|
||||
toggleField: (field, key) =>
|
||||
set((state) => ({
|
||||
...(field === 'read'
|
||||
? { readSelected: { ...state.readSelected, [key]: !state.readSelected[key] } }
|
||||
: { writeSelected: { ...state.writeSelected, [key]: !state.writeSelected[key] } }),
|
||||
})),
|
||||
selectAll: (field, keys) =>
|
||||
set((_state) => ({
|
||||
...(field === 'read'
|
||||
? {
|
||||
readSelected: keys.reduce((acc, key) => {
|
||||
acc[key] = true;
|
||||
return acc;
|
||||
}, {} as SelectionState),
|
||||
}
|
||||
: {
|
||||
writeSelected: keys.reduce((acc, key) => {
|
||||
acc[key] = true;
|
||||
return acc;
|
||||
}, {} as SelectionState),
|
||||
}),
|
||||
})),
|
||||
clearAll: (field, keys) =>
|
||||
set((_state) => ({
|
||||
...(field === 'read'
|
||||
? {
|
||||
readSelected: keys.reduce((acc, key) => {
|
||||
acc[key] = false;
|
||||
return acc;
|
||||
}, {} as SelectionState),
|
||||
}
|
||||
: {
|
||||
writeSelected: keys.reduce((acc, key) => {
|
||||
acc[key] = false;
|
||||
return acc;
|
||||
}, {} as SelectionState),
|
||||
}),
|
||||
})),
|
||||
getSelections: () => {
|
||||
const state = get();
|
||||
if (state.target !== 'cuesheet') return null;
|
||||
|
||||
return {
|
||||
read: Object.entries(state.readSelected)
|
||||
.filter(([_, selected]) => selected)
|
||||
.map(([key]) => key),
|
||||
write: Object.entries(state.writeSelected)
|
||||
.filter(([_, selected]) => selected)
|
||||
.map(([key]) => key),
|
||||
};
|
||||
},
|
||||
}));
|
||||
@@ -3,7 +3,7 @@ import { create } from 'zustand';
|
||||
import { booleanFromLocalStorage } from '../utils/localStorage';
|
||||
|
||||
enum LocalEventKeys {
|
||||
Mirror = 'ontime-view-mirror',
|
||||
Mirror = 'view-mirror',
|
||||
}
|
||||
|
||||
type ViewOptionsStore = {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { resolvePath } from 'react-router-dom';
|
||||
import { Path, resolvePath } from 'react-router';
|
||||
import { OntimeView, URLPreset } from 'ontime-types';
|
||||
|
||||
import { arePathsEquivalent, generatePathFromPreset, getRouteFromPreset, validateUrlPresetPath } from '../urlPresets';
|
||||
import {
|
||||
arePathsEquivalent,
|
||||
generatePathFromPreset,
|
||||
generateUrlPresetOptions,
|
||||
getCurrentPath,
|
||||
getRouteFromPreset,
|
||||
validateUrlPresetPath,
|
||||
} from '../urlPresets';
|
||||
|
||||
describe('validateUrlPresetPaths()', () => {
|
||||
test.each([
|
||||
@@ -23,11 +31,13 @@ describe('validateUrlPresetPaths()', () => {
|
||||
});
|
||||
|
||||
describe('getRouteFromPreset()', () => {
|
||||
const presets = [
|
||||
const presets: URLPreset[] = [
|
||||
{
|
||||
enabled: true,
|
||||
alias: 'demopage',
|
||||
pathAndParams: '/timer?user=guest',
|
||||
target: OntimeView.Timer,
|
||||
search: 'user=guest',
|
||||
options: {},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -63,28 +73,28 @@ describe('getRouteFromPreset()', () => {
|
||||
|
||||
describe('handle url sharing edge cases', () => {
|
||||
it('finds the correct preset when the url contains extra arguments', () => {
|
||||
const location = resolvePath('/demopage?locked=true&token=123');
|
||||
const location = resolvePath('/demopage?n=1&token=123');
|
||||
expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('appends the feature params to the alias', () => {
|
||||
const location = resolvePath('/demopage?locked=true&token=123');
|
||||
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123');
|
||||
const location = resolvePath('/demopage?n=1&token=123');
|
||||
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generatePathFromPreset()', () => {
|
||||
test.each([
|
||||
['timer?user=guest', 'demopage', 'timer?user=guest&alias=demopage'],
|
||||
['timer?user=admin', 'demopage', 'timer?user=admin&alias=demopage'],
|
||||
])('generates a path from a preset: %s', (path, alias, expected) => {
|
||||
expect(generatePathFromPreset(path, alias, null, null)).toEqual(expected);
|
||||
['timer', 'user=guest', 'demopage', false, 'timer?user=guest&alias=demopage'],
|
||||
['timer', 'user=admin', 'demopage', false, 'timer?user=admin&alias=demopage'],
|
||||
])('generates a path from a preset: %s', (target, search, alias, locked, expected) => {
|
||||
expect(generatePathFromPreset(target, search, alias, locked, null)).toEqual(expected);
|
||||
});
|
||||
|
||||
test('appends the feature params to the alias', () => {
|
||||
expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe(
|
||||
'timer?user=guest&alias=demopage&locked=true&token=123',
|
||||
expect(generatePathFromPreset('timer', 'user=guest', 'demopage', true, '123')).toBe(
|
||||
'timer?user=guest&alias=demopage&n=1&token=123',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -101,8 +111,84 @@ describe('arePathsEquivalent()', () => {
|
||||
expect(arePathsEquivalent('timer?test=a', 'timer?test=a')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('checks whether we are in a locked preset', () => {
|
||||
expect(arePathsEquivalent('preset/minimal', 'preset/minimal?test=b')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('considers edge cases for the url sharing feature', () => {
|
||||
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=b')).toBeFalsy();
|
||||
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy();
|
||||
expect(arePathsEquivalent('timer?test=a&n=1=token=123', 'timer?test=b')).toBeFalsy();
|
||||
expect(arePathsEquivalent('timer?test=a&n=1=token=123', 'timer?test=a')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateUrlPresetOptions', () => {
|
||||
it.each([
|
||||
[
|
||||
'cloud URL without protocol',
|
||||
'test',
|
||||
'www.getontime.no/timer?param1=value1¶m2=value2',
|
||||
{
|
||||
alias: 'test',
|
||||
target: 'timer',
|
||||
search: 'param1=value1¶m2=value2',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
'cloud URL',
|
||||
'test',
|
||||
'https://cloud.getontime.no/timer?param1=value1¶m2=value2',
|
||||
{
|
||||
alias: 'test',
|
||||
target: 'timer',
|
||||
search: 'param1=value1¶m2=value2',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
'local URL',
|
||||
'test',
|
||||
'http://localhost:4001/timer?param1=value1¶m2=value2',
|
||||
{
|
||||
alias: 'test',
|
||||
target: 'timer',
|
||||
search: 'param1=value1¶m2=value2',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
'IP-based URL',
|
||||
'test',
|
||||
'http://192.168.0.1:4001/timer?param1=value1¶m2=value2',
|
||||
{
|
||||
alias: 'test',
|
||||
target: 'timer',
|
||||
search: 'param1=value1¶m2=value2',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
])('should generate URL preset options for %s', (_description, alias, url, expected) => {
|
||||
expect(generateUrlPresetOptions(alias, url)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('throws on invalid URL', () => {
|
||||
expect(() => generateUrlPresetOptions('test', 'invalid-url')).toThrow();
|
||||
});
|
||||
|
||||
it('throws on on invalid route', () => {
|
||||
expect(() => generateUrlPresetOptions('test', 'www.getontime.no/somethingelse/')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrentPath()', () => {
|
||||
test.each([
|
||||
[resolvePath('http://localhost:4001/timer'), 'timer'],
|
||||
[resolvePath('http://192.168.0.1:654321/minimal'), 'minimal'],
|
||||
[resolvePath('https://user-hosted.io/cuesheet'), 'cuesheet'],
|
||||
[resolvePath('https://cloud.getontime.no/team-hash/op'), 'op'],
|
||||
[resolvePath('https://cloud.getontime.no/team-hash/backstage/?params-with-slash=true'), 'backstage'],
|
||||
[resolvePath('https://cloud.getontime.no/team-hash/timeline?params-are-ignored=true'), 'timeline'],
|
||||
])('resolves the current: %s', (location, expected) => {
|
||||
expect(getCurrentPath(location as Path)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { baseURI } from '../../externals';
|
||||
|
||||
export function booleanFromLocalStorage(key: string, fallback: boolean): boolean {
|
||||
const valueInStorage = localStorage.getItem(key);
|
||||
if (valueInStorage) {
|
||||
@@ -7,3 +9,8 @@ export function booleanFromLocalStorage(key: string, fallback: boolean): boolean
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function makeStageKey(key: string) {
|
||||
if (baseURI) return `${baseURI}-${key}`;
|
||||
return key;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ export function getOffsetText(offset: MaybeNumber): string {
|
||||
}
|
||||
|
||||
export function getOffsetState(offset: MaybeNumber): 'over' | 'under' | 'muted' | null {
|
||||
if (offset === null) return null;
|
||||
if (offset === 0) return 'muted';
|
||||
return offset < 0 ? 'over' : 'under';
|
||||
if (offset === null) return 'muted';
|
||||
if (offset === 0) return null;
|
||||
// a positive value means that we are in over time aka behind schedule
|
||||
return offset > 0 ? 'over' : 'under';
|
||||
}
|
||||
|
||||
@@ -11,3 +11,4 @@ export const isAlphanumeric = /^[a-z0-9]+$/i;
|
||||
export const isASCII = /^[ -~]+$/; //https://catonmat.net/my-favorite-regex
|
||||
export const isASCIIorEmpty = /^$|^[ -~]+$/; //https://catonmat.net/my-favorite-regex
|
||||
export const isNotEmpty = /\S/;
|
||||
export const isUrlSafe = /^[a-zA-Z0-9_-]*$/; // https://stackoverflow.com/questions/24419067/validate-a-string-to-be-url-safe-using-regex
|
||||
|
||||
@@ -10,7 +10,16 @@ import {
|
||||
} from 'ontime-types';
|
||||
|
||||
import { isProduction, websocketUrl } from '../../externals';
|
||||
import { CLIENT_LIST, CUSTOM_FIELDS, PROJECT_DATA, REPORT, RUNDOWN, RUNTIME, VIEW_SETTINGS } from '../api/constants';
|
||||
import {
|
||||
CLIENT_LIST,
|
||||
CUSTOM_FIELDS,
|
||||
PROJECT_DATA,
|
||||
REPORT,
|
||||
RUNDOWN,
|
||||
RUNTIME,
|
||||
URL_PRESETS,
|
||||
VIEW_SETTINGS,
|
||||
} from '../api/constants';
|
||||
import { invalidateAllCaches } from '../api/utils';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
import {
|
||||
@@ -133,16 +142,8 @@ export const connectSocket = () => {
|
||||
break;
|
||||
}
|
||||
case MessageTag.RuntimeData: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- removing the key from the payload
|
||||
const { ping, ...serverPayload } = payload;
|
||||
patchRuntime(serverPayload);
|
||||
updateDevTools(serverPayload);
|
||||
break;
|
||||
}
|
||||
case MessageTag.RuntimePatch: {
|
||||
const patch = payload;
|
||||
patchRuntime(patch);
|
||||
updateDevTools(patch);
|
||||
patchRuntime(payload);
|
||||
updateDevTools(payload);
|
||||
break;
|
||||
}
|
||||
case MessageTag.Refetch: {
|
||||
@@ -166,6 +167,9 @@ export const connectSocket = () => {
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
|
||||
break;
|
||||
case RefetchKey.UrlPresets:
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: URL_PRESETS });
|
||||
break;
|
||||
case RefetchKey.ViewSettings:
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: VIEW_SETTINGS });
|
||||
break;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MaybeNumber, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
|
||||
import {
|
||||
calculateTimeUntilStart,
|
||||
formatFromMillis,
|
||||
getExpectedStart,
|
||||
MILLIS_PER_HOUR,
|
||||
MILLIS_PER_MINUTE,
|
||||
MILLIS_PER_SECOND,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
|
||||
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
|
||||
import { APP_SETTINGS } from '../api/constants';
|
||||
import { useTimeUntilData } from '../hooks/useSocket';
|
||||
import { useExpectedStartData } from '../hooks/useSocket';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
|
||||
/**
|
||||
@@ -105,8 +105,7 @@ export const formatTime = (
|
||||
|
||||
/**
|
||||
* Handles case for formatting a duration time
|
||||
* @param duration
|
||||
* @returns
|
||||
* eg: "1h 0m 0s" or "0h 30m"
|
||||
*/
|
||||
export function formatDuration(duration: number, hideSeconds = true): string {
|
||||
// durations should never be negative, we handle it here to flag if there is an issue in future
|
||||
@@ -134,18 +133,24 @@ export function formatDuration(duration: number, hideSeconds = true): string {
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param totalGap accumulated gap from the current event
|
||||
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
|
||||
* @returns
|
||||
*/
|
||||
export function useTimeUntilStart(
|
||||
export function useTimeUntilExpectedStart(
|
||||
// typed like this to make it very clear what the data is
|
||||
data: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'> & {
|
||||
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'> | null,
|
||||
state: {
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
},
|
||||
): number {
|
||||
const { offset, clock, currentDay, offsetMode, actualStart, plannedStart } = useTimeUntilData();
|
||||
return calculateTimeUntilStart({ ...data, currentDay, clock, offset, offsetMode, actualStart, plannedStart });
|
||||
const { offset, currentDay, mode, actualStart, plannedStart, clock } = useExpectedStartData();
|
||||
if (event === null) return 0;
|
||||
|
||||
const expectedStart = getExpectedStart(
|
||||
{ ...event },
|
||||
{ ...state, currentDay, offset, mode, actualStart, plannedStart },
|
||||
);
|
||||
return expectedStart - clock;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Path, resolvePath } from 'react-router-dom';
|
||||
import { URLPreset } from 'ontime-types';
|
||||
import { Path, resolvePath } from 'react-router';
|
||||
import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types';
|
||||
import { checkRegex } from 'ontime-utils';
|
||||
|
||||
/**
|
||||
* Validates a preset against defined parameters
|
||||
@@ -26,59 +27,75 @@ export function validateUrlPresetPath(preset: string): { message: string; isVali
|
||||
return { isValid: true, message: 'ok' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility removes trailing slash from a string
|
||||
*/
|
||||
function removeTrailingSlash(text: string): string {
|
||||
return text.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the current location corresponds to a preset and returns the new path if necessary
|
||||
*/
|
||||
export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): string | null {
|
||||
// current url is the pathname without the leading slash
|
||||
const currentURL = location.pathname.substring(1);
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
|
||||
// check if we have token or locked in the search params
|
||||
const locked = searchParams.get('locked');
|
||||
const token = searchParams.get('token');
|
||||
|
||||
// we need to check if the whole url is an alias
|
||||
const foundPreset = urlPresets.find((preset) => preset.alias === removeTrailingSlash(currentURL) && preset.enabled);
|
||||
if (foundPreset) {
|
||||
// if so, we can redirect to the preset path
|
||||
return generatePathFromPreset(foundPreset.pathAndParams, foundPreset.alias, locked, token);
|
||||
}
|
||||
|
||||
// if the current url is not an alias, we check if the alias is in the search parameters
|
||||
const presetOnPage = searchParams.get('alias');
|
||||
if (!presetOnPage) {
|
||||
// if we're already on a preset path, no need to redirect
|
||||
if (isPresetPath(location)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// NOTE: verify that this resolves correctly in cloud
|
||||
const currentPath = `${location.pathname}${location.search}`.substring(1);
|
||||
const currentURL = getCurrentPath(location);
|
||||
const token = new URLSearchParams(location.search).get('token');
|
||||
const isLocked = location.search.includes('n=1');
|
||||
|
||||
for (const preset of urlPresets) {
|
||||
// if the page has a known enabled alias, we check if we need to redirect
|
||||
if (preset.alias === presetOnPage && preset.enabled) {
|
||||
const newPath = generatePathFromPreset(preset.pathAndParams, preset.alias, locked, token);
|
||||
if (!arePathsEquivalent(currentPath, newPath)) {
|
||||
// if current path is out of date
|
||||
// return new path so we can redirect
|
||||
return newPath;
|
||||
}
|
||||
if (!preset.enabled) continue;
|
||||
/**
|
||||
* If the page is a known alias it would be like
|
||||
* /preset/{alias} <- locked to a preset
|
||||
* or
|
||||
* /{target}?alias={alias} <- unwrapped preset options
|
||||
*
|
||||
* we need to compare the saved preset to the current path to see if we need to redirect
|
||||
*/
|
||||
if (preset.alias === currentURL || preset.target === currentURL) {
|
||||
const newPath = generatePathFromPreset(preset.target, preset.search, preset.alias, isLocked, token);
|
||||
/**
|
||||
* if the current path is equivalent to the new path, we return null
|
||||
* this means we will not redirect
|
||||
*/
|
||||
return arePathsEquivalent(currentPath, newPath) ? null : newPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles generating a path and search parameters from a preset
|
||||
* Resolves the current path accounting for the base URI
|
||||
* Returns the alias if it's a preset path, or the last segment otherwise
|
||||
*/
|
||||
export function generatePathFromPreset(pathAndParams: string, alias: string, locked: string | null, token: string | null ): string {
|
||||
const path = resolvePath(pathAndParams);
|
||||
export function getCurrentPath(location: Path): string {
|
||||
// 1. get path without query parameters
|
||||
const pathWithoutQuery = location.pathname.split('?')[0];
|
||||
// 2. split path into segments and filter out empty segments
|
||||
const segments = pathWithoutQuery.split('/').filter(Boolean);
|
||||
|
||||
// If this is a preset path, return the alias (last segment)
|
||||
if (segments[0] === 'preset' && segments.length > 1) {
|
||||
return segments[1];
|
||||
}
|
||||
|
||||
// Otherwise return the last segment (view name)
|
||||
return segments[segments.length - 1] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles generating a path and search parameters from a preset
|
||||
* This is done when we want to keep the current navigation and unwrap the search params
|
||||
*/
|
||||
export function generatePathFromPreset(
|
||||
target: Omit<OntimeView, 'editor'>,
|
||||
search: string,
|
||||
alias: string,
|
||||
locked: boolean,
|
||||
token: string | null,
|
||||
): string {
|
||||
const path = resolvePath(`${target}?${search}`);
|
||||
const searchParams = new URLSearchParams(path.search);
|
||||
|
||||
// save the alias so we have a reference to it being a preset and can update if necessary
|
||||
@@ -86,7 +103,7 @@ export function generatePathFromPreset(pathAndParams: string, alias: string, loc
|
||||
|
||||
// maintain params from the URL search feature
|
||||
if (locked) {
|
||||
searchParams.set('locked', locked);
|
||||
searchParams.set('n', '1');
|
||||
}
|
||||
|
||||
if (token) {
|
||||
@@ -99,28 +116,64 @@ export function generatePathFromPreset(pathAndParams: string, alias: string, loc
|
||||
|
||||
/**
|
||||
* Utility checks if two paths are equivalent
|
||||
* Considers the edge cases for url sharing where a path may contain extra arguments from the alias
|
||||
* - token
|
||||
* - locked
|
||||
* For preset paths, only compares the path (since params are stored in session)
|
||||
* For regular paths, compares path and search params (ignoring token)
|
||||
*/
|
||||
export function arePathsEquivalent(currentPath: string, newPath: string): boolean {
|
||||
const currentUrl = new URL(currentPath, document.location.origin);
|
||||
const newUrl = new URL(newPath, document.location.origin);
|
||||
|
||||
// check path
|
||||
|
||||
// For preset paths, only compare the path
|
||||
if (currentUrl.pathname.startsWith('/preset/') || newUrl.pathname.startsWith('/preset/')) {
|
||||
return currentUrl.pathname === newUrl.pathname;
|
||||
}
|
||||
|
||||
// For regular paths, compare path and search params (ignoring token)
|
||||
if (currentUrl.pathname !== newUrl.pathname) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
// check search params
|
||||
// if the params match, we dont need further checks
|
||||
if (currentUrl.searchParams.toString() === newUrl.searchParams.toString()) {
|
||||
return true
|
||||
}
|
||||
|
||||
// if there is no match, we check the edge cases for the url sharing feature
|
||||
currentUrl.searchParams.delete('token');
|
||||
currentUrl.searchParams.delete('locked');
|
||||
currentUrl.searchParams.delete('n');
|
||||
newUrl.searchParams.delete('token');
|
||||
newUrl.searchParams.delete('n');
|
||||
|
||||
return currentUrl.searchParams.toString() === newUrl.searchParams.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a URL preset from a user given alias and URL.
|
||||
*/
|
||||
export function generateUrlPresetOptions(alias: string, userUrl: string): URLPreset {
|
||||
let sanitisedUrl = userUrl;
|
||||
// we need to ensure the URL has a protocol, but it doesnt matter which
|
||||
if (!checkRegex.startsWithHttp(userUrl.toLowerCase())) {
|
||||
sanitisedUrl = `http://${sanitisedUrl}`;
|
||||
}
|
||||
|
||||
const url = new URL(sanitisedUrl);
|
||||
const path = getCurrentPath(url);
|
||||
|
||||
if (!isPresettableView(path)) {
|
||||
throw new Error(`Invalid target view: ${path}`);
|
||||
}
|
||||
|
||||
return {
|
||||
alias,
|
||||
target: path,
|
||||
search: url.searchParams.toString(),
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isPresettableView(view: string): view is OntimeViewPresettable {
|
||||
return view !== OntimeView.Editor && Object.values(OntimeView).includes(view as OntimeView);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current location is a preset path
|
||||
*/
|
||||
export function isPresetPath(location: Path): boolean {
|
||||
const segments = location.pathname.split('/').filter(Boolean);
|
||||
return segments[0] === 'preset';
|
||||
}
|
||||
|
||||
+21
-1
@@ -1,3 +1,5 @@
|
||||
import { AppMode } from '../ontimeConfig';
|
||||
|
||||
declare module '*.scss' {
|
||||
const content: Record<string, string>;
|
||||
export default content;
|
||||
@@ -21,7 +23,17 @@ declare global {
|
||||
}
|
||||
|
||||
/**
|
||||
* We pass a custom property to the table meta to allow field update
|
||||
* Declare custom data we pass to the table
|
||||
* - `handleUpdate` callback to update the entry when the user edits a cell
|
||||
* - `handleUpdateTimer` callback to update the timer for a specific event
|
||||
* - `options-showDelayedTimes` whether to show or hide delayed times
|
||||
* - `options-hideTableSeconds` whether to hide seconds in the table
|
||||
* - `options-hideIndexColumn` whether to hide the index column
|
||||
* - `options-cuesheetMode` run or edit mode
|
||||
*
|
||||
* And metadata specific for each column
|
||||
* - `canWrite` whether the user can write to this column
|
||||
* - `colour` background colour associated with a custom field
|
||||
*/
|
||||
declare module '@tanstack/react-table' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
@@ -31,8 +43,16 @@ declare module '@tanstack/react-table' {
|
||||
options: {
|
||||
showDelayedTimes: boolean;
|
||||
hideTableSeconds: boolean;
|
||||
hideIndexColumn: boolean;
|
||||
cuesheetMode: AppMode;
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
canWrite: boolean;
|
||||
colour?: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -74,3 +74,26 @@ function resolveBaseURI(): string {
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a session scope for the session
|
||||
*/
|
||||
export const sessionScope = resolveSessionScope();
|
||||
export const getIsNavigationLocked = () => new URLSearchParams(window.location.search).get('n') === '1';
|
||||
|
||||
/**
|
||||
* The session scope is read from the cookie and will only exist if the app is password protected
|
||||
*/
|
||||
function resolveSessionScope() {
|
||||
const tokenCookie = document.cookie.split('; ').find((cookie) => cookie.startsWith('token='));
|
||||
|
||||
if (tokenCookie) {
|
||||
try {
|
||||
const { scope } = JSON.parse(tokenCookie.split('=')[1]);
|
||||
return scope;
|
||||
} catch {
|
||||
return 'rw';
|
||||
}
|
||||
}
|
||||
return 'rw';
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable react/display-name */
|
||||
import { ComponentType, useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
|
||||
import useUrlPresets from '../common/hooks-query/useUrlPresets';
|
||||
import { getRouteFromPreset } from '../common/utils/urlPresets';
|
||||
|
||||
@@ -106,6 +106,7 @@ $inner-padding: 1rem;
|
||||
th,
|
||||
td {
|
||||
padding: 0.5rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
@@ -131,6 +132,7 @@ $inner-padding: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function AboutPanel() {
|
||||
<ExternalLink href={buyMeACoffeeUrl}>Buy Me a Coffee</ExternalLink>
|
||||
</Panel.Paragraph>
|
||||
<Panel.Paragraph>
|
||||
And trying out our cloud service
|
||||
If you are looking for an online version of Ontime, consider trying out our cloud service
|
||||
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
|
||||
</Panel.Paragraph>
|
||||
</Panel.Card>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
import useAppVersion from '../../../../common/hooks-query/useAppVersion';
|
||||
import { appVersion } from '../../../../externals';
|
||||
import { appVersion, isOntimeCloud } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
export default function AppVersion() {
|
||||
@@ -17,13 +17,17 @@ export default function AppVersion() {
|
||||
|
||||
if (data.hasUpdates) {
|
||||
return (
|
||||
<>
|
||||
<Panel.Paragraph>{`You are currently using Ontime version ${appVersion}.`}</Panel.Paragraph>
|
||||
<Panel.Paragraph>
|
||||
{`A new version ${data.version} is available.`}
|
||||
<ExternalLink href={data.url}> Please visit the release page to download</ExternalLink>
|
||||
</Panel.Paragraph>
|
||||
</>
|
||||
<Panel.Paragraph>
|
||||
{`You are currently using Ontime version ${appVersion}.`}
|
||||
<br />
|
||||
<br />
|
||||
{`A new version ${data.version} is available.`} <br />
|
||||
{isOntimeCloud ? (
|
||||
'You can restart your stage to get the latest available version.'
|
||||
) : (
|
||||
<ExternalLink href={data.url}>Please visit the release page to download</ExternalLink>
|
||||
)}
|
||||
</Panel.Paragraph>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ interface AutomationFormProps {
|
||||
}
|
||||
|
||||
export default function AutomationForm({ automation, onClose }: AutomationFormProps) {
|
||||
'no memo'; // RHF and react-compiler dont seem to get along
|
||||
const isEdit = isAutomation(automation);
|
||||
const { data } = useCustomFields();
|
||||
const { refetch } = useAutomationSettings();
|
||||
|
||||
+2
-4
@@ -25,8 +25,6 @@ export default function AutomationSettingsForm({
|
||||
enabledOscIn,
|
||||
oscPortIn,
|
||||
}: AutomationSettingsProps) {
|
||||
'no memo'; // RHF and react-compiler dont seem to get along
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
reset,
|
||||
@@ -38,15 +36,15 @@ export default function AutomationSettingsForm({
|
||||
} = useForm<AutomationSettingsProps>({
|
||||
mode: 'onChange',
|
||||
defaultValues: { enabledAutomations, enabledOscIn, oscPortIn },
|
||||
values: { enabledAutomations, enabledOscIn, oscPortIn },
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
keepDirtyValues: false,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: AutomationSettingsProps) => {
|
||||
try {
|
||||
await editAutomationSettings(formData);
|
||||
reset(formData);
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
setError('root', { message });
|
||||
|
||||
@@ -31,7 +31,6 @@ export default function TriggerForm({
|
||||
onCancel,
|
||||
postSubmit,
|
||||
}: TriggerFormProps) {
|
||||
'use no memo'; // RHF and react-compiler don't seem to get along
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
|
||||
+10
-10
@@ -16,18 +16,18 @@ const staticAutocompleteOptions = [
|
||||
'{{timer.duration}}',
|
||||
'{{timer.elapsed}}',
|
||||
'{{timer.expectedFinish}}',
|
||||
'{{timer.finishedAt}}',
|
||||
'{{timer.secondaryTimer}}',
|
||||
'{{timer.startedAt}}',
|
||||
'{{runtime.selectedEventIndex}}',
|
||||
'{{runtime.numEvents}}',
|
||||
'{{runtime.offset}}',
|
||||
'{{runtime.plannedStart}}',
|
||||
'{{runtime.plannedEnd}}',
|
||||
'{{runtime.actualStart}}',
|
||||
'{{runtime.expectedEnd}}',
|
||||
'{{currentBlock.block}}',
|
||||
'{{currentBlock.startedAt}}',
|
||||
'{{rundown.selectedEventIndex}}',
|
||||
'{{rundown.numEvents}}',
|
||||
'{{rundown.plannedStart}}',
|
||||
'{{rundown.plannedEnd}}',
|
||||
'{{rundown.actualStart}}',
|
||||
'{{offset.absolute}}',
|
||||
'{{offset.relative}}',
|
||||
'{{offset.expectedRundownEnd}}',
|
||||
'{{offset.expectedGroupEnd}}',
|
||||
'{{offset.expectedFlagStart}}',
|
||||
];
|
||||
|
||||
const eventStaticPropertiesNow = [
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { isOntimeCloud } from '../../../../externals';
|
||||
import GenerateLinkFormExport from '../../../sharing/GenerateLinkFormExport';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import InfoNif from '../network-panel/NetworkInterfaces';
|
||||
|
||||
import GenerateLinkFormExport from './GenerateLinkFormExport';
|
||||
import ReportSettings from './ReportSettings';
|
||||
import UrlPresetsForm from './UrlPresetsForm';
|
||||
import URLPresets from './URLPresets';
|
||||
|
||||
export default function FeaturePanel({ location }: PanelBaseProps) {
|
||||
const presetsRef = useScrollIntoView<HTMLDivElement>('presets', location);
|
||||
@@ -17,7 +17,7 @@ export default function FeaturePanel({ location }: PanelBaseProps) {
|
||||
<>
|
||||
<Panel.Header>Sharing and reporting</Panel.Header>
|
||||
<div ref={presetsRef}>
|
||||
<UrlPresetsForm />
|
||||
<URLPresets />
|
||||
</div>
|
||||
<div ref={linkRef}>
|
||||
<Panel.Section>
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
'use no memo'; // RHF and react-compiler don't seem to get along
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import QRCode from 'react-qr-code';
|
||||
|
||||
import { generateUrl } from '../../../../common/api/session';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import Select from '../../../../common/components/select/Select';
|
||||
import Switch from '../../../../common/components/switch/Switch';
|
||||
import copyToClipboard from '../../../../common/utils/copyToClipboard';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { linkToOtherHost } from '../../../../common/utils/linkUtils';
|
||||
import { currentHostName, isOntimeCloud, serverURL } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './GenerateLinkForm.module.scss';
|
||||
|
||||
interface GenerateLinkFormProps {
|
||||
hostOptions: { value: string; label: string }[];
|
||||
pathOptions: { value: string; label: string }[];
|
||||
isLockedToView?: boolean;
|
||||
}
|
||||
|
||||
interface GenerateLinkFormOptions {
|
||||
baseUrl: string;
|
||||
path: string;
|
||||
lock: boolean;
|
||||
authenticate: boolean;
|
||||
}
|
||||
|
||||
type GenerateLinkState = 'pending' | 'loading' | 'success' | 'error';
|
||||
|
||||
export default function GenerateLinkForm({ hostOptions, pathOptions, isLockedToView }: GenerateLinkFormProps) {
|
||||
'use no memo'; // RHF and react-compiler don't seem to get along
|
||||
const [formState, setFormState] = useState<GenerateLinkState>('pending');
|
||||
const [url, setUrl] = useState(serverURL);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
setError,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<GenerateLinkFormOptions>({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
baseUrl: currentHostName,
|
||||
path: isLockedToView ? pathOptions[0].value : 'timer',
|
||||
lock: false,
|
||||
authenticate: false,
|
||||
},
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (options: GenerateLinkFormOptions) => {
|
||||
try {
|
||||
setFormState('loading');
|
||||
const baseUrl = linkToOtherHost(options.baseUrl);
|
||||
const url = await generateUrl(baseUrl, options.path, options.lock, options.authenticate);
|
||||
await copyToClipboard(url);
|
||||
setUrl(url);
|
||||
setFormState('success');
|
||||
setTimeout(() => {
|
||||
setFormState('pending');
|
||||
}, 4000);
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
setError('root', { message });
|
||||
setFormState('error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} onKeyDown={(event) => preventEscape(event)}>
|
||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
{!isLockedToView ? (
|
||||
<Info>You can generate a link to share with your team or to use in automation (such as companion).</Info>
|
||||
) : (
|
||||
<Info>You can generate a link to share with your team</Info>
|
||||
)}
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Host IP'
|
||||
description={`Which IP address will be used${isOntimeCloud ? ' (not applicable in Ontime Cloud)' : ''}`}
|
||||
/>
|
||||
<Select
|
||||
disabled={isOntimeCloud}
|
||||
options={hostOptions}
|
||||
value={watch('baseUrl')}
|
||||
onValueChange={(value) => setValue('baseUrl', value)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
{isLockedToView ? (
|
||||
<input type='hidden' value={watch('path')} />
|
||||
) : (
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Ontime view' description='Which view or preset will the link point to' />
|
||||
<Select options={pathOptions} value={watch('path')} onValueChange={(value) => setValue('path', value)} />
|
||||
</Panel.ListItem>
|
||||
)}
|
||||
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Lock navigation'
|
||||
description='Prevent showing navigation (will only work for non production URLs)'
|
||||
/>
|
||||
<Switch
|
||||
size='large'
|
||||
name='lock'
|
||||
checked={watch('lock')}
|
||||
onCheckedChange={(checked) => setValue('lock', checked)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Authenticate' description='Whether the URL should be pre-authenticated' />
|
||||
<Switch
|
||||
size='large'
|
||||
name='authenticate'
|
||||
checked={watch('authenticate')}
|
||||
onCheckedChange={(checked) => setValue('authenticate', checked)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Generate link' description='Fill form and generate link and QR code' />
|
||||
<Button variant='primary' loading={formState === 'loading'} type='submit' style={{ alignSelf: 'end' }}>
|
||||
{formState === 'success' ? 'Link copied to clipboard!' : 'Update share link'}
|
||||
</Button>
|
||||
<div className={style.column}>
|
||||
<QRCode size={172} value={url} className={style.qrCode} />
|
||||
<div className={style.copiableLink}>{url}</div>
|
||||
</div>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
|
||||
|
||||
import GenerateLinkForm from './GenerateLinkForm';
|
||||
|
||||
interface GenerateLinkFormExportProps {
|
||||
lockedPath?: { value: string; label: string };
|
||||
}
|
||||
|
||||
export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormExportProps) {
|
||||
const { data: infoData } = useInfo();
|
||||
const { data: urlPresetData } = useUrlPresets({ skip: lockedPath === undefined });
|
||||
|
||||
const hostOptions = useMemo(
|
||||
() =>
|
||||
infoData.networkInterfaces.map((nif) => ({
|
||||
value: nif.address,
|
||||
label: `${nif.name} - ${nif.address}`,
|
||||
})),
|
||||
[infoData.networkInterfaces],
|
||||
);
|
||||
|
||||
const pathOptions = useMemo(() => {
|
||||
if (lockedPath) {
|
||||
return [{ value: lockedPath.value, label: lockedPath.label }];
|
||||
}
|
||||
return [
|
||||
{ value: 'timer', label: 'Timer' },
|
||||
{ value: 'cuesheet', label: 'Cuesheet' },
|
||||
{ value: 'op', label: 'Operator' },
|
||||
{ value: '', label: 'Companion' },
|
||||
...urlPresetData.map((preset) => ({
|
||||
value: preset.alias,
|
||||
label: `Preset: ${preset.alias}`,
|
||||
})),
|
||||
];
|
||||
}, [lockedPath, urlPresetData]);
|
||||
|
||||
return <GenerateLinkForm hostOptions={hostOptions} pathOptions={pathOptions} isLockedToView={Boolean(lockedPath)} />;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState } from 'react';
|
||||
import { IoAdd, IoOpenOutline, IoPencil, IoTrash } from 'react-icons/io5';
|
||||
import { URLPreset } from 'ontime-types';
|
||||
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
import Switch from '../../../../common/components/switch/Switch';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import useUrlPresets, { useUpdateUrlPreset } from '../../../../common/hooks-query/useUrlPresets';
|
||||
import { handleLinks } from '../../../../common/utils/linkUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import URLPresetForm from './composite/URLPresetForm';
|
||||
|
||||
type FormState = {
|
||||
isOpen: boolean;
|
||||
preset?: URLPreset;
|
||||
};
|
||||
|
||||
const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/';
|
||||
|
||||
export default function URLPresets() {
|
||||
const [formState, setFormState] = useState<FormState>({ isOpen: false, preset: undefined });
|
||||
const { data, status } = useUrlPresets();
|
||||
const { deletePreset, isMutating } = useUpdateUrlPreset();
|
||||
|
||||
const openNewForm = () => setFormState({ isOpen: true });
|
||||
const openEditForm = (preset: URLPreset) => setFormState({ isOpen: true, preset });
|
||||
const closeForm = () => setFormState({ isOpen: false, preset: undefined });
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
URL presets
|
||||
<Button onClick={openNewForm}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Info>
|
||||
URL presets are user pre-defined aliases to Ontime URLs.
|
||||
<br />
|
||||
This URL can contain full configuration including parameters, or simply route to a specific view.
|
||||
<br />
|
||||
<br />
|
||||
The easiest way to get started is to copy an URL from your browser and paste it into the form.
|
||||
<ExternalLink href={urlPresetsDocs}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={status === 'pending'} />
|
||||
{formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />}
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Enabled</th>
|
||||
<th>Target view</th>
|
||||
<th>Alias</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 && <Panel.TableEmpty handleClick={openNewForm} />}
|
||||
{data.map((preset, index) => {
|
||||
return (
|
||||
<tr key={preset.alias}>
|
||||
<td>
|
||||
<Switch defaultChecked={preset.enabled} onCheckedChange={() => {}} />
|
||||
</td>
|
||||
<td>
|
||||
<Tag>{preset.target}</Tag>
|
||||
</td>
|
||||
<td style={{ width: '100%' }}>{preset.alias}</td>
|
||||
<Panel.InlineElements relation='inner' as='td'>
|
||||
<IconButton
|
||||
variant='ghosted-white'
|
||||
onClick={(event) => handleLinks(preset.alias, event)}
|
||||
disabled={isMutating}
|
||||
>
|
||||
<IoOpenOutline />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => openEditForm(preset)}
|
||||
variant='ghosted-white'
|
||||
aria-label='Edit entry'
|
||||
data-testid={`field__edit_${index}`}
|
||||
disabled={isMutating}
|
||||
>
|
||||
<IoPencil />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => deletePreset(preset.alias)}
|
||||
variant='ghosted-destructive'
|
||||
aria-label='Delete entry'
|
||||
data-testid={`field__delete_${index}`}
|
||||
disabled={isMutating}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</Panel.InlineElements>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { IoAdd, IoOpenOutline, IoTrash } from 'react-icons/io5';
|
||||
import { URLPreset } from 'ontime-types';
|
||||
|
||||
import { postUrlPresets } from '../../../../common/api/urlPresets';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
import Switch from '../../../../common/components/switch/Switch';
|
||||
import Tooltip from '../../../../common/components/tooltip/Tooltip';
|
||||
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { handleLinks } from '../../../../common/utils/linkUtils';
|
||||
import { validateUrlPresetPath } from '../../../../common/utils/urlPresets';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './FeaturePanel.module.scss';
|
||||
|
||||
const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/';
|
||||
|
||||
type FormData = {
|
||||
data: URLPreset[];
|
||||
};
|
||||
|
||||
export default function UrlPresetsForm() {
|
||||
'use no memo'; // RHF and react-compiler don't seem to get along
|
||||
const { data, status, refetch } = useUrlPresets();
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
setError,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { isSubmitting, isDirty, isValid, errors },
|
||||
} = useForm<FormData>({
|
||||
mode: 'onChange',
|
||||
defaultValues: { data },
|
||||
values: { data },
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
const { fields, prepend, remove } = useFieldArray({
|
||||
name: 'data',
|
||||
control,
|
||||
});
|
||||
|
||||
// reset form if we get new data from backend
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
reset({ data });
|
||||
}
|
||||
}, [data, reset]);
|
||||
|
||||
const onSubmit = async (formData: FormData) => {
|
||||
for (let i = 0; i < formData.data.length; i++) {
|
||||
const preset = formData.data[i];
|
||||
const { isValid, message } = validateUrlPresetPath(preset.pathAndParams);
|
||||
if (!isValid) {
|
||||
setError(`data.${i}.pathAndParams`, { message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await postUrlPresets(formData.data);
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
setError('root', { message });
|
||||
} finally {
|
||||
await refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const onReset = () => {
|
||||
reset({ data });
|
||||
};
|
||||
|
||||
const addNew = () => {
|
||||
prepend({
|
||||
enabled: true,
|
||||
alias: '',
|
||||
pathAndParams: '',
|
||||
});
|
||||
};
|
||||
|
||||
const isLoading = status === 'pending';
|
||||
const canSubmit = !isSubmitting && isDirty && isValid;
|
||||
|
||||
return (
|
||||
<Panel.Section
|
||||
as='form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
onKeyDown={(event) => preventEscape(event, onReset)}
|
||||
data-testid='url-preset-form'
|
||||
>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
URL presets
|
||||
<Panel.InlineElements>
|
||||
<Button variant='ghosted' onClick={onReset} disabled={!canSubmit}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button variant='primary' type='submit' disabled={!canSubmit} loading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Info>
|
||||
URL presets are user defined aliases to Ontime URLs
|
||||
<br />
|
||||
<br />
|
||||
<b>Preset Name</b> <br />
|
||||
The alias for the URL. This will be the URL you will be calling. eg: <br />
|
||||
<Panel.BlockQuote>
|
||||
Preset name <Panel.Highlight>cam3</Panel.Highlight> called as{' '}
|
||||
<Panel.Highlight>http://localhost:4001/cam3</Panel.Highlight>
|
||||
</Panel.BlockQuote>
|
||||
<br />
|
||||
<b>URL Segment</b> <br />
|
||||
The corresponding alias path and configuration parameters. eg: <br />
|
||||
<Panel.BlockQuote>
|
||||
URL segment <Panel.Highlight>backstage?hidePast=true&stopCycle=true</Panel.Highlight> corresponds to
|
||||
complete URL
|
||||
<Panel.Highlight>http://localhost:4001/backstage?hidePast=true&stopCycle=true</Panel.Highlight>
|
||||
</Panel.BlockQuote>
|
||||
<br />
|
||||
You will need to save the changes before the presets are functional.
|
||||
<br />
|
||||
<ExternalLink href={urlPresetsDocs}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={isLoading} />
|
||||
<Panel.Title>
|
||||
Manage presets
|
||||
<Button onClick={addNew}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.Title>
|
||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
{errors?.data && <Panel.Error>{errors.data.message}</Panel.Error>}
|
||||
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={style.fit}>Active</th>
|
||||
<th className={style.aliasConstrain}>Preset name</th>
|
||||
<th className={style.fullWidth}>URL segment</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fields.length === 0 && <Panel.TableEmpty handleClick={addNew} />}
|
||||
{fields.map((preset, index) => {
|
||||
const maybeAliasError = errors.data?.[index]?.alias?.message;
|
||||
const maybeUrlError = errors.data?.[index]?.pathAndParams?.message;
|
||||
// only saved and enabled URLs can be tested
|
||||
const canTest =
|
||||
preset.alias && preset.enabled && preset.pathAndParams && !maybeAliasError && !maybeUrlError;
|
||||
|
||||
return (
|
||||
<tr key={preset.id}>
|
||||
<td className={style.fit}>
|
||||
<Switch
|
||||
size='large'
|
||||
checked={watch(`data.${index}.enabled`)}
|
||||
onCheckedChange={(value: boolean) =>
|
||||
setValue(`data.${index}.enabled`, value, { shouldDirty: true })
|
||||
}
|
||||
data-testid={`field__enable_${index}`}
|
||||
/>
|
||||
</td>
|
||||
<td className={style.aliasConstrain}>
|
||||
<Input
|
||||
{...register(`data.${index}.alias`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
})}
|
||||
fluid
|
||||
placeholder='URL Preset'
|
||||
data-testid={`field__alias_${index}`}
|
||||
/>
|
||||
<Panel.Error>{maybeAliasError}</Panel.Error>
|
||||
</td>
|
||||
<td className={style.fullWidth}>
|
||||
<Input
|
||||
{...register(`data.${index}.pathAndParams`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
})}
|
||||
fluid
|
||||
placeholder='URL (portion after ontime Port)'
|
||||
data-testid={`field__url_${index}`}
|
||||
/>
|
||||
<Panel.Error>{maybeUrlError}</Panel.Error>
|
||||
</td>
|
||||
<Panel.InlineElements relation='inner' as='td'>
|
||||
<Tooltip
|
||||
text='Test preset'
|
||||
render={<IconButton variant='ghosted-white' />}
|
||||
data-testid={`field__test_${index}`}
|
||||
onClick={(event) => handleLinks(preset.alias, event)}
|
||||
disabled={!canTest}
|
||||
>
|
||||
<IoOpenOutline />
|
||||
</Tooltip>
|
||||
<IconButton
|
||||
onClick={() => remove(index)}
|
||||
variant='ghosted-destructive'
|
||||
aria-label='Delete entry'
|
||||
data-testid={`field__delete_${index}`}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</Panel.InlineElements>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
.expand {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types';
|
||||
|
||||
import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils';
|
||||
import Button from '../../../../../common/components/buttons/Button';
|
||||
import Input from '../../../../../common/components/input/input/Input';
|
||||
import Textarea from '../../../../../common/components/input/textarea/Textarea';
|
||||
import Select, { SelectOption } from '../../../../../common/components/select/Select';
|
||||
import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets';
|
||||
import { preventEscape } from '../../../../../common/utils/keyEvent';
|
||||
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './URLPresetForm.module.scss';
|
||||
|
||||
const targetOptions: SelectOption<OntimeViewPresettable>[] = [
|
||||
{ value: OntimeView.Cuesheet, label: 'Cuesheet' },
|
||||
{ value: OntimeView.Operator, label: 'Operator' },
|
||||
{ value: OntimeView.Timer, label: 'Timer' },
|
||||
{ value: OntimeView.Backstage, label: 'Backstage' },
|
||||
{ value: OntimeView.Timeline, label: 'Timeline' },
|
||||
{ value: OntimeView.StudioClock, label: 'Studio Clock' },
|
||||
{ value: OntimeView.Countdown, label: 'Countdown' },
|
||||
{ value: OntimeView.ProjectInfo, label: 'Project Info' },
|
||||
];
|
||||
|
||||
const defaultValues: URLPreset = {
|
||||
alias: '',
|
||||
target: OntimeView.Timer,
|
||||
search: '',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
interface URLPresetFormProps {
|
||||
urlPreset?: URLPreset;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps) {
|
||||
const { addPreset, updatePreset, isMutating } = useUpdateUrlPreset();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
setFocus,
|
||||
setError,
|
||||
clearErrors,
|
||||
setValue,
|
||||
getValues,
|
||||
watch,
|
||||
formState: { errors, isSubmitting, isValid, isDirty },
|
||||
} = useForm<URLPreset>({
|
||||
defaultValues: urlPreset ?? defaultValues,
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
const urlRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const setupSubmit = async (data: URLPreset) => {
|
||||
try {
|
||||
if (urlPreset) {
|
||||
await updatePreset(urlPreset.alias, data);
|
||||
} else {
|
||||
await addPreset(data);
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setError('root', { message: maybeAxiosError(error) });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setFocus('alias');
|
||||
}, [setFocus]);
|
||||
|
||||
const generateOptions = () => {
|
||||
clearErrors();
|
||||
|
||||
try {
|
||||
const preset = generateUrlPresetOptions(getValues('alias'), urlRef.current?.value.trim() ?? '');
|
||||
setValue('target', preset.target, { shouldDirty: true, shouldValidate: true });
|
||||
setValue('search', preset.search, { shouldDirty: true, shouldValidate: true });
|
||||
} catch (error) {
|
||||
setError('root', { message: unwrapError(error) });
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const validateParams = (value: string) => {
|
||||
try {
|
||||
new URLSearchParams(value);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return unwrapError(error) || 'Invalid URL parameters';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Indent
|
||||
as='form'
|
||||
onSubmit={handleSubmit(setupSubmit)}
|
||||
onKeyDown={(event) => preventEscape(event, onClose)}
|
||||
className={style.column}
|
||||
>
|
||||
<input hidden name='enabled' value='true' />
|
||||
|
||||
<div>1. Enter URL and let Ontime generate the preset options</div>
|
||||
<Panel.InlineElements>
|
||||
<div>
|
||||
<Panel.Description>Alias</Panel.Description>
|
||||
<Input {...register('alias', { required: 'Alias is required' })} />
|
||||
</div>
|
||||
<div className={style.expand}>
|
||||
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
|
||||
<Panel.InlineElements>
|
||||
<Input placeholder='Paste URL' fluid ref={urlRef} />
|
||||
<Button onClick={generateOptions}>Generate</Button>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
</Panel.InlineElements>
|
||||
<div> - or -</div>
|
||||
<div>2. Choose a view and its parameters</div>
|
||||
<div>
|
||||
<Panel.Description>Target</Panel.Description>
|
||||
<Select
|
||||
options={targetOptions}
|
||||
{...register('target', { required: 'Target is required' })}
|
||||
value={watch('target')}
|
||||
onValueChange={(value) => setValue('target', value, { shouldDirty: true })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Panel.Description>Parameters</Panel.Description>
|
||||
<Textarea
|
||||
fluid
|
||||
rows={3}
|
||||
{...register('search', {
|
||||
validate: validateParams,
|
||||
})}
|
||||
/>
|
||||
<Panel.Error>{errors.search?.message}</Panel.Error>
|
||||
</div>
|
||||
<div>
|
||||
<Panel.Error>{errors.root?.message}</Panel.Error>
|
||||
<Panel.InlineElements align='end'>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant='primary' type='submit' disabled={!isValid || !isDirty} loading={isSubmitting || isMutating}>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
</Panel.Indent>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { CustomField, CustomFieldKey } from 'ontime-types';
|
||||
import { deleteCustomField, editCustomField, postCustomField } from '../../../../common/api/customFields';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import AppLink from '../../../../common/components/link/app-link/AppLink';
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
|
||||
import { customFieldsDocsUrl } from '../../../../externals';
|
||||
@@ -60,7 +61,8 @@ export default function CustomFieldSettings() {
|
||||
Custom fields allow for additional information to be added to an event.
|
||||
<br />
|
||||
<br />
|
||||
This data can be used in the Automation feature by using the generated key.
|
||||
To use custom fields as a data source in an{' '}
|
||||
<AppLink search='settings=automation__automations'>Automation</AppLink>, please note the generated key.
|
||||
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
</Panel.Section>
|
||||
@@ -72,7 +74,7 @@ export default function CustomFieldSettings() {
|
||||
<th>Colour</th>
|
||||
<th>Type</th>
|
||||
<th>Name</th>
|
||||
<th>Key (used in Integrations)</th>
|
||||
<th>Key (used in Automations)</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
+2
-3
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { CustomField } from 'ontime-types';
|
||||
import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
|
||||
import { checkRegex, customFieldLabelToKey } from 'ontime-utils';
|
||||
|
||||
import { maybeAxiosError } from '../../../../../common/api/utils';
|
||||
import Button from '../../../../../common/components/buttons/Button';
|
||||
@@ -32,7 +32,6 @@ export default function CustomFieldForm({
|
||||
initialLabel,
|
||||
initialKey,
|
||||
}: CustomFieldsFormProps) {
|
||||
'use no memo'; // RHF and react-compiler don't seem to get along
|
||||
const { data } = useCustomFields();
|
||||
|
||||
// we use this to force an update
|
||||
@@ -117,7 +116,7 @@ export default function CustomFieldForm({
|
||||
onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'),
|
||||
validate: (value) => {
|
||||
if (value.trim().length === 0) return 'Required field';
|
||||
if (!isAlphanumericWithSpace(value)) return 'Only alphanumeric characters and space are allowed';
|
||||
if (!checkRegex.isAlphanumericWithSpace(value)) return 'Only alphanumeric characters and space are allowed';
|
||||
if (!isEditMode) {
|
||||
if (isEditMode && Object.keys(data).includes(value)) return 'Custom fields must be unique';
|
||||
}
|
||||
|
||||
+4
-4
@@ -6,12 +6,12 @@ const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadshee
|
||||
export default function GSheetInfo() {
|
||||
return (
|
||||
<Info>
|
||||
Ontime allows you to synchronize your rundown with a Google Sheet.
|
||||
Ontime can import data from spreadsheets by: <br />
|
||||
- importing the spreadsheet file in Ontime <br />
|
||||
- synchronising your project with a Google Sheet
|
||||
<br />
|
||||
<br />
|
||||
To enable this feature, you will need to generate tokens in your Google account and provide them to Ontime.
|
||||
<br />
|
||||
Once set up, you will be able to synchronize data between Ontime and your Google Sheet. <br />
|
||||
To synchronise with a Google Sheet, you will need to allow Ontime to authenticate with your Google account.
|
||||
<ExternalLink href={googleSheetDocsUrl}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
);
|
||||
|
||||
+2
-3
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { IoAdd, IoTrash } from 'react-icons/io5';
|
||||
import { ImportMap, isAlphanumericWithSpace } from 'ontime-utils';
|
||||
import { checkRegex, ImportMap } from 'ontime-utils';
|
||||
|
||||
import Button from '../../../../../../common/components/buttons/Button';
|
||||
import IconButton from '../../../../../../common/components/buttons/IconButton';
|
||||
@@ -31,7 +31,6 @@ export default function ImportMapForm({
|
||||
onSubmitExport,
|
||||
onSubmitImport,
|
||||
}: ImportMapFormProps) {
|
||||
'use no memo'; // RHF and react-compiler don't seem to get along
|
||||
const namedImportMap = getPersistedOptions();
|
||||
const { revoke } = useGoogleSheet();
|
||||
const {
|
||||
@@ -200,7 +199,7 @@ export default function ImportMapForm({
|
||||
placeholder='Name of the field as shown in Ontime'
|
||||
{...register(`custom.${index}.ontimeName`, {
|
||||
validate: (value) => {
|
||||
if (!isAlphanumericWithSpace(value))
|
||||
if (!checkRegex.isAlphanumericWithSpace(value))
|
||||
return 'Only alphanumeric characters and space are allowed';
|
||||
return true;
|
||||
},
|
||||
|
||||
+2
-2
@@ -64,11 +64,11 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
|
||||
}
|
||||
|
||||
export function persistImportMap(options: NamedImportMap) {
|
||||
localStorage.setItem('ontime-import-options', JSON.stringify(options));
|
||||
localStorage.setItem('import-options', JSON.stringify(options));
|
||||
}
|
||||
|
||||
function getPersistImportMap(): unknown {
|
||||
const options = localStorage.getItem('ontime-import-options');
|
||||
const options = localStorage.getItem('import-options');
|
||||
if (!options) {
|
||||
throw new Error('no import options found');
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { Fragment } from 'react';
|
||||
import { IoLink } from 'react-icons/io5';
|
||||
import { CustomFields, isOntimeBlock, isOntimeEvent, Rundown } from 'ontime-types';
|
||||
import { CustomFields, isOntimeEvent, isOntimeGroup, Rundown } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import Tag from '../../../../../../common/components/tag/Tag';
|
||||
@@ -55,7 +55,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
||||
<tbody>
|
||||
{rundown.order.map((entryId) => {
|
||||
const entry = rundown.entries[entryId];
|
||||
if (isOntimeBlock(entry)) {
|
||||
if (isOntimeGroup(entry)) {
|
||||
return (
|
||||
<tr key={entry.id}>
|
||||
<td className={style.center}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ChangeEvent, useRef, useState } from 'react';
|
||||
import { IoAdd } from 'react-icons/io5';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { uploadProjectFile } from '../../../../common/api/db';
|
||||
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
|
||||
|
||||
@@ -23,7 +23,6 @@ type ProjectCreateFormValues = {
|
||||
};
|
||||
|
||||
export default function ProjectCreateForm({ onClose }: ProjectCreateFromProps) {
|
||||
'use no memo'; // RHF and react-compiler don't seem to get along
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
|
||||
@@ -20,7 +20,6 @@ interface ProjectFormProps {
|
||||
}
|
||||
|
||||
export default function ProjectForm({ action, filename, onSubmit, onCancel }: ProjectFormProps) {
|
||||
'use no memo'; // RHF and react-compiler don't seem to get along
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PROJECT_DATA } from '../../../../common/api/constants';
|
||||
import { getDb, patchData } from '../../../../common/api/db';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import Switch from '../../../../common/components/switch/Switch';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
@@ -21,14 +22,13 @@ interface ProjectMergeFromProps {
|
||||
|
||||
type ProjectMergeFormValues = {
|
||||
project: boolean;
|
||||
rundown: boolean;
|
||||
rundowns: boolean;
|
||||
viewSettings: boolean;
|
||||
urlPresets: boolean;
|
||||
automation: boolean;
|
||||
};
|
||||
|
||||
export default function ProjectMergeForm({ onClose, fileName }: ProjectMergeFromProps) {
|
||||
'use no memo'; // RHF and react-compiler don't seem to get along
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function ProjectMergeForm({ onClose, fileName }: ProjectMergeFrom
|
||||
} = useForm<ProjectMergeFormValues>({
|
||||
defaultValues: {
|
||||
project: false,
|
||||
rundown: false,
|
||||
rundowns: false,
|
||||
viewSettings: false,
|
||||
urlPresets: false,
|
||||
automation: false,
|
||||
@@ -62,6 +62,10 @@ export default function ProjectMergeForm({ onClose, fileName }: ProjectMergeFrom
|
||||
|
||||
// make patch object
|
||||
const { data } = await getDb(fileName);
|
||||
if (!data.settings.version.startsWith('4.')) {
|
||||
setError('The project you are attempting to merge is from an older version and it need to be migrated first');
|
||||
return;
|
||||
}
|
||||
const patch = await makeProjectPatch(data, values);
|
||||
|
||||
// request patch
|
||||
@@ -76,7 +80,7 @@ export default function ProjectMergeForm({ onClose, fileName }: ProjectMergeFrom
|
||||
return (
|
||||
<Panel.Section as='form' onSubmit={handleSubmit(handleSubmitCreate)}>
|
||||
<Panel.Title>
|
||||
Merge {`"${fileName}"`}
|
||||
Partial project merge
|
||||
<Panel.InlineElements>
|
||||
<Button onClick={onClose} variant='ghosted' disabled={isSubmitting}>
|
||||
Cancel
|
||||
@@ -89,9 +93,12 @@ export default function ProjectMergeForm({ onClose, fileName }: ProjectMergeFrom
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
<Panel.Section className={cx([style.innerColumn, style.inlineLabels])}>
|
||||
<Panel.Description>
|
||||
Select partial data from {`"${fileName}"`} to merge into the current project.
|
||||
<br /> This process is irreversible.
|
||||
Select data from <i>{`"${fileName}"`}</i> to merge into the current project.
|
||||
</Panel.Description>
|
||||
<Info type='warning'>
|
||||
This process is irreversible and can result in data loss. <br />
|
||||
You may want to create a duplicate backup beforehand.
|
||||
</Info>
|
||||
<label>
|
||||
<Switch
|
||||
size='large'
|
||||
@@ -103,8 +110,8 @@ export default function ProjectMergeForm({ onClose, fileName }: ProjectMergeFrom
|
||||
<label>
|
||||
<Switch
|
||||
size='large'
|
||||
checked={watch('rundown')}
|
||||
onCheckedChange={(value: boolean) => setValue('rundown', value, { shouldDirty: true })}
|
||||
checked={watch('rundowns')}
|
||||
onCheckedChange={(value: boolean) => setValue('rundowns', value, { shouldDirty: true })}
|
||||
/>
|
||||
Rundown + Custom Fields
|
||||
</label>
|
||||
|
||||
@@ -16,7 +16,6 @@ import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import GeneralPinInput from './composite/GeneralPinInput';
|
||||
|
||||
export default function GeneralSettings() {
|
||||
'use no memo'; // RHF and react-compiler don't seem to get along
|
||||
const { data, status, refetch } = useSettings();
|
||||
const {
|
||||
handleSubmit,
|
||||
|
||||
@@ -4,12 +4,12 @@ import { IoAdd, IoDownloadOutline, IoTrash } from 'react-icons/io5';
|
||||
import { type ProjectData } from 'ontime-types';
|
||||
|
||||
import { projectLogoPath } from '../../../../common/api/constants';
|
||||
import { postProjectData, uploadProjectLogo } from '../../../../common/api/project';
|
||||
import { uploadProjectLogo } from '../../../../common/api/project';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import Textarea from '../../../../common/components/input/textarea/Textarea';
|
||||
import useProjectData from '../../../../common/hooks-query/useProjectData';
|
||||
import useProjectData, { useUpdateProjectData } from '../../../../common/hooks-query/useProjectData';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { validateLogo } from '../../../../common/utils/uploadUtils';
|
||||
import { documentationUrl } from '../../../../externals';
|
||||
@@ -18,8 +18,8 @@ import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import style from './SettingsPanel.module.scss';
|
||||
|
||||
export default function ProjectData() {
|
||||
'use no memo'; // RHF and react-compiler don't seem to get along
|
||||
const { data, status, refetch } = useProjectData();
|
||||
const { data, status } = useProjectData();
|
||||
const { updateProjectData } = useUpdateProjectData();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
@@ -93,16 +93,14 @@ export default function ProjectData() {
|
||||
|
||||
const onSubmit = async (formData: ProjectData) => {
|
||||
try {
|
||||
await postProjectData(formData);
|
||||
await updateProjectData(formData);
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
setError('root', { message });
|
||||
} finally {
|
||||
await refetch();
|
||||
}
|
||||
};
|
||||
|
||||
// populate with new data if we get an update
|
||||
// reset data to the initial state
|
||||
const onReset = () => {
|
||||
reset(data);
|
||||
};
|
||||
@@ -245,12 +243,14 @@ export default function ProjectData() {
|
||||
<div className={style.customImage}>
|
||||
<Input
|
||||
fluid
|
||||
defaultValue={field.value}
|
||||
defaultValue={field.url}
|
||||
placeholder='Paste image URL (optional)'
|
||||
{...register(`custom.${idx}.url`)}
|
||||
/>
|
||||
<div className={style.imageContainer}>
|
||||
<img src={watch(`custom.${idx}.url`)} alt='' loading='lazy' className='info__image' />
|
||||
{watch(`custom.${idx}.url`) && (
|
||||
<img src={watch(`custom.${idx}.url`)} alt='' loading='lazy' className='info__image' />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -18,8 +18,7 @@ import CodeEditorModal from './composite/StyleEditorModal';
|
||||
const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/';
|
||||
|
||||
export default function ViewSettings() {
|
||||
'use no memo'; // RHF and react-compiler don't seem to get along
|
||||
const { data, isPending, mutateAsync } = useViewSettings();
|
||||
const { data, status, mutateAsync } = useViewSettings();
|
||||
const [isCodeEditorOpen, codeEditorHandler] = useDisclosure();
|
||||
|
||||
const {
|
||||
@@ -88,7 +87,7 @@ export default function ViewSettings() {
|
||||
<ExternalLink href={cssOverrideDocsUrl}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={isPending} />
|
||||
<Panel.Loader isLoading={status === 'pending'} />
|
||||
<Panel.Error>{errors.root?.message}</Panel.Error>
|
||||
<Panel.ListGroup>
|
||||
<CodeEditorModal isOpen={isCodeEditorOpen} onClose={codeEditorHandler.close} />
|
||||
|
||||
@@ -20,7 +20,6 @@ interface QuickStartProps {
|
||||
}
|
||||
|
||||
export default function QuickStart({ isOpen, onClose }: QuickStartProps) {
|
||||
'use no memo'; // RHF and react-compiler don't seem to get along
|
||||
const { defaultWarnTime, defaultDangerTime, setDangerTime, setWarnTime } = useEditorSettings();
|
||||
|
||||
const {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { SettingsOptionId } from './useAppSettingsMenu';
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Playback } from 'ontime-types';
|
||||
|
||||
import { usePlaybackControl } from '../../../common/hooks/useSocket';
|
||||
|
||||
import AddTime from './add-time/AddTime';
|
||||
@@ -14,7 +12,7 @@ export default function PlaybackControl() {
|
||||
|
||||
return (
|
||||
<div className={style.mainContainer}>
|
||||
<PlaybackTimer playback={data.playback as Playback}>
|
||||
<PlaybackTimer>
|
||||
<AddTime playback={data.playback} />
|
||||
</PlaybackTimer>
|
||||
<PlaybackButtons
|
||||
|
||||
@@ -11,10 +11,6 @@ import TimerDisplay from '../timer-display/TimerDisplay';
|
||||
|
||||
import style from './PlaybackTimer.module.scss';
|
||||
|
||||
interface PlaybackTimerProps {
|
||||
playback: Playback;
|
||||
}
|
||||
|
||||
function resolveAddedTimeLabel(addedTime: number) {
|
||||
if (addedTime > 0) {
|
||||
return `Added ${formatDuration(addedTime, false)}`;
|
||||
@@ -27,11 +23,10 @@ function resolveAddedTimeLabel(addedTime: number) {
|
||||
return '';
|
||||
}
|
||||
|
||||
export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProps>) {
|
||||
const { playback, children } = props;
|
||||
export default function PlaybackTimer({ children }: PropsWithChildren) {
|
||||
const timer = useTimer();
|
||||
|
||||
const isRolling = playback === Playback.Roll;
|
||||
const isRolling = timer.playback === Playback.Roll;
|
||||
const isWaiting = timer.phase === TimerPhase.Pending;
|
||||
const isOvertime = timer.phase === TimerPhase.Overtime;
|
||||
const hasAddedTime = Boolean(timer.addedTime);
|
||||
@@ -52,7 +47,7 @@ export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProp
|
||||
{isWaiting ? (
|
||||
<span className={style.rolltag}>Roll: Countdown to start</span>
|
||||
) : (
|
||||
<RunningStatus startedAt={timer.startedAt} expectedFinish={timer.expectedFinish} playback={playback} />
|
||||
<RunningStatus startedAt={timer.startedAt} expectedFinish={timer.expectedFinish} playback={timer.playback} />
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
@@ -65,9 +60,7 @@ interface RunningStatusProps {
|
||||
expectedFinish: MaybeNumber;
|
||||
playback: Playback;
|
||||
}
|
||||
function RunningStatus(props: RunningStatusProps) {
|
||||
const { startedAt, expectedFinish, playback } = props;
|
||||
|
||||
function RunningStatus({ startedAt, expectedFinish, playback }: RunningStatusProps) {
|
||||
if (playback === Playback.Stop) {
|
||||
return <StoppedStatus />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { isOntimeBlock, isOntimeEvent } from 'ontime-types';
|
||||
import { isOntimeEvent, isOntimeGroup, OntimeView } from 'ontime-types';
|
||||
|
||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||
@@ -16,8 +16,8 @@ import { getDefaultFormat } from '../../common/utils/time';
|
||||
|
||||
import EditModal from './edit-modal/EditModal';
|
||||
import FollowButton from './follow-button/FollowButton';
|
||||
import OperatorBlock from './operator-block/OperatorBlock';
|
||||
import OperatorEvent from './operator-event/OperatorEvent';
|
||||
import OperatorGroup from './operator-group/OperatorGroup';
|
||||
import StatusBar from './status-bar/StatusBar';
|
||||
import { getOperatorOptions, useOperatorOptions } from './operator.options';
|
||||
import type { EditEvent } from './operator.types';
|
||||
@@ -35,7 +35,7 @@ export default function Operator() {
|
||||
const timeoutId = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const { selectedEventId } = useSelectedEventId();
|
||||
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast } = useOperatorOptions();
|
||||
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
|
||||
const { data: settings } = useSettings();
|
||||
|
||||
const [showEditPrompt, setShowEditPrompt] = useState(false);
|
||||
@@ -116,7 +116,7 @@ export default function Operator() {
|
||||
|
||||
return (
|
||||
<div className={style.operatorContainer} data-testid='operator-view'>
|
||||
<ViewParamsEditor viewOptions={operatorOptions} />
|
||||
<ViewParamsEditor target={OntimeView.Operator} viewOptions={operatorOptions} />
|
||||
{editEvent && <EditModal event={editEvent} onClose={() => setEditEvent(null)} />}
|
||||
|
||||
<StatusBar />
|
||||
@@ -160,6 +160,7 @@ export default function Operator() {
|
||||
isSelected={isSelected}
|
||||
isPast={isPast}
|
||||
selectedRef={isSelected ? selectedRef : undefined}
|
||||
showStart={showStart}
|
||||
subscribed={subscribedData}
|
||||
totalGap={totalGap}
|
||||
onLongPress={canEdit ? handleEdit : () => undefined}
|
||||
@@ -167,10 +168,10 @@ export default function Operator() {
|
||||
);
|
||||
}
|
||||
|
||||
if (isOntimeBlock(entry)) {
|
||||
if (isOntimeGroup(entry)) {
|
||||
return (
|
||||
<Fragment key={entry.id}>
|
||||
<OperatorBlock key={entry.id} title={entry.title} />
|
||||
<OperatorGroup key={entry.id} title={entry.title} />
|
||||
{entry.entries.map((nestedEntryId) => {
|
||||
const nestedEntry = data.entries[nestedEntryId];
|
||||
if (!isOntimeEvent(nestedEntry)) {
|
||||
@@ -208,6 +209,7 @@ export default function Operator() {
|
||||
isSelected={isSelected}
|
||||
isPast={isPast}
|
||||
selectedRef={isSelected ? selectedRef : undefined}
|
||||
showStart={showStart}
|
||||
subscribed={subscribedData}
|
||||
totalGap={totalGap}
|
||||
onLongPress={canEdit ? handleEdit : () => undefined}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
|
||||
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
|
||||
import { getIsNavigationLocked } from '../../externals';
|
||||
|
||||
import Operator from './Operator';
|
||||
|
||||
export default function OperatorExport() {
|
||||
return (
|
||||
<ProtectRoute permission='operator'>
|
||||
<ViewNavigationMenu isLockable />
|
||||
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
|
||||
<Operator />
|
||||
</ProtectRoute>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user