mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-04 06:58:02 +00:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c41b40c5f | |||
| 3b38756dda | |||
| 5a4494ba85 | |||
| 02ad852dd4 | |||
| 7ce1097826 | |||
| 7ee0de5341 | |||
| 0db249871b | |||
| 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 |
+13
-19
@@ -20,19 +20,20 @@ development.
|
||||
Locally, we would need to run both the React client and the node.js server in development mode
|
||||
|
||||
From the project root, run the following commands
|
||||
|
||||
- __Install the project dependencies__ by running `pnpm i`
|
||||
- __Run dev mode__ by running `turbo dev`
|
||||
- __Run dev mode__ by running `pnpm turbo dev`
|
||||
|
||||
|
||||
### Debugging backend
|
||||
|
||||
To debug backend code in Node.js:
|
||||
The previous command will start the development servers for both the client, server and electron applications.
|
||||
Typically in dev mode we prefer to start these in separate terminals to help with error tracking and debugging.
|
||||
|
||||
- Open two separate terminals and navigate to the `apps/client` and `apps/server` directories.
|
||||
- In each terminal, run the command `pnpm dev` to start the development servers for both the client and server
|
||||
applications.
|
||||
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by
|
||||
running `pnpm dev:inspect`.
|
||||
We do that by creating two terminals an running
|
||||
- __Run the React UI__ by running `pnpm turbo dev --filter=ontime-ui`
|
||||
- __Run the nodejs server__ by running `pnpm turbo dev --filter=ontime-server`
|
||||
|
||||
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by running `pnpm turbo dev:inspect --filter=ontime-server`.
|
||||
|
||||
## TESTING
|
||||
|
||||
@@ -45,7 +46,7 @@ Generally we have 2 types of tests.
|
||||
|
||||
Unit tests are contained in mostly all the apps and packages (client, server and utils)
|
||||
|
||||
You can run unit tests by running `turbo run test:pipeline` from the project root.
|
||||
You can run unit tests by running `pnpm turbo test:pipeline` from the project root.
|
||||
This will run all tests and close test runner.
|
||||
|
||||
Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode
|
||||
@@ -76,13 +77,13 @@ You can generate a distribution for your OS by running the following steps.
|
||||
From the project root, run the following commands
|
||||
|
||||
- __Install the project dependencies__ by running `pnpm i`
|
||||
- __Build the UI and server__ by running `turbo run build:electron`
|
||||
- __Create the package__ by running `turbo run dist-win`, `turbo run dist-mac` or `turbo run dist-linux`
|
||||
- __Build the UI and server__ by running `pnpm turbo run build:electron`
|
||||
- __Create the package__ by running `pnpm turbo run dist-win`, `pnpm turbo run dist-mac` or `pnpm turbo run dist-linux`
|
||||
|
||||
The build distribution assets will be at `.apps/electron/dist`
|
||||
|
||||
Note: The MacOS build will only work in CI, locally it will fail due to notarisation issues.
|
||||
Use the `turbo run dist-mac:local` command to build a MacOS distribution locally.
|
||||
Use the `pnpm turbo run dist-mac:local` command to build a MacOS distribution locally and skip the notary process.
|
||||
|
||||
## DOCKER
|
||||
|
||||
@@ -98,10 +99,3 @@ Other useful commands
|
||||
|
||||
- __List running processes__ by running `docker ps`
|
||||
- __Kill running process__ by running `docker kill <process-id>`
|
||||
|
||||
## General Info
|
||||
|
||||
# APP Building
|
||||
|
||||
We build the app from app.js for almost all applications. The output file will still be named index.cjs. This is because of Electron.
|
||||
Building the app from index.ts only applies for applications that don't use electron. index.ts will take over the initialization of the server and UI when electron isn't present.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "4.0.0-alpha.2",
|
||||
"version": "4.0.0-alpha.4",
|
||||
"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.2",
|
||||
"version": "4.0.0-alpha.4",
|
||||
"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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
border-radius: 3px;
|
||||
box-shadow: $box-shadow-l1;
|
||||
border: 1px solid $gray-1100;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
.list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 99px;
|
||||
border: 2px solid $gray-1200;
|
||||
color: $ui-white;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
+2
-1
@@ -14,7 +14,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 1rem;
|
||||
padding: 0.5em;
|
||||
padding-top: 0.75rem;
|
||||
padding-left: 0.5em;
|
||||
|
||||
position: fixed;
|
||||
left: 0;
|
||||
|
||||
+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'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
}
|
||||
|
||||
.text {
|
||||
display: block;
|
||||
margin-inline: auto;;
|
||||
font-weight: 600;
|
||||
font-size: 2em;
|
||||
max-width: 600px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@ import style from './Empty.module.scss';
|
||||
|
||||
interface EmptyProps {
|
||||
text?: string;
|
||||
style?: CSSProperties;
|
||||
injectedStyles?: CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Empty({ text, className, ...rest }: EmptyProps) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
.emptyCell {
|
||||
margin-inline: auto;
|
||||
text-align: center;
|
||||
padding-top: 10vh;
|
||||
}
|
||||
|
||||
.empty {
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
}
|
||||
|
||||
.thumb {
|
||||
aspect-ratio: 1 / 1;
|
||||
aspect-ratio: 1;
|
||||
height: 100%;
|
||||
border-radius: 99px;
|
||||
background-color: $ui-white;
|
||||
@@ -55,5 +55,4 @@
|
||||
&[data-checked] {
|
||||
translate: calc(var(--width) - var(--height)) 0;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createContext } from 'react';
|
||||
import { URLPreset } from 'ontime-types';
|
||||
|
||||
export const PresetContext = createContext<URLPreset | undefined>(undefined);
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { URLPreset } from 'ontime-types';
|
||||
import { OntimeView, URLPreset } from 'ontime-types';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { URL_PRESETS } from '../api/constants';
|
||||
@@ -21,6 +22,14 @@ export default function useUrlPresets({ skip = false }: FetchProps = {}) {
|
||||
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();
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -552,7 +552,7 @@ export const useEntryActions = () => {
|
||||
);
|
||||
|
||||
/**
|
||||
* Calls mutation to dissolve a block
|
||||
* Calls mutation to dissolve a group
|
||||
* @private
|
||||
*/
|
||||
const { mutateAsync: ungroupMutation } = useMutation({
|
||||
@@ -574,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);
|
||||
}
|
||||
@@ -588,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({
|
||||
@@ -610,7 +610,7 @@ export const useEntryActions = () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a block with a selection
|
||||
* Create a group with a selection
|
||||
*/
|
||||
const groupEntries = useCallback(
|
||||
async (entryIds: EntryId[]) => {
|
||||
@@ -674,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],
|
||||
);
|
||||
@@ -798,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,8 +150,8 @@ export const useClock = createSelector((state: RuntimeStore) => ({
|
||||
}));
|
||||
|
||||
export const useNextFlag = createSelector((state: RuntimeStore) => ({
|
||||
id: state.nextFlag?.id ?? null,
|
||||
expectedStart: state.nextFlag?.expectedStart ?? null,
|
||||
id: state.eventFlag?.id ?? null,
|
||||
expectedStart: state.offset.expectedFlagStart,
|
||||
}));
|
||||
|
||||
/** Used by the progress bar components */
|
||||
@@ -163,35 +162,35 @@ 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.offsetAbs : state.runtime.offsetRel,
|
||||
numEvents: state.rundown.numEvents,
|
||||
selectedEventIndex: state.rundown.selectedEventIndex,
|
||||
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
|
||||
|
||||
blockExpectedEnd: state.blockNow?.expectedEnd ?? null,
|
||||
groupExpectedEnd: state.offset.expectedGroupEnd,
|
||||
}));
|
||||
|
||||
export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
offset: state.runtime.offsetAbs,
|
||||
offset: state.offset.absolute,
|
||||
}));
|
||||
|
||||
export const useExpectedStartData = createSelector((state: RuntimeStore) => ({
|
||||
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offsetAbs : state.runtime.offsetRel,
|
||||
offsetMode: state.runtime.offsetMode,
|
||||
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,
|
||||
}));
|
||||
|
||||
@@ -200,7 +199,7 @@ export const useCurrentDay = createSelector((state: RuntimeStore) => ({
|
||||
}));
|
||||
|
||||
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
|
||||
offset: state.runtime.offsetAbs,
|
||||
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,9 +1,11 @@
|
||||
import { resolvePath } from 'react-router-dom';
|
||||
import { Path, resolvePath } from 'react-router';
|
||||
import { OntimeView, URLPreset } from 'ontime-types';
|
||||
|
||||
import {
|
||||
arePathsEquivalent,
|
||||
generatePathFromPreset,
|
||||
generateUrlPresetOptions,
|
||||
getCurrentPath,
|
||||
getRouteFromPreset,
|
||||
validateUrlPresetPath,
|
||||
} from '../urlPresets';
|
||||
@@ -29,12 +31,13 @@ describe('validateUrlPresetPaths()', () => {
|
||||
});
|
||||
|
||||
describe('getRouteFromPreset()', () => {
|
||||
const presets = [
|
||||
const presets: URLPreset[] = [
|
||||
{
|
||||
enabled: true,
|
||||
alias: 'demopage',
|
||||
target: 'timer',
|
||||
target: OntimeView.Timer,
|
||||
search: 'user=guest',
|
||||
options: {},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -70,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', (target, path, alias, expected) => {
|
||||
expect(generatePathFromPreset(target, 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -108,9 +111,13 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -172,3 +179,16 @@ describe('generateUrlPresetOptions', () => {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -21,5 +21,6 @@ export function getOffsetText(offset: MaybeNumber): string {
|
||||
export function getOffsetState(offset: MaybeNumber): 'over' | 'under' | 'muted' | null {
|
||||
if (offset === null) return 'muted';
|
||||
if (offset === 0) return null;
|
||||
return offset < 0 ? 'over' : 'under';
|
||||
// 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;
|
||||
|
||||
@@ -145,12 +145,12 @@ export function useTimeUntilExpectedStart(
|
||||
isLinkedToLoaded: boolean;
|
||||
},
|
||||
): number {
|
||||
const { offset, currentDay, offsetMode, actualStart, plannedStart, clock } = useExpectedStartData();
|
||||
const { offset, currentDay, mode, actualStart, plannedStart, clock } = useExpectedStartData();
|
||||
if (event === null) return 0;
|
||||
|
||||
const expectedStart = getExpectedStart(
|
||||
{ ...event },
|
||||
{ ...state, currentDay, offset, offsetMode, actualStart, plannedStart },
|
||||
{ ...state, currentDay, offset, mode, actualStart, plannedStart },
|
||||
);
|
||||
return expectedStart - clock;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Path, resolvePath } from 'react-router-dom';
|
||||
import { OntimeView, URLPreset } from 'ontime-types';
|
||||
import { Path, resolvePath } from 'react-router';
|
||||
import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types';
|
||||
import { checkRegex } from 'ontime-utils';
|
||||
|
||||
/**
|
||||
@@ -27,62 +27,72 @@ 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.target, foundPreset.search, 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.target, preset.search, 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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: string | null,
|
||||
locked: boolean,
|
||||
token: string | null,
|
||||
): string {
|
||||
const path = resolvePath(`${target}?${search}`);
|
||||
@@ -93,7 +103,7 @@ export function generatePathFromPreset(
|
||||
|
||||
// maintain params from the URL search feature
|
||||
if (locked) {
|
||||
searchParams.set('locked', locked);
|
||||
searchParams.set('n', '1');
|
||||
}
|
||||
|
||||
if (token) {
|
||||
@@ -106,28 +116,27 @@ export function generatePathFromPreset(
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
@@ -136,33 +145,35 @@ export function arePathsEquivalent(currentPath: string, newPath: string): boolea
|
||||
* Generates a URL preset from a user given alias and URL.
|
||||
*/
|
||||
export function generateUrlPresetOptions(alias: string, userUrl: string): URLPreset {
|
||||
let sanitisedUrl = userUrl.toLowerCase();
|
||||
let sanitisedUrl = userUrl;
|
||||
// we need to ensure the URL has a protocol, but it doesnt matter which
|
||||
if (!checkRegex.startsWithHttp(sanitisedUrl)) {
|
||||
if (!checkRegex.startsWithHttp(userUrl.toLowerCase())) {
|
||||
sanitisedUrl = `http://${sanitisedUrl}`;
|
||||
}
|
||||
|
||||
const url = new URL(sanitisedUrl);
|
||||
const target = extractLastSegment(url.pathname);
|
||||
const path = getCurrentPath(url);
|
||||
|
||||
if (target === 'editor' || !Object.values(OntimeView).includes(target as OntimeView)) {
|
||||
throw new Error(`Invalid target view: ${target}`);
|
||||
if (!isPresettableView(path)) {
|
||||
throw new Error(`Invalid target view: ${path}`);
|
||||
}
|
||||
|
||||
return {
|
||||
alias,
|
||||
target,
|
||||
target: path,
|
||||
search: url.searchParams.toString(),
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* the path can contain the stage hash
|
||||
* "/team-hash/timer" or "/timer"
|
||||
* we need to extract ontime view it targets
|
||||
*/
|
||||
function extractLastSegment(pathname: string): string {
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
return segments[segments.length - 1] || '';
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { AppMode } from '../ontimeConfig';
|
||||
|
||||
declare module '*.scss' {
|
||||
const content: Record<string, string>;
|
||||
export default content;
|
||||
@@ -26,6 +28,8 @@ declare global {
|
||||
* - `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
|
||||
@@ -39,6 +43,8 @@ declare module '@tanstack/react-table' {
|
||||
options: {
|
||||
showDelayedTimes: boolean;
|
||||
hideTableSeconds: boolean;
|
||||
hideIndexColumn: boolean;
|
||||
cuesheetMode: AppMode;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -132,6 +132,7 @@ $inner-padding: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
+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,10 +1,10 @@
|
||||
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 URLPresets from './URLPresets';
|
||||
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
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) {
|
||||
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: `URL Preset: ${preset.alias}`,
|
||||
})),
|
||||
];
|
||||
}, [lockedPath, urlPresetData]);
|
||||
|
||||
return <GenerateLinkForm hostOptions={hostOptions} pathOptions={pathOptions} isLockedToView={Boolean(lockedPath)} />;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
@@ -57,9 +58,8 @@ export default function URLPresets() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Enabled</th>
|
||||
<th>Alias</th>
|
||||
<th>Target view</th>
|
||||
<th>URL parameters</th>
|
||||
<th>Alias</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -71,9 +71,10 @@ export default function URLPresets() {
|
||||
<td>
|
||||
<Switch defaultChecked={preset.enabled} onCheckedChange={() => {}} />
|
||||
</td>
|
||||
<td>{preset.alias}</td>
|
||||
<td>{preset.target}</td>
|
||||
<td>{preset.search}</td>
|
||||
<td>
|
||||
<Tag>{preset.target}</Tag>
|
||||
</td>
|
||||
<td style={{ width: '100%' }}>{preset.alias}</td>
|
||||
<Panel.InlineElements relation='inner' as='td'>
|
||||
<IconButton
|
||||
variant='ghosted-white'
|
||||
|
||||
+8
-6
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { OntimeView, URLPreset } from 'ontime-types';
|
||||
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';
|
||||
@@ -13,7 +14,7 @@ import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './URLPresetForm.module.scss';
|
||||
|
||||
const targetOptions: SelectOption[] = [
|
||||
const targetOptions: SelectOption<OntimeViewPresettable>[] = [
|
||||
{ value: OntimeView.Cuesheet, label: 'Cuesheet' },
|
||||
{ value: OntimeView.Operator, label: 'Operator' },
|
||||
{ value: OntimeView.Timer, label: 'Timer' },
|
||||
@@ -114,7 +115,7 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
|
||||
<div className={style.expand}>
|
||||
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
|
||||
<Panel.InlineElements>
|
||||
<Input placeholder='Paste URL' fluid ref={urlRef} required />
|
||||
<Input placeholder='Paste URL' fluid ref={urlRef} />
|
||||
<Button onClick={generateOptions}>Generate</Button>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
@@ -126,14 +127,15 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
|
||||
<Select
|
||||
options={targetOptions}
|
||||
{...register('target', { required: 'Target is required' })}
|
||||
value={watch('target') as OntimeView}
|
||||
onValueChange={(value) => setValue('target', value)}
|
||||
value={watch('target')}
|
||||
onValueChange={(value) => setValue('target', value, { shouldDirty: true })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Panel.Description>Parameters</Panel.Description>
|
||||
<Input
|
||||
<Textarea
|
||||
fluid
|
||||
rows={3}
|
||||
{...register('search', {
|
||||
validate: validateParams,
|
||||
})}
|
||||
|
||||
+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';
|
||||
|
||||
@@ -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,7 +22,7 @@ interface ProjectMergeFromProps {
|
||||
|
||||
type ProjectMergeFormValues = {
|
||||
project: boolean;
|
||||
rundown: boolean;
|
||||
rundowns: boolean;
|
||||
viewSettings: boolean;
|
||||
urlPresets: boolean;
|
||||
automation: boolean;
|
||||
@@ -39,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,
|
||||
@@ -61,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
|
||||
@@ -75,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
|
||||
@@ -88,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'
|
||||
@@ -102,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>
|
||||
|
||||
@@ -243,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,7 +18,7 @@ import CodeEditorModal from './composite/StyleEditorModal';
|
||||
const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/';
|
||||
|
||||
export default function ViewSettings() {
|
||||
const { data, isPending, mutateAsync } = useViewSettings();
|
||||
const { data, status, mutateAsync } = useViewSettings();
|
||||
const [isCodeEditorOpen, codeEditorHandler] = useDisclosure();
|
||||
|
||||
const {
|
||||
@@ -87,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} />
|
||||
|
||||
@@ -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,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';
|
||||
@@ -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 />
|
||||
@@ -168,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)) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import style from './OperatorBlock.module.scss';
|
||||
|
||||
interface OperatorBlockProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
function OperatorBlock({ title }: OperatorBlockProps) {
|
||||
return <div className={style.block}>{title}</div>;
|
||||
}
|
||||
|
||||
export default memo(OperatorBlock);
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
.block {
|
||||
.group {
|
||||
width: 100%;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: $gray-1350;
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
// tablet
|
||||
@media (min-width: $min-tablet) {
|
||||
.block {
|
||||
.group {
|
||||
padding: 0.25rem 1rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import style from './OperatorGroup.module.scss';
|
||||
|
||||
interface OperatorGroup {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default memo(OperatorGroup);
|
||||
function OperatorGroup({ title }: OperatorGroup) {
|
||||
return <div className={style.group}>{title}</div>;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { CustomFields, OntimeEvent } from 'ontime-types';
|
||||
|
||||
import { getTimeOption } from '../../common/components/view-params-editor/common.options';
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
}
|
||||
|
||||
.timers {
|
||||
padding-block: 0.75rem 0.5rem;
|
||||
padding-inline: 1rem 0;
|
||||
padding-block: 1rem 0.5rem;
|
||||
padding-inline: 1rem 0.5rem;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
@@ -18,11 +18,21 @@
|
||||
.runningTimer {
|
||||
grid-area: timers;
|
||||
justify-self: center;
|
||||
|
||||
:nth-child(2) {
|
||||
font-size: 2.5rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
.timeNow {
|
||||
grid-area: clock;
|
||||
justify-self: right;
|
||||
|
||||
:nth-child(2) {
|
||||
font-size: 2.5rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
.progressOverride {
|
||||
|
||||
@@ -8,15 +8,15 @@ import {
|
||||
TbFolderPin,
|
||||
TbFolderStar,
|
||||
} from 'react-icons/tb';
|
||||
import { OntimeBlock, OntimeEvent, TimerPhase, TimerType } from 'ontime-types';
|
||||
import { OntimeEvent, OntimeGroup, TimerPhase, TimerType } from 'ontime-types';
|
||||
import { isPlaybackActive, millisToString } from 'ontime-utils';
|
||||
|
||||
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||
import {
|
||||
useClock,
|
||||
useCurrentBlockId,
|
||||
useCurrentGroupId,
|
||||
useNextFlag,
|
||||
useRuntimeOverview,
|
||||
useRundownOverview,
|
||||
useRuntimePlaybackOverview,
|
||||
useTimer,
|
||||
} from '../../../common/hooks/useSocket';
|
||||
@@ -31,7 +31,7 @@ import { OverUnder, TimeColumn } from './TimeLayout';
|
||||
import style from './TimeElements.module.scss';
|
||||
|
||||
export function StartTimes() {
|
||||
const { plannedEnd, plannedStart, actualStart, expectedEnd } = useRuntimeOverview();
|
||||
const { plannedEnd, plannedStart, actualStart, expectedEnd } = useRundownOverview();
|
||||
|
||||
const plannedStartText = plannedStart === null ? timerPlaceholder : formatTime(plannedStart);
|
||||
|
||||
@@ -98,15 +98,15 @@ export function MetadataTimes() {
|
||||
|
||||
//TODO: there a some things here we still need to think about, mainly what to do whit the planed group duration in relation to the events
|
||||
function GroupTimes() {
|
||||
const { clock, blockExpectedEnd } = useRuntimePlaybackOverview();
|
||||
const { currentBlockId } = useCurrentBlockId();
|
||||
const group = useEntry(currentBlockId) as OntimeBlock | null;
|
||||
const { clock, groupExpectedEnd } = useRuntimePlaybackOverview();
|
||||
const { currentGroupId } = useCurrentGroupId();
|
||||
const group = useEntry(currentGroupId) as OntimeGroup | null;
|
||||
|
||||
// the group end time dose not encode any day offsets
|
||||
const plannedGroupEnd = group && group.timeStart !== null ? group.timeStart + group.duration - clock : null;
|
||||
const plannedTimeUntilGroupEnd = formattedTime(plannedGroupEnd, 3, TimerType.CountDown);
|
||||
|
||||
const expectedGroupEnd = blockExpectedEnd !== null ? blockExpectedEnd - clock : null;
|
||||
const expectedGroupEnd = groupExpectedEnd !== null ? groupExpectedEnd - clock : null;
|
||||
const expectedTimeUntilGroupEnd = formattedTime(expectedGroupEnd, 3, TimerType.CountDown);
|
||||
|
||||
const groupTitle = group?.title ?? null;
|
||||
@@ -120,7 +120,7 @@ function GroupTimes() {
|
||||
</div>
|
||||
<div className={style.labelledElement}>
|
||||
<Tooltip text='Time to expected group end' render={<TbFolderStar className={style.icon} />} />
|
||||
<span className={cx([style.time, blockExpectedEnd === null && style.muted])}>{expectedTimeUntilGroupEnd}</span>
|
||||
<span className={cx([style.time, groupExpectedEnd === null && style.muted])}>{expectedTimeUntilGroupEnd}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -144,11 +144,15 @@ function FlagTimes() {
|
||||
<span className={title ? style.labelTitle : style.label}>{`${title ? title : 'Flag'} `}</span>
|
||||
<div className={style.labelledElement}>
|
||||
<Tooltip text='Time to next flag planned start' render={<TbFlagPin className={style.icon} />} />
|
||||
<span className={cx([style.time, !entry && style.muted])}>{plannedTimeUntilDisplay}</span>
|
||||
<span data-testid='flag-plannedStart' className={cx([style.time, !entry && style.muted])}>
|
||||
{plannedTimeUntilDisplay}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.labelledElement}>
|
||||
<Tooltip text='Time to next flag expected start' render={<TbFlagStar className={style.icon} />} />
|
||||
<span className={cx([style.time, expectedTimeUntil === null && style.muted])}>{expectedTimeUntilDisplay}</span>
|
||||
<span data-testid='flag-expectedStart' className={cx([style.time, expectedTimeUntil === null && style.muted])}>
|
||||
{expectedTimeUntilDisplay}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -167,9 +171,8 @@ export function OffsetOverview() {
|
||||
const { offset, playback } = useRuntimePlaybackOverview();
|
||||
|
||||
const isPlaying = isPlaybackActive(playback);
|
||||
const correctedOffset = offset * -1;
|
||||
const offsetState = getOffsetState(isPlaying ? offset : null);
|
||||
const offsetText = getOffsetText(isPlaying ? correctedOffset : null);
|
||||
const offsetText = getOffsetText(isPlaying ? offset : null);
|
||||
|
||||
return <OverUnder state={offsetState} value={offsetText} testId='offset' />;
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
type EntryId,
|
||||
type MaybeString,
|
||||
type Rundown,
|
||||
isOntimeBlock,
|
||||
isOntimeEvent,
|
||||
isOntimeGroup,
|
||||
OntimeEntry,
|
||||
Playback,
|
||||
SupportedEntry,
|
||||
@@ -25,9 +25,9 @@ import {
|
||||
import {
|
||||
getFirstNormal,
|
||||
getLastNormal,
|
||||
getNextBlockNormal,
|
||||
getNextGroupNormal,
|
||||
getNextNormal,
|
||||
getPreviousBlockNormal,
|
||||
getPreviousGroupNormal,
|
||||
getPreviousNormal,
|
||||
reorderArray,
|
||||
} from 'ontime-utils';
|
||||
@@ -41,8 +41,8 @@ import { AppMode, sessionKeys } from '../../ontimeConfig';
|
||||
|
||||
import QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons';
|
||||
import QuickAddInline from './entry-editor/quick-add-cursor/QuickAddInline';
|
||||
import RundownBlock from './rundown-block/RundownBlock';
|
||||
import RundownBlockEnd from './rundown-block/RundownBlockEnd';
|
||||
import RundownGroup from './rundown-group/RundownGroup';
|
||||
import RundownGroupEnd from './rundown-group/RundownGroupEnd';
|
||||
import { canDrop, makeRundownMetadata, makeSortableList } from './rundown.utils';
|
||||
import RundownEmpty from './RundownEmpty';
|
||||
import { useEventSelection } from './useEventSelection';
|
||||
@@ -127,7 +127,7 @@ export default function Rundown({ data }: RundownProps) {
|
||||
[addEntry],
|
||||
);
|
||||
|
||||
const selectBlock = useCallback(
|
||||
const selectGroup = useCallback(
|
||||
(cursor: string | null, direction: 'up' | 'down') => {
|
||||
if (order.length < 1) {
|
||||
return;
|
||||
@@ -137,7 +137,7 @@ export default function Rundown({ data }: RundownProps) {
|
||||
// there is no cursor, we select the first or last depending on direction
|
||||
const selected = direction === 'up' ? getLastNormal(entries, order) : getFirstNormal(entries, order);
|
||||
|
||||
if (isOntimeBlock(selected)) {
|
||||
if (isOntimeGroup(selected)) {
|
||||
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
|
||||
return;
|
||||
}
|
||||
@@ -151,8 +151,8 @@ export default function Rundown({ data }: RundownProps) {
|
||||
// otherwise we select the next or previous
|
||||
const selected =
|
||||
direction === 'up'
|
||||
? getPreviousBlockNormal(entries, order, newCursor)
|
||||
: getNextBlockNormal(entries, order, newCursor);
|
||||
? getPreviousGroupNormal(entries, order, newCursor)
|
||||
: getNextGroupNormal(entries, order, newCursor);
|
||||
|
||||
if (selected.entry !== null && selected.index !== null) {
|
||||
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
|
||||
@@ -188,11 +188,11 @@ export default function Rundown({ data }: RundownProps) {
|
||||
);
|
||||
|
||||
/**
|
||||
* Checks whether a block is collapsed
|
||||
* Checks whether a group is collapsed
|
||||
*/
|
||||
const getIsCollapsed = useCallback(
|
||||
(blockId: EntryId): boolean => {
|
||||
return Boolean(collapsedGroups.find((id) => id === blockId));
|
||||
(groupId: EntryId): boolean => {
|
||||
return Boolean(collapsedGroups.find((id) => id === groupId));
|
||||
},
|
||||
[collapsedGroups],
|
||||
);
|
||||
@@ -223,10 +223,10 @@ export default function Rundown({ data }: RundownProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const movedIntoBlockId = await move(cursor, direction);
|
||||
// if we are moving into a block, we need to make sure it is expanded
|
||||
if (movedIntoBlockId) {
|
||||
handleCollapseGroup(false, movedIntoBlockId);
|
||||
const movedIntoGroupId = await move(cursor, direction);
|
||||
// if we are moving into a group, we need to make sure it is expanded
|
||||
if (movedIntoGroupId) {
|
||||
handleCollapseGroup(false, movedIntoGroupId);
|
||||
}
|
||||
},
|
||||
[handleCollapseGroup, move],
|
||||
@@ -237,8 +237,8 @@ export default function Rundown({ data }: RundownProps) {
|
||||
['alt + ArrowDown', () => selectEntry(cursor, 'down'), { preventDefault: true, usePhysicalKeys: true }],
|
||||
['alt + ArrowUp', () => selectEntry(cursor, 'up'), { preventDefault: true, usePhysicalKeys: true }],
|
||||
|
||||
['alt + shift + ArrowDown', () => selectBlock(cursor, 'down'), { preventDefault: true, usePhysicalKeys: true }],
|
||||
['alt + shift + ArrowUp', () => selectBlock(cursor, 'up'), { preventDefault: true, usePhysicalKeys: true }],
|
||||
['alt + shift + ArrowDown', () => selectGroup(cursor, 'down'), { preventDefault: true, usePhysicalKeys: true }],
|
||||
['alt + shift + ArrowUp', () => selectGroup(cursor, 'up'), { preventDefault: true, usePhysicalKeys: true }],
|
||||
|
||||
['alt + mod + ArrowDown', () => moveEntry(cursor, 'down'), { preventDefault: true, usePhysicalKeys: true }],
|
||||
['alt + mod + ArrowUp', () => moveEntry(cursor, 'up'), { preventDefault: true, usePhysicalKeys: true }],
|
||||
@@ -260,12 +260,12 @@ export default function Rundown({ data }: RundownProps) {
|
||||
|
||||
[
|
||||
'alt + G',
|
||||
() => insertAtId({ type: SupportedEntry.Block }, cursor),
|
||||
() => insertAtId({ type: SupportedEntry.Group }, cursor),
|
||||
{ preventDefault: true, usePhysicalKeys: true },
|
||||
],
|
||||
[
|
||||
'alt + shift + G',
|
||||
() => insertAtId({ type: SupportedEntry.Block }, cursor, true),
|
||||
() => insertAtId({ type: SupportedEntry.Group }, cursor, true),
|
||||
{ preventDefault: true, usePhysicalKeys: true },
|
||||
],
|
||||
|
||||
@@ -335,7 +335,10 @@ export default function Rundown({ data }: RundownProps) {
|
||||
}
|
||||
|
||||
// prevent dropping a group inside another
|
||||
if (active.data.current?.type === 'block' && !canDrop(over.data.current?.type, over.data.current?.parent)) {
|
||||
if (
|
||||
active.data.current?.type === SupportedEntry.Group &&
|
||||
!canDrop(over.data.current?.type, over.data.current?.parent)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -346,10 +349,10 @@ export default function Rundown({ data }: RundownProps) {
|
||||
let order: 'before' | 'after' | 'insert' = fromIndex < toIndex ? 'after' : 'before';
|
||||
|
||||
/**
|
||||
* We need to specially handle the end blocks
|
||||
* Dragging before and end block will add the entry to the end of the block
|
||||
* Dragging after an end block will add the event after the block itself
|
||||
* Dragging to the top of a block either place before first entry or if no entries do insert
|
||||
* We need to specially handle the end-group
|
||||
* Dragging before a end-group will add the entry to the end of the group
|
||||
* Dragging after a end-group will add the event after the group itself
|
||||
* Dragging to the top of a group either place before first entry or if no entries do insert
|
||||
*/
|
||||
if (destinationId.startsWith('end-')) {
|
||||
destinationId = destinationId.replace('end-', '');
|
||||
@@ -358,11 +361,11 @@ export default function Rundown({ data }: RundownProps) {
|
||||
order = 'insert';
|
||||
}
|
||||
} else {
|
||||
const block = data.entries[destinationId];
|
||||
if (isOntimeBlock(block) && order === 'after') {
|
||||
if (block.entries.length === 0) order = 'insert';
|
||||
const group = data.entries[destinationId];
|
||||
if (isOntimeGroup(group) && order === 'after') {
|
||||
if (group.entries.length === 0) order = 'insert';
|
||||
else {
|
||||
destinationId = block.entries[0];
|
||||
destinationId = group.entries[0];
|
||||
order = 'before';
|
||||
}
|
||||
}
|
||||
@@ -380,31 +383,31 @@ export default function Rundown({ data }: RundownProps) {
|
||||
};
|
||||
|
||||
/**
|
||||
* When we drag a block, we force collapse it
|
||||
* This avoids strange scenarios like dropping a block inside itself
|
||||
* When we drag a group, we force collapse it
|
||||
* This avoids strange scenarios like dropping a group inside itself
|
||||
*/
|
||||
const collapseDraggedBlocks = (event: DragStartEvent) => {
|
||||
const isBlock = event.active.data.current?.type === 'block';
|
||||
if (isBlock) {
|
||||
const collapseDraggedGroups = (event: DragStartEvent) => {
|
||||
const isGroup = event.active.data.current?.type === SupportedEntry.Group;
|
||||
if (isGroup) {
|
||||
handleCollapseGroup(true, event.active.id as EntryId);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* When we drag over a block, we expand it if it is collapsed
|
||||
* When we drag over a group, we expand it if it is collapsed
|
||||
*/
|
||||
const expandOverBlock = (event: DragOverEvent) => {
|
||||
// if we are dragging a block, the drop operation is invalid so we dont expand
|
||||
if (event.active.data.current?.type === 'block') {
|
||||
const expandOverGroup = (event: DragOverEvent) => {
|
||||
// if we are dragging a group, the drop operation is invalid so we dont expand
|
||||
if (event.active.data.current?.type === 'group') {
|
||||
return;
|
||||
}
|
||||
if (event.over?.data.current?.type !== 'block') {
|
||||
if (event.over?.data.current?.type !== 'group') {
|
||||
return;
|
||||
}
|
||||
const blockId = event.over?.id as EntryId;
|
||||
const isCollapsed = getIsCollapsed(blockId);
|
||||
const groupId = event.over?.id as EntryId;
|
||||
const isCollapsed = getIsCollapsed(groupId);
|
||||
if (isCollapsed) {
|
||||
handleCollapseGroup(false, blockId);
|
||||
handleCollapseGroup(false, groupId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -424,37 +427,37 @@ export default function Rundown({ data }: RundownProps) {
|
||||
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
|
||||
<DndContext
|
||||
onDragEnd={handleOnDragEnd}
|
||||
onDragStart={collapseDraggedBlocks}
|
||||
onDragOver={expandOverBlock}
|
||||
onDragStart={collapseDraggedGroups}
|
||||
onDragOver={expandOverGroup}
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
>
|
||||
<SortableContext items={sortableData} strategy={verticalListSortingStrategy}>
|
||||
<div className={style.list}>
|
||||
{isEditMode && <QuickAddButtons previousEventId={null} parentBlock={null} />}
|
||||
{isEditMode && <QuickAddButtons previousEventId={null} parentGroup={null} />}
|
||||
{sortableData.map((entryId, index) => {
|
||||
// the entry might be a pseudo block-end which does not generate metadata and should not be processed
|
||||
// the entry might be a pseudo end-group which does not generate metadata and should not be processed
|
||||
if (entryId.startsWith('end-')) {
|
||||
const parentId = entryId.split('end-')[1];
|
||||
const isBlockCollapsed = getIsCollapsed(parentId);
|
||||
const isGroupCollapsed = getIsCollapsed(parentId);
|
||||
|
||||
if (isBlockCollapsed) {
|
||||
if (isGroupCollapsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// if the previous element is selected, it will have its own QuickAddInline
|
||||
// we use thisId instead of previousEntryId because the block end does not process
|
||||
// we use thisId instead of previousEntryId because the end-group does not process
|
||||
// and it does not cause the reassignment of the iteration id to the previous entry
|
||||
return (
|
||||
<Fragment key={entryId}>
|
||||
{isEditMode && rundownMetadata.groupEntries === 0 && (
|
||||
<QuickAddButtons
|
||||
previousEventId={null}
|
||||
parentBlock={parentId}
|
||||
parentGroup={parentId}
|
||||
backgroundColor={rundownMetadata.groupColour}
|
||||
/>
|
||||
)}
|
||||
<RundownBlockEnd key={entryId} id={entryId} colour={rundownMetadata.groupColour} />
|
||||
<RundownGroupEnd key={entryId} id={entryId} colour={rundownMetadata.groupColour} />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -469,7 +472,7 @@ export default function Rundown({ data }: RundownProps) {
|
||||
|
||||
// if the entry has a parent, and it is collapsed, render nothing
|
||||
if (
|
||||
entry.type !== SupportedEntry.Block &&
|
||||
entry.type !== SupportedEntry.Group &&
|
||||
rundownMetadata.groupId !== null &&
|
||||
getIsCollapsed(rundownMetadata.groupId)
|
||||
) {
|
||||
@@ -480,12 +483,12 @@ export default function Rundown({ data }: RundownProps) {
|
||||
const hasCursor = entry.id === cursor;
|
||||
|
||||
/**
|
||||
* Outside a block, the value will be undefined
|
||||
* Outside a group, the value will be undefined
|
||||
* If the colour is empty string ''
|
||||
* ie: we are inside a block, but there is no defined colour
|
||||
* ie: we are inside a group, but there is no defined colour
|
||||
* we default to $gray-500 #9d9d9d
|
||||
*/
|
||||
const blockColour = rundownMetadata.groupColour === '' ? '#9d9d9d' : rundownMetadata.groupColour;
|
||||
const groupColour = rundownMetadata.groupColour === '' ? '#9d9d9d' : rundownMetadata.groupColour;
|
||||
|
||||
const isFirst = index === 0;
|
||||
const isLast = entryId === order.at(-1);
|
||||
@@ -510,10 +513,10 @@ export default function Rundown({ data }: RundownProps) {
|
||||
* - if it is not the first entry (the buttons would be there)
|
||||
*/}
|
||||
{isEditMode && hasCursor && !isFirst && (
|
||||
<QuickAddInline previousEventId={rundownMetadata.previousEntryId} parentBlock={parentIdForBefore} />
|
||||
<QuickAddInline previousEventId={rundownMetadata.previousEntryId} parentGroup={parentIdForBefore} />
|
||||
)}
|
||||
{isOntimeBlock(entry) ? (
|
||||
<RundownBlock
|
||||
{isOntimeGroup(entry) ? (
|
||||
<RundownGroup
|
||||
data={entry}
|
||||
hasCursor={hasCursor}
|
||||
collapsed={getIsCollapsed(entry.id)}
|
||||
@@ -523,7 +526,7 @@ export default function Rundown({ data }: RundownProps) {
|
||||
<div
|
||||
className={style.entryWrapper}
|
||||
data-testid={`entry-${rundownMetadata.eventIndex}`}
|
||||
style={blockColour ? { '--user-bg': blockColour } : {}}
|
||||
style={groupColour ? { '--user-bg': groupColour } : {}}
|
||||
>
|
||||
{isOntimeEvent(entry) && (
|
||||
<div className={style.entryIndex}>
|
||||
@@ -556,16 +559,16 @@ export default function Rundown({ data }: RundownProps) {
|
||||
* - edit mode only
|
||||
* - if there is a cursor
|
||||
* - if it is not the last entry (the buttons would be there)
|
||||
* - if the entry is not the block header
|
||||
* - if the entry is not the group header
|
||||
*/}
|
||||
{isEditMode && hasCursor && !isLast && (
|
||||
<QuickAddInline previousEventId={entry.id} parentBlock={parentIdForAfter} />
|
||||
<QuickAddInline previousEventId={entry.id} parentGroup={parentIdForAfter} />
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{isEditMode && (
|
||||
<QuickAddButtons previousEventId={rundownMetadata.groupId ?? rundownMetadata.thisId} parentBlock={null} />
|
||||
<QuickAddButtons previousEventId={rundownMetadata.groupId ?? rundownMetadata.thisId} parentGroup={null} />
|
||||
)}
|
||||
<div className={style.spacer} />
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function RundownEmpty(props: RundownEmptyProps) {
|
||||
|
||||
return (
|
||||
<div className={style.empty}>
|
||||
<Empty style={{ marginTop: '5vh', marginBottom: '3rem' }} />
|
||||
<Empty injectedStyles={{ marginTop: '5vh', marginBottom: '3rem' }} />
|
||||
<div className={style.inline}>
|
||||
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
|
||||
<IoAdd />
|
||||
@@ -25,7 +25,7 @@ export default function RundownEmpty(props: RundownEmptyProps) {
|
||||
|
||||
<Editor.Separator />
|
||||
|
||||
<Button onClick={() => handleAddNew(SupportedEntry.Block)} variant='primary' size='large'>
|
||||
<Button onClick={() => handleAddNew(SupportedEntry.Group)} variant='primary' size='large'>
|
||||
<IoAdd /> Create Group
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -25,12 +25,12 @@ export type EventItemActions =
|
||||
| 'event-before'
|
||||
| 'delay'
|
||||
| 'delay-before'
|
||||
| 'block'
|
||||
| 'block-before'
|
||||
| 'group'
|
||||
| 'group-before'
|
||||
| 'swap'
|
||||
| 'delete'
|
||||
| 'clone'
|
||||
| 'group'
|
||||
| 'make-group'
|
||||
| 'update';
|
||||
|
||||
interface RundownEntryProps {
|
||||
@@ -106,11 +106,11 @@ export default function RundownEntry({
|
||||
case 'delay-before': {
|
||||
return addEntry({ type: SupportedEntry.Delay }, { after: previousEntryId });
|
||||
}
|
||||
case 'block': {
|
||||
return addEntry({ type: SupportedEntry.Block }, { after: data.id });
|
||||
case 'group': {
|
||||
return addEntry({ type: SupportedEntry.Group }, { after: data.id });
|
||||
}
|
||||
case 'block-before': {
|
||||
return addEntry({ type: SupportedEntry.Block }, { after: previousEntryId });
|
||||
case 'group-before': {
|
||||
return addEntry({ type: SupportedEntry.Group }, { after: previousEntryId });
|
||||
}
|
||||
case 'swap': {
|
||||
const { value } = payload as FieldValue;
|
||||
@@ -129,7 +129,7 @@ export default function RundownEntry({
|
||||
addEntry(newEvent, { after: data.id });
|
||||
break;
|
||||
}
|
||||
case 'group': {
|
||||
case 'make-group': {
|
||||
if (selectedEvents.size > 1) {
|
||||
clearMultiSelection();
|
||||
return groupEntries(Array.from(selectedEvents));
|
||||
|
||||
@@ -43,6 +43,5 @@
|
||||
border-radius: 0 8px 8px 0;
|
||||
|
||||
flex: 1 1 auto; /* flex-grow: 1, flex-shrink: 1, flex-basis: auto */
|
||||
min-width: 30rem;
|
||||
max-width: 45rem;
|
||||
}
|
||||
|
||||
@@ -55,10 +55,11 @@ function RundownExport() {
|
||||
<ProtectRoute permission='editor'>
|
||||
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
|
||||
<FinderPlacement />
|
||||
{isExtracted && <ViewNavigationMenu suppressSettings />}
|
||||
<div className={style.rundown}>
|
||||
<div className={style.list}>
|
||||
<ErrorBoundary>
|
||||
<Corner onClick={(event) => handleLinks('rundown', event)} />
|
||||
{!isExtracted && <Corner onClick={(event) => handleLinks('rundown', event)} />}
|
||||
<RundownContextMenu>
|
||||
<RundownWrapper />
|
||||
</RundownContextMenu>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EntryId, OntimeBlock, OntimeDelay, OntimeEvent, RundownEntries, SupportedEntry } from 'ontime-types';
|
||||
import { EntryId, OntimeDelay, OntimeEvent, OntimeGroup, RundownEntries, SupportedEntry } from 'ontime-types';
|
||||
|
||||
import { makeRundownMetadata, makeSortableList, moveDown, moveUp, orderEntries } from '../rundown.utils';
|
||||
|
||||
@@ -18,16 +18,16 @@ describe('makeRundownMetadata()', () => {
|
||||
skip: false,
|
||||
linkStart: false,
|
||||
} as OntimeEvent,
|
||||
block: {
|
||||
id: 'block',
|
||||
type: SupportedEntry.Block,
|
||||
group: {
|
||||
id: 'group',
|
||||
type: SupportedEntry.Group,
|
||||
entries: ['11', 'delay', '12', '13'],
|
||||
colour: 'red',
|
||||
} as OntimeBlock,
|
||||
} as OntimeGroup,
|
||||
'11': {
|
||||
id: '11',
|
||||
type: SupportedEntry.Event,
|
||||
parent: 'block',
|
||||
parent: 'group',
|
||||
timeStart: 10,
|
||||
timeEnd: 11,
|
||||
duration: 1,
|
||||
@@ -39,13 +39,13 @@ describe('makeRundownMetadata()', () => {
|
||||
delay: {
|
||||
id: 'delay',
|
||||
type: SupportedEntry.Delay,
|
||||
parent: 'block',
|
||||
parent: 'group',
|
||||
duration: 0,
|
||||
} as OntimeDelay,
|
||||
'12': {
|
||||
id: '12',
|
||||
type: SupportedEntry.Event,
|
||||
parent: 'block',
|
||||
parent: 'group',
|
||||
timeStart: 11,
|
||||
timeEnd: 12,
|
||||
duration: 1,
|
||||
@@ -57,7 +57,7 @@ describe('makeRundownMetadata()', () => {
|
||||
'13': {
|
||||
id: '13',
|
||||
type: SupportedEntry.Event,
|
||||
parent: 'block',
|
||||
parent: 'group',
|
||||
timeStart: 12,
|
||||
timeEnd: 13,
|
||||
duration: 1,
|
||||
@@ -114,25 +114,25 @@ describe('makeRundownMetadata()', () => {
|
||||
groupEntries: undefined,
|
||||
});
|
||||
|
||||
expect(process(demoEvents['block'])).toMatchObject({
|
||||
expect(process(demoEvents['group'])).toMatchObject({
|
||||
previousEvent: demoEvents['1'],
|
||||
latestEvent: demoEvents['1'],
|
||||
previousEntryId: demoEvents['1'].id,
|
||||
thisId: demoEvents['block'].id,
|
||||
thisId: demoEvents['group'].id,
|
||||
eventIndex: 1,
|
||||
isPast: true,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: 'block',
|
||||
groupId: 'group',
|
||||
groupColour: 'red',
|
||||
});
|
||||
|
||||
expect(process(demoEvents['11'])).toMatchObject({
|
||||
previousEvent: demoEvents['1'],
|
||||
latestEvent: demoEvents['11'],
|
||||
previousEntryId: demoEvents['block'].id,
|
||||
previousEntryId: demoEvents['group'].id,
|
||||
thisId: demoEvents['11'].id,
|
||||
eventIndex: 2,
|
||||
isPast: true,
|
||||
@@ -140,7 +140,7 @@ describe('makeRundownMetadata()', () => {
|
||||
totalGap: 10,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: 'block',
|
||||
groupId: 'group',
|
||||
groupColour: 'red',
|
||||
});
|
||||
|
||||
@@ -155,7 +155,7 @@ describe('makeRundownMetadata()', () => {
|
||||
totalGap: 10,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: 'block',
|
||||
groupId: 'group',
|
||||
groupColour: 'red',
|
||||
});
|
||||
|
||||
@@ -170,7 +170,7 @@ describe('makeRundownMetadata()', () => {
|
||||
totalGap: 10,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: true,
|
||||
groupId: 'block',
|
||||
groupId: 'group',
|
||||
groupColour: 'red',
|
||||
});
|
||||
|
||||
@@ -185,7 +185,7 @@ describe('makeRundownMetadata()', () => {
|
||||
totalGap: 10,
|
||||
isLinkedToLoaded: true,
|
||||
isLoaded: false,
|
||||
groupId: 'block',
|
||||
groupId: 'group',
|
||||
groupColour: 'red',
|
||||
});
|
||||
|
||||
@@ -205,18 +205,18 @@ describe('makeRundownMetadata()', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('populates previousEntries in blocks', () => {
|
||||
const rundownStartsWithBlock = {
|
||||
block: {
|
||||
id: 'block',
|
||||
type: SupportedEntry.Block,
|
||||
it('populates previousEntries in groups', () => {
|
||||
const rundownStartsWithGroup = {
|
||||
group: {
|
||||
id: 'group',
|
||||
type: SupportedEntry.Group,
|
||||
colour: 'red',
|
||||
entries: ['1', '2'],
|
||||
} as OntimeBlock,
|
||||
} as OntimeGroup,
|
||||
'1': {
|
||||
id: '1',
|
||||
type: SupportedEntry.Event,
|
||||
parent: 'block',
|
||||
parent: 'group',
|
||||
timeStart: 1,
|
||||
timeEnd: 2,
|
||||
duration: 1,
|
||||
@@ -228,7 +228,7 @@ describe('makeRundownMetadata()', () => {
|
||||
'2': {
|
||||
id: '2',
|
||||
type: SupportedEntry.Event,
|
||||
parent: 'block',
|
||||
parent: 'group',
|
||||
timeStart: 2,
|
||||
timeEnd: 3,
|
||||
duration: 1,
|
||||
@@ -240,49 +240,49 @@ describe('makeRundownMetadata()', () => {
|
||||
};
|
||||
const { process } = makeRundownMetadata(null);
|
||||
|
||||
expect(process(rundownStartsWithBlock.block)).toStrictEqual({
|
||||
expect(process(rundownStartsWithGroup.group)).toStrictEqual({
|
||||
previousEvent: null,
|
||||
latestEvent: null,
|
||||
previousEntryId: null,
|
||||
thisId: rundownStartsWithBlock.block.id,
|
||||
thisId: rundownStartsWithGroup.group.id,
|
||||
eventIndex: 0,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: rundownStartsWithBlock.block.id,
|
||||
groupId: rundownStartsWithGroup.group.id,
|
||||
groupColour: 'red',
|
||||
groupEntries: 2,
|
||||
});
|
||||
|
||||
expect(process(rundownStartsWithBlock['1'])).toStrictEqual({
|
||||
expect(process(rundownStartsWithGroup['1'])).toStrictEqual({
|
||||
previousEvent: null,
|
||||
latestEvent: rundownStartsWithBlock['1'],
|
||||
previousEntryId: rundownStartsWithBlock.block.id,
|
||||
thisId: rundownStartsWithBlock['1'].id,
|
||||
latestEvent: rundownStartsWithGroup['1'],
|
||||
previousEntryId: rundownStartsWithGroup.group.id,
|
||||
thisId: rundownStartsWithGroup['1'].id,
|
||||
eventIndex: 1,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: rundownStartsWithBlock.block.id,
|
||||
groupId: rundownStartsWithGroup.group.id,
|
||||
groupColour: 'red',
|
||||
groupEntries: 2,
|
||||
});
|
||||
expect(process(rundownStartsWithBlock['2'])).toStrictEqual({
|
||||
previousEvent: rundownStartsWithBlock['1'],
|
||||
latestEvent: rundownStartsWithBlock['2'],
|
||||
previousEntryId: rundownStartsWithBlock['1'].id,
|
||||
thisId: rundownStartsWithBlock['2'].id,
|
||||
expect(process(rundownStartsWithGroup['2'])).toStrictEqual({
|
||||
previousEvent: rundownStartsWithGroup['1'],
|
||||
latestEvent: rundownStartsWithGroup['2'],
|
||||
previousEntryId: rundownStartsWithGroup['1'].id,
|
||||
thisId: rundownStartsWithGroup['2'].id,
|
||||
eventIndex: 2,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: rundownStartsWithBlock.block.id,
|
||||
groupId: rundownStartsWithGroup.group.id,
|
||||
groupColour: 'red',
|
||||
groupEntries: 2,
|
||||
});
|
||||
@@ -290,52 +290,52 @@ describe('makeRundownMetadata()', () => {
|
||||
});
|
||||
|
||||
describe('makeSortableList()', () => {
|
||||
it('generates a list with block ends', () => {
|
||||
const order = ['block-1', '2', 'block-3', 'block-4'];
|
||||
it('generates a list with group ends', () => {
|
||||
const order = ['group-1', '2', 'group-3', 'group-4'];
|
||||
const entries: RundownEntries = {
|
||||
'block-1': { type: SupportedEntry.Block, id: 'block-1', entries: ['11'] } as OntimeBlock,
|
||||
'11': { type: SupportedEntry.Event, id: '11', parent: 'block-1' } as OntimeEvent,
|
||||
'group-1': { type: SupportedEntry.Group, id: 'group-1', entries: ['11'] } as OntimeGroup,
|
||||
'11': { type: SupportedEntry.Event, id: '11', parent: 'group-1' } as OntimeEvent,
|
||||
'2': { type: SupportedEntry.Event, id: '2', parent: null } as OntimeEvent,
|
||||
'block-3': { type: SupportedEntry.Block, id: 'block-3', entries: ['31'] } as OntimeBlock,
|
||||
'31': { type: SupportedEntry.Event, id: '31', parent: 'block-3' } as OntimeEvent,
|
||||
'block-4': { type: SupportedEntry.Block, id: 'block-4', entries: [] as string[] } as OntimeBlock,
|
||||
'group-3': { type: SupportedEntry.Group, id: 'group-3', entries: ['31'] } as OntimeGroup,
|
||||
'31': { type: SupportedEntry.Event, id: '31', parent: 'group-3' } as OntimeEvent,
|
||||
'group-4': { type: SupportedEntry.Group, id: 'group-4', entries: [] as string[] } as OntimeGroup,
|
||||
};
|
||||
|
||||
const sortableList = makeSortableList(order, entries);
|
||||
expect(sortableList).toStrictEqual([
|
||||
'block-1',
|
||||
'group-1',
|
||||
'11',
|
||||
'end-block-1',
|
||||
'end-group-1',
|
||||
'2',
|
||||
'block-3',
|
||||
'group-3',
|
||||
'31',
|
||||
'end-block-3',
|
||||
'block-4',
|
||||
'end-block-4',
|
||||
'end-group-3',
|
||||
'group-4',
|
||||
'end-group-4',
|
||||
]);
|
||||
});
|
||||
|
||||
it('closes dangling blocks', () => {
|
||||
const order = ['block'];
|
||||
it('closes dangling group', () => {
|
||||
const order = ['group'];
|
||||
const entries: RundownEntries = {
|
||||
block: { type: SupportedEntry.Block, id: 'block-1', entries: ['11', '12'] } as OntimeBlock,
|
||||
'11': { type: SupportedEntry.Event, id: '11', parent: 'block-1' } as OntimeEvent,
|
||||
'12': { type: SupportedEntry.Event, id: '12', parent: 'block-1' } as OntimeEvent,
|
||||
group: { type: SupportedEntry.Group, id: 'group-1', entries: ['11', '12'] } as OntimeGroup,
|
||||
'11': { type: SupportedEntry.Event, id: '11', parent: 'group-1' } as OntimeEvent,
|
||||
'12': { type: SupportedEntry.Event, id: '12', parent: 'group-1' } as OntimeEvent,
|
||||
};
|
||||
|
||||
const sortableList = makeSortableList(order, entries);
|
||||
expect(sortableList).toStrictEqual(['block-1', '11', '12', 'end-block-1']);
|
||||
expect(sortableList).toStrictEqual(['group-1', '11', '12', 'end-group-1']);
|
||||
});
|
||||
|
||||
it('handles a list with a with just blocks', () => {
|
||||
const order = ['block-1', 'block-2'];
|
||||
it('handles a list with a with just groups', () => {
|
||||
const order = ['group-1', 'group-2'];
|
||||
const entries: RundownEntries = {
|
||||
'block-1': { type: SupportedEntry.Block, id: 'block-1', entries: [] as string[] } as OntimeBlock,
|
||||
'block-2': { type: SupportedEntry.Block, id: 'block-2', entries: [] as string[] } as OntimeBlock,
|
||||
'group-1': { type: SupportedEntry.Group, id: 'group-1', entries: [] as string[] } as OntimeGroup,
|
||||
'group-2': { type: SupportedEntry.Group, id: 'group-2', entries: [] as string[] } as OntimeGroup,
|
||||
};
|
||||
|
||||
const sortableList = makeSortableList(order, entries);
|
||||
expect(sortableList).toStrictEqual(['block-1', 'end-block-1', 'block-2', 'end-block-2']);
|
||||
expect(sortableList).toStrictEqual(['group-1', 'end-group-1', 'group-2', 'end-group-2']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -345,15 +345,15 @@ describe('moveUp()', () => {
|
||||
'1': { id: '1', type: 'event', parent: null } as OntimeEvent,
|
||||
'2': { id: '2', type: 'event', parent: null } as OntimeEvent,
|
||||
'3': { id: '3', type: 'event', parent: null } as OntimeEvent,
|
||||
block: { id: 'block', type: 'block', entries: ['11', '12'] } as OntimeBlock,
|
||||
'11': { id: '11', type: 'event', parent: 'block' } as OntimeEvent,
|
||||
'12': { id: '12', type: 'event', parent: 'block' } as OntimeEvent,
|
||||
group: { id: 'group', type: 'group', entries: ['11', '12'] } as OntimeGroup,
|
||||
'11': { id: '11', type: 'event', parent: 'group' } as OntimeEvent,
|
||||
'12': { id: '12', type: 'event', parent: 'group' } as OntimeEvent,
|
||||
'4': { id: '4', type: 'event', parent: null } as OntimeEvent,
|
||||
block2: { id: 'block2', type: 'block', entries: [] as EntryId[] } as OntimeBlock,
|
||||
group2: { id: 'group2', type: 'group', entries: [] as EntryId[] } as OntimeGroup,
|
||||
'5': { id: '5', type: 'event', parent: null } as OntimeEvent,
|
||||
},
|
||||
order: ['1', '2', '3', 'block', '4', 'block2', '5'],
|
||||
flatOrder: ['1', '2', '3', 'block', '11', '12', '4', 'block2', '5'],
|
||||
order: ['1', '2', '3', 'group', '4', 'group2', '5'],
|
||||
flatOrder: ['1', '2', '3', 'group', '11', '12', '4', 'group2', '5'],
|
||||
};
|
||||
|
||||
it('moving the first event is a noop', () => {
|
||||
@@ -370,7 +370,7 @@ describe('moveUp()', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('moves an entry up inside a block', () => {
|
||||
it('moves an entry up inside a group', () => {
|
||||
expect(moveUp('12', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||
destinationId: '11',
|
||||
order: 'before',
|
||||
@@ -379,7 +379,7 @@ describe('moveUp()', () => {
|
||||
|
||||
it('moves an entry up into an empty group', () => {
|
||||
expect(moveUp('5', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||
destinationId: 'block2',
|
||||
destinationId: 'group2',
|
||||
order: 'insert',
|
||||
});
|
||||
});
|
||||
@@ -393,45 +393,45 @@ describe('moveUp()', () => {
|
||||
|
||||
it('moves an entry up out of a group', () => {
|
||||
expect(moveUp('11', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||
destinationId: 'block',
|
||||
destinationId: 'group',
|
||||
order: 'before',
|
||||
});
|
||||
});
|
||||
|
||||
it('moves a block in the rundown', () => {
|
||||
expect(moveUp('block', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||
it('moves a group in the rundown', () => {
|
||||
expect(moveUp('group', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||
destinationId: '3',
|
||||
order: 'before',
|
||||
});
|
||||
});
|
||||
|
||||
it('swaps two blocks', () => {
|
||||
it('swaps two groups', () => {
|
||||
const rundown = {
|
||||
entries: {
|
||||
block: { id: 'block', type: 'block', entries: ['11'] } as OntimeBlock,
|
||||
'11': { id: '11', type: 'event', parent: 'block' } as OntimeEvent,
|
||||
block2: { id: 'block2', type: 'block', entries: [] as EntryId[] } as OntimeBlock,
|
||||
group: { id: 'group', type: 'group', entries: ['11'] } as OntimeGroup,
|
||||
'11': { id: '11', type: 'event', parent: 'group' } as OntimeEvent,
|
||||
group2: { id: 'group2', type: 'group', entries: [] as EntryId[] } as OntimeGroup,
|
||||
},
|
||||
order: ['block', 'block2'],
|
||||
flatOrder: ['block', '11', 'block2'],
|
||||
order: ['group', 'group2'],
|
||||
flatOrder: ['group', '11', 'group2'],
|
||||
};
|
||||
expect(moveUp('block2', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||
destinationId: 'block',
|
||||
expect(moveUp('group2', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||
destinationId: 'group',
|
||||
order: 'before',
|
||||
});
|
||||
});
|
||||
|
||||
it('moves before a block', () => {
|
||||
it('moves before a group', () => {
|
||||
const rundown = {
|
||||
entries: {
|
||||
block: { id: 'block', type: 'block', entries: ['11'] } as OntimeBlock,
|
||||
'11': { id: '11', type: 'event', parent: 'block' } as OntimeEvent,
|
||||
group: { id: 'group', type: 'group', entries: ['11'] } as OntimeGroup,
|
||||
'11': { id: '11', type: 'event', parent: 'group' } as OntimeEvent,
|
||||
},
|
||||
order: ['block'],
|
||||
flatOrder: ['block', '11'],
|
||||
order: ['group'],
|
||||
flatOrder: ['group', '11'],
|
||||
};
|
||||
expect(moveUp('11', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||
destinationId: 'block',
|
||||
destinationId: 'group',
|
||||
order: 'before',
|
||||
});
|
||||
});
|
||||
@@ -443,15 +443,15 @@ describe('moveDown()', () => {
|
||||
'1': { id: '1', type: 'event', parent: null } as OntimeEvent,
|
||||
'2': { id: '2', type: 'event', parent: null } as OntimeEvent,
|
||||
'3': { id: '3', type: 'event', parent: null } as OntimeEvent,
|
||||
block: { id: 'block', type: 'block', entries: ['11', '12'] } as OntimeBlock,
|
||||
'11': { id: '11', type: 'event', parent: 'block' } as OntimeEvent,
|
||||
'12': { id: '12', type: 'event', parent: 'block' } as OntimeEvent,
|
||||
group: { id: 'group', type: 'group', entries: ['11', '12'] } as OntimeGroup,
|
||||
'11': { id: '11', type: 'event', parent: 'group' } as OntimeEvent,
|
||||
'12': { id: '12', type: 'event', parent: 'group' } as OntimeEvent,
|
||||
'4': { id: '4', type: 'event', parent: null } as OntimeEvent,
|
||||
block2: { id: 'block2', type: 'block', entries: [] as EntryId[] } as OntimeBlock,
|
||||
group2: { id: 'group2', type: 'group', entries: [] as EntryId[] } as OntimeGroup,
|
||||
'5': { id: '5', type: 'event', parent: null } as OntimeEvent,
|
||||
},
|
||||
order: ['1', '2', '3', 'block', '4', 'block2', '5'],
|
||||
flatOrder: ['1', '2', '3', 'block', '11', '12', '4', 'block2', '5'],
|
||||
order: ['1', '2', '3', 'group', '4', 'group2', '5'],
|
||||
flatOrder: ['1', '2', '3', 'group', '11', '12', '4', 'group2', '5'],
|
||||
};
|
||||
|
||||
it('moving the last event is a noop', () => {
|
||||
@@ -468,7 +468,7 @@ describe('moveDown()', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('moves an entry down inside a block', () => {
|
||||
it('moves an entry down inside a group', () => {
|
||||
expect(moveDown('11', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||
destinationId: '12',
|
||||
order: 'after',
|
||||
@@ -477,14 +477,14 @@ describe('moveDown()', () => {
|
||||
|
||||
it('moves an entry down into an empty group', () => {
|
||||
expect(moveDown('4', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||
destinationId: 'block2',
|
||||
destinationId: 'group2',
|
||||
order: 'insert',
|
||||
});
|
||||
});
|
||||
|
||||
it('moves an entry down out of a group', () => {
|
||||
expect(moveDown('12', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||
destinationId: 'block',
|
||||
destinationId: 'group',
|
||||
order: 'after',
|
||||
});
|
||||
});
|
||||
@@ -496,40 +496,40 @@ describe('moveDown()', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('moves a block in the rundown', () => {
|
||||
expect(moveDown('block', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||
it('moves a group in the rundown', () => {
|
||||
expect(moveDown('group', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||
destinationId: '4',
|
||||
order: 'after',
|
||||
});
|
||||
});
|
||||
|
||||
it('swaps two blocks', () => {
|
||||
it('swaps two groups', () => {
|
||||
const rundown = {
|
||||
entries: {
|
||||
block: { id: 'block', type: 'block', entries: ['11'] } as OntimeBlock,
|
||||
'11': { id: '11', type: 'event', parent: 'block' } as OntimeEvent,
|
||||
block2: { id: 'block2', type: 'block', entries: [] as EntryId[] } as OntimeBlock,
|
||||
group: { id: 'group', type: 'group', entries: ['11'] } as OntimeGroup,
|
||||
'11': { id: '11', type: 'event', parent: 'group' } as OntimeEvent,
|
||||
group2: { id: 'group2', type: 'group', entries: [] as EntryId[] } as OntimeGroup,
|
||||
},
|
||||
order: ['block', 'block2'],
|
||||
flatOrder: ['block', '11', 'block2'],
|
||||
order: ['group', 'group2'],
|
||||
flatOrder: ['group', '11', 'group2'],
|
||||
};
|
||||
expect(moveDown('block', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||
destinationId: 'block2',
|
||||
expect(moveDown('group', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||
destinationId: 'group2',
|
||||
order: 'after',
|
||||
});
|
||||
});
|
||||
|
||||
it('moves after a block', () => {
|
||||
it('moves after a group', () => {
|
||||
const rundown = {
|
||||
entries: {
|
||||
block: { id: 'block', type: 'block', entries: ['11'] } as OntimeBlock,
|
||||
'11': { id: '11', type: 'event', parent: 'block' } as OntimeEvent,
|
||||
group: { id: 'group', type: 'group', entries: ['11'] } as OntimeGroup,
|
||||
'11': { id: '11', type: 'event', parent: 'group' } as OntimeEvent,
|
||||
},
|
||||
order: ['block'],
|
||||
flatOrder: ['block', '11'],
|
||||
order: ['group'],
|
||||
flatOrder: ['group', '11'],
|
||||
};
|
||||
expect(moveDown('11', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||
destinationId: 'block',
|
||||
destinationId: 'group',
|
||||
order: 'after',
|
||||
});
|
||||
});
|
||||
|
||||
+5
-6
@@ -9,13 +9,12 @@ import style from './TitleEditor.module.scss';
|
||||
|
||||
interface TitleEditorProps {
|
||||
title: string;
|
||||
eventId: string;
|
||||
entryId: string;
|
||||
placeholder: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function EditableBlockTitle(props: TitleEditorProps) {
|
||||
const { title, eventId, placeholder, className } = props;
|
||||
export default function TitleEditor({ title, entryId, placeholder, className }: TitleEditorProps) {
|
||||
const { updateEntry } = useEntryActions();
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
const submitCallback = useCallback(
|
||||
@@ -25,9 +24,9 @@ export default function EditableBlockTitle(props: TitleEditorProps) {
|
||||
}
|
||||
|
||||
const cleanVal = text.trim();
|
||||
updateEntry({ id: eventId, title: cleanVal });
|
||||
updateEntry({ id: entryId, title: cleanVal });
|
||||
},
|
||||
[title, updateEntry, eventId],
|
||||
[title, updateEntry, entryId],
|
||||
);
|
||||
|
||||
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(title, submitCallback, ref, {
|
||||
@@ -38,7 +37,7 @@ export default function EditableBlockTitle(props: TitleEditorProps) {
|
||||
|
||||
return (
|
||||
<Input
|
||||
data-testid='block__title'
|
||||
data-testid='entry__title'
|
||||
variant='ghosted'
|
||||
fluid
|
||||
ref={ref}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { isOntimeBlock, isOntimeEvent, OntimeEntry } from 'ontime-types';
|
||||
import { isOntimeEvent, isOntimeGroup, OntimeEntry } from 'ontime-types';
|
||||
|
||||
import useRundown from '../../../common/hooks-query/useRundown';
|
||||
|
||||
import BlockEditor from './BlockEditor';
|
||||
import EventEditor from './EventEditor';
|
||||
import GroupEditor from './GroupEditor';
|
||||
|
||||
import style from './EntryEditor.module.scss';
|
||||
|
||||
@@ -38,10 +38,10 @@ export default function CuesheetEntryEditor({ entryId }: CuesheetEntryEditorProp
|
||||
);
|
||||
}
|
||||
|
||||
if (isOntimeBlock(entry)) {
|
||||
if (isOntimeGroup(entry)) {
|
||||
return (
|
||||
<div className={style.inModal} data-testid='editor-container'>
|
||||
<BlockEditor block={entry} />
|
||||
<GroupEditor group={entry} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
overflow-y: auto;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.timeSettings {
|
||||
@@ -99,10 +99,6 @@
|
||||
width: 7.5em;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: 1px solid $gray-1100;
|
||||
|
||||
&:hover {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.inactive {
|
||||
|
||||
+27
-27
@@ -1,5 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import { MaybeNumber, OntimeBlock } from 'ontime-types';
|
||||
import { MaybeNumber, OntimeGroup } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
|
||||
@@ -19,39 +19,39 @@ import TargetDurationInput from './composite/TargetDurationInput';
|
||||
import style from './EntryEditor.module.scss';
|
||||
|
||||
// title + colour + custom field labels
|
||||
export type BlockEditorUpdateTextFields = 'title' | 'colour' | string;
|
||||
export type BlockEditorUpdateMaybeNumberFields = 'targetDuration';
|
||||
export type GroupEditorUpdateTextFields = 'title' | 'colour' | string;
|
||||
export type GroupEditorUpdateMaybeNumberFields = 'targetDuration';
|
||||
|
||||
interface BlockEditorProps {
|
||||
block: OntimeBlock;
|
||||
interface GroupEditorProps {
|
||||
group: OntimeGroup;
|
||||
}
|
||||
|
||||
export default function BlockEditor({ block }: BlockEditorProps) {
|
||||
export default function GroupEditor({ group }: GroupEditorProps) {
|
||||
const { data: customFields } = useCustomFields();
|
||||
const { updateEntry } = useEntryActions();
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(field: BlockEditorUpdateTextFields | BlockEditorUpdateMaybeNumberFields, value: string | MaybeNumber) => {
|
||||
(field: GroupEditorUpdateTextFields | GroupEditorUpdateMaybeNumberFields, value: string | MaybeNumber) => {
|
||||
// Handle custom fields
|
||||
if (typeof field === 'string' && field.startsWith('custom-')) {
|
||||
const fieldLabel = field.split('custom-')[1];
|
||||
updateEntry({ id: block.id, custom: { [fieldLabel]: value as string } });
|
||||
updateEntry({ id: group.id, custom: { [fieldLabel]: value as string } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (field === 'targetDuration') {
|
||||
return updateEntry({ id: block.id, targetDuration: value as MaybeNumber });
|
||||
return updateEntry({ id: group.id, targetDuration: value as MaybeNumber });
|
||||
}
|
||||
|
||||
// all other strings are text fields
|
||||
return updateEntry({ id: block.id, [field]: value as string });
|
||||
return updateEntry({ id: group.id, [field]: value as string });
|
||||
},
|
||||
[block.id, updateEntry],
|
||||
[group.id, updateEntry],
|
||||
);
|
||||
|
||||
const isEditor = window.location.pathname.includes('editor');
|
||||
const planOffset = typeof block.targetDuration !== 'number' ? null : block.duration - block.targetDuration;
|
||||
const planOffsetLabel = planOffset !== null ? getOffsetState(planOffset * -1) : null;
|
||||
const planOffset = group.targetDuration === null ? null : group.duration - group.targetDuration;
|
||||
const planOffsetLabel = planOffset !== null ? getOffsetState(planOffset) : null;
|
||||
|
||||
return (
|
||||
<div className={style.content}>
|
||||
@@ -63,20 +63,20 @@ export default function BlockEditor({ block }: BlockEditorProps) {
|
||||
// TODO: format with user time settings
|
||||
}
|
||||
<Editor.Label>First event start</Editor.Label>
|
||||
<TextLikeInput className={style.textLikeInput}>
|
||||
{millisToString(block.timeStart, { fallback: timerPlaceholder })}
|
||||
<TextLikeInput className={style.textLikeInput} disabled>
|
||||
{millisToString(group.timeStart, { fallback: timerPlaceholder })}
|
||||
</TextLikeInput>
|
||||
</div>
|
||||
<div>
|
||||
<Editor.Label>Last event end</Editor.Label>
|
||||
<TextLikeInput className={style.textLikeInput}>
|
||||
{millisToString(block.timeEnd, { fallback: timerPlaceholder })}
|
||||
<TextLikeInput className={style.textLikeInput} disabled>
|
||||
{millisToString(group.timeEnd, { fallback: timerPlaceholder })}
|
||||
</TextLikeInput>
|
||||
</div>
|
||||
<div>
|
||||
<Editor.Label htmlFor='duration'>Scheduled duration</Editor.Label>
|
||||
<TextLikeInput className={style.textLikeInput}>
|
||||
{millisToString(block.duration, { fallback: enDash })}
|
||||
<TextLikeInput className={style.textLikeInput} disabled>
|
||||
{millisToString(group.duration, { fallback: enDash })}
|
||||
</TextLikeInput>
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,28 +86,28 @@ export default function BlockEditor({ block }: BlockEditorProps) {
|
||||
<TextLikeInput
|
||||
offset={planOffsetLabel}
|
||||
className={cx([style.textLikeInput, planOffset === null && style.inactive])}
|
||||
tabIndex={-1}
|
||||
disabled
|
||||
>
|
||||
{planOffset !== null && planOffset > 0 ? '+' : ''}
|
||||
{millisToString(planOffset, { fallback: enDash })}
|
||||
</TextLikeInput>
|
||||
</div>
|
||||
<TargetDurationInput
|
||||
duration={block.duration}
|
||||
targetDuration={block.targetDuration}
|
||||
duration={group.duration}
|
||||
targetDuration={group.targetDuration}
|
||||
submitHandler={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.column}>
|
||||
<Editor.Title>Block data</Editor.Title>
|
||||
<Editor.Title>Group data</Editor.Title>
|
||||
<div>
|
||||
<Editor.Label>Colour</Editor.Label>
|
||||
<SwatchSelect name='colour' value={block.colour} handleChange={handleSubmit} />
|
||||
<SwatchSelect name='colour' value={group.colour} handleChange={handleSubmit} />
|
||||
</div>
|
||||
<EntryEditorTextInput field='title' label='Title' initialValue={block.title} submitHandler={handleSubmit} />
|
||||
<EventTextArea field='note' label='Note' initialValue={block.note} submitHandler={handleSubmit} />
|
||||
<EntryEditorTextInput field='title' label='Title' initialValue={group.title} submitHandler={handleSubmit} />
|
||||
<EventTextArea field='note' label='Note' initialValue={group.note} submitHandler={handleSubmit} />
|
||||
</div>
|
||||
|
||||
<div className={style.column}>
|
||||
@@ -115,7 +115,7 @@ export default function BlockEditor({ block }: BlockEditorProps) {
|
||||
Custom Fields
|
||||
{isEditor && <AppLink search='settings=manage__custom'>Manage Custom Fields</AppLink>}
|
||||
</Editor.Title>
|
||||
<EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} entry={block} />
|
||||
<EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} entry={group} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
isOntimeBlock,
|
||||
isOntimeDelay,
|
||||
isOntimeEvent,
|
||||
isOntimeGroup,
|
||||
isOntimeMilestone,
|
||||
OntimeBlock,
|
||||
OntimeEvent,
|
||||
OntimeGroup,
|
||||
OntimeMilestone,
|
||||
} from 'ontime-types';
|
||||
|
||||
@@ -13,9 +13,9 @@ import useRundown from '../../../common/hooks-query/useRundown';
|
||||
import { useEventSelection } from '../useEventSelection';
|
||||
|
||||
import EventEditorFooter from './composite/EventEditorFooter';
|
||||
import BlockEditor from './BlockEditor';
|
||||
import EventEditor from './EventEditor';
|
||||
import EventEditorEmpty from './EventEditorEmpty';
|
||||
import GroupEditor from './GroupEditor';
|
||||
import MilestoneEditor from './MilestoneEditor';
|
||||
|
||||
import style from './EntryEditor.module.scss';
|
||||
@@ -24,7 +24,7 @@ export default function RundownEntryEditor() {
|
||||
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
||||
const { data } = useRundown();
|
||||
|
||||
const [entry, setEntry] = useState<OntimeEvent | OntimeBlock | OntimeMilestone | null>(null);
|
||||
const [entry, setEntry] = useState<OntimeEvent | OntimeGroup | OntimeMilestone | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (data.order.length === 0) {
|
||||
@@ -67,10 +67,10 @@ export default function RundownEntryEditor() {
|
||||
);
|
||||
}
|
||||
|
||||
if (isOntimeBlock(entry)) {
|
||||
if (isOntimeGroup(entry)) {
|
||||
return (
|
||||
<div className={style.entryEditor} data-testid='editor-container'>
|
||||
<BlockEditor block={entry} />
|
||||
<GroupEditor group={entry} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CSSProperties, Fragment } from 'react';
|
||||
import { CustomFields, OntimeBlock, OntimeEvent, OntimeMilestone } from 'ontime-types';
|
||||
import { CustomFields, OntimeEvent, OntimeGroup, OntimeMilestone } from 'ontime-types';
|
||||
|
||||
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||
import { EventEditorUpdateFields } from '../EventEditor';
|
||||
@@ -12,7 +12,7 @@ import style from '../EntryEditor.module.scss';
|
||||
|
||||
interface EntryEditorCustomFieldsProps {
|
||||
fields: CustomFields;
|
||||
entry: OntimeEvent | OntimeBlock | OntimeMilestone;
|
||||
entry: OntimeEvent | OntimeGroup | OntimeMilestone;
|
||||
handleSubmit: (field: EventEditorUpdateFields, value: string) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ import { useCallback, useRef } from 'react';
|
||||
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
||||
import Input, { type InputProps } from '../../../../common/components/input/input/Input';
|
||||
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
|
||||
import { BlockEditorUpdateTextFields } from '../BlockEditor';
|
||||
import { EventEditorUpdateFields } from '../EventEditor';
|
||||
import { GroupEditorUpdateTextFields } from '../GroupEditor';
|
||||
|
||||
interface EntryEditorTextInputProps extends InputProps {
|
||||
field: EventEditorUpdateFields | BlockEditorUpdateTextFields;
|
||||
field: EventEditorUpdateFields | GroupEditorUpdateTextFields;
|
||||
label: string;
|
||||
initialValue: string;
|
||||
placeholder?: string;
|
||||
|
||||
@@ -17,27 +17,27 @@ interface TargetDurationInputProps {
|
||||
}
|
||||
|
||||
export default function TargetDurationInput({ duration, targetDuration, submitHandler }: TargetDurationInputProps) {
|
||||
const isBlocked = targetDuration !== null;
|
||||
const isLocked = targetDuration !== null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Editor.Label htmlFor='targetDuration'>Target duration</Editor.Label>
|
||||
<TimeInputGroup hasDelay={isBlocked && targetDuration !== duration}>
|
||||
<TimeInputGroup hasDelay={isLocked && targetDuration !== duration}>
|
||||
<NullableTimeInput
|
||||
name='targetDuration'
|
||||
time={targetDuration}
|
||||
submitHandler={submitHandler}
|
||||
emptyDisplay={enDash}
|
||||
className={isBlocked ? '' : style.inactive}
|
||||
className={isLocked ? '' : style.inactive}
|
||||
/>
|
||||
<Tooltip
|
||||
text='Lock to target duration'
|
||||
className={cx([style.timeAction, isBlocked && style.active])}
|
||||
onClick={() => submitHandler('targetDuration', isBlocked ? null : duration)}
|
||||
className={cx([style.timeAction, isLocked && style.active])}
|
||||
onClick={() => submitHandler('targetDuration', isLocked ? null : duration)}
|
||||
data-testid='lock__duration'
|
||||
render={<IconButton variant='subtle-white' className={isBlocked ? style.active : style.inactive} />}
|
||||
render={<IconButton variant='subtle-white' className={isLocked ? style.active : style.inactive} />}
|
||||
>
|
||||
{isBlocked ? <IoLockClosed /> : <IoLockOpenOutline />}
|
||||
{isLocked ? <IoLockClosed /> : <IoLockOpenOutline />}
|
||||
</Tooltip>
|
||||
</TimeInputGroup>
|
||||
</div>
|
||||
|
||||
+14
-14
@@ -11,19 +11,19 @@ import style from './QuickAddButtons.module.scss';
|
||||
|
||||
interface QuickAddButtonsProps {
|
||||
previousEventId: MaybeString;
|
||||
parentBlock: MaybeString;
|
||||
parentGroup: MaybeString;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export default memo(QuickAddButtons);
|
||||
function QuickAddButtons({ previousEventId, parentBlock, backgroundColor }: QuickAddButtonsProps) {
|
||||
function QuickAddButtons({ previousEventId, parentGroup, backgroundColor }: QuickAddButtonsProps) {
|
||||
const { addEntry } = useEntryActions();
|
||||
|
||||
const addEvent = () => {
|
||||
addEntry(
|
||||
{
|
||||
type: SupportedEntry.Event,
|
||||
parent: parentBlock,
|
||||
parent: parentGroup,
|
||||
},
|
||||
{
|
||||
after: previousEventId,
|
||||
@@ -34,7 +34,7 @@ function QuickAddButtons({ previousEventId, parentBlock, backgroundColor }: Quic
|
||||
|
||||
const addDelay = () => {
|
||||
addEntry(
|
||||
{ type: SupportedEntry.Delay, parent: parentBlock },
|
||||
{ type: SupportedEntry.Delay, parent: parentGroup },
|
||||
{
|
||||
lastEventId: previousEventId,
|
||||
after: previousEventId,
|
||||
@@ -44,7 +44,7 @@ function QuickAddButtons({ previousEventId, parentBlock, backgroundColor }: Quic
|
||||
|
||||
const addMilestone = () => {
|
||||
addEntry(
|
||||
{ type: SupportedEntry.Milestone, parent: parentBlock },
|
||||
{ type: SupportedEntry.Milestone, parent: parentGroup },
|
||||
{
|
||||
lastEventId: previousEventId,
|
||||
after: previousEventId,
|
||||
@@ -52,12 +52,12 @@ function QuickAddButtons({ previousEventId, parentBlock, backgroundColor }: Quic
|
||||
);
|
||||
};
|
||||
|
||||
const addBlock = () => {
|
||||
if (parentBlock !== null) {
|
||||
const addGroup = () => {
|
||||
if (parentGroup !== null) {
|
||||
return;
|
||||
}
|
||||
addEntry(
|
||||
{ type: SupportedEntry.Block },
|
||||
{ type: SupportedEntry.Group },
|
||||
{
|
||||
lastEventId: previousEventId,
|
||||
after: previousEventId,
|
||||
@@ -67,15 +67,15 @@ function QuickAddButtons({ previousEventId, parentBlock, backgroundColor }: Quic
|
||||
|
||||
/**
|
||||
* If the colour is empty string ''
|
||||
* ie: we are inside a block, but there is no defined colour
|
||||
* ie: we are inside a group, but there is no defined colour
|
||||
* we default to $gray-500 #9d9d9d
|
||||
*/
|
||||
const blockColour = backgroundColor === '' ? '#9d9d9d' : backgroundColor;
|
||||
const groupColour = backgroundColor === '' ? '#9d9d9d' : backgroundColor;
|
||||
|
||||
return (
|
||||
<Toolbar.Root
|
||||
className={cx([style.quickAdd, Boolean(parentBlock) && style.indent])}
|
||||
style={blockColour ? { '--user-bg': blockColour } : {}}
|
||||
className={cx([style.quickAdd, Boolean(parentGroup) && style.indent])}
|
||||
style={groupColour ? { '--user-bg': groupColour } : {}}
|
||||
data-testid='quick-add-buttons'
|
||||
>
|
||||
<Toolbar.Button render={<Button size='small' />} onClick={addEvent}>
|
||||
@@ -93,8 +93,8 @@ function QuickAddButtons({ previousEventId, parentBlock, backgroundColor }: Quic
|
||||
Milestone
|
||||
</Toolbar.Button>
|
||||
|
||||
{parentBlock === null && (
|
||||
<Toolbar.Button render={<Button size='small' />} onClick={addBlock}>
|
||||
{parentGroup === null && (
|
||||
<Toolbar.Button render={<Button size='small' />} onClick={addGroup}>
|
||||
<IoAdd />
|
||||
Group
|
||||
</Toolbar.Button>
|
||||
|
||||
@@ -10,18 +10,18 @@ import style from './QuickAddInline.module.scss';
|
||||
|
||||
interface QuickAddInlineProps {
|
||||
previousEventId: MaybeString;
|
||||
parentBlock: MaybeString;
|
||||
parentGroup: MaybeString;
|
||||
}
|
||||
|
||||
export default memo(QuickAddInline);
|
||||
function QuickAddInline({ previousEventId, parentBlock }: QuickAddInlineProps) {
|
||||
function QuickAddInline({ previousEventId, parentGroup }: QuickAddInlineProps) {
|
||||
const { addEntry } = useEntryActions();
|
||||
|
||||
const addEvent = () => {
|
||||
addEntry(
|
||||
{
|
||||
type: SupportedEntry.Event,
|
||||
parent: parentBlock,
|
||||
parent: parentGroup,
|
||||
},
|
||||
{
|
||||
after: previousEventId,
|
||||
@@ -32,7 +32,7 @@ function QuickAddInline({ previousEventId, parentBlock }: QuickAddInlineProps) {
|
||||
|
||||
const addDelay = () => {
|
||||
addEntry(
|
||||
{ type: SupportedEntry.Delay, parent: parentBlock },
|
||||
{ type: SupportedEntry.Delay, parent: parentGroup },
|
||||
{
|
||||
lastEventId: previousEventId,
|
||||
after: previousEventId,
|
||||
@@ -42,7 +42,7 @@ function QuickAddInline({ previousEventId, parentBlock }: QuickAddInlineProps) {
|
||||
|
||||
const addMilestone = () => {
|
||||
addEntry(
|
||||
{ type: SupportedEntry.Milestone, parent: parentBlock },
|
||||
{ type: SupportedEntry.Milestone, parent: parentGroup },
|
||||
{
|
||||
lastEventId: previousEventId,
|
||||
after: previousEventId,
|
||||
@@ -50,12 +50,12 @@ function QuickAddInline({ previousEventId, parentBlock }: QuickAddInlineProps) {
|
||||
);
|
||||
};
|
||||
|
||||
const addBlock = () => {
|
||||
if (parentBlock !== null) {
|
||||
const addGroup = () => {
|
||||
if (parentGroup !== null) {
|
||||
return;
|
||||
}
|
||||
addEntry(
|
||||
{ type: SupportedEntry.Block },
|
||||
{ type: SupportedEntry.Group },
|
||||
{
|
||||
lastEventId: previousEventId,
|
||||
after: previousEventId,
|
||||
@@ -70,7 +70,7 @@ function QuickAddInline({ previousEventId, parentBlock }: QuickAddInlineProps) {
|
||||
{ type: 'item', icon: IoAdd, label: 'Add Event', onClick: addEvent },
|
||||
{ type: 'item', icon: IoAdd, label: 'Add Delay', onClick: addDelay },
|
||||
{ type: 'item', icon: IoAdd, label: 'Add Milestone', onClick: addMilestone },
|
||||
{ type: 'item', icon: IoAdd, label: 'Add Group', onClick: addBlock, disabled: parentBlock !== null },
|
||||
{ type: 'item', icon: IoAdd, label: 'Add Group', onClick: addGroup, disabled: parentGroup !== null },
|
||||
]}
|
||||
render={<IconButton size='small' variant='primary' className={style.addButton} />}
|
||||
>
|
||||
|
||||
@@ -130,7 +130,7 @@ export default function RundownEvent({
|
||||
}),
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{ type: 'item', label: 'Group', icon: IoFolder, onClick: () => actionHandler('group') },
|
||||
{ type: 'item', label: 'Group', icon: IoFolder, onClick: () => actionHandler('make-group') },
|
||||
{ type: 'divider' },
|
||||
{ type: 'item', label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
|
||||
]
|
||||
@@ -205,8 +205,8 @@ export default function RundownEvent({
|
||||
}
|
||||
|
||||
const elementInFocus = document.activeElement;
|
||||
// we know the block is the grandparent of our binder
|
||||
const blockElement = handleRef.current.closest('#event-block');
|
||||
// we know the group is the grandparent of our binder
|
||||
const blockElement = handleRef.current.closest('#event-group');
|
||||
|
||||
// we only move focus if the block doesnt already contain focus
|
||||
if (blockElement && !blockElement.contains(elementInFocus)) {
|
||||
|
||||
@@ -14,7 +14,7 @@ import { EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||
|
||||
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import EditableBlockTitle from '../common/EditableBlockTitle';
|
||||
import TitleEditor from '../common/TitleEditor';
|
||||
import TimeInputFlow from '../time-input-flow/TimeInputFlow';
|
||||
|
||||
import RundownEventChip from './composite/RundownEventChip';
|
||||
@@ -105,7 +105,7 @@ function RundownEventInner({
|
||||
/>
|
||||
</div>
|
||||
<div className={style.titleSection}>
|
||||
<EditableBlockTitle title={title} eventId={eventId} placeholder='Event title' className={style.eventTitle} />
|
||||
<TitleEditor title={title} entryId={eventId} placeholder='Event title' className={style.eventTitle} />
|
||||
{isNext && <span className={style.nextTag}>UP NEXT</span>}
|
||||
</div>
|
||||
<EventBlockPlayback
|
||||
@@ -130,7 +130,7 @@ function RundownEventInner({
|
||||
duration={duration}
|
||||
/>
|
||||
)}
|
||||
<div className={style.statusElements} id='block-status' data-timertype={timerType}>
|
||||
<div className={style.statusElements} id='entry-status' data-timertype={timerType}>
|
||||
<span className={style.eventNote}>{note}</span>
|
||||
<div className={loaded ? style.progressBg : `${style.progressBg} ${style.hidden}`}>
|
||||
{loaded && <EventBlockProgressBar />}
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
@use '../blockMixins' as *;
|
||||
|
||||
.block {
|
||||
.group {
|
||||
@include block-styling;
|
||||
|
||||
margin-block: 0.5rem;
|
||||
@@ -22,7 +22,7 @@
|
||||
.binder {
|
||||
grid-area: binder;
|
||||
height: 100%;
|
||||
background-color: var(--block-color, $gray-1050);
|
||||
background-color: var(--user-bg, $gray-1050);
|
||||
color: $section-white;
|
||||
font-size: 1rem;
|
||||
display: grid;
|
||||
+19
-16
@@ -9,7 +9,8 @@ import {
|
||||
} from 'react-icons/io5';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { EntryId, OntimeBlock } from 'ontime-types';
|
||||
import { EntryId, OntimeGroup } from 'ontime-types';
|
||||
import { MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||
|
||||
import IconButton from '../../../common/components/buttons/IconButton';
|
||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
@@ -17,24 +18,24 @@ import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||
import { getOffsetState } from '../../../common/utils/offset';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { formatDuration, formatTime } from '../../../common/utils/time';
|
||||
import EditableBlockTitle from '../common/EditableBlockTitle';
|
||||
import TitleEditor from '../common/TitleEditor';
|
||||
import { canDrop } from '../rundown.utils';
|
||||
import { useEventSelection } from '../useEventSelection';
|
||||
|
||||
import style from './RundownBlock.module.scss';
|
||||
import style from './RundownGroup.module.scss';
|
||||
|
||||
interface RundownBlockProps {
|
||||
data: OntimeBlock;
|
||||
interface RundownGroupProps {
|
||||
data: OntimeGroup;
|
||||
hasCursor: boolean;
|
||||
collapsed: boolean;
|
||||
onCollapse: (collapsed: boolean, groupId: EntryId) => void;
|
||||
}
|
||||
|
||||
//TODO: the block should maybe include a multiple day indicator
|
||||
export default function RundownBlock({ data, hasCursor, collapsed, onCollapse }: RundownBlockProps) {
|
||||
//TODO: the group should maybe include a multiple day indicator
|
||||
export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }: RundownGroupProps) {
|
||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||
const { clone, ungroup, deleteEntry } = useEntryActions();
|
||||
const { selectedEvents, setSelectedBlock } = useEventSelection();
|
||||
const { selectedEvents, setSingleEntrySelection } = useEventSelection();
|
||||
|
||||
const [onContextMenu] = useContextMenu<HTMLDivElement>([
|
||||
{
|
||||
@@ -71,7 +72,7 @@ export default function RundownBlock({ data, hasCursor, collapsed, onCollapse }:
|
||||
} = useSortable({
|
||||
id: data.id,
|
||||
data: {
|
||||
type: 'block',
|
||||
type: 'group',
|
||||
},
|
||||
animateLayoutChanges: () => false,
|
||||
});
|
||||
@@ -87,7 +88,7 @@ export default function RundownBlock({ data, hasCursor, collapsed, onCollapse }:
|
||||
}
|
||||
|
||||
// UI indexes are 1 based
|
||||
setSelectedBlock({ id: data.id });
|
||||
setSingleEntrySelection({ id: data.id });
|
||||
};
|
||||
|
||||
const binderColours = data.colour && getAccessibleColour(data.colour);
|
||||
@@ -102,8 +103,11 @@ export default function RundownBlock({ data, hasCursor, collapsed, onCollapse }:
|
||||
if (offset === 0) {
|
||||
return [null, 'under'];
|
||||
}
|
||||
|
||||
return [offset < 0 ? `-${formatDuration(offset * -1)}` : `+${formatDuration(offset)}`, getOffsetState(offset * -1)];
|
||||
const absOffset = Math.abs(offset);
|
||||
return [
|
||||
`${offset < 0 ? '-' : '+'}${formatDuration(absOffset, absOffset > 2 * MILLIS_PER_MINUTE)}`,
|
||||
getOffsetState(offset),
|
||||
];
|
||||
})();
|
||||
|
||||
const dragStyle = {
|
||||
@@ -115,16 +119,15 @@ export default function RundownBlock({ data, hasCursor, collapsed, onCollapse }:
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx([style.block, hasCursor && style.hasCursor, !collapsed && style.expanded])}
|
||||
className={cx([style.group, hasCursor && style.hasCursor, !collapsed && style.expanded])}
|
||||
ref={setNodeRef}
|
||||
onClick={handleFocusClick}
|
||||
onContextMenu={onContextMenu}
|
||||
style={{
|
||||
// ...(binderColours ? { '--user-bg': binderColours.backgroundColor } : {}),
|
||||
...dragStyle,
|
||||
'--user-bg': data.colour || '#929292',
|
||||
}}
|
||||
data-testid='rundown-block'
|
||||
data-testid='rundown-group'
|
||||
>
|
||||
<div className={style.binder} style={{ ...binderColours }} tabIndex={-1}>
|
||||
<span
|
||||
@@ -138,7 +141,7 @@ export default function RundownBlock({ data, hasCursor, collapsed, onCollapse }:
|
||||
</div>
|
||||
<div className={style.header}>
|
||||
<div className={style.titleRow}>
|
||||
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Group title' />
|
||||
<TitleEditor title={data.title} entryId={data.id} placeholder='Group title' />
|
||||
<IconButton aria-label='Collapse' variant='subtle-white' onClick={() => onCollapse(!collapsed, data.id)}>
|
||||
{collapsed ? <IoChevronUp /> : <IoChevronDown />}
|
||||
</IconButton>
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
@use '../blockMixins' as *;
|
||||
|
||||
.blockEnd {
|
||||
.groupEnd {
|
||||
cursor: default;
|
||||
height: 1rem;
|
||||
background-color: var(--user-bg, $gray-1050);
|
||||
+6
-6
@@ -1,14 +1,14 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import style from './RundownBlockEnd.module.scss';
|
||||
import style from './RundownGroupEnd.module.scss';
|
||||
|
||||
interface BlockEndProps {
|
||||
interface RundownGroupEndProps {
|
||||
id: string;
|
||||
colour?: string;
|
||||
}
|
||||
|
||||
export default function RundownBlockEnd({ id, colour }: BlockEndProps) {
|
||||
export default function RundownGroupEnd({ id, colour }: RundownGroupEndProps) {
|
||||
const {
|
||||
attributes: dragAttributes,
|
||||
listeners: dragListeners,
|
||||
@@ -18,10 +18,10 @@ export default function RundownBlockEnd({ id, colour }: BlockEndProps) {
|
||||
} = useSortable({
|
||||
id,
|
||||
data: {
|
||||
type: 'end-block',
|
||||
type: 'end-group',
|
||||
},
|
||||
animateLayoutChanges: () => false,
|
||||
disabled: true, // we do not want to drag end blocks
|
||||
disabled: true, // we do not want to drag end groups
|
||||
});
|
||||
|
||||
const dragStyle = {
|
||||
@@ -31,7 +31,7 @@ export default function RundownBlockEnd({ id, colour }: BlockEndProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={style.blockEnd}
|
||||
className={style.groupEnd}
|
||||
ref={setNodeRef}
|
||||
{...dragAttributes}
|
||||
{...dragListeners}
|
||||
@@ -17,7 +17,7 @@ function RundownMenu() {
|
||||
|
||||
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
|
||||
const [editorMode] = useSessionStorage({
|
||||
key: sessionKeys.cuesheetMode,
|
||||
key: sessionKeys.editorMode,
|
||||
defaultValue: AppMode.Edit,
|
||||
});
|
||||
const { deleteAllEntries } = useEntryActions();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user