Compare commits

..

47 Commits

Author SHA1 Message Date
Carlos Valente 3c41b40c5f refactor: create fast tracks for cookie parsing 2025-08-18 22:00:16 +02:00
arc-alex 3b38756dda fix: use new token with scope 2025-08-18 22:00:16 +02:00
Carlos Valente 5a4494ba85 refactor: improve timers visibility 2025-08-18 22:00:16 +02:00
Carlos Valente 02ad852dd4 fix: external demo connects to hash 2025-08-18 22:00:16 +02:00
Carlos Valente 7ce1097826 fix: prevent reflow between entry editors 2025-08-18 22:00:16 +02:00
Carlos Valente 7ee0de5341 docs: improve development documentation 2025-08-18 22:00:16 +02:00
Carlos Valente 0db249871b bump version to 4.0.0-alpha.4 2025-08-18 22:00:16 +02:00
Carlos Valente 87df656470 docs: demo project is runtime data documentation 2025-08-18 06:16:53 +02:00
Carlos Valente 54f07c4330 refactor: simplify messages 2025-08-18 06:16:53 +02:00
Carlos Valente ae061cb69a bump version 4.0.0-alpha.3 2025-08-18 06:16:53 +02:00
google-labs-jules[bot] ba529def34 fix(client): use fresh state in row observer to prevent stale closures
The IntersectionObserver callback in rowObserver.ts was capturing the `addVisibleRow` and `removeVisibleRow` actions from the Zustand store only once, at the time of the observer's creation.

This created a potential for a stale closure bug, where the observer would call outdated action functions if the store's state or actions were ever re-initialized. This would lead to the application's state not being updated correctly, causing the bug where visible rows would not render their content.

This commit fixes the issue by calling `useVisibleRowsStore.getState()` inside the observer callback. This ensures that the latest, freshest versions of the action functions are always used, preventing the stale state bug.
2025-08-18 06:16:53 +02:00
Carlos Valente 210a85c463 fix: bug with default field url 2025-08-18 06:16:53 +02:00
Carlos Valente 3ad3ad088f ux: improve warning message in partial merge 2025-08-18 06:16:53 +02:00
Carlos Valente 617da68ab5 fix: storage key conflict across stages 2025-08-18 06:16:53 +02:00
Carlos Valente efceb930e5 refactor: improve navigation and settings locks 2025-08-18 06:16:53 +02:00
Carlos Valente 836fa52757 fix: small style tweaks and bug fixes
fix: remove interactive styles from display elements

fix: automation form reset on submit

fix: prevent empty image src
2025-08-18 06:16:53 +02:00
Carlos Valente 902edf4a85 fix: ensure filename sanitation in all routes 2025-08-18 06:16:53 +02:00
Alex Christoffer Rasmussen 0e770655dc Fix: still counting to un-flaged event (#1726)
* create failing test

* ensuere all values a reset before calculations
2025-08-18 06:16:53 +02:00
arc-alex db922a67e2 chore: cleanup 2025-08-18 06:16:53 +02:00
Alex Christoffer Rasmussen 048a9c96ea Refactor: split runtime state (#1725)
* refactor: runtime types

* refactor: runtimeState functions to use the split type

* refactor: RuntimeService to use the new split data

* refactor: use the split data in the UI

* update comments

* refactor: rename runtime to offset

* fix utils test
2025-08-18 06:16:53 +02:00
Alex Christoffer Rasmussen ed93cf313a fix: move playing event into group (#1723)
* fix: moving paying event into group

* add comment
2025-08-18 06:16:53 +02:00
Alex Christoffer Rasmussen 97a0e17192 feat: abort partial load if there is a version missmatch (#1724)
* abort partial load on version missmatch

* reword comment

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-08-18 06:16:53 +02:00
Alex Christoffer Rasmussen 022a58e142 refactor: runtime service (#1722)
* refactor: trim type

* refactor: update runtime service data push logic

* keep using just deep

* reword didDependencyUpdate

* remove dulicate
2025-08-18 06:16:53 +02:00
Alex Christoffer Rasmussen e0149c1cbf Invert overtime (#1715)
* refactor: invert offset calculation

* chore: update tests

* chore: flip offset in UI

* fix seconds display on group target duration

* update comment

* fix rebase
2025-08-18 06:16:53 +02:00
Alex Christoffer Rasmussen 4665f9c2f7 fix: partial load (#1720) 2025-08-18 06:16:53 +02:00
Carlos Valente 9ab6b30aea refactor: small code improvements 2025-08-18 06:16:53 +02:00
Carlos Valente e5d2457717 chore: rename blocks to groups 2025-08-18 06:16:53 +02:00
Alex Christoffer Rasmussen 486d89ecf4 refactor: remove finishedAt from runtime data (#1716) 2025-08-18 06:16:53 +02:00
Carlos Valente 94b1f63c7c refactor: remove onAir 2025-08-18 06:16:53 +02:00
Alex Christoffer Rasmussen 0299463f62 fix: ensure the restore point is the first thing loaded (#1713) 2025-08-18 06:16:53 +02:00
Carlos Valente f7ccb6bb51 chore: upgrade packages 2025-08-18 06:16:53 +02:00
Carlos Valente 6c6f5c2c0f feat: cuesheet sharing
feat: locked presets

refactor: simplify locked param

refactor: create share links
2025-08-18 06:16:53 +02:00
Carlos Valente 1695b4dc68 refactor: add presets to refetch list 2025-08-18 06:16:53 +02:00
Carlos Valente 1ed08df198 refactor: extract project info data to hook 2025-08-18 06:16:53 +02:00
Carlos Valente c300fc18e1 refactor: extract timeline data to hook 2025-08-18 06:16:53 +02:00
Carlos Valente bea7e2bb2b refactor: extract studio data to hook 2025-08-18 06:16:53 +02:00
Carlos Valente ad7f658f15 refactor: extract backstage data to hook 2025-08-18 06:16:53 +02:00
Carlos Valente f23adfa45c refactor: extract countdown data to hook 2025-08-18 06:16:53 +02:00
Carlos Valente 20ee391525 refactor: extract timer data to hook 2025-08-18 06:16:53 +02:00
Carlos Valente 78184b23b7 refactor: simplify routing 2025-08-18 06:16:53 +02:00
Carlos Valente e204f671be refactor: small code improvements
refactor: remove unused code

refactor: extract loader component

refactor: prevent keyword collision

refactor: cleanup hook pending states
2025-08-18 06:16:53 +02:00
Carlos Valente 8c90f5043f chore: upgrade react router 2025-08-18 06:16:53 +02:00
Carlos Valente 7a322406da chore: directory restructure 2025-08-18 06:16:53 +02:00
Carlos Valente 307f0ffd34 feat: expose presets in view options 2025-08-18 06:16:53 +02:00
Carlos Valente 5f8a5c4995 refactor: small style tweaks 2025-08-18 06:16:53 +02:00
Carlos Valente 3747dbc1a8 refactor: style and ux tweaks 2025-08-18 06:16:53 +02:00
Alex Christoffer Rasmussen 9ebb3decdb V3 project migration (#1652)
* migrate settings

* migrate viewsettings

* migrate url preset

* migrate project data

* migrate custom fields

* migrate automations

* migrate rundown

* lint

* return custom field translation table

* check regex

* new url type

* refactor

* migrate old OSC subscriptions

* migrate old http subscriptions

* update comments

* migrate the whole db

* remove automation logging unlis there is an error

* return a new object

* refactor: copy currup is only used in one place

* make a copy of original migrated file

* refactor: ensure image flder is part of project service init

* add entrys to groups

* small cleanup

* just drop incorrect custom fields in the default data parser
2025-08-07 15:48:58 +02:00
257 changed files with 6345 additions and 3391 deletions
+13 -19
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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 />
</>
);
}
+4 -4
View File
@@ -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 });
+3 -8
View File
@@ -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,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';
@@ -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;
@@ -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';
+14 -16
View File
@@ -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);
}
}
+73 -24
View File
@@ -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;
};
+3 -1
View File
@@ -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),
};
},
}));
+1 -1
View File
@@ -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;
}
+2 -1
View File
@@ -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';
}
+1
View File
@@ -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
+15 -11
View File
@@ -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;
+2 -2
View File
@@ -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;
}
+74 -63
View File
@@ -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';
}
+6
View File
@@ -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;
};
}
+23
View File
@@ -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 -1
View File
@@ -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();
@@ -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 });
@@ -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'
@@ -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,
})}
@@ -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');
}
@@ -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);
@@ -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' />;
}
+65 -62
View File
@@ -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',
});
});
@@ -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 {
@@ -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>
@@ -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 />}
@@ -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;
@@ -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,6 +1,6 @@
@use '../blockMixins' as *;
.blockEnd {
.groupEnd {
cursor: default;
height: 1rem;
background-color: var(--user-bg, $gray-1050);
@@ -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