mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-11 02:13:48 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e2aabe3646 | |||
| 7545f6f7d0 | |||
| 958c68b6f6 | |||
| a0c5375376 | |||
| 9708f0bfc6 | |||
| 13eca98133 | |||
| 126e31403e | |||
| 53beea2768 | |||
| 0bb09dd039 | |||
| 93fb48ea1c | |||
| 1de3e01216 | |||
| 8d427a6bbe | |||
| 23dcca5527 | |||
| a3bbd74db7 | |||
| 5323e627fb | |||
| cd9dbcdc68 | |||
| af99882919 | |||
| 1d4dc3a26f | |||
| e5fdf27f6c | |||
| 657cc22b44 | |||
| 51c31adaf1 |
+29
-8
@@ -1,38 +1,48 @@
|
|||||||
# GETTING STARTED
|
# GETTING STARTED
|
||||||
|
|
||||||
Ontime consists of 3 distinct parts
|
Ontime consists of 3 distinct parts
|
||||||
- __client__: A React app for Ontime's UI and web clients
|
|
||||||
|
- __client__: A React app for Ontime's UI and web clients
|
||||||
- __electron__: An electron app which facilitates the cross-platform distribution of Ontime
|
- __electron__: An electron app which facilitates the cross-platform distribution of Ontime
|
||||||
- __server__: A node application which handles the domains services and integrations
|
- __server__: A node application which handles the domains services and integrations
|
||||||
|
|
||||||
The steps below will assume you have locally installed the necessary dependencies.
|
The steps below will assume you have locally installed the necessary dependencies.
|
||||||
Other dependencies will be installed as part of the setup
|
Other dependencies will be installed as part of the setup
|
||||||
|
|
||||||
- __node__ (>=16.16)
|
- __node__ (>=16.16)
|
||||||
- __pnpm__ (>=7)
|
- __pnpm__ (>=7)
|
||||||
- __docker__ (only necessary to run and build docker images)
|
- __docker__ (only necessary to run and build docker images)
|
||||||
|
|
||||||
## LOCAL DEVELOPMENT
|
## LOCAL DEVELOPMENT
|
||||||
|
|
||||||
The electron app is only necessary to distribute an installable version of the app and is not required for local development.
|
The electron app is only necessary to distribute an installable version of the app and is not required for local
|
||||||
|
development.
|
||||||
Locally, we would need to run both the React client and the node.js server in development mode
|
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
|
From the project root, run the following commands
|
||||||
|
|
||||||
- __Install the project dependencies__ by running `pnpm i`
|
- __Install the project dependencies__ by running `pnpm i`
|
||||||
- __Run dev mode__ by running `turbo dev`
|
- __Run dev mode__ by running `turbo dev`
|
||||||
|
|
||||||
### Debugging backend
|
### Debugging backend
|
||||||
|
|
||||||
To debug backend code in Node.js:
|
To debug backend code in Node.js:
|
||||||
|
|
||||||
- Open two separate terminals and navigate to the `apps/client` and `apps/server` directories.
|
- 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.
|
- In each terminal, run the command `pnpm dev` to start the development servers for both the client and server
|
||||||
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by running `pnpm dev:inspect`.
|
applications.
|
||||||
|
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by
|
||||||
|
running `pnpm dev:inspect`.
|
||||||
|
|
||||||
## TESTING
|
## TESTING
|
||||||
|
|
||||||
Generally we have 2 types of tests.
|
Generally we have 2 types of tests.
|
||||||
|
|
||||||
- Unit tests for functions that contain business logic
|
- Unit tests for functions that contain business logic
|
||||||
- End-to-end tests for core features
|
- End-to-end tests for core features
|
||||||
|
|
||||||
### Unit tests
|
### Unit tests
|
||||||
|
|
||||||
Unit tests are contained in mostly all the apps and packages (client, server and utils)
|
Unit tests are contained in mostly all the apps and packages (client, server and utils)
|
||||||
|
|
||||||
You can run unit tests by running turbo `turbo test:pipeline` from the project root.
|
You can run unit tests by running turbo `turbo test:pipeline` from the project root.
|
||||||
@@ -41,12 +51,20 @@ This will run all tests and close test runner.
|
|||||||
Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode
|
Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode
|
||||||
|
|
||||||
### E2E tests
|
### E2E tests
|
||||||
E2E tests are in a separate package. On running, [playwright](https://playwright.dev/) will spin up an instance of the webserver to test against
|
|
||||||
|
E2E tests are in a separate package. On running, [playwright](https://playwright.dev/) will spin up an instance of the
|
||||||
|
webserver to test against
|
||||||
These tests also run against a separate version of the DB (test-db)
|
These tests also run against a separate version of the DB (test-db)
|
||||||
|
|
||||||
You can run playwright tests from project root with `pnpm e2e`
|
You can run playwright tests from project root with `pnpm e2e`
|
||||||
|
|
||||||
When writing tests, it can be handy to run playwright in interactive mode with `pnpm e2e:i`. You would need to manually start the webserver with `pnpm dev:server`
|
When writing tests, it can be handy to run playwright in interactive mode with `pnpm e2e:i`. You would need to manually
|
||||||
|
start the webserver with `pnpm dev:server`
|
||||||
|
|
||||||
|
Some other useful commands
|
||||||
|
|
||||||
|
- `pnpm e2e --ui` open playwright UI
|
||||||
|
- `pnpm e2e --headed` run tests with a visible browser window
|
||||||
|
|
||||||
## CREATE AN INSTALLABLE FILE (Windows | MacOS | Linux)
|
## CREATE AN INSTALLABLE FILE (Windows | MacOS | Linux)
|
||||||
|
|
||||||
@@ -54,6 +72,7 @@ Ontime uses Electron to distribute the application.
|
|||||||
You can generate a distribution for your OS by running the following steps.
|
You can generate a distribution for your OS by running the following steps.
|
||||||
|
|
||||||
From the project root, run the following commands
|
From the project root, run the following commands
|
||||||
|
|
||||||
- __Install the project dependencies__ by running `pnpm i`
|
- __Install the project dependencies__ by running `pnpm i`
|
||||||
- __Build the UI and server__ by running `turbo build:local`
|
- __Build the UI and server__ by running `turbo build:local`
|
||||||
- __Create the package__ by running `turbo dist-win`, `turbo dist-mac` or `turbo dist-linux`
|
- __Create the package__ by running `turbo dist-win`, `turbo dist-mac` or `turbo dist-linux`
|
||||||
@@ -66,10 +85,12 @@ Ontime provides a docker-compose file to aid with building and running docker im
|
|||||||
While it should allow for a generic setup, it might need to be modified to fit your infrastructure.
|
While it should allow for a generic setup, it might need to be modified to fit your infrastructure.
|
||||||
|
|
||||||
From the project root, run the following commands
|
From the project root, run the following commands
|
||||||
|
|
||||||
- __Install the project dependencies__ by running `pnpm i`
|
- __Install the project dependencies__ by running `pnpm i`
|
||||||
- __Build docker image from__ by running `docker build -t getontime/ontime`
|
- __Build docker image from__ by running `docker build -t getontime/ontime`
|
||||||
- __Run docker image from compose__ by running `docker-compose up -d`
|
- __Run docker image from compose__ by running `docker-compose up -d`
|
||||||
|
|
||||||
Other useful commands
|
Other useful commands
|
||||||
|
|
||||||
- __List running processes__ by running `docker ps`
|
- __List running processes__ by running `docker ps`
|
||||||
- __Kill running process__ by running `docker kill <process-id>`
|
- __Kill running process__ by running `docker kill <process-id>`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "2.3.9",
|
"version": "2.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chakra-ui/react": "^2.7.0",
|
"@chakra-ui/react": "^2.7.0",
|
||||||
|
|||||||
+12
-12
@@ -4,9 +4,9 @@ import { ChakraProvider } from '@chakra-ui/react';
|
|||||||
import { QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
|
|
||||||
|
import { ContextMenu } from './common/components/context-menu/ContextMenu';
|
||||||
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
|
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
|
||||||
import { AppContextProvider } from './common/context/AppContext';
|
import { AppContextProvider } from './common/context/AppContext';
|
||||||
import { ContextMenuProvider } from './common/context/ContextMenuContext';
|
|
||||||
import useElectronEvent from './common/hooks/useElectronEvent';
|
import useElectronEvent from './common/hooks/useElectronEvent';
|
||||||
import { ontimeQueryClient } from './common/queryClient';
|
import { ontimeQueryClient } from './common/queryClient';
|
||||||
import { socketClientName } from './common/stores/connectionName';
|
import { socketClientName } from './common/stores/connectionName';
|
||||||
@@ -52,18 +52,18 @@ function App() {
|
|||||||
<ChakraProvider resetCSS theme={theme}>
|
<ChakraProvider resetCSS theme={theme}>
|
||||||
<QueryClientProvider client={ontimeQueryClient}>
|
<QueryClientProvider client={ontimeQueryClient}>
|
||||||
<AppContextProvider>
|
<AppContextProvider>
|
||||||
<ContextMenuProvider>
|
<BrowserRouter>
|
||||||
<BrowserRouter>
|
<div className='App'>
|
||||||
<div className='App'>
|
<ErrorBoundary>
|
||||||
<ErrorBoundary>
|
<TranslationProvider>
|
||||||
<TranslationProvider>
|
<ContextMenu>
|
||||||
<AppRouter />
|
<AppRouter />
|
||||||
</TranslationProvider>
|
</ContextMenu>
|
||||||
</ErrorBoundary>
|
</TranslationProvider>
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
</ErrorBoundary>
|
||||||
</div>
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
</BrowserRouter>
|
</div>
|
||||||
</ContextMenuProvider>
|
</BrowserRouter>
|
||||||
</AppContextProvider>
|
</AppContextProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ChakraProvider>
|
</ChakraProvider>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { lazy, Suspense } from 'react';
|
import { lazy, Suspense } from 'react';
|
||||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
import withData from './features/viewers/ViewWrapper';
|
|
||||||
import withAlias from './features/AliasWrapper';
|
import withAlias from './features/AliasWrapper';
|
||||||
|
import withData from './features/viewers/ViewWrapper';
|
||||||
|
|
||||||
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
|
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
|
||||||
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
|
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
|
||||||
|
const Operator = lazy(() => import('./features/operator/Operator'));
|
||||||
|
|
||||||
const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
|
const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
|
||||||
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
|
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
|
||||||
@@ -57,6 +59,9 @@ export default function AppRouter() {
|
|||||||
{/*/!* Lower cannot have fallback *!/*/}
|
{/*/!* Lower cannot have fallback *!/*/}
|
||||||
<Route path='/lower' element={<SLowerThird />} />
|
<Route path='/lower' element={<SLowerThird />} />
|
||||||
|
|
||||||
|
<Route path='/op' element={<Operator />} />
|
||||||
|
<Route path='/operator' element={<Operator />} />
|
||||||
|
|
||||||
{/*/!* Protected Routes *!/*/}
|
{/*/!* Protected Routes *!/*/}
|
||||||
<Route path='/editor' element={<Editor />} />
|
<Route path='/editor' element={<Editor />} />
|
||||||
<Route path='/cuesheet' element={<Cuesheet />} />
|
<Route path='/cuesheet' element={<Cuesheet />} />
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
export const STATIC_PORT = 4001;
|
|
||||||
|
|
||||||
// REST stuff
|
// REST stuff
|
||||||
export const EVENT_DATA = ['eventdata'];
|
export const PROJECT_DATA = ['project'];
|
||||||
export const ALIASES = ['aliases'];
|
export const ALIASES = ['aliases'];
|
||||||
export const USERFIELDS = ['userFields'];
|
export const USERFIELDS = ['userFields'];
|
||||||
export const RUNDOWN_TABLE_KEY = 'rundown';
|
export const RUNDOWN_TABLE_KEY = 'rundown';
|
||||||
@@ -14,12 +12,14 @@ export const RUNTIME = ['runtimeStore'];
|
|||||||
|
|
||||||
const location = window.location;
|
const location = window.location;
|
||||||
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
export const isProduction = import.meta.env.MODE === 'production';
|
||||||
|
|
||||||
export const serverPort = import.meta.env.DEV ? STATIC_PORT : location.port;
|
const STATIC_PORT = 4001;
|
||||||
export const serverURL = import.meta.env.DEV ? `http://${location.hostname}:${serverPort}` : location.origin;
|
export const serverPort = isProduction ? location.port : STATIC_PORT;
|
||||||
|
export const serverURL = `${location.protocol}//${location.hostname}:${serverPort}`;
|
||||||
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
|
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
|
||||||
|
|
||||||
export const eventURL = `${serverURL}/eventdata`;
|
export const projectDataURL = `${serverURL}/project`;
|
||||||
export const rundownURL = `${serverURL}/events`;
|
export const rundownURL = `${serverURL}/events`;
|
||||||
export const ontimeURL = `${serverURL}/ontime`;
|
export const ontimeURL = `${serverURL}/ontime`;
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,14 @@ import { addLog } from '../stores/logger';
|
|||||||
import { nowInMillis } from '../utils/time';
|
import { nowInMillis } from '../utils/time';
|
||||||
|
|
||||||
export function logAxiosError(prepend: string, error: unknown) {
|
export function logAxiosError(prepend: string, error: unknown) {
|
||||||
const message = axios.isAxiosError(error)
|
let message;
|
||||||
? `${prepend} ${(error as AxiosError).response?.statusText ?? ''}: ${(error as AxiosError).response?.data ?? ''}`
|
if (axios.isAxiosError(error)) {
|
||||||
: `${prepend}: ${error}`;
|
const statusText = (error as AxiosError).response?.statusText ?? '';
|
||||||
|
const data = (error as AxiosError).response?.data ?? '';
|
||||||
|
message = `${prepend} ${statusText}: ${data}`;
|
||||||
|
} else {
|
||||||
|
message = `${prepend}: ${error}`;
|
||||||
|
}
|
||||||
|
|
||||||
addLog({
|
addLog({
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
import { EventData } from 'ontime-types';
|
|
||||||
|
|
||||||
import { eventURL } from './apiConstants';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description HTTP request to fetch event data
|
|
||||||
* @return {Promise}
|
|
||||||
*/
|
|
||||||
export async function fetchEventData(): Promise<EventData> {
|
|
||||||
const res = await axios.get(eventURL);
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description HTTP request to mutate event data
|
|
||||||
* @return {Promise}
|
|
||||||
*/
|
|
||||||
export async function postEventData(data: EventData) {
|
|
||||||
return axios.post(eventURL, data);
|
|
||||||
}
|
|
||||||
@@ -50,6 +50,19 @@ export async function requestApplyDelay(eventId: string) {
|
|||||||
return axios.patch(`${rundownURL}/applydelay/${eventId}`);
|
return axios.patch(`${rundownURL}/applydelay/${eventId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SwapEntry = {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description HTTP request to swap two events
|
||||||
|
* @return {Promise}
|
||||||
|
*/
|
||||||
|
export async function requestEventSwap(data: SwapEntry) {
|
||||||
|
return axios.patch(`${rundownURL}/swap`, data);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description HTTP request to delete given event
|
* @description HTTP request to delete given event
|
||||||
* @return {Promise}
|
* @return {Promise}
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import {
|
import { Alias, OSCSettings, OscSubscription, ProjectData, Settings, UserFields, ViewSettings } from 'ontime-types';
|
||||||
Alias,
|
|
||||||
EventData,
|
|
||||||
OSCSettings,
|
|
||||||
OscSubscription,
|
|
||||||
Settings,
|
|
||||||
UserFields,
|
|
||||||
ViewSettings,
|
|
||||||
} from 'ontime-types';
|
|
||||||
|
|
||||||
import { apiRepoLatest } from '../../externals';
|
import { apiRepoLatest } from '../../externals';
|
||||||
import { InfoType } from '../models/Info';
|
import { InfoType } from '../models/Info';
|
||||||
@@ -186,6 +178,6 @@ export async function getLatestVersion(): Promise<HasUpdate> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postNew(initialData: Partial<EventData>) {
|
export async function postNew(initialData: Partial<ProjectData>) {
|
||||||
return axios.post(`${ontimeURL}/new`, initialData);
|
return axios.post(`${ontimeURL}/new`, initialData);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { ProjectData } from 'ontime-types';
|
||||||
|
|
||||||
|
import { projectDataURL } from './apiConstants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description HTTP request to fetch project data
|
||||||
|
* @return {Promise}
|
||||||
|
*/
|
||||||
|
export async function getProjectData(): Promise<ProjectData> {
|
||||||
|
const res = await axios.get(projectDataURL);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description HTTP request to mutate project data
|
||||||
|
* @return {Promise}
|
||||||
|
*/
|
||||||
|
export async function postProjectData(data: ProjectData) {
|
||||||
|
return axios.post(projectDataURL, data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// logic (with some modifications) culled from:
|
||||||
|
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
|
||||||
|
|
||||||
|
import { Fragment, ReactElement } from 'react';
|
||||||
|
import { Menu, MenuButton, MenuDivider, MenuItem, MenuList } from '@chakra-ui/react';
|
||||||
|
import { IconType } from '@react-icons/all-files';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
import style from './ContextMenu.module.scss';
|
||||||
|
|
||||||
|
type ContextMenuCoords = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Option = {
|
||||||
|
label: string;
|
||||||
|
icon: IconType;
|
||||||
|
onClick: () => void;
|
||||||
|
withDivider?: boolean;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ContextMenuStore = {
|
||||||
|
coords: ContextMenuCoords;
|
||||||
|
options: Option[];
|
||||||
|
isOpen: boolean;
|
||||||
|
setContextMenu: (coords: ContextMenuCoords, options: Option[]) => void;
|
||||||
|
setIsOpen: (newIsOpen: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useContextMenuStore = create<ContextMenuStore>((set) => ({
|
||||||
|
coords: { x: 0, y: 0 },
|
||||||
|
options: [],
|
||||||
|
isOpen: false,
|
||||||
|
setContextMenu: (coords, options) => set(() => ({ coords, options, isOpen: true })),
|
||||||
|
setIsOpen: (newIsOpen) => set(() => ({ isOpen: newIsOpen })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface ContextMenuProps {
|
||||||
|
// ReactElement type required due to early `return` (line 51) returning {children}
|
||||||
|
children: ReactElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContextMenu = ({ children }: ContextMenuProps) => {
|
||||||
|
const { coords, options, isOpen, setIsOpen } = useContextMenuStore();
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
return setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
<div className={style.contextMenuBackdrop} />
|
||||||
|
<Menu isOpen gutter={0} onClose={onClose} isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
|
||||||
|
<MenuButton
|
||||||
|
className={style.contextMenuButton}
|
||||||
|
aria-hidden
|
||||||
|
w={1}
|
||||||
|
h={1}
|
||||||
|
style={{
|
||||||
|
left: coords.x,
|
||||||
|
top: coords.y,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MenuList>
|
||||||
|
{options.map(({ label, icon: Icon, onClick, withDivider, isDisabled }, i) => (
|
||||||
|
<Fragment key={label}>
|
||||||
|
{withDivider && <MenuDivider />}
|
||||||
|
<MenuItem key={i} icon={<Icon />} onClick={onClick} isDisabled={isDisabled}>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</MenuList>
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
@use '../../../theme/v2Styles' as *;
|
||||||
|
|
||||||
|
.delaySymbol {
|
||||||
|
svg {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: $ontime-delay;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Tooltip } from '@chakra-ui/react';
|
||||||
|
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
|
||||||
|
import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp';
|
||||||
|
|
||||||
|
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||||
|
import { millisToDelayString } from '../../utils/dateConfig';
|
||||||
|
|
||||||
|
import style from './DelayIndicator.module.scss';
|
||||||
|
|
||||||
|
interface DelayIndicatorProps {
|
||||||
|
delayValue?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DelayIndicator(props: DelayIndicatorProps) {
|
||||||
|
const { delayValue } = props;
|
||||||
|
|
||||||
|
if (typeof delayValue === 'number') {
|
||||||
|
if (delayValue < 0) {
|
||||||
|
return (
|
||||||
|
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
|
||||||
|
<span className={style.delaySymbol}>
|
||||||
|
<IoChevronDown />
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delayValue > 0) {
|
||||||
|
return (
|
||||||
|
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
|
||||||
|
<span className={style.delaySymbol}>
|
||||||
|
<IoChevronUp />
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { TitleActions } from '../../../../features/event-editor/composite/EventEditorTitles';
|
import { TitleActions } from '../../../../features/event-editor/composite/EventEditorDataLeft';
|
||||||
|
|
||||||
import Swatch from './Swatch';
|
import Swatch from './Swatch';
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,17 @@ function ButtonTooltip(name: TimeEntryField, warning?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TimeInput(props: TimeInputProps) {
|
export default function TimeInput(props: TimeInputProps) {
|
||||||
const { id, name, submitHandler, time = 0, delay = 0, placeholder, validationHandler, previousEnd = 0, warning } = props;
|
const {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
submitHandler,
|
||||||
|
time = 0,
|
||||||
|
delay = 0,
|
||||||
|
placeholder,
|
||||||
|
validationHandler,
|
||||||
|
previousEnd = 0,
|
||||||
|
warning,
|
||||||
|
} = props;
|
||||||
const { emitError } = useEmitLog();
|
const { emitError } = useEmitLog();
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [value, setValue] = useState<string>('');
|
const [value, setValue] = useState<string>('');
|
||||||
@@ -191,7 +201,7 @@ export default function TimeInput(props: TimeInputProps) {
|
|||||||
<Input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
id={id}
|
id={id}
|
||||||
data-testid='time-input'
|
data-testid={`time-input-${name}`}
|
||||||
className={style.inputField}
|
className={style.inputField}
|
||||||
type='text'
|
type='text'
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ function NavigationMenu() {
|
|||||||
const handleMirror = () => toggleMirror();
|
const handleMirror = () => toggleMirror();
|
||||||
|
|
||||||
const showEditFormDrawer = () => {
|
const showEditFormDrawer = () => {
|
||||||
searchParams.append('edit', 'true');
|
searchParams.set('edit', 'true');
|
||||||
setSearchParams(searchParams);
|
setSearchParams(searchParams);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -116,6 +116,10 @@ function NavigationMenu() {
|
|||||||
Cuesheet
|
Cuesheet
|
||||||
<IoArrowUp className={style.linkIcon} />
|
<IoArrowUp className={style.linkIcon} />
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to='/op' className={style.link} tabIndex={0}>
|
||||||
|
Operator
|
||||||
|
<IoArrowUp className={style.linkIcon} />
|
||||||
|
</Link>
|
||||||
<hr className={style.separator} />
|
<hr className={style.separator} />
|
||||||
{navigatorConstants.map((route) => (
|
{navigatorConstants.map((route) => (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
+8
-2
@@ -8,10 +8,12 @@ import { tooltipDelayFast } from '../../../ontimeConfig';
|
|||||||
|
|
||||||
interface PlaybackIconProps {
|
interface PlaybackIconProps {
|
||||||
state: Playback;
|
state: Playback;
|
||||||
|
skipTooltip?: boolean;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PlaybackIcon(props: PlaybackIconProps) {
|
export default function PlaybackIcon(props: PlaybackIconProps) {
|
||||||
const { state } = props;
|
const { state, skipTooltip, className } = props;
|
||||||
|
|
||||||
// if timer is Pause or Armed
|
// if timer is Pause or Armed
|
||||||
let label = 'Timer Paused';
|
let label = 'Timer Paused';
|
||||||
@@ -28,9 +30,13 @@ export default function PlaybackIcon(props: PlaybackIconProps) {
|
|||||||
Icon = IoStop;
|
Icon = IoStop;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (skipTooltip) {
|
||||||
|
return <Icon className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip openDelay={tooltipDelayFast} label={label} shouldWrapChildren>
|
<Tooltip openDelay={tooltipDelayFast} label={label} shouldWrapChildren>
|
||||||
<Icon />
|
<Icon className={className} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,10 +6,11 @@ import ScheduleItem from './ScheduleItem';
|
|||||||
import './Schedule.scss';
|
import './Schedule.scss';
|
||||||
|
|
||||||
interface ScheduleProps {
|
interface ScheduleProps {
|
||||||
|
isProduction?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Schedule({ className }: ScheduleProps) {
|
export default function Schedule({ isProduction, className }: ScheduleProps) {
|
||||||
const { paginatedEvents, selectedEventId, isBackstage, scheduleType } = useSchedule();
|
const { paginatedEvents, selectedEventId, isBackstage, scheduleType } = useSchedule();
|
||||||
|
|
||||||
if (paginatedEvents?.length < 1) {
|
if (paginatedEvents?.length < 1) {
|
||||||
@@ -30,12 +31,16 @@ export default function Schedule({ className }: ScheduleProps) {
|
|||||||
selectedState = 'future';
|
selectedState = 'future';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const timeStart = isProduction ? event.timeStart + (event?.delay ?? 0) : event.timeStart;
|
||||||
|
const timeEnd = isProduction ? event.timeEnd + (event?.delay ?? 0) : event.timeEnd;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScheduleItem
|
<ScheduleItem
|
||||||
key={event.id}
|
key={event.id}
|
||||||
selected={selectedState}
|
selected={selectedState}
|
||||||
timeStart={event.timeStart}
|
timeStart={timeStart}
|
||||||
timeEnd={event.timeEnd}
|
timeEnd={timeEnd}
|
||||||
title={event.title}
|
title={event.title}
|
||||||
colour={isBackstage ? event.colour : ''}
|
colour={isBackstage ? event.colour : ''}
|
||||||
backstageEvent={!event.isPublic}
|
backstageEvent={!event.isPublic}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { CSSProperties } from 'react';
|
import { CSSProperties } from 'react';
|
||||||
|
|
||||||
import { ReactComponent as Emptyimage } from '@/assets/images/empty.svg';
|
import { ReactComponent as Emptyimage } from '@/assets/images/empty.svg';
|
||||||
|
|
||||||
import style from './Empty.module.scss';
|
import style from './Empty.module.scss';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
@use '../../../theme/ontimeColours' as *;
|
@use '../../../theme/ontimeColours' as *;
|
||||||
|
|
||||||
.drawerContent {
|
.drawerContent {
|
||||||
background-color: $gray-1200;
|
background-color: $gray-1250;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawerHeader {
|
.drawerHeader {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { UserFields } from 'ontime-types';
|
||||||
|
|
||||||
import { ParamField } from './types';
|
import { ParamField } from './types';
|
||||||
|
|
||||||
export const TIME_FORMAT_OPTION: ParamField = {
|
export const TIME_FORMAT_OPTION: ParamField = {
|
||||||
@@ -201,3 +203,61 @@ export const STUDIO_CLOCK_OPTIONS: ParamField[] = [
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const getOperatorOptions = (userFields: UserFields): ParamField[] => {
|
||||||
|
return [
|
||||||
|
TIME_FORMAT_OPTION,
|
||||||
|
{
|
||||||
|
id: 'showseconds',
|
||||||
|
title: 'Show seconds',
|
||||||
|
description: 'Schedule shows hh:mm:ss',
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hidepast',
|
||||||
|
title: 'Hide Past Events',
|
||||||
|
description: 'Whether to events that have passed',
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'main',
|
||||||
|
title: 'Main data field',
|
||||||
|
description: 'Field to be shown in the first line of text',
|
||||||
|
type: 'option',
|
||||||
|
values: {
|
||||||
|
title: 'Title',
|
||||||
|
subtitle: 'Subtitle',
|
||||||
|
presenter: 'Presenter',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'secondary',
|
||||||
|
title: 'Secondary data field',
|
||||||
|
description: 'Field to be shown in the second line of text',
|
||||||
|
type: 'option',
|
||||||
|
values: {
|
||||||
|
title: 'Title',
|
||||||
|
subtitle: 'Subtitle',
|
||||||
|
presenter: 'Presenter',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'subscribe',
|
||||||
|
title: 'Highlight Field',
|
||||||
|
description: 'Choose a field to highlight',
|
||||||
|
type: 'option',
|
||||||
|
values: {
|
||||||
|
user0: userFields.user0 || 'user0',
|
||||||
|
user1: userFields.user1 || 'user1',
|
||||||
|
user2: userFields.user2 || 'user2',
|
||||||
|
user3: userFields.user3 || 'user3',
|
||||||
|
user4: userFields.user4 || 'user4',
|
||||||
|
user5: userFields.user5 || 'user5',
|
||||||
|
user6: userFields.user6 || 'user6',
|
||||||
|
user7: userFields.user7 || 'user7',
|
||||||
|
user8: userFields.user8 || 'user8',
|
||||||
|
user9: userFields.user9 || 'user9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
// logic (with some modifications) culled from:
|
|
||||||
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
|
|
||||||
|
|
||||||
import { createContext, ReactNode, useState } from 'react';
|
|
||||||
import { Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react';
|
|
||||||
import { IconType } from '@react-icons/all-files';
|
|
||||||
|
|
||||||
import style from './ContextMenuContext.module.scss';
|
|
||||||
|
|
||||||
type ContextMenuCoords = {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ContextMenuContextType = {
|
|
||||||
createContextMenu: (options: Option[], menuCoordinates: ContextMenuCoords) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ContextMenuContext = createContext<ContextMenuContextType | null>(null);
|
|
||||||
|
|
||||||
export type Option = {
|
|
||||||
label: string;
|
|
||||||
icon: IconType;
|
|
||||||
onClick: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ContextMenuProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
const [coords, setCoords] = useState<ContextMenuCoords>({ x: 0, y: 0 });
|
|
||||||
const [options, setOptions] = useState<Option[]>([]);
|
|
||||||
|
|
||||||
const onClose = () => {
|
|
||||||
return setIsOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const createContextMenu = (options: Option[], menuCoords: ContextMenuCoords) => {
|
|
||||||
setCoords(menuCoords);
|
|
||||||
setOptions(options);
|
|
||||||
setIsOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ContextMenuContext.Provider value={{ createContextMenu }}>
|
|
||||||
{children}
|
|
||||||
{isOpen && (
|
|
||||||
<>
|
|
||||||
<div className={style.contextMenuBackdrop} />
|
|
||||||
<Menu isOpen gutter={0} onClose={onClose} isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
|
|
||||||
<MenuButton
|
|
||||||
className={style.contextMenuButton}
|
|
||||||
aria-hidden
|
|
||||||
w={1}
|
|
||||||
h={1}
|
|
||||||
style={{
|
|
||||||
left: coords.x,
|
|
||||||
top: coords.y,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<MenuList>
|
|
||||||
{options.map(({ label, icon: Icon, onClick }, i) => (
|
|
||||||
<MenuItem key={i} icon={<Icon />} onClick={onClick}>
|
|
||||||
{label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</MenuList>
|
|
||||||
</Menu>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ContextMenuContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// roughly from https://github.com/juliencrn/usehooks-ts/blob/master/packages/usehooks-ts/src/useMediaQuery/useMediaQuery.ts
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
function getMatches(query: string): boolean {
|
||||||
|
return window.matchMedia(query).matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: debounce handleChange
|
||||||
|
export default function useMediaQuery(query: string): boolean {
|
||||||
|
const [matches, setMatches] = useState<boolean>(getMatches(query));
|
||||||
|
|
||||||
|
const handleChange = useCallback(() => {
|
||||||
|
setMatches(getMatches(query));
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const matchMedia = window.matchMedia(query);
|
||||||
|
|
||||||
|
// Triggered at the first client-side load and if query changes
|
||||||
|
handleChange();
|
||||||
|
|
||||||
|
// Listen matchMedia
|
||||||
|
matchMedia.addEventListener('change', handleChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
matchMedia.removeEventListener('change', handleChange);
|
||||||
|
};
|
||||||
|
}, [handleChange, query]);
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
+7
-7
@@ -1,15 +1,15 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||||
import { EVENT_DATA } from '../api/apiConstants';
|
import { PROJECT_DATA } from '../api/apiConstants';
|
||||||
import { fetchEventData } from '../api/eventDataApi';
|
import { getProjectData } from '../api/projectDataApi';
|
||||||
import { eventDataPlaceholder } from '../models/EventData';
|
import { projectDataPlaceholder } from '../models/ProjectData';
|
||||||
|
|
||||||
export default function useEventData() {
|
export default function useProjectData() {
|
||||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||||
queryKey: EVENT_DATA,
|
queryKey: PROJECT_DATA,
|
||||||
queryFn: fetchEventData,
|
queryFn: getProjectData,
|
||||||
placeholderData: eventDataPlaceholder,
|
placeholderData: projectDataPlaceholder,
|
||||||
retry: 5,
|
retry: 5,
|
||||||
retryDelay: (attempt) => attempt * 2500,
|
retryDelay: (attempt) => attempt * 2500,
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
@@ -1,22 +1,16 @@
|
|||||||
import { MouseEvent, useContext } from 'react';
|
import { MouseEvent } from 'react';
|
||||||
|
|
||||||
import { ContextMenuContext, Option } from '../context/ContextMenuContext';
|
import { Option, useContextMenuStore } from '../components/context-menu/ContextMenu';
|
||||||
|
|
||||||
export const useContextMenu = <T extends HTMLElement>(options: Option[]) => {
|
export const useContextMenu = <T extends HTMLElement>(options: Option[]) => {
|
||||||
const contextMenuContext = useContext(ContextMenuContext);
|
const { setContextMenu } = useContextMenuStore();
|
||||||
|
|
||||||
if (contextMenuContext === null) {
|
|
||||||
throw new Error('useContextMenu should be wrapped by ContextMenuProvider');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { createContextMenu } = contextMenuContext;
|
|
||||||
|
|
||||||
const localCreateContextMenu = (contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => {
|
const localCreateContextMenu = (contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => {
|
||||||
// prevent browser default context menu from showing up
|
// prevent browser default context menu from showing up
|
||||||
contextMenuEvent.preventDefault();
|
contextMenuEvent.preventDefault();
|
||||||
|
|
||||||
const { pageX, pageY } = contextMenuEvent;
|
const { pageX, pageY } = contextMenuEvent;
|
||||||
return createContextMenu(options, { x: pageX, y: pageY });
|
return setContextMenu({ x: pageX, y: pageY }, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
return [localCreateContextMenu];
|
return [localCreateContextMenu];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { OntimeRundown, OntimeRundownEntry, SupportedEvent } from 'ontime-types';
|
import { isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
|
||||||
|
import { getCueCandidate, swapOntimeEvents } from 'ontime-utils';
|
||||||
|
|
||||||
import { RUNDOWN_TABLE, RUNDOWN_TABLE_KEY } from '../api/apiConstants';
|
import { RUNDOWN_TABLE, RUNDOWN_TABLE_KEY } from '../api/apiConstants';
|
||||||
import { logAxiosError } from '../api/apiUtils';
|
import { logAxiosError } from '../api/apiUtils';
|
||||||
@@ -9,9 +10,11 @@ import {
|
|||||||
requestApplyDelay,
|
requestApplyDelay,
|
||||||
requestDelete,
|
requestDelete,
|
||||||
requestDeleteAll,
|
requestDeleteAll,
|
||||||
|
requestEventSwap,
|
||||||
requestPostEvent,
|
requestPostEvent,
|
||||||
requestPutEvent,
|
requestPutEvent,
|
||||||
requestReorderEvent,
|
requestReorderEvent,
|
||||||
|
SwapEntry,
|
||||||
} from '../api/eventsApi';
|
} from '../api/eventsApi';
|
||||||
import { useEditorSettings } from '../stores/editorSettings';
|
import { useEditorSettings } from '../stores/editorSettings';
|
||||||
|
|
||||||
@@ -28,9 +31,10 @@ export const useEventAction = () => {
|
|||||||
* Calls mutation to add new event
|
* Calls mutation to add new event
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const _addEventMutation = useMutation(requestPostEvent, {
|
const _addEventMutation = useMutation({
|
||||||
// Mutation finished, failed or successful
|
// Mutation finished, failed or successful
|
||||||
// Fetch anyway, just to be sure
|
// Fetch anyway, just to be sure
|
||||||
|
mutationFn: requestPostEvent,
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries(RUNDOWN_TABLE);
|
queryClient.invalidateQueries(RUNDOWN_TABLE);
|
||||||
},
|
},
|
||||||
@@ -55,7 +59,7 @@ export const useEventAction = () => {
|
|||||||
const newEvent: Partial<OntimeRundownEntry> = { ...event };
|
const newEvent: Partial<OntimeRundownEntry> = { ...event };
|
||||||
|
|
||||||
// ************* CHECK OPTIONS specific to events
|
// ************* CHECK OPTIONS specific to events
|
||||||
if (newEvent.type === SupportedEvent.Event) {
|
if (isOntimeEvent(newEvent)) {
|
||||||
const applicationOptions = {
|
const applicationOptions = {
|
||||||
defaultPublic: options?.defaultPublic ?? defaultPublic,
|
defaultPublic: options?.defaultPublic ?? defaultPublic,
|
||||||
startTimeIsLastEnd: options?.startTimeIsLastEnd ?? startTimeIsLastEnd,
|
startTimeIsLastEnd: options?.startTimeIsLastEnd ?? startTimeIsLastEnd,
|
||||||
@@ -63,16 +67,20 @@ export const useEventAction = () => {
|
|||||||
after: options?.after,
|
after: options?.after,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (newEvent?.cue === undefined) {
|
||||||
|
newEvent.cue = getCueCandidate(queryClient.getQueryData(RUNDOWN_TABLE) || [], options?.after);
|
||||||
|
}
|
||||||
|
|
||||||
// hard coding duration value to be as expected for now
|
// hard coding duration value to be as expected for now
|
||||||
// this until timeOptions gets implemented
|
// this until timeOptions gets implemented
|
||||||
if (typeof newEvent?.timeStart !== 'undefined' && typeof newEvent.timeEnd !== 'undefined') {
|
if (newEvent?.timeStart !== undefined && newEvent.timeEnd !== undefined) {
|
||||||
newEvent.duration = Math.max(0, newEvent?.timeEnd - newEvent?.timeStart) || 0;
|
newEvent.duration = Math.max(0, newEvent?.timeEnd - newEvent?.timeStart) || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) {
|
if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) {
|
||||||
const rundown = queryClient.getQueryData(RUNDOWN_TABLE) as OntimeRundown;
|
const rundown = queryClient.getQueryData(RUNDOWN_TABLE) as OntimeRundown;
|
||||||
const previousEvent = rundown.find((event) => event.id === applicationOptions.lastEventId);
|
const previousEvent = rundown.find((event) => event.id === applicationOptions.lastEventId);
|
||||||
if (typeof previousEvent !== 'undefined' && previousEvent.type === 'event') {
|
if (previousEvent !== undefined && previousEvent.type === 'event') {
|
||||||
newEvent.timeStart = previousEvent.timeEnd;
|
newEvent.timeStart = previousEvent.timeEnd;
|
||||||
newEvent.timeEnd = previousEvent.timeEnd;
|
newEvent.timeEnd = previousEvent.timeEnd;
|
||||||
}
|
}
|
||||||
@@ -102,7 +110,8 @@ export const useEventAction = () => {
|
|||||||
* Calls mutation to update existing event
|
* Calls mutation to update existing event
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const _updateEventMutation = useMutation(requestPutEvent, {
|
const _updateEventMutation = useMutation({
|
||||||
|
mutationFn: requestPutEvent,
|
||||||
// we optimistically update here
|
// we optimistically update here
|
||||||
onMutate: async (newEvent) => {
|
onMutate: async (newEvent) => {
|
||||||
// cancel ongoing queries
|
// cancel ongoing queries
|
||||||
@@ -117,7 +126,6 @@ export const useEventAction = () => {
|
|||||||
// Return a context with the previous and new events
|
// Return a context with the previous and new events
|
||||||
return { previousEvent, newEvent };
|
return { previousEvent, newEvent };
|
||||||
},
|
},
|
||||||
|
|
||||||
// Mutation fails, rollback undoes optimist update
|
// Mutation fails, rollback undoes optimist update
|
||||||
onError: (_error, _newEvent, context) => {
|
onError: (_error, _newEvent, context) => {
|
||||||
queryClient.setQueryData([RUNDOWN_TABLE_KEY, context?.newEvent.id], context?.previousEvent);
|
queryClient.setQueryData([RUNDOWN_TABLE_KEY, context?.newEvent.id], context?.previousEvent);
|
||||||
@@ -148,7 +156,8 @@ export const useEventAction = () => {
|
|||||||
* Calls mutation to delete an event
|
* Calls mutation to delete an event
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const _deleteEventMutation = useMutation(requestDelete, {
|
const _deleteEventMutation = useMutation({
|
||||||
|
mutationFn: requestDelete,
|
||||||
// we optimistically update here
|
// we optimistically update here
|
||||||
onMutate: async (eventId) => {
|
onMutate: async (eventId) => {
|
||||||
// cancel ongoing queries
|
// cancel ongoing queries
|
||||||
@@ -196,7 +205,8 @@ export const useEventAction = () => {
|
|||||||
* Calls mutation to delete all events
|
* Calls mutation to delete all events
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const _deleteAllEventsMutation = useMutation(requestDeleteAll, {
|
const _deleteAllEventsMutation = useMutation({
|
||||||
|
mutationFn: requestDeleteAll,
|
||||||
// we optimistically update here
|
// we optimistically update here
|
||||||
onMutate: async () => {
|
onMutate: async () => {
|
||||||
// cancel ongoing queries
|
// cancel ongoing queries
|
||||||
@@ -239,7 +249,8 @@ export const useEventAction = () => {
|
|||||||
* Calls mutation to apply a delay
|
* Calls mutation to apply a delay
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const _applyDelayMutation = useMutation(requestApplyDelay, {
|
const _applyDelayMutation = useMutation({
|
||||||
|
mutationFn: requestApplyDelay,
|
||||||
// Mutation finished, failed or successful
|
// Mutation finished, failed or successful
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries(RUNDOWN_TABLE);
|
queryClient.invalidateQueries(RUNDOWN_TABLE);
|
||||||
@@ -265,7 +276,8 @@ export const useEventAction = () => {
|
|||||||
* Calls mutation to reorder an event
|
* Calls mutation to reorder an event
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const _reorderEventMutation = useMutation(requestReorderEvent, {
|
const _reorderEventMutation = useMutation({
|
||||||
|
mutationFn: requestReorderEvent,
|
||||||
// we optimistically update here
|
// we optimistically update here
|
||||||
onMutate: async (data) => {
|
onMutate: async (data) => {
|
||||||
// cancel ongoing queries
|
// cancel ongoing queries
|
||||||
@@ -316,5 +328,67 @@ export const useEventAction = () => {
|
|||||||
[_reorderEventMutation],
|
[_reorderEventMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { addEvent, updateEvent, deleteEvent, deleteAllEvents, applyDelay, reorderEvent };
|
/**
|
||||||
|
* Calls mutation to swap events
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
const _swapEvents = useMutation({
|
||||||
|
mutationFn: requestEventSwap,
|
||||||
|
// we optimistically update here
|
||||||
|
onMutate: async ({ from, to }) => {
|
||||||
|
// cancel ongoing queries
|
||||||
|
await queryClient.cancelQueries(RUNDOWN_TABLE, { exact: true });
|
||||||
|
|
||||||
|
// Snapshot the previous value
|
||||||
|
const rundown = queryClient.getQueryData(RUNDOWN_TABLE) as OntimeRundown;
|
||||||
|
|
||||||
|
const fromEventIndex = rundown.findIndex((event) => event.id === from);
|
||||||
|
const toEventIndex = rundown.findIndex((event) => event.id === to);
|
||||||
|
|
||||||
|
const previousEvents = swapOntimeEvents(rundown, fromEventIndex, toEventIndex);
|
||||||
|
|
||||||
|
// optimistically update object
|
||||||
|
queryClient.setQueryData(RUNDOWN_TABLE, previousEvents);
|
||||||
|
|
||||||
|
// Return a context with the previous events
|
||||||
|
return { previousEvents };
|
||||||
|
},
|
||||||
|
|
||||||
|
// Mutation fails, rollback undoes optimist update
|
||||||
|
onError: (_error, _eventId, context) => {
|
||||||
|
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
|
||||||
|
},
|
||||||
|
// Mutation finished, failed or successful
|
||||||
|
// Fetch anyway, just to be sure
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries(RUNDOWN_TABLE);
|
||||||
|
},
|
||||||
|
networkMode: 'always',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swaps the schedule of two events
|
||||||
|
*/
|
||||||
|
const swapEvents = useCallback(
|
||||||
|
async ({ from, to }: SwapEntry) => {
|
||||||
|
// TODO: before calling `/swapEvents`,
|
||||||
|
// we should determine the events are of type `OntimeEvent`
|
||||||
|
try {
|
||||||
|
await _swapEvents.mutateAsync({ from, to });
|
||||||
|
} catch (error) {
|
||||||
|
logAxiosError('Error re-ordering event', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[_swapEvents],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
addEvent,
|
||||||
|
updateEvent,
|
||||||
|
deleteEvent,
|
||||||
|
deleteAllEvents,
|
||||||
|
applyDelay,
|
||||||
|
reorderEvent,
|
||||||
|
swapEvents,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { MutableRefObject, useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
|
||||||
|
componentRef: MutableRefObject<ComponentRef>,
|
||||||
|
scrollRef: MutableRefObject<ScrollRef>,
|
||||||
|
topOffset: number,
|
||||||
|
) {
|
||||||
|
if (!componentRef.current || !scrollRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentRect = componentRef.current.getBoundingClientRect();
|
||||||
|
const scrollRect = scrollRef.current.getBoundingClientRect();
|
||||||
|
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - topOffset;
|
||||||
|
|
||||||
|
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseFollowComponentProps {
|
||||||
|
followRef: MutableRefObject<HTMLElement | null>;
|
||||||
|
scrollRef: MutableRefObject<HTMLElement | null>;
|
||||||
|
doFollow: boolean;
|
||||||
|
topOffset?: number;
|
||||||
|
setScrollFlag?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useFollowComponent(props: UseFollowComponentProps) {
|
||||||
|
const { followRef, scrollRef, doFollow, topOffset = 100, setScrollFlag } = props;
|
||||||
|
|
||||||
|
// when cursor moves, view should follow
|
||||||
|
useEffect(() => {
|
||||||
|
if (!doFollow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (followRef.current && scrollRef.current) {
|
||||||
|
// Use requestAnimationFrame to ensure the component is fully loaded
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
setScrollFlag?.();
|
||||||
|
scrollToComponent(
|
||||||
|
followRef as MutableRefObject<HTMLElement>,
|
||||||
|
scrollRef as MutableRefObject<HTMLElement>,
|
||||||
|
topOffset,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line -- the prompt seems incorrect
|
||||||
|
}, [followRef?.current, scrollRef?.current]);
|
||||||
|
|
||||||
|
const scrollToRefComponent = useCallback(
|
||||||
|
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
|
||||||
|
if (componentRef.current && containerRef.current) {
|
||||||
|
// @ts-expect-error -- we know this are not null
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
scrollToComponent(componentRef!, scrollRef!, offset);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[followRef, scrollRef, topOffset],
|
||||||
|
);
|
||||||
|
|
||||||
|
return scrollToRefComponent;
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ export default function useFullscreen() {
|
|||||||
});
|
});
|
||||||
} else if (element.webkitRequestFullscreen) {
|
} else if (element.webkitRequestFullscreen) {
|
||||||
// iOS Safari fullscreen API is supported
|
// iOS Safari fullscreen API is supported
|
||||||
element.webkitRequestFullscreen().catch(() => {
|
element.webkitRequestFullscreen?.().catch(() => {
|
||||||
/* nothing to do */
|
/* nothing to do */
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -55,7 +55,7 @@ export default function useFullscreen() {
|
|||||||
});
|
});
|
||||||
} else if ((document as WebkitDocument).webkitExitFullscreen) {
|
} else if ((document as WebkitDocument).webkitExitFullscreen) {
|
||||||
// iOS Safari fullscreen API is supported
|
// iOS Safari fullscreen API is supported
|
||||||
(document as WebkitDocument).webkitExitFullscreen().catch(() => {
|
(document as WebkitDocument).webkitExitFullscreen?.().catch(() => {
|
||||||
/* nothing to do */
|
/* nothing to do */
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ export const useRundownEditor = () => {
|
|||||||
return useRuntimeStore(featureSelector, deepCompare);
|
return useRuntimeStore(featureSelector, deepCompare);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useOperator = () => {
|
||||||
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
|
playback: state.playback,
|
||||||
|
selectedEventId: state.loaded.selectedEventId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return useRuntimeStore(featureSelector, deepCompare);
|
||||||
|
};
|
||||||
|
|
||||||
export const useMessageControl = () => {
|
export const useMessageControl = () => {
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
timerMessage: state.timerMessage,
|
timerMessage: state.timerMessage,
|
||||||
@@ -70,7 +79,8 @@ export const setPlayback = {
|
|||||||
|
|
||||||
export const useInfoPanel = () => {
|
export const useInfoPanel = () => {
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
titles: state.titles,
|
eventNow: state.eventNow,
|
||||||
|
eventNext: state.eventNext,
|
||||||
playback: state.playback,
|
playback: state.playback,
|
||||||
selectedEventIndex: state.loaded.selectedEventIndex,
|
selectedEventIndex: state.loaded.selectedEventIndex,
|
||||||
numEvents: state.loaded.numEvents,
|
numEvents: state.loaded.numEvents,
|
||||||
@@ -85,7 +95,7 @@ export const useCuesheet = () => {
|
|||||||
selectedEventId: state.loaded.selectedEventId,
|
selectedEventId: state.loaded.selectedEventId,
|
||||||
selectedEventIndex: state.loaded.selectedEventIndex,
|
selectedEventIndex: state.loaded.selectedEventIndex,
|
||||||
numEvents: state.loaded.numEvents,
|
numEvents: state.loaded.numEvents,
|
||||||
titleNow: state.titles.titleNow,
|
titleNow: state.eventNow?.title || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector, deepCompare);
|
return useRuntimeStore(featureSelector, deepCompare);
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
import { EventData } from 'ontime-types';
|
import { ProjectData } from 'ontime-types';
|
||||||
|
|
||||||
export const eventDataPlaceholder: EventData = {
|
export const projectDataPlaceholder: ProjectData = {
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
publicUrl: '',
|
publicUrl: '',
|
||||||
@@ -42,26 +42,10 @@ export const runtimeStorePlaceholder = {
|
|||||||
nextEventId: null,
|
nextEventId: null,
|
||||||
nextPublicEventId: null,
|
nextPublicEventId: null,
|
||||||
},
|
},
|
||||||
titles: {
|
eventNow: null,
|
||||||
titleNow: null,
|
eventNext: null,
|
||||||
subtitleNow: null,
|
publicEventNow: null,
|
||||||
presenterNow: null,
|
publicEventNext: null,
|
||||||
noteNow: null,
|
|
||||||
titleNext: null,
|
|
||||||
subtitleNext: null,
|
|
||||||
presenterNext: null,
|
|
||||||
noteNext: null,
|
|
||||||
},
|
|
||||||
titlesPublic: {
|
|
||||||
titleNow: null,
|
|
||||||
subtitleNow: null,
|
|
||||||
presenterNow: null,
|
|
||||||
noteNow: null,
|
|
||||||
titleNext: null,
|
|
||||||
subtitleNext: null,
|
|
||||||
presenterNext: null,
|
|
||||||
noteNext: null,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const runtime = createStore<RuntimeStore>(() => ({
|
export const runtime = createStore<RuntimeStore>(() => ({
|
||||||
|
|||||||
@@ -1,536 +0,0 @@
|
|||||||
import { formatEventList, getEventsWithDelay, trimRundown } from '../eventsManager';
|
|
||||||
|
|
||||||
describe('getEventsWithDelay function', () => {
|
|
||||||
test('with positive delays', () => {
|
|
||||||
const testData = [
|
|
||||||
{
|
|
||||||
title: 'Welcome to Ontime',
|
|
||||||
timeStart: 28800000,
|
|
||||||
timeEnd: 30600000,
|
|
||||||
colour: '',
|
|
||||||
type: 'event',
|
|
||||||
id: '5946',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
duration: 60000,
|
|
||||||
type: 'delay',
|
|
||||||
id: '24240',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Unless recalled by the OSC address',
|
|
||||||
timeStart: 34920000,
|
|
||||||
timeEnd: 35520000,
|
|
||||||
colour: '',
|
|
||||||
type: 'event',
|
|
||||||
id: '8ee5',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Use simpler times to create a timer',
|
|
||||||
timeStart: 120000,
|
|
||||||
timeEnd: 720000,
|
|
||||||
colour: '',
|
|
||||||
type: 'event',
|
|
||||||
id: '8222',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
duration: 900000,
|
|
||||||
type: 'delay',
|
|
||||||
revision: 0,
|
|
||||||
id: 'a386',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Add delay blocks to affect all events',
|
|
||||||
timeStart: 37320000,
|
|
||||||
timeEnd: 38520000,
|
|
||||||
colour: '',
|
|
||||||
type: 'event',
|
|
||||||
id: '6dce',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Add and remove events with [+] and [-]',
|
|
||||||
timeStart: 38520000,
|
|
||||||
timeEnd: 45120000,
|
|
||||||
colour: '',
|
|
||||||
type: 'event',
|
|
||||||
id: '2651',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'block',
|
|
||||||
id: 'e6a1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'And control whether they are public',
|
|
||||||
timeStart: 46800000,
|
|
||||||
timeEnd: 57600000,
|
|
||||||
colour: '',
|
|
||||||
type: 'event',
|
|
||||||
id: '1358',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const expected = [
|
|
||||||
{
|
|
||||||
title: 'Welcome to Ontime',
|
|
||||||
timeStart: 28800000,
|
|
||||||
timeEnd: 30600000,
|
|
||||||
colour: '',
|
|
||||||
type: 'event',
|
|
||||||
id: '5946',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Unless recalled by the OSC address',
|
|
||||||
timeStart: 34920000 + 60000,
|
|
||||||
timeEnd: 35520000 + 60000,
|
|
||||||
colour: '',
|
|
||||||
type: 'event',
|
|
||||||
id: '8ee5',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Use simpler times to create a timer',
|
|
||||||
timeStart: 120000 + 60000,
|
|
||||||
timeEnd: 720000 + 60000,
|
|
||||||
colour: '',
|
|
||||||
type: 'event',
|
|
||||||
id: '8222',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Add delay blocks to affect all events',
|
|
||||||
timeStart: 37320000 + 60000 + 900000,
|
|
||||||
timeEnd: 38520000 + 60000 + 900000,
|
|
||||||
colour: '',
|
|
||||||
type: 'event',
|
|
||||||
id: '6dce',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Add and remove events with [+] and [-]',
|
|
||||||
timeStart: 38520000 + 60000 + 900000,
|
|
||||||
timeEnd: 45120000 + 60000 + 900000,
|
|
||||||
colour: '',
|
|
||||||
type: 'event',
|
|
||||||
id: '2651',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'And control whether they are public',
|
|
||||||
timeStart: 46800000,
|
|
||||||
timeEnd: 57600000,
|
|
||||||
colour: '',
|
|
||||||
type: 'event',
|
|
||||||
id: '1358',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
|
|
||||||
});
|
|
||||||
test('with negative delays', () => {
|
|
||||||
const testData = [
|
|
||||||
{
|
|
||||||
duration: -20,
|
|
||||||
type: 'delay',
|
|
||||||
id: '24240',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Welcome to Ontime',
|
|
||||||
timeStart: 100,
|
|
||||||
timeEnd: 200,
|
|
||||||
colour: '',
|
|
||||||
type: 'event',
|
|
||||||
id: '5946',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const expected = [
|
|
||||||
{
|
|
||||||
title: 'Welcome to Ontime',
|
|
||||||
timeStart: 80,
|
|
||||||
timeEnd: 180,
|
|
||||||
colour: '',
|
|
||||||
type: 'event',
|
|
||||||
id: '5946',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getEventsWithDelay edge cases', () => {
|
|
||||||
it('ensures time start cannot be below 0', () => {
|
|
||||||
const testData = [
|
|
||||||
{
|
|
||||||
duration: -200,
|
|
||||||
type: 'delay',
|
|
||||||
id: '24240',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Welcome to Ontime',
|
|
||||||
timeStart: 10,
|
|
||||||
timeEnd: 20,
|
|
||||||
colour: '',
|
|
||||||
type: 'event',
|
|
||||||
id: '5946',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const expected = [
|
|
||||||
{
|
|
||||||
title: 'Welcome to Ontime',
|
|
||||||
timeStart: 0,
|
|
||||||
timeEnd: 0,
|
|
||||||
colour: '',
|
|
||||||
type: 'event',
|
|
||||||
id: '5946',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
|
|
||||||
});
|
|
||||||
it('does not modify original array', () => {
|
|
||||||
const testData = [
|
|
||||||
{
|
|
||||||
duration: 10,
|
|
||||||
type: 'delay',
|
|
||||||
id: '24240',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Welcome to Ontime',
|
|
||||||
timeStart: 10,
|
|
||||||
timeEnd: 20,
|
|
||||||
colour: '',
|
|
||||||
type: 'event',
|
|
||||||
id: '5946',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const expected = [
|
|
||||||
{
|
|
||||||
title: 'Welcome to Ontime',
|
|
||||||
timeStart: 20,
|
|
||||||
timeEnd: 30,
|
|
||||||
colour: '',
|
|
||||||
type: 'event',
|
|
||||||
id: '5946',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const expectedSafe = [
|
|
||||||
{
|
|
||||||
title: 'Welcome to Ontime',
|
|
||||||
timeStart: 20,
|
|
||||||
timeEnd: 30,
|
|
||||||
colour: '',
|
|
||||||
type: 'event',
|
|
||||||
id: '5946',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
|
|
||||||
expect(getEventsWithDelay(expectedSafe)).toStrictEqual(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('given an empty array', () => {
|
|
||||||
const emptyArray = {
|
|
||||||
test: [],
|
|
||||||
expect: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(getEventsWithDelay(emptyArray.test)).toStrictEqual(emptyArray.expect);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('given an undefined object', () => {
|
|
||||||
const withUndefined = {
|
|
||||||
test: undefined,
|
|
||||||
expect: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(getEventsWithDelay(withUndefined.test)).toStrictEqual(withUndefined.expect);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('given a corrupted event object', () => {
|
|
||||||
const testData = [
|
|
||||||
{
|
|
||||||
title: 'Welcome to Ontime',
|
|
||||||
timeEnd: 30600000,
|
|
||||||
colour: '',
|
|
||||||
type: 'event',
|
|
||||||
id: '5946',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
duration: 60000,
|
|
||||||
type: 'delay',
|
|
||||||
id: '24240',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Unless recalled by the OSC address',
|
|
||||||
timeStart: 34920000,
|
|
||||||
timeEnd: 35520000,
|
|
||||||
colour: '',
|
|
||||||
type: 'event',
|
|
||||||
id: '8ee5',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const expected = [
|
|
||||||
{
|
|
||||||
title: 'Welcome to Ontime',
|
|
||||||
timeEnd: 30600000,
|
|
||||||
colour: '',
|
|
||||||
type: 'event',
|
|
||||||
id: '5946',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Unless recalled by the OSC address',
|
|
||||||
timeStart: 34920000 + 60000,
|
|
||||||
timeEnd: 35520000 + 60000,
|
|
||||||
colour: '',
|
|
||||||
type: 'event',
|
|
||||||
id: '8ee5',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('given a corrupted delay object', () => {
|
|
||||||
const testData = [
|
|
||||||
{
|
|
||||||
title: 'Welcome to Ontime',
|
|
||||||
timeStart: 28800000,
|
|
||||||
timeEnd: 30600000,
|
|
||||||
colour: '',
|
|
||||||
type: 'event',
|
|
||||||
id: '5946',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'delay',
|
|
||||||
id: '24240',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Unless recalled by the OSC address',
|
|
||||||
timeStart: 34920000,
|
|
||||||
timeEnd: 35520000,
|
|
||||||
colour: '',
|
|
||||||
type: 'event',
|
|
||||||
id: '8ee5',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const expected = [
|
|
||||||
{
|
|
||||||
title: 'Welcome to Ontime',
|
|
||||||
timeStart: 28800000,
|
|
||||||
timeEnd: 30600000,
|
|
||||||
colour: '',
|
|
||||||
type: 'event',
|
|
||||||
id: '5946',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Unless recalled by the OSC address',
|
|
||||||
timeStart: 34920000,
|
|
||||||
timeEnd: 35520000,
|
|
||||||
colour: '',
|
|
||||||
type: 'event',
|
|
||||||
id: '8ee5',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('test trimEventlist function', () => {
|
|
||||||
const limit = 8;
|
|
||||||
const testData = [
|
|
||||||
{ id: '1' },
|
|
||||||
{ id: '2' },
|
|
||||||
{ id: '3' },
|
|
||||||
{ id: '4' },
|
|
||||||
{ id: '5' },
|
|
||||||
{ id: '6' },
|
|
||||||
{ id: '7' },
|
|
||||||
{ id: '8' },
|
|
||||||
{ id: '9' },
|
|
||||||
{ id: '10' },
|
|
||||||
{ id: '11' },
|
|
||||||
{ id: '12' },
|
|
||||||
];
|
|
||||||
|
|
||||||
it('when we use the first item', () => {
|
|
||||||
const selectedId = '1';
|
|
||||||
const expected = [
|
|
||||||
{ id: '1' },
|
|
||||||
{ id: '2' },
|
|
||||||
{ id: '3' },
|
|
||||||
{ id: '4' },
|
|
||||||
{ id: '5' },
|
|
||||||
{ id: '6' },
|
|
||||||
{ id: '7' },
|
|
||||||
{ id: '8' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const l = trimRundown(testData, selectedId, limit);
|
|
||||||
expect(l.length).toBe(limit);
|
|
||||||
expect(l).toStrictEqual(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('when we use the third item', () => {
|
|
||||||
const selectedId = '3';
|
|
||||||
const expected = [
|
|
||||||
{ id: '1' },
|
|
||||||
{ id: '2' },
|
|
||||||
{ id: '3' },
|
|
||||||
{ id: '4' },
|
|
||||||
{ id: '5' },
|
|
||||||
{ id: '6' },
|
|
||||||
{ id: '7' },
|
|
||||||
{ id: '8' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const l = trimRundown(testData, selectedId, limit);
|
|
||||||
expect(l.length).toBe(limit);
|
|
||||||
expect(l).toStrictEqual(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('when we use the fourth item', () => {
|
|
||||||
const selectedId = '4';
|
|
||||||
const expected = [
|
|
||||||
{ id: '2' },
|
|
||||||
{ id: '3' },
|
|
||||||
{ id: '4' },
|
|
||||||
{ id: '5' },
|
|
||||||
{ id: '6' },
|
|
||||||
{ id: '7' },
|
|
||||||
{ id: '8' },
|
|
||||||
{ id: '9' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const l = trimRundown(testData, selectedId, limit);
|
|
||||||
expect(l.length).toBe(limit);
|
|
||||||
expect(l).toStrictEqual(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('if selected is not found', () => {
|
|
||||||
const selectedId = '15';
|
|
||||||
const expected = [
|
|
||||||
{ id: '1' },
|
|
||||||
{ id: '2' },
|
|
||||||
{ id: '3' },
|
|
||||||
{ id: '4' },
|
|
||||||
{ id: '5' },
|
|
||||||
{ id: '6' },
|
|
||||||
{ id: '7' },
|
|
||||||
{ id: '8' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const l = trimRundown(testData, selectedId, limit);
|
|
||||||
expect(l.length).toBe(limit);
|
|
||||||
expect(l).toStrictEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('test formatEvents function', () => {
|
|
||||||
const testEvent = [
|
|
||||||
{
|
|
||||||
title: 'Welcome to Ontime',
|
|
||||||
subtitle: 'Subtitles are useful',
|
|
||||||
presenter: 'cpvalente',
|
|
||||||
note: 'Maybe a running note for the operator?',
|
|
||||||
timeStart: 28800000,
|
|
||||||
timeEnd: 30600000,
|
|
||||||
isPublic: false,
|
|
||||||
colour: '',
|
|
||||||
type: 'event',
|
|
||||||
revision: 0,
|
|
||||||
id: '5946',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Unless recalled by the OSC address',
|
|
||||||
subtitle: '',
|
|
||||||
presenter: '',
|
|
||||||
note: 'In green, below',
|
|
||||||
timeStart: 34800000,
|
|
||||||
timeEnd: 35400000,
|
|
||||||
isPublic: false,
|
|
||||||
colour: '',
|
|
||||||
type: 'event',
|
|
||||||
revision: 0,
|
|
||||||
id: '8ee5',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
it('it parses correctly', () => {
|
|
||||||
const selectedId = 'otherEvent';
|
|
||||||
const nextId = 'notHere';
|
|
||||||
const expected = [
|
|
||||||
{
|
|
||||||
id: '5946',
|
|
||||||
time: '08:00 - 08:30',
|
|
||||||
title: 'Welcome to Ontime',
|
|
||||||
isNow: false,
|
|
||||||
isNext: false,
|
|
||||||
colour: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '8ee5',
|
|
||||||
time: '09:40 - 09:50',
|
|
||||||
title: 'Unless recalled by the OSC address',
|
|
||||||
isNow: false,
|
|
||||||
isNext: false,
|
|
||||||
colour: '',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const parsed = formatEventList(testEvent, selectedId, nextId, { showEnd: true });
|
|
||||||
expect(parsed).toStrictEqual(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('it handles selected correctly', () => {
|
|
||||||
const selectedId = '5946';
|
|
||||||
const nextId = '8ee5';
|
|
||||||
const expected = [
|
|
||||||
{
|
|
||||||
id: '5946',
|
|
||||||
time: '08:00 - 08:30',
|
|
||||||
title: 'Welcome to Ontime',
|
|
||||||
isNow: true,
|
|
||||||
isNext: false,
|
|
||||||
colour: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '8ee5',
|
|
||||||
time: '09:40 - 09:50',
|
|
||||||
title: 'Unless recalled by the OSC address',
|
|
||||||
isNow: false,
|
|
||||||
isNext: true,
|
|
||||||
colour: '',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const parsed = formatEventList(testEvent, selectedId, nextId, { showEnd: true });
|
|
||||||
expect(parsed).toStrictEqual(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('it handles next correctly', () => {
|
|
||||||
const selectedId = '8ee5';
|
|
||||||
const nextId = 'notHere';
|
|
||||||
|
|
||||||
const expected = [
|
|
||||||
{
|
|
||||||
id: '5946',
|
|
||||||
time: '08:00 - 08:30',
|
|
||||||
title: 'Welcome to Ontime',
|
|
||||||
isNow: false,
|
|
||||||
isNext: false,
|
|
||||||
colour: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '8ee5',
|
|
||||||
time: '09:40 - 09:50',
|
|
||||||
title: 'Unless recalled by the OSC address',
|
|
||||||
isNow: true,
|
|
||||||
isNext: false,
|
|
||||||
colour: '',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const parsed = formatEventList(testEvent, selectedId, nextId, { showEnd: true });
|
|
||||||
expect(parsed).toStrictEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { EndAction, OntimeEvent, SupportedEvent, TimerType } from 'ontime-types';
|
||||||
|
|
||||||
|
import { cloneEvent } from '../eventsManager';
|
||||||
|
|
||||||
|
describe('cloneEvent()', () => {
|
||||||
|
it('creates a stem from a given event', () => {
|
||||||
|
const original = {
|
||||||
|
id: 'unique',
|
||||||
|
type: SupportedEvent.Event,
|
||||||
|
title: 'title',
|
||||||
|
cue: 'cue',
|
||||||
|
subtitle: 'subtitle',
|
||||||
|
presenter: 'presenter',
|
||||||
|
note: 'note',
|
||||||
|
timeStart: 0,
|
||||||
|
duration: 10,
|
||||||
|
timeEnd: 10,
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
|
endAction: EndAction.None,
|
||||||
|
isPublic: false,
|
||||||
|
skip: false,
|
||||||
|
colour: 'F00',
|
||||||
|
revision: 10,
|
||||||
|
user0: 'user0',
|
||||||
|
user1: 'user1',
|
||||||
|
user2: 'user2',
|
||||||
|
user3: 'user3',
|
||||||
|
user4: 'user4',
|
||||||
|
user5: 'user5',
|
||||||
|
user6: 'user6',
|
||||||
|
user7: 'user7',
|
||||||
|
user8: 'user8',
|
||||||
|
user9: 'user9',
|
||||||
|
} as OntimeEvent;
|
||||||
|
|
||||||
|
const cloned = cloneEvent(original);
|
||||||
|
expect(cloned).not.toBe(original);
|
||||||
|
// @ts-expect-error -- safeguarding this
|
||||||
|
expect(cloned?.id).toBe(undefined);
|
||||||
|
expect(cloned.title).toBe(original.title);
|
||||||
|
expect(cloned.subtitle).toBe(original.subtitle);
|
||||||
|
expect(cloned.presenter).toBe(original.presenter);
|
||||||
|
expect(cloned.note).toBe(original.note);
|
||||||
|
expect(cloned.endAction).toBe(original.endAction);
|
||||||
|
expect(cloned.timerType).toBe(original.timerType);
|
||||||
|
expect(cloned.timeStart).toBe(original.timeStart);
|
||||||
|
expect(cloned.timeEnd).toBe(original.timeEnd);
|
||||||
|
expect(cloned.duration).toBe(original.duration);
|
||||||
|
expect(cloned.isPublic).toBe(original.isPublic);
|
||||||
|
expect(cloned.skip).toBe(original.skip);
|
||||||
|
expect(cloned.colour).toBe(original.colour);
|
||||||
|
expect(cloned.type).toBe(SupportedEvent.Event);
|
||||||
|
expect(cloned.revision).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import getDelayTo from '../getDelayTo';
|
|
||||||
|
|
||||||
describe('getDelayTo function', () => {
|
|
||||||
it('handles list with delays', () => {
|
|
||||||
const delayDuration = 100;
|
|
||||||
const events = [
|
|
||||||
{ type: 'event' },
|
|
||||||
{ type: 'delay', duration: delayDuration },
|
|
||||||
{ type: 'event' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const notDelayed = getDelayTo(events, 0);
|
|
||||||
expect(notDelayed).toBe(0);
|
|
||||||
const delayedEvent = getDelayTo(events, 2);
|
|
||||||
expect(delayedEvent).toBe(delayDuration);
|
|
||||||
});
|
|
||||||
it('handles list without delays', () => {
|
|
||||||
const events = [{ type: 'event' }, { type: 'event' }];
|
|
||||||
const notDelayed = getDelayTo(events, 1);
|
|
||||||
expect(notDelayed).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles list with multiple delays', () => {
|
|
||||||
const delayDuration = 100;
|
|
||||||
const events = [
|
|
||||||
{ type: 'event' },
|
|
||||||
{ type: 'delay', duration: delayDuration },
|
|
||||||
{ type: 'event' },
|
|
||||||
{ type: 'delay', duration: delayDuration },
|
|
||||||
{ type: 'event' },
|
|
||||||
];
|
|
||||||
const doubleDelay = getDelayTo(events, 4);
|
|
||||||
expect(doubleDelay).toBe(delayDuration * 2);
|
|
||||||
});
|
|
||||||
it('handles list with blocks', () => {
|
|
||||||
const events = [
|
|
||||||
{ type: 'event' },
|
|
||||||
{ type: 'delay', duration: 100 },
|
|
||||||
{ type: 'event' },
|
|
||||||
{ type: 'block' },
|
|
||||||
{ type: 'event' },
|
|
||||||
];
|
|
||||||
const notDelayed = getDelayTo(events, 4);
|
|
||||||
expect(notDelayed).toBe(0);
|
|
||||||
});
|
|
||||||
it('handles index greater than list', () => {
|
|
||||||
const events = [{ type: 'event' }, { type: 'delay', duration: 100 }, { type: 'event' }];
|
|
||||||
const notDelayed = getDelayTo(events, 3);
|
|
||||||
expect(notDelayed).toBe(0);
|
|
||||||
});
|
|
||||||
it('handles negative index (not found)', () => {
|
|
||||||
const events = [{ type: 'event' }, { type: 'delay', duration: 100 }, { type: 'event' }];
|
|
||||||
const notDelayed = getDelayTo(events, -1);
|
|
||||||
expect(notDelayed).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Alias } from 'ontime-types';
|
|
||||||
import isEqual from 'react-fast-compare';
|
import isEqual from 'react-fast-compare';
|
||||||
import { Location, resolvePath } from 'react-router-dom';
|
import { Location, resolvePath } from 'react-router-dom';
|
||||||
|
import { Alias } from 'ontime-types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates an alias against defined parameters
|
* Validates an alias against defined parameters
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export function debounce(callback: () => void, wait: number) {
|
||||||
|
let timeout: NodeJS.Timeout | null;
|
||||||
|
return () => {
|
||||||
|
if (timeout) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
timeout = null;
|
||||||
|
callback();
|
||||||
|
}, wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export function isMacOS() {
|
||||||
|
const userAgent = navigator.userAgent.toLowerCase();
|
||||||
|
return userAgent.includes('macintosh') || userAgent.includes('mac os');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deviceAlt = isMacOS() ? '⌥' : 'Alt';
|
||||||
@@ -1,174 +1,32 @@
|
|||||||
import { OntimeEvent, OntimeRundownEntry, SupportedEvent } from 'ontime-types';
|
import { OntimeEvent, SupportedEvent } from 'ontime-types';
|
||||||
|
|
||||||
import { formatTime } from './time';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description From a list of events, returns only events of type event with calculated delays
|
|
||||||
* @param {Object[]} rundown - given rundown
|
|
||||||
* @returns {Object[]} Filtered events with calculated delays
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const getEventsWithDelay = (rundown: OntimeRundownEntry[]): OntimeEvent[] => {
|
|
||||||
if (rundown == null) return [];
|
|
||||||
|
|
||||||
const delayedEvents: OntimeEvent[] = [];
|
|
||||||
|
|
||||||
// Add running delay
|
|
||||||
let delay = 0;
|
|
||||||
for (const event of rundown) {
|
|
||||||
if (event.type === SupportedEvent.Block) delay = 0;
|
|
||||||
else if (event.type === SupportedEvent.Delay) {
|
|
||||||
if (typeof event.duration === 'number') {
|
|
||||||
delay += event.duration;
|
|
||||||
}
|
|
||||||
} else if (event.type === SupportedEvent.Event) {
|
|
||||||
const delayedEvent = { ...event };
|
|
||||||
if (delay !== 0) {
|
|
||||||
delayedEvent.timeStart = Math.max(delayedEvent.timeStart + delay, 0);
|
|
||||||
delayedEvent.timeEnd = Math.max(delayedEvent.timeEnd + delay, 0);
|
|
||||||
}
|
|
||||||
delayedEvents.push(delayedEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return delayedEvents;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description Returns trimmed event list array
|
|
||||||
* @param {Object[]} rundown - given rundown
|
|
||||||
* @param {string} selectedId - id of currently selected event
|
|
||||||
* @param {number} limit - max number of events to return
|
|
||||||
* @returns {Object[]} Event list with maximum <limit> objects
|
|
||||||
*/
|
|
||||||
export const trimRundown = (rundown: OntimeEvent[], selectedId: string, limit: number): OntimeEvent[] => {
|
|
||||||
if (rundown == null) return [];
|
|
||||||
|
|
||||||
const BEFORE = 2;
|
|
||||||
const trimmedRundown = [...rundown];
|
|
||||||
|
|
||||||
// limit events length if necessary
|
|
||||||
if (limit != null) {
|
|
||||||
while (trimmedRundown.length > limit) {
|
|
||||||
const idx = trimmedRundown.findIndex((e) => e.id === selectedId);
|
|
||||||
if (idx <= BEFORE) {
|
|
||||||
trimmedRundown.pop();
|
|
||||||
} else {
|
|
||||||
trimmedRundown.shift();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return trimmedRundown;
|
|
||||||
};
|
|
||||||
|
|
||||||
type FormatEventListOptionsProp = {
|
|
||||||
showEnd?: boolean;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* @description Returns list of events formatted to be displayed
|
|
||||||
* @param {Object[]} rundown - given rundown
|
|
||||||
* @param {string} selectedId - id of currently selected event
|
|
||||||
* @param {string} nextId - id of next event
|
|
||||||
* @param {object} [options]
|
|
||||||
* @param {boolean} [options.showEnd] - whether to show the end time
|
|
||||||
* @returns {Object[]} Formatted list of events [{time: -, title: -, isNow, isNext}]
|
|
||||||
*/
|
|
||||||
export const formatEventList = (
|
|
||||||
rundown: OntimeEvent[],
|
|
||||||
selectedId: string,
|
|
||||||
nextId: string,
|
|
||||||
options: FormatEventListOptionsProp,
|
|
||||||
): ScheduleEvent[] => {
|
|
||||||
if (rundown == null) return [];
|
|
||||||
const { showEnd = false } = options;
|
|
||||||
|
|
||||||
const givenEvents = [...rundown];
|
|
||||||
|
|
||||||
// format list
|
|
||||||
const formattedEvents = [];
|
|
||||||
for (const event of givenEvents) {
|
|
||||||
const start = formatTime(event.timeStart);
|
|
||||||
const end = formatTime(event.timeEnd);
|
|
||||||
|
|
||||||
formattedEvents.push({
|
|
||||||
id: event.id,
|
|
||||||
time: showEnd ? `${start} - ${end}` : start,
|
|
||||||
title: event.title,
|
|
||||||
isNow: event.id === selectedId,
|
|
||||||
isNext: event.id === nextId,
|
|
||||||
colour: event.colour,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return formattedEvents;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ScheduleEvent = {
|
|
||||||
id: string;
|
|
||||||
time: string;
|
|
||||||
title: string;
|
|
||||||
isNow: boolean;
|
|
||||||
isNext: boolean;
|
|
||||||
colour: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Creates a safe duplicate of an event
|
* @description Creates a safe duplicate of an event
|
||||||
* @param {object} event
|
* @param {OntimeEvent} event
|
||||||
* @return {object} clean event
|
* @param {string} [after]
|
||||||
|
* @return {OntimeEvent} clean event
|
||||||
*/
|
*/
|
||||||
type ClonedEvent = OntimeEvent | { after?: string };
|
type ClonedEvent = Omit<
|
||||||
|
OntimeEvent,
|
||||||
|
'id' | 'user0' | 'user1' | 'user2' | 'user3' | 'user4' | 'user5' | 'user6' | 'user7' | 'user8' | 'user9'
|
||||||
|
>;
|
||||||
export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
|
export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
|
||||||
return {
|
return {
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
title: event.title,
|
title: event.title,
|
||||||
|
cue: event.cue,
|
||||||
subtitle: event.subtitle,
|
subtitle: event.subtitle,
|
||||||
presenter: event.presenter,
|
presenter: event.presenter,
|
||||||
note: event.note,
|
note: event.note,
|
||||||
timeStart: event.timeStart,
|
timeStart: event.timeStart,
|
||||||
|
duration: event.duration,
|
||||||
timeEnd: event.timeEnd,
|
timeEnd: event.timeEnd,
|
||||||
|
timerType: event.timerType,
|
||||||
|
endAction: event.endAction,
|
||||||
isPublic: event.isPublic,
|
isPublic: event.isPublic,
|
||||||
skip: event.skip,
|
skip: event.skip,
|
||||||
colour: event.colour,
|
colour: event.colour,
|
||||||
after: after,
|
after: after,
|
||||||
|
revision: 0,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets first event in rundown, if it exists
|
|
||||||
* @param {OntimeRundownEntry[]} rundown
|
|
||||||
* @return {OntimeEvent | null}
|
|
||||||
*/
|
|
||||||
export function getFirstEvent(rundown: OntimeRundownEntry[]) {
|
|
||||||
return rundown.length ? rundown[0] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets next event in rundown, if it exists
|
|
||||||
* @param {OntimeRundownEntry[]} rundown
|
|
||||||
* @param {string} currentId
|
|
||||||
* @return {OntimeEvent | null}
|
|
||||||
*/
|
|
||||||
export function getNextEvent(rundown: OntimeRundownEntry[], currentId: string) {
|
|
||||||
const index = rundown.findIndex((event) => event.id === currentId);
|
|
||||||
if (index !== -1 && index + 1 < rundown.length) {
|
|
||||||
return rundown[index + 1];
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets previous event in rundown, if it exists
|
|
||||||
* @param {OntimeRundownEntry[]} rundown
|
|
||||||
* @param {string} currentId
|
|
||||||
* @return {OntimeEvent | null}
|
|
||||||
*/
|
|
||||||
export function getPreviousEvent(rundown: OntimeRundownEntry[], currentId: string) {
|
|
||||||
const index = rundown.findIndex((event) => event.id === currentId);
|
|
||||||
if (index !== -1 && index - 1 >= 0) {
|
|
||||||
return rundown[index - 1];
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
/**
|
|
||||||
* @description calculates delay to a given event
|
|
||||||
* @param {array} events
|
|
||||||
* @param {number} eventIndex
|
|
||||||
* @return {number} - delay value of given event
|
|
||||||
*/
|
|
||||||
export default function getDelayTo(events, eventIndex) {
|
|
||||||
let delay = 0;
|
|
||||||
let index = 0;
|
|
||||||
if (eventIndex >= 0) {
|
|
||||||
for (const event of events) {
|
|
||||||
if (eventIndex === index) {
|
|
||||||
return delay;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === 'delay') {
|
|
||||||
delay += event.duration;
|
|
||||||
} else if (event.type === 'block') {
|
|
||||||
delay = 0;
|
|
||||||
}
|
|
||||||
index++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Log, RuntimeStore } from 'ontime-types';
|
import { Log, RuntimeStore } from 'ontime-types';
|
||||||
|
|
||||||
import { RUNTIME, websocketUrl } from '../api/apiConstants';
|
import { isProduction, RUNTIME, websocketUrl } from '../api/apiConstants';
|
||||||
import { ontimeQueryClient } from '../queryClient';
|
import { ontimeQueryClient } from '../queryClient';
|
||||||
import { socketClientName } from '../stores/connectionName';
|
import { socketClientName } from '../stores/connectionName';
|
||||||
import { addLog } from '../stores/logger';
|
import { addLog } from '../stores/logger';
|
||||||
@@ -64,7 +64,7 @@ export const connectSocket = (preferredClientName?: string) => {
|
|||||||
}
|
}
|
||||||
case 'ontime': {
|
case 'ontime': {
|
||||||
runtime.setState(payload as RuntimeStore);
|
runtime.setState(payload as RuntimeStore);
|
||||||
if (import.meta.env.DEV) {
|
if (!isProduction) {
|
||||||
ontimeQueryClient.setQueryData(RUNTIME, data.payload);
|
ontimeQueryClient.setQueryData(RUNTIME, data.payload);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -87,18 +87,6 @@ export const connectSocket = (preferredClientName?: string) => {
|
|||||||
runtime.setState(state);
|
runtime.setState(state);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'ontime-titles': {
|
|
||||||
const state = runtime.getState();
|
|
||||||
state.titles = payload;
|
|
||||||
runtime.setState(state);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'ontime-titlesPublic': {
|
|
||||||
const state = runtime.getState();
|
|
||||||
state.titlesPublic = payload;
|
|
||||||
runtime.setState(state);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'ontime-timerMessage': {
|
case 'ontime-timerMessage': {
|
||||||
const state = runtime.getState();
|
const state = runtime.getState();
|
||||||
state.timerMessage = payload;
|
state.timerMessage = payload;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Color from 'color';
|
|||||||
type ColourCombination = {
|
type ColourCombination = {
|
||||||
backgroundColor: string;
|
backgroundColor: string;
|
||||||
color: string;
|
color: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Selects text colour to maintain accessible contrast
|
* @description Selects text colour to maintain accessible contrast
|
||||||
@@ -19,11 +19,11 @@ export const getAccessibleColour = (bgColour: string): ColourCombination => {
|
|||||||
console.log(`Unable to parse colour: ${bgColour}`);
|
console.log(`Unable to parse colour: ${bgColour}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { backgroundColor: '#000', color: "#fffffa" };
|
return { backgroundColor: '#000', color: '#fffffa' };
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Creates a list of classnames from array of css module conditions
|
* @description Creates a list of classnames from array of css module conditions
|
||||||
* @param classNames - css modules objects
|
* @param classNames - css modules objects
|
||||||
*/
|
*/
|
||||||
export const cx = (classNames: any[]) => classNames.filter(Boolean).join(" ");
|
export const cx = (classNames: any[]) => classNames.filter(Boolean).join(' ');
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export const formatTime = (milliseconds: number | null, options?: FormatOptions,
|
|||||||
return '...';
|
return '...';
|
||||||
}
|
}
|
||||||
const timeFormat = resolver();
|
const timeFormat = resolver();
|
||||||
const { showSeconds = false, format: formatString = 'hh:mm a' } = options || {};
|
const fallback = options?.showSeconds ? 'hh:mm:ss a' : 'hh:mm a';
|
||||||
|
const { showSeconds = false, format: formatString = fallback } = options || {};
|
||||||
return timeFormat === '12' ? formatFromMillis(milliseconds, formatString) : millisToString(milliseconds, showSeconds);
|
return timeFormat === '12' ? formatFromMillis(milliseconds, formatString) : millisToString(milliseconds, showSeconds);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
/* eslint-disable react/display-name */
|
/* eslint-disable react/display-name */
|
||||||
|
import { ComponentType, useEffect } from 'react';
|
||||||
|
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import useAliases from '../common/hooks-query/useAliases';
|
import useAliases from '../common/hooks-query/useAliases';
|
||||||
import { getAliasRoute } from '../common/utils/aliases';
|
import { getAliasRoute } from '../common/utils/aliases';
|
||||||
import { ComponentType, useEffect } from 'react';
|
|
||||||
import { useSearchParams, useNavigate, useLocation } from 'react-router-dom';
|
|
||||||
|
|
||||||
const withAlias = <P extends object>(Component: ComponentType<P>) => {
|
const withAlias = <P extends object>(Component: ComponentType<P>) => {
|
||||||
return (props: Partial<P>) => {
|
return (props: Partial<P>) => {
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ $table-header-font-size: calc(1rem - 3px);
|
|||||||
.eventRow {
|
.eventRow {
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
outline: 1px solid $blue-700;
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
background-color: $gray-1250;
|
background-color: $gray-1250;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
@@ -113,14 +118,6 @@ $table-header-font-size: calc(1rem - 3px);
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.delaySymbol {
|
|
||||||
svg {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
color: $ontime-delay;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.delayedTime {
|
.delayedTime {
|
||||||
color: $ontime-delay-text;
|
color: $ontime-delay-text;
|
||||||
font-size: calc(1rem - 2px);
|
font-size: calc(1rem - 2px);
|
||||||
|
|||||||
@@ -1,33 +1,20 @@
|
|||||||
import { MutableRefObject, useEffect, useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import { Tooltip } from '@chakra-ui/react';
|
|
||||||
import {
|
|
||||||
closestCenter,
|
|
||||||
DndContext,
|
|
||||||
DragEndEvent,
|
|
||||||
KeyboardSensor,
|
|
||||||
PointerSensor,
|
|
||||||
TouchSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
} from '@dnd-kit/core';
|
|
||||||
import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
|
||||||
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||||
import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, OntimeRundownEntry, SupportedEvent } from 'ontime-types';
|
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
|
||||||
|
|
||||||
|
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||||
import { useLocalStorage } from '../../common/hooks/useLocalStorage';
|
import { useLocalStorage } from '../../common/hooks/useLocalStorage';
|
||||||
import { millisToDelayString } from '../../common/utils/dateConfig';
|
|
||||||
import { getAccessibleColour } from '../../common/utils/styleUtils';
|
|
||||||
import { tooltipDelayFast } from '../../ontimeConfig';
|
|
||||||
|
|
||||||
|
import BlockRow from './cuesheet-table-elements/BlockRow';
|
||||||
|
import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
|
||||||
|
import DelayRow from './cuesheet-table-elements/DelayRow';
|
||||||
|
import EventRow from './cuesheet-table-elements/EventRow';
|
||||||
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
|
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
|
||||||
import { useCuesheetSettings } from './store/CuesheetSettings';
|
import { useCuesheetSettings } from './store/CuesheetSettings';
|
||||||
import { SortableCell } from './tableElements/SortableCell';
|
|
||||||
import { initialColumnOrder } from './cuesheetCols';
|
import { initialColumnOrder } from './cuesheetCols';
|
||||||
|
|
||||||
import style from './Cuesheet.module.scss';
|
import style from './Cuesheet.module.scss';
|
||||||
|
|
||||||
const pastOpacity = '0.2';
|
|
||||||
|
|
||||||
interface CuesheetProps {
|
interface CuesheetProps {
|
||||||
data: OntimeRundown;
|
data: OntimeRundown;
|
||||||
columns: ColumnDef<OntimeRundownEntry>[];
|
columns: ColumnDef<OntimeRundownEntry>[];
|
||||||
@@ -46,6 +33,8 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
|||||||
const [columnSizing, setColumnSizing] = useLocalStorage('table-sizes', {});
|
const [columnSizing, setColumnSizing] = useLocalStorage('table-sizes', {});
|
||||||
|
|
||||||
const selectedRef = useRef<HTMLTableRowElement | null>(null);
|
const selectedRef = useRef<HTMLTableRowElement | null>(null);
|
||||||
|
const tableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected });
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
@@ -63,78 +52,6 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
|||||||
onColumnSizingChange: setColumnSizing,
|
onColumnSizingChange: setColumnSizing,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
});
|
});
|
||||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor, {
|
|
||||||
activationConstraint: {
|
|
||||||
delay: 100,
|
|
||||||
tolerance: 50,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
useSensor(TouchSensor, {
|
|
||||||
activationConstraint: {
|
|
||||||
delay: 100,
|
|
||||||
tolerance: 50,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
useSensor(KeyboardSensor, {
|
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// when selection moves, view should follow
|
|
||||||
useEffect(() => {
|
|
||||||
function scrollToComponent(
|
|
||||||
componentRef: MutableRefObject<HTMLTableRowElement>,
|
|
||||||
scrollRef: MutableRefObject<HTMLDivElement>,
|
|
||||||
) {
|
|
||||||
const componentRect = componentRef.current.getBoundingClientRect();
|
|
||||||
const scrollRect = scrollRef.current.getBoundingClientRect();
|
|
||||||
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - 100;
|
|
||||||
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!followSelected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedRef.current && tableContainerRef.current) {
|
|
||||||
// Use requestAnimationFrame to ensure the component is fully loaded
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
scrollToComponent(
|
|
||||||
selectedRef as MutableRefObject<HTMLTableRowElement>,
|
|
||||||
tableContainerRef as MutableRefObject<HTMLDivElement>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line -- the prompt seems incorrect, we need the refs
|
|
||||||
}, [selectedRef.current, tableContainerRef.current, followSelected]);
|
|
||||||
|
|
||||||
const handleOnDragEnd = (event: DragEndEvent) => {
|
|
||||||
const { delta, active, over } = event;
|
|
||||||
|
|
||||||
// cancel if delta y is greater than 200
|
|
||||||
if (delta.y > 200) return;
|
|
||||||
// cancel if we do not have an over id
|
|
||||||
if (over?.id == null) return;
|
|
||||||
|
|
||||||
// get index of from
|
|
||||||
const fromIndex = columnOrder.indexOf(active.id as string);
|
|
||||||
|
|
||||||
// get index of to
|
|
||||||
const toIndex = columnOrder.indexOf(over.id as string);
|
|
||||||
|
|
||||||
if (toIndex === -1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reorderedCols = [...columnOrder];
|
|
||||||
const reorderedItem = reorderedCols.splice(fromIndex, 1);
|
|
||||||
reorderedCols.splice(toIndex, 0, reorderedItem[0]);
|
|
||||||
|
|
||||||
saveColumnOrder(reorderedCols);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetColumnOrder = () => {
|
const resetColumnOrder = () => {
|
||||||
saveColumnOrder(initialColumnOrder);
|
saveColumnOrder(initialColumnOrder);
|
||||||
@@ -148,6 +65,8 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
|||||||
setColumnSizing({});
|
setColumnSizing({});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const headerGroups = table.getHeaderGroups;
|
||||||
|
|
||||||
let eventIndex = 0;
|
let eventIndex = 0;
|
||||||
let isPast = Boolean(selectedId);
|
let isPast = Boolean(selectedId);
|
||||||
|
|
||||||
@@ -163,70 +82,28 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
|||||||
)}
|
)}
|
||||||
<div ref={tableContainerRef} className={style.cuesheetContainer}>
|
<div ref={tableContainerRef} className={style.cuesheetContainer}>
|
||||||
<table className={style.cuesheet}>
|
<table className={style.cuesheet}>
|
||||||
<thead className={style.tableHeader}>
|
<CuesheetHeader headerGroups={headerGroups} />
|
||||||
{table.getHeaderGroups().map((headerGroup) => {
|
|
||||||
const key = headerGroup.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DndContext key={key} sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleOnDragEnd}>
|
|
||||||
<tr key={headerGroup.id}>
|
|
||||||
<th className={style.indexColumn}>
|
|
||||||
<Tooltip label='Event Order' openDelay={tooltipDelayFast}>
|
|
||||||
#
|
|
||||||
</Tooltip>
|
|
||||||
</th>
|
|
||||||
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
|
|
||||||
{headerGroup.headers.map((header) => {
|
|
||||||
const width = header.getSize();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SortableCell key={header.column.columnDef.id} header={header} style={{ width }}>
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
|
||||||
</SortableCell>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SortableContext>
|
|
||||||
</tr>
|
|
||||||
</DndContext>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</thead>
|
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{table.getRowModel().rows.map((row) => {
|
{table.getRowModel().rows.map((row) => {
|
||||||
const entryType = row.original.type as SupportedEvent;
|
|
||||||
const key = row.original.id;
|
const key = row.original.id;
|
||||||
const isSelected = selectedId === key;
|
const isSelected = selectedId === key;
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
isPast = false;
|
isPast = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entryType === SupportedEvent.Block) {
|
if (isOntimeBlock(row.original)) {
|
||||||
const title = (row.original as OntimeBlock).title;
|
return <BlockRow key={key} title={row.original.title} />;
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={key} className={style.blockRow}>
|
|
||||||
<td>{title}</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (entryType === SupportedEvent.Delay) {
|
if (isOntimeDelay(row.original)) {
|
||||||
const delayVal = (row.original as OntimeDelay).duration;
|
const delayVal = row.original.duration;
|
||||||
|
|
||||||
if (!showDelayBlock || delayVal === 0) {
|
if (!showDelayBlock || delayVal === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const delayTime = millisToDelayString(delayVal);
|
return <DelayRow key={key} duration={delayVal} />;
|
||||||
return (
|
|
||||||
<tr key={key} className={style.delayRow}>
|
|
||||||
<td>{delayTime}</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (entryType === SupportedEvent.Event) {
|
if (isOntimeEvent(row.original)) {
|
||||||
eventIndex++;
|
eventIndex++;
|
||||||
const isSelected = key === selectedId;
|
const isSelected = key === selectedId;
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
@@ -237,25 +114,20 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bgFallback = 'transparent';
|
|
||||||
const bgColour = (row.original as OntimeEvent).colour || bgFallback;
|
|
||||||
const textColour = bgColour === bgFallback ? undefined : getAccessibleColour(bgColour);
|
|
||||||
const isSkipped = (row.original as OntimeEvent).skip;
|
|
||||||
|
|
||||||
let rowBgColour: string | undefined;
|
let rowBgColour: string | undefined;
|
||||||
if (row.original.id === selectedId) {
|
if (isSelected) {
|
||||||
rowBgColour = '#D20300'; // $red-700
|
rowBgColour = 'var(--cuesheet-running-bg-override, #D20300)'; // $red-700
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<EventRow
|
||||||
key={key}
|
key={key}
|
||||||
className={`${style.eventRow} ${isSkipped ? style.skip : ''}`}
|
eventIndex={eventIndex}
|
||||||
style={{ opacity: `${isPast ? pastOpacity : '1'}` }}
|
isPast={isPast}
|
||||||
ref={isSelected ? selectedRef : undefined}
|
selectedRef={isSelected ? selectedRef : undefined}
|
||||||
|
skip={row.original.skip}
|
||||||
|
colour={row.original.colour}
|
||||||
>
|
>
|
||||||
<td className={style.indexColumn} style={{ backgroundColor: bgColour, color: textColour?.color }}>
|
|
||||||
{eventIndex}
|
|
||||||
</td>
|
|
||||||
{row.getVisibleCells().map((cell) => {
|
{row.getVisibleCells().map((cell) => {
|
||||||
return (
|
return (
|
||||||
<td key={cell.id} style={{ width: cell.column.getSize(), backgroundColor: rowBgColour }}>
|
<td key={cell.id} style={{ width: cell.column.getSize(), backgroundColor: rowBgColour }}>
|
||||||
@@ -263,7 +135,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
|||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tr>
|
</EventRow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo } from 'react';
|
import { useCallback, useEffect, useMemo } from 'react';
|
||||||
import { EventData, OntimeRundownEntry } from 'ontime-types';
|
import { OntimeRundownEntry, ProjectData } from 'ontime-types';
|
||||||
|
|
||||||
import Empty from '../../common/components/state/Empty';
|
import Empty from '../../common/components/state/Empty';
|
||||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||||
@@ -69,7 +69,7 @@ export default function CuesheetWrapper() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const exportHandler = useCallback(
|
const exportHandler = useCallback(
|
||||||
(headerData: EventData) => {
|
(headerData: ProjectData) => {
|
||||||
if (!headerData || !rundown || !userFields) {
|
if (!headerData || !rundown || !userFields) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ exports[`makeTable() > returns array of arrays with given fields 1`] = `
|
|||||||
"Ontime · Schedule Template",
|
"Ontime · Schedule Template",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"Event Name",
|
"Project Title",
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Project Description",
|
||||||
"",
|
"",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ describe('parseField()', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns an empty string on undefined fields', () => {
|
it('returns an empty string on undefined fields', () => {
|
||||||
expect(parseField('presenter', undefined)).toBe('');
|
expect(parseField('presenter')).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('simply returns any other value in any other field', () => {
|
describe('simply returns any other value in any other field', () => {
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
import style from '../Cuesheet.module.scss';
|
||||||
|
|
||||||
|
interface BlockRowProps {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BlockRow(props: BlockRowProps) {
|
||||||
|
const { title } = props;
|
||||||
|
return (
|
||||||
|
<tr className={style.blockRow}>
|
||||||
|
<td>{title}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(BlockRow);
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { Tooltip } from '@chakra-ui/react';
|
||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
DragEndEvent,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
TouchSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
||||||
|
import { flexRender, HeaderGroup } from '@tanstack/react-table';
|
||||||
|
import { OntimeRundownEntry } from 'ontime-types';
|
||||||
|
|
||||||
|
import { useLocalStorage } from '../../../common/hooks/useLocalStorage';
|
||||||
|
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||||
|
import { initialColumnOrder } from '../cuesheetCols';
|
||||||
|
|
||||||
|
import { SortableCell } from './SortableCell';
|
||||||
|
|
||||||
|
import style from '../Cuesheet.module.scss';
|
||||||
|
|
||||||
|
interface CuesheetHeaderProps {
|
||||||
|
headerGroups: () => HeaderGroup<OntimeRundownEntry>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function CuesheetHeader(props: CuesheetHeaderProps) {
|
||||||
|
const { headerGroups } = props;
|
||||||
|
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>('table-order', initialColumnOrder);
|
||||||
|
|
||||||
|
const handleOnDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { delta, active, over } = event;
|
||||||
|
|
||||||
|
// cancel if delta y is greater than 200
|
||||||
|
if (delta.y > 200) return;
|
||||||
|
// cancel if we do not have an over id
|
||||||
|
if (over?.id == null) return;
|
||||||
|
|
||||||
|
// get index of from
|
||||||
|
const fromIndex = columnOrder.indexOf(active.id as string);
|
||||||
|
|
||||||
|
// get index of to
|
||||||
|
const toIndex = columnOrder.indexOf(over.id as string);
|
||||||
|
|
||||||
|
if (toIndex === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reorderedCols = [...columnOrder];
|
||||||
|
const reorderedItem = reorderedCols.splice(fromIndex, 1);
|
||||||
|
reorderedCols.splice(toIndex, 0, reorderedItem[0]);
|
||||||
|
|
||||||
|
saveColumnOrder(reorderedCols);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
delay: 100,
|
||||||
|
tolerance: 50,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(TouchSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
delay: 100,
|
||||||
|
tolerance: 50,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<thead className={style.tableHeader}>
|
||||||
|
{headerGroups().map((headerGroup) => {
|
||||||
|
const key = headerGroup.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext key={key} sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleOnDragEnd}>
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
<th className={style.indexColumn}>
|
||||||
|
<Tooltip label='Event Order' openDelay={tooltipDelayFast}>
|
||||||
|
#
|
||||||
|
</Tooltip>
|
||||||
|
</th>
|
||||||
|
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
const width = header.getSize();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SortableCell key={header.column.columnDef.id} header={header} style={{ width }}>
|
||||||
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</SortableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SortableContext>
|
||||||
|
</tr>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</thead>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(CuesheetHeader);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
interface CuesheetRowProps {
|
||||||
|
row: OntimeRundownEntry;
|
||||||
|
isSelected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CuesheetRow() {}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
import { millisToDelayString } from '../../../common/utils/dateConfig';
|
||||||
|
|
||||||
|
import style from '../Cuesheet.module.scss';
|
||||||
|
|
||||||
|
interface DelayRowProps {
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DelayRow(props: DelayRowProps) {
|
||||||
|
const { duration } = props;
|
||||||
|
const delayTime = millisToDelayString(duration);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className={style.delayRow}>
|
||||||
|
<td>{delayTime}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(DelayRow);
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { memo, MutableRefObject, PropsWithChildren, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||||
|
|
||||||
|
import style from '../Cuesheet.module.scss';
|
||||||
|
|
||||||
|
const pastOpacity = '0.2';
|
||||||
|
|
||||||
|
interface EventRowProps {
|
||||||
|
eventIndex: number;
|
||||||
|
isPast?: boolean;
|
||||||
|
selectedRef?: MutableRefObject<HTMLTableRowElement | null>;
|
||||||
|
skip?: boolean;
|
||||||
|
colour?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventRow(props: PropsWithChildren<EventRowProps>) {
|
||||||
|
const { children, eventIndex, isPast, selectedRef, skip, colour } = props;
|
||||||
|
const ownRef = useRef<HTMLTableRowElement>(null);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
const bgFallback = 'transparent';
|
||||||
|
const bgColour = colour || bgFallback;
|
||||||
|
const textColour = bgColour === bgFallback ? undefined : getAccessibleColour(bgColour);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setIsVisible(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: null,
|
||||||
|
threshold: 0.01,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRefCurrent = ownRef.current;
|
||||||
|
if (selectedRef) {
|
||||||
|
setIsVisible(true);
|
||||||
|
} else if (handleRefCurrent) {
|
||||||
|
observer.observe(handleRefCurrent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (handleRefCurrent) {
|
||||||
|
observer.unobserve(handleRefCurrent);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [ownRef, selectedRef]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
className={`${style.eventRow} ${skip ? style.skip : ''}`}
|
||||||
|
style={{ opacity: `${isPast ? pastOpacity : '1'}` }}
|
||||||
|
ref={selectedRef ?? ownRef}
|
||||||
|
>
|
||||||
|
<td className={style.indexColumn} style={{ backgroundColor: bgColour, color: textColour?.color }}>
|
||||||
|
{eventIndex}
|
||||||
|
</td>
|
||||||
|
{isVisible ? children : null}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(EventRow);
|
||||||
+4
-4
@@ -101,13 +101,13 @@ $active-colour: $gray-500;
|
|||||||
.actionIcon {
|
.actionIcon {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: $active-colour;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.enabled {
|
&.enabled {
|
||||||
color: $active-indicator;
|
color: $active-indicator;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $active-colour;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,21 +3,20 @@ import { IoContract } from '@react-icons/all-files/io5/IoContract';
|
|||||||
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
|
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
|
||||||
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
|
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
|
||||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||||
import { EventData, Playback } from 'ontime-types';
|
import { Playback, ProjectData } from 'ontime-types';
|
||||||
import { formatDisplay } from 'ontime-utils';
|
|
||||||
|
|
||||||
|
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
|
||||||
import useFullscreen from '../../../common/hooks/useFullscreen';
|
import useFullscreen from '../../../common/hooks/useFullscreen';
|
||||||
import { useTimer } from '../../../common/hooks/useSocket';
|
import useProjectData from '../../../common/hooks-query/useProjectData';
|
||||||
import useEventData from '../../../common/hooks-query/useEventData';
|
|
||||||
import { formatTime } from '../../../common/utils/time';
|
|
||||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||||
import { useCuesheetSettings } from '../store/CuesheetSettings';
|
import { useCuesheetSettings } from '../store/CuesheetSettings';
|
||||||
import PlaybackIcon from '../tableElements/PlaybackIcon';
|
|
||||||
|
import CuesheetTableHeaderTimers from './CuesheetTableHeaderTimers';
|
||||||
|
|
||||||
import style from './CuesheetTableHeader.module.scss';
|
import style from './CuesheetTableHeader.module.scss';
|
||||||
|
|
||||||
interface CuesheetTableHeaderProps {
|
interface CuesheetTableHeaderProps {
|
||||||
handleCSVExport: (headerData: EventData) => void;
|
handleCSVExport: (headerData: ProjectData) => void;
|
||||||
featureData: {
|
featureData: {
|
||||||
playback: Playback;
|
playback: Playback;
|
||||||
selectedEventIndex: number | null;
|
selectedEventIndex: number | null;
|
||||||
@@ -31,13 +30,12 @@ export default function CuesheetTableHeader({ handleCSVExport, featureData }: Cu
|
|||||||
const showSettings = useCuesheetSettings((state) => state.showSettings);
|
const showSettings = useCuesheetSettings((state) => state.showSettings);
|
||||||
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
|
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
|
||||||
const toggleFollow = useCuesheetSettings((state) => state.toggleFollow);
|
const toggleFollow = useCuesheetSettings((state) => state.toggleFollow);
|
||||||
const timer = useTimer();
|
|
||||||
const { isFullScreen, toggleFullScreen } = useFullscreen();
|
const { isFullScreen, toggleFullScreen } = useFullscreen();
|
||||||
const { data: event } = useEventData();
|
const { data: project } = useProjectData();
|
||||||
|
|
||||||
const exportCsv = () => {
|
const exportCsv = () => {
|
||||||
if (event) {
|
if (project) {
|
||||||
handleCSVExport(event);
|
handleCSVExport(project);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -47,32 +45,17 @@ export default function CuesheetTableHeader({ handleCSVExport, featureData }: Cu
|
|||||||
featureData.numEvents ? featureData.numEvents : '-'
|
featureData.numEvents ? featureData.numEvents : '-'
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
// prepare presentation variables
|
|
||||||
const isOvertime = (timer.current ?? 0) < 0;
|
|
||||||
const timerNow = timer.current == null ? '-' : `${isOvertime ? '-' : ''}${formatDisplay(timer.current)}`;
|
|
||||||
const timeNow = formatTime(timer.clock, {
|
|
||||||
showSeconds: true,
|
|
||||||
format: 'hh:mm:ss a',
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.header}>
|
<div className={style.header}>
|
||||||
<div className={style.event}>
|
<div className={style.event}>
|
||||||
<div className={style.title}>{event?.title || '-'}</div>
|
<div className={style.title}>{project?.title || '-'}</div>
|
||||||
<div className={style.eventNow}>{featureData?.titleNow || '-'}</div>
|
<div className={style.eventNow}>{featureData?.titleNow || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.playback}>
|
<div className={style.playback}>
|
||||||
<div className={style.playbackLabel}>{selected}</div>
|
<div className={style.playbackLabel}>{selected}</div>
|
||||||
<PlaybackIcon state={featureData.playback} />
|
<PlaybackIcon state={featureData.playback} />
|
||||||
</div>
|
</div>
|
||||||
<div className={style.timer}>
|
<CuesheetTableHeaderTimers />
|
||||||
<div className={style.timerLabel}>Running Timer</div>
|
|
||||||
<div className={style.value}>{timerNow}</div>
|
|
||||||
</div>
|
|
||||||
<div className={style.clock}>
|
|
||||||
<div className={style.clockLabel}>Time Now</div>
|
|
||||||
<div className={style.value}>{timeNow}</div>
|
|
||||||
</div>
|
|
||||||
<div className={style.headerActions}>
|
<div className={style.headerActions}>
|
||||||
<Tooltip openDelay={tooltipDelayFast} label='Toggle follow'>
|
<Tooltip openDelay={tooltipDelayFast} label='Toggle follow'>
|
||||||
<span onClick={() => toggleFollow()} className={`${style.actionIcon} ${followSelected ? style.enabled : ''}`}>
|
<span onClick={() => toggleFollow()} className={`${style.actionIcon} ${followSelected ? style.enabled : ''}`}>
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { formatDisplay } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { useTimer } from '../../../common/hooks/useSocket';
|
||||||
|
import { formatTime } from '../../../common/utils/time';
|
||||||
|
|
||||||
|
import style from './CuesheetTableHeader.module.scss';
|
||||||
|
|
||||||
|
export default function CuesheetTableHeaderTimers() {
|
||||||
|
const timer = useTimer();
|
||||||
|
|
||||||
|
// prepare presentation variables
|
||||||
|
const isOvertime = (timer.current ?? 0) < 0;
|
||||||
|
const timerNow = timer.current == null ? '-' : `${isOvertime ? '-' : ''}${formatDisplay(timer.current)}`;
|
||||||
|
const timeNow = formatTime(timer.clock, {
|
||||||
|
showSeconds: true,
|
||||||
|
format: 'hh:mm:ss a',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={style.timer}>
|
||||||
|
<div className={style.timerLabel}>Running Timer</div>
|
||||||
|
<div className={style.value}>{timerNow}</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.clock}>
|
||||||
|
<div className={style.clockLabel}>Time Now</div>
|
||||||
|
<div className={style.value}>{timeNow}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
import { Button, Checkbox, Switch } from '@chakra-ui/react';
|
import { Button, Checkbox, Switch } from '@chakra-ui/react';
|
||||||
import { Column } from '@tanstack/react-table';
|
import { Column } from '@tanstack/react-table';
|
||||||
import { OntimeRundownEntry } from 'ontime-types';
|
import { OntimeRundownEntry } from 'ontime-types';
|
||||||
@@ -19,7 +20,7 @@ interface CuesheetTableSettingsProps {
|
|||||||
handleClearToggles: () => void;
|
handleClearToggles: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
|
function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
|
||||||
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
|
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
|
||||||
const showPrevious = useCuesheetSettings((state) => state.showPrevious);
|
const showPrevious = useCuesheetSettings((state) => state.showPrevious);
|
||||||
const togglePreviousVisibility = useCuesheetSettings((state) => state.togglePreviousVisibility);
|
const togglePreviousVisibility = useCuesheetSettings((state) => state.togglePreviousVisibility);
|
||||||
@@ -81,3 +82,5 @@ export default function CuesheetTableSettings(props: CuesheetTableSettingsProps)
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(CuesheetTableSettings);
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { Tooltip } from '@chakra-ui/react';
|
|
||||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
||||||
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
|
|
||||||
import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp';
|
|
||||||
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
||||||
import { OntimeEvent, OntimeRundownEntry, UserFields } from 'ontime-types';
|
import { OntimeEvent, OntimeRundownEntry, UserFields } from 'ontime-types';
|
||||||
import { millisToString } from 'ontime-utils';
|
import { millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
import { millisToDelayString } from '../../common/utils/dateConfig';
|
import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator';
|
||||||
import { tooltipDelayFast } from '../../ontimeConfig';
|
|
||||||
|
|
||||||
|
import EditableCell from './cuesheet-table-elements/EditableCell';
|
||||||
import { useCuesheetSettings } from './store/CuesheetSettings';
|
import { useCuesheetSettings } from './store/CuesheetSettings';
|
||||||
import EditableCell from './tableElements/EditableCell';
|
|
||||||
|
|
||||||
import style from './Cuesheet.module.scss';
|
import style from './Cuesheet.module.scss';
|
||||||
|
|
||||||
@@ -20,30 +16,6 @@ function makePublic(row: CellContext<OntimeRundownEntry, unknown>) {
|
|||||||
return cellValue ? <IoCheckmark className={style.check} /> : '';
|
return cellValue ? <IoCheckmark className={style.check} /> : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function DelayIndicator(props: { delayValue: number }) {
|
|
||||||
const { delayValue } = props;
|
|
||||||
if (delayValue < 0) {
|
|
||||||
return (
|
|
||||||
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
|
|
||||||
<span className={style.delaySymbol}>
|
|
||||||
<IoChevronDown />
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (delayValue > 0) {
|
|
||||||
return (
|
|
||||||
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
|
|
||||||
<span className={style.delaySymbol}>
|
|
||||||
<IoChevronUp />
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEntry, unknown>) {
|
function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEntry, unknown>) {
|
||||||
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
|
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
|
||||||
const cellValue = (getValue() as number | null) ?? 0;
|
const cellValue = (getValue() as number | null) ?? 0;
|
||||||
@@ -77,6 +49,13 @@ function MakeUserField({ getValue, row: { index }, column: { id }, table }: Cell
|
|||||||
|
|
||||||
export function makeCuesheetColumns(userFields?: UserFields): ColumnDef<OntimeRundownEntry>[] {
|
export function makeCuesheetColumns(userFields?: UserFields): ColumnDef<OntimeRundownEntry>[] {
|
||||||
return [
|
return [
|
||||||
|
{
|
||||||
|
accessorKey: 'cue',
|
||||||
|
id: 'cue',
|
||||||
|
header: 'Cue',
|
||||||
|
cell: (row) => row.getValue(),
|
||||||
|
size: 75,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'isPublic',
|
accessorKey: 'isPublic',
|
||||||
id: 'isPublic',
|
id: 'isPublic',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { stringify } from 'csv-stringify/browser/esm/sync';
|
import { stringify } from 'csv-stringify/browser/esm/sync';
|
||||||
import { EventData, OntimeEntryCommonKeys, OntimeRundown, UserFields } from 'ontime-types';
|
import { OntimeEntryCommonKeys, OntimeRundown, ProjectData, UserFields } from 'ontime-types';
|
||||||
import { millisToString } from 'ontime-utils';
|
import { millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,10 +38,11 @@ export const parseField = (field: keyof OntimeRundown, data: unknown): string =>
|
|||||||
* @param {object} userFields
|
* @param {object} userFields
|
||||||
* @return {(string[])[]}
|
* @return {(string[])[]}
|
||||||
*/
|
*/
|
||||||
export const makeTable = (headerData: EventData, rundown: OntimeRundown, userFields: UserFields): string[][] => {
|
export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, userFields: UserFields): string[][] => {
|
||||||
const data = [
|
const data = [
|
||||||
['Ontime · Schedule Template'],
|
['Ontime · Schedule Template'],
|
||||||
['Event Name', headerData?.title || ''],
|
['Project Title', headerData?.title || ''],
|
||||||
|
['Project Description', headerData?.description || ''],
|
||||||
['Public URL', headerData?.publicUrl || ''],
|
['Public URL', headerData?.publicUrl || ''],
|
||||||
['Backstage URL', headerData?.backstageUrl || ''],
|
['Backstage URL', headerData?.backstageUrl || ''],
|
||||||
[],
|
[],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { OntimeEntryCommonKeys, OntimeEvent } from 'ontime-types';
|
|||||||
*/
|
*/
|
||||||
export const defaultColumnOrder: OntimeEntryCommonKeys[] = [
|
export const defaultColumnOrder: OntimeEntryCommonKeys[] = [
|
||||||
'isPublic',
|
'isPublic',
|
||||||
|
'cue',
|
||||||
'timeStart',
|
'timeStart',
|
||||||
'timeEnd',
|
'timeEnd',
|
||||||
'duration',
|
'duration',
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ $playback-width: 26rem;
|
|||||||
.eventEditor {
|
.eventEditor {
|
||||||
border-radius: 8px 8px 0 0;
|
border-radius: 8px 8px 0 0;
|
||||||
background-color: $bg-container-l2;
|
background-color: $bg-container-l2;
|
||||||
box-shadow: rgba(0, 0, 0, 0.35) 0 3px 6px 6px;
|
box-shadow: $large-bottom-drawer-shadow;
|
||||||
border-top: 1px solid $white-20;
|
border-top: 1px solid $white-20;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
@@ -164,7 +164,7 @@ $playback-width: 26rem;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
background-color: $gray-1200;
|
background-color: $gray-1250;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
border-left: 1px solid $white-10;
|
border-left: 1px solid $white-10;
|
||||||
border-radius: 0 8px 0 0;
|
border-radius: 0 8px 0 0;
|
||||||
@@ -215,6 +215,11 @@ $playback-width: 26rem;
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.mainContainer > .rundown {
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding-top: 1.5rem;
|
padding-top: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,14 @@
|
|||||||
gap: max(1rem, 2vh);
|
gap: max(1rem, 2vh);
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-areas:
|
grid-template-areas: 'time left right';
|
||||||
'eventInfo eventActions'
|
grid-template-columns: auto 1fr 1fr;
|
||||||
'timeOptions titles';
|
}
|
||||||
grid-template-columns: auto 1fr;
|
|
||||||
|
.timeOptions {
|
||||||
|
grid-area: time;
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
|
||||||
.timers,
|
.timers,
|
||||||
.timeSettings {
|
.timeSettings {
|
||||||
@@ -19,51 +23,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.eventInfo {
|
.left,
|
||||||
grid-area: eventInfo;
|
.right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
.eventId {
|
|
||||||
margin-left:$element-spacing;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.eventActions {
|
.left {
|
||||||
grid-area: eventActions;
|
grid-area: left;
|
||||||
margin-left: auto;
|
padding: 0 1rem;
|
||||||
|
border-left: 1px solid $border-color-ondark;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeOptions {
|
.right {
|
||||||
grid-area: timeOptions;
|
padding-left: 1rem;
|
||||||
display: flex;
|
grid-area: right;
|
||||||
gap: 1.5rem;
|
border-left: 1px solid $border-color-ondark;
|
||||||
}
|
|
||||||
|
|
||||||
.titles {
|
|
||||||
grid-area: titles;
|
|
||||||
display: grid;
|
|
||||||
grid-template-areas: 'left right';
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
|
|
||||||
.left,
|
|
||||||
.right {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left {
|
|
||||||
grid-area: left;
|
|
||||||
padding: 0 1rem;
|
|
||||||
border-left: 1px solid $border-color-ondark;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right {
|
|
||||||
padding-left: 1rem;
|
|
||||||
grid-area: right;
|
|
||||||
border-left: 1px solid $border-color-ondark;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin input-label() {
|
@mixin input-label() {
|
||||||
@@ -94,6 +70,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.eventActions {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.spacer {
|
.spacer {
|
||||||
height: 1.25rem;
|
height: 1.25rem;
|
||||||
}
|
}
|
||||||
@@ -104,6 +86,12 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.splitTwo {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.column {
|
.column {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -116,4 +104,4 @@
|
|||||||
|
|
||||||
.fullHeight {
|
.fullHeight {
|
||||||
height: 100%
|
height: 100%
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,45 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { OntimeEvent } from 'ontime-types';
|
import { isOntimeEvent, OntimeEvent } from 'ontime-types';
|
||||||
|
|
||||||
import CopyTag from '../../common/components/copy-tag/CopyTag';
|
import CopyTag from '../../common/components/copy-tag/CopyTag';
|
||||||
|
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||||
import useRundown from '../../common/hooks-query/useRundown';
|
import useRundown from '../../common/hooks-query/useRundown';
|
||||||
import { useAppMode } from '../../common/stores/appModeStore';
|
import { useAppMode } from '../../common/stores/appModeStore';
|
||||||
import getDelayTo from '../../common/utils/getDelayTo';
|
|
||||||
|
|
||||||
|
import EventEditorDataLeft from './composite/EventEditorDataLeft';
|
||||||
|
import EventEditorDataRight from './composite/EventEditorDataRight';
|
||||||
import EventEditorTimes from './composite/EventEditorTimes';
|
import EventEditorTimes from './composite/EventEditorTimes';
|
||||||
import EventEditorTitles from './composite/EventEditorTitles';
|
|
||||||
|
|
||||||
import style from './EventEditor.module.scss';
|
import style from './EventEditor.module.scss';
|
||||||
|
|
||||||
export type EventEditorSubmitActions = keyof OntimeEvent;
|
export type EventEditorSubmitActions = keyof OntimeEvent;
|
||||||
|
export type EditorUpdateFields = 'cue' | 'title' | 'presenter' | 'subtitle' | 'note' | 'colour';
|
||||||
|
|
||||||
export default function EventEditor() {
|
export default function EventEditor() {
|
||||||
const openId = useAppMode((state) => state.editId);
|
const openId = useAppMode((state) => state.editId);
|
||||||
const { data } = useRundown();
|
const { data } = useRundown();
|
||||||
|
const { updateEvent } = useEventAction();
|
||||||
|
|
||||||
const [event, setEvent] = useState<OntimeEvent | null>(null);
|
const [event, setEvent] = useState<OntimeEvent | null>(null);
|
||||||
const [delay, setDelay] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data || !openId) {
|
if (!data || !openId) {
|
||||||
|
setEvent(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventIndex = data.findIndex((event) => event.id === openId);
|
const event = data.find((event) => event.id === openId);
|
||||||
if (eventIndex > -1) {
|
if (event && isOntimeEvent(event)) {
|
||||||
const event = data[eventIndex];
|
setEvent(event);
|
||||||
if (event.type === 'event') {
|
|
||||||
setDelay(getDelayTo(data, eventIndex));
|
|
||||||
setEvent(data[eventIndex] as OntimeEvent);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [data, event, openId]);
|
}, [data, openId]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(field: EditorUpdateFields, value: string) => {
|
||||||
|
updateEvent({ id: event?.id, [field]: value });
|
||||||
|
},
|
||||||
|
[event?.id, updateEvent],
|
||||||
|
);
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return <span>Loading...</span>;
|
return <span>Loading...</span>;
|
||||||
@@ -40,34 +47,35 @@ export default function EventEditor() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.eventEditor}>
|
<div className={style.eventEditor}>
|
||||||
<div className={style.eventInfo}>
|
|
||||||
Event ID
|
|
||||||
<span className={style.eventId}>
|
|
||||||
<CopyTag label={event.id}>{event.id}</CopyTag>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className={style.eventActions}>
|
|
||||||
<CopyTag label='OSC trigger'>{`/ontime/gotoid/${event.id}`}</CopyTag>
|
|
||||||
</div>
|
|
||||||
<EventEditorTimes
|
<EventEditorTimes
|
||||||
eventId={event.id}
|
eventId={event.id}
|
||||||
timeStart={event.timeStart}
|
timeStart={event.timeStart}
|
||||||
timeEnd={event.timeEnd}
|
timeEnd={event.timeEnd}
|
||||||
duration={event.duration}
|
duration={event.duration}
|
||||||
delay={delay}
|
delay={event.delay ?? 0}
|
||||||
isPublic={event.isPublic}
|
isPublic={event.isPublic}
|
||||||
endAction={event.endAction}
|
endAction={event.endAction}
|
||||||
timerType={event.timerType}
|
timerType={event.timerType}
|
||||||
/>
|
/>
|
||||||
<EventEditorTitles
|
<EventEditorDataLeft
|
||||||
key={event.id}
|
key={`${event.id}-left`}
|
||||||
eventId={event.id}
|
eventId={event.id}
|
||||||
|
cue={event.cue}
|
||||||
title={event.title}
|
title={event.title}
|
||||||
presenter={event.presenter}
|
presenter={event.presenter}
|
||||||
subtitle={event.subtitle}
|
subtitle={event.subtitle}
|
||||||
|
handleSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
<EventEditorDataRight
|
||||||
|
key={`${event.id}-right`}
|
||||||
note={event.note}
|
note={event.note}
|
||||||
colour={event.colour}
|
colour={event.colour}
|
||||||
/>
|
handleSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
<CopyTag label='Event ID'>{event.id}</CopyTag>
|
||||||
|
<CopyTag label='OSC trigger by id'>{`/ontime/gotoid/${event.id}`}</CopyTag>
|
||||||
|
<CopyTag label='OSC trigger by cue'>{`/ontime/gotocue/${event.cue}`}</CopyTag>
|
||||||
|
</EventEditorDataRight>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Textarea } from '@chakra-ui/react';
|
|||||||
|
|
||||||
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
|
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
|
||||||
|
|
||||||
import { TitleActions } from './EventEditorTitles';
|
import { TitleActions } from './EventEditorDataLeft';
|
||||||
|
|
||||||
import style from '../EventEditor.module.scss';
|
import style from '../EventEditor.module.scss';
|
||||||
|
|
||||||
@@ -24,7 +24,9 @@ export default function CountedTextArea(props: CountedTextAreaProps) {
|
|||||||
return (
|
return (
|
||||||
<div className={`${style.column} ${style.fullHeight}`}>
|
<div className={`${style.column} ${style.fullHeight}`}>
|
||||||
<div className={style.countedInput}>
|
<div className={style.countedInput}>
|
||||||
<label className={style.inputLabel} htmlFor={field}>{label}</label>
|
<label className={style.inputLabel} htmlFor={field}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
<span className={style.charCount}>{`${value.length} characters`}</span>
|
<span className={style.charCount}>{`${value.length} characters`}</span>
|
||||||
</div>
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { Input } from '@chakra-ui/react';
|
import { Input, InputProps } from '@chakra-ui/react';
|
||||||
|
|
||||||
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
|
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
|
||||||
|
import { EditorUpdateFields } from '../EventEditor';
|
||||||
import { TitleActions } from './EventEditorTitles';
|
|
||||||
|
|
||||||
import style from '../EventEditor.module.scss';
|
import style from '../EventEditor.module.scss';
|
||||||
|
|
||||||
interface CountedTextInputProps {
|
interface CountedTextInputProps extends InputProps {
|
||||||
field: TitleActions;
|
field: EditorUpdateFields;
|
||||||
label: string;
|
label: string;
|
||||||
initialValue: string;
|
initialValue: string;
|
||||||
submitHandler: (field: TitleActions, value: string) => void;
|
submitHandler: (field: EditorUpdateFields, value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CountedTextInput(props: CountedTextInputProps) {
|
export default function CountedTextInput(props: CountedTextInputProps) {
|
||||||
const { field, label, initialValue, submitHandler } = props;
|
const { field, label, initialValue, submitHandler, maxLength } = props;
|
||||||
|
|
||||||
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
|
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
|
||||||
|
|
||||||
@@ -26,7 +25,9 @@ export default function CountedTextInput(props: CountedTextInputProps) {
|
|||||||
return (
|
return (
|
||||||
<div className={style.column}>
|
<div className={style.column}>
|
||||||
<div className={style.countedInput}>
|
<div className={style.countedInput}>
|
||||||
<label className={style.inputLabel} htmlFor={field}>{label}</label>
|
<label className={style.inputLabel} htmlFor={field}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
<span className={style.charCount}>{`${value.length} characters`}</span>
|
<span className={style.charCount}>{`${value.length} characters`}</span>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
@@ -35,6 +36,7 @@ export default function CountedTextInput(props: CountedTextInputProps) {
|
|||||||
variant='ontime-filled'
|
variant='ontime-filled'
|
||||||
data-testid='input-textfield'
|
data-testid='input-textfield'
|
||||||
value={value}
|
value={value}
|
||||||
|
maxLength={maxLength || 50}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { Input } from '@chakra-ui/react';
|
||||||
|
import { sanitiseCue } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { type EditorUpdateFields } from '../EventEditor';
|
||||||
|
|
||||||
|
import CountedTextInput from './CountedTextInput';
|
||||||
|
|
||||||
|
import style from '../EventEditor.module.scss';
|
||||||
|
|
||||||
|
interface EventEditorLeftProps {
|
||||||
|
eventId: string;
|
||||||
|
cue: string;
|
||||||
|
title: string;
|
||||||
|
presenter: string;
|
||||||
|
subtitle: string;
|
||||||
|
handleSubmit: (field: EditorUpdateFields, value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EventEditorDataLeft = (props: EventEditorLeftProps) => {
|
||||||
|
const { eventId, cue, title, presenter, subtitle, handleSubmit } = props;
|
||||||
|
|
||||||
|
const cueSubmitHandler = (_field: string, newValue: string) => {
|
||||||
|
handleSubmit('cue', sanitiseCue(newValue));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.left}>
|
||||||
|
<div className={style.splitTwo}>
|
||||||
|
<div className={style.column}>
|
||||||
|
<div className={style.countedInput}>
|
||||||
|
<label className={style.inputLabel} htmlFor='eventId'>
|
||||||
|
Event ID (read only)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
id='eventId'
|
||||||
|
size='sm'
|
||||||
|
variant='ontime-filled'
|
||||||
|
data-testid='input-textfield'
|
||||||
|
value={eventId}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CountedTextInput field='cue' label='Cue' initialValue={cue} submitHandler={cueSubmitHandler} maxLength={10} />
|
||||||
|
</div>
|
||||||
|
<CountedTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} />
|
||||||
|
<CountedTextInput field='presenter' label='Presenter' initialValue={presenter} submitHandler={handleSubmit} />
|
||||||
|
<CountedTextInput field='subtitle' label='Subtitle' initialValue={subtitle} submitHandler={handleSubmit} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(EventEditorDataLeft);
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { memo, PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
|
||||||
|
import { EditorUpdateFields } from '../EventEditor';
|
||||||
|
|
||||||
|
import CountedTextArea from './CountedTextArea';
|
||||||
|
|
||||||
|
import style from '../EventEditor.module.scss';
|
||||||
|
|
||||||
|
interface EventEditorRightProps {
|
||||||
|
note: string;
|
||||||
|
colour: string;
|
||||||
|
handleSubmit: (field: EditorUpdateFields, value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EventEditorDataRight = (props: PropsWithChildren<EventEditorRightProps>) => {
|
||||||
|
const { children, note, colour, handleSubmit } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.right}>
|
||||||
|
<div className={style.column}>
|
||||||
|
<label className={style.inputLabel}>Colour</label>
|
||||||
|
<div className={style.inline}>
|
||||||
|
<SwatchSelect name='colour' value={colour} handleChange={handleSubmit} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CountedTextArea field='note' label='Note' initialValue={note} submitHandler={handleSubmit} />
|
||||||
|
<div className={style.eventActions}>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(EventEditorDataRight);
|
||||||
@@ -130,6 +130,7 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
|
|||||||
>
|
>
|
||||||
<option value={TimerType.CountDown}>Count down</option>
|
<option value={TimerType.CountDown}>Count down</option>
|
||||||
<option value={TimerType.CountUp}>Count up</option>
|
<option value={TimerType.CountUp}>Count up</option>
|
||||||
|
<option value={TimerType.TimeToEnd}>Time to end</option>
|
||||||
<option value={TimerType.Clock}>Clock</option>
|
<option value={TimerType.Clock}>Clock</option>
|
||||||
</Select>
|
</Select>
|
||||||
<label className={style.inputLabel}>End Action</label>
|
<label className={style.inputLabel}>End Action</label>
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
import { memo } from 'react';
|
|
||||||
|
|
||||||
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
|
|
||||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
|
||||||
|
|
||||||
import CountedTextArea from './CountedTextArea';
|
|
||||||
import CountedTextInput from './CountedTextInput';
|
|
||||||
|
|
||||||
import style from '../EventEditor.module.scss';
|
|
||||||
|
|
||||||
interface EventEditorTitlesProps {
|
|
||||||
eventId: string;
|
|
||||||
title: string;
|
|
||||||
presenter: string;
|
|
||||||
subtitle: string;
|
|
||||||
note: string;
|
|
||||||
colour: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TitleActions = 'title' | 'presenter' | 'subtitle' | 'note' | 'colour';
|
|
||||||
|
|
||||||
const EventEditorTitles = (props: EventEditorTitlesProps) => {
|
|
||||||
const { eventId, title, presenter, subtitle, note, colour } = props;
|
|
||||||
const { updateEvent } = useEventAction();
|
|
||||||
|
|
||||||
const handleSubmit = (field: TitleActions, value: string) => {
|
|
||||||
updateEvent({ id: eventId, [field]: value });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={style.titles}>
|
|
||||||
<div className={style.left}>
|
|
||||||
<CountedTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} />
|
|
||||||
<CountedTextInput field='presenter' label='Presenter' initialValue={presenter} submitHandler={handleSubmit} />
|
|
||||||
<CountedTextInput field='subtitle' label='Subtitle' initialValue={subtitle} submitHandler={handleSubmit} />
|
|
||||||
</div>
|
|
||||||
<div className={style.right}>
|
|
||||||
<div className={style.column}>
|
|
||||||
<label className={style.inputLabel}>Colour</label>
|
|
||||||
<div className={style.inline}>
|
|
||||||
<SwatchSelect name='colour' value={colour} handleChange={handleSubmit} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<CountedTextArea field='note' label='Note' initialValue={note} submitHandler={handleSubmit} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default memo(EventEditorTitles);
|
|
||||||
@@ -12,17 +12,17 @@ export default function Info() {
|
|||||||
const showNif = useEditorSettings((state) => state.eventSettings.showNif);
|
const showNif = useEditorSettings((state) => state.eventSettings.showNif);
|
||||||
|
|
||||||
const titlesNow = {
|
const titlesNow = {
|
||||||
title: data.titles.titleNow || '',
|
title: data.eventNow?.title || '',
|
||||||
subtitle: data.titles.subtitleNow || '',
|
subtitle: data.eventNow?.subtitle || '',
|
||||||
presenter: data.titles.presenterNow || '',
|
presenter: data.eventNow?.presenter || '',
|
||||||
note: data.titles.noteNow || '',
|
note: data.eventNow?.note || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const titlesNext = {
|
const titlesNext = {
|
||||||
title: data.titles.titleNext || '',
|
title: data.eventNext?.title || '',
|
||||||
subtitle: data.titles.subtitleNext || '',
|
subtitle: data.eventNext?.subtitle || '',
|
||||||
presenter: data.titles.presenterNext || '',
|
presenter: data.eventNext?.presenter || '',
|
||||||
note: data.titles.noteNext || '',
|
note: data.eventNext?.note || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const selected = !data.numEvents
|
const selected = !data.numEvents
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||||
|
|
||||||
|
import { serverPort } from '../../common/api/apiConstants';
|
||||||
import useInfo from '../../common/hooks-query/useInfo';
|
import useInfo from '../../common/hooks-query/useInfo';
|
||||||
import { openLink } from '../../common/utils/linkUtils';
|
import { openLink } from '../../common/utils/linkUtils';
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ export default function InfoNif() {
|
|||||||
const { data } = useInfo();
|
const { data } = useInfo();
|
||||||
|
|
||||||
const handleClick = (address: string) => {
|
const handleClick = (address: string) => {
|
||||||
const baseURL = 'http://__IP__:4001';
|
const baseURL = `http://__IP__:${serverPort}`;
|
||||||
openLink(baseURL.replace('__IP__', address));
|
openLink(baseURL.replace('__IP__', address));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import useEventData from '../../../common/hooks-query/useEventData';
|
import useProjectData from '../../../common/hooks-query/useProjectData';
|
||||||
|
|
||||||
import style from '../Info.module.scss';
|
import style from '../Info.module.scss';
|
||||||
|
|
||||||
export default function InfoHeader({ selected }: { selected: string }) {
|
export default function InfoHeader({ selected }: { selected: string }) {
|
||||||
const { data } = useEventData();
|
const { data } = useProjectData();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.headerButtons {
|
.headerButtons {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
padding-top: 24px;
|
padding: 1.5rem 1rem 0 1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ import {
|
|||||||
ModalOverlay,
|
ModalOverlay,
|
||||||
Textarea,
|
Textarea,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import type { EventData } from 'ontime-types';
|
import type { ProjectData } from 'ontime-types';
|
||||||
|
|
||||||
import { EVENT_DATA, RUNDOWN_TABLE } from '../../../common/api/apiConstants';
|
import { PROJECT_DATA, RUNDOWN_TABLE } from '../../../common/api/apiConstants';
|
||||||
import { postNew } from '../../../common/api/ontimeApi';
|
import { postNew } from '../../../common/api/ontimeApi';
|
||||||
import useEventData from '../../../common/hooks-query/useEventData';
|
import useProjectData from '../../../common/hooks-query/useProjectData';
|
||||||
import { eventDataPlaceholder } from '../../../common/models/EventData';
|
import { projectDataPlaceholder } from '../../../common/models/ProjectData';
|
||||||
import { ontimeQueryClient } from '../../../common/queryClient';
|
import { ontimeQueryClient } from '../../../common/queryClient';
|
||||||
|
|
||||||
import styles from '../Modal.module.scss';
|
import styles from '../Modal.module.scss';
|
||||||
@@ -32,7 +32,7 @@ interface QuickStartProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
|
export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
|
||||||
const { data, status } = useEventData();
|
const { data, status } = useProjectData();
|
||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
register,
|
register,
|
||||||
@@ -49,10 +49,10 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
|
|||||||
if (data) reset(data);
|
if (data) reset(data);
|
||||||
}, [data, reset]);
|
}, [data, reset]);
|
||||||
|
|
||||||
const onSubmit = async (data: Partial<EventData>) => {
|
const onSubmit = async (data: Partial<ProjectData>) => {
|
||||||
try {
|
try {
|
||||||
await postNew(data);
|
await postNew(data);
|
||||||
await ontimeQueryClient.invalidateQueries(EVENT_DATA);
|
await ontimeQueryClient.invalidateQueries(PROJECT_DATA);
|
||||||
await ontimeQueryClient.invalidateQueries(RUNDOWN_TABLE);
|
await ontimeQueryClient.invalidateQueries(RUNDOWN_TABLE);
|
||||||
|
|
||||||
onClose();
|
onClose();
|
||||||
@@ -61,7 +61,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onReset = () => reset(eventDataPlaceholder);
|
const onReset = () => reset(projectDataPlaceholder);
|
||||||
|
|
||||||
const disableButtons = status !== 'success' || isSubmitting;
|
const disableButtons = status !== 'success' || isSubmitting;
|
||||||
return (
|
return (
|
||||||
@@ -86,13 +86,13 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
|
|||||||
<div className={styles.column}>
|
<div className={styles.column}>
|
||||||
<AlertTitle>Note</AlertTitle>
|
<AlertTitle>Note</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
On submit, application options will be kept but rundown and event data will be reset
|
On submit, application options will be kept but rundown and project data will be reset
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
</Alert>
|
</Alert>
|
||||||
<div className={styles.entryRow}>
|
<div className={styles.entryRow}>
|
||||||
<label className={styles.sectionTitle}>
|
<label className={styles.sectionTitle}>
|
||||||
Event title
|
Project title
|
||||||
<Input
|
<Input
|
||||||
variant='ontime-filled-on-light'
|
variant='ontime-filled-on-light'
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -104,7 +104,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.entryRow}>
|
<div className={styles.entryRow}>
|
||||||
<label className={styles.sectionTitle}>
|
<label className={styles.sectionTitle}>
|
||||||
Event description
|
Project description
|
||||||
<Input
|
<Input
|
||||||
variant='ontime-filled-on-light'
|
variant='ontime-filled-on-light'
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -116,7 +116,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.entryRow}>
|
<div className={styles.entryRow}>
|
||||||
<label className={styles.sectionTitle}>
|
<label className={styles.sectionTitle}>
|
||||||
Public Info
|
Public info
|
||||||
<Textarea
|
<Textarea
|
||||||
variant='ontime-filled-on-light'
|
variant='ontime-filled-on-light'
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -128,7 +128,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.entryRow}>
|
<div className={styles.entryRow}>
|
||||||
<label className={styles.sectionTitle}>
|
<label className={styles.sectionTitle}>
|
||||||
Public QR Code Url
|
Public QR code Url
|
||||||
<Input
|
<Input
|
||||||
variant='ontime-filled-on-light'
|
variant='ontime-filled-on-light'
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -139,7 +139,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.entryRow}>
|
<div className={styles.entryRow}>
|
||||||
<label className={styles.sectionTitle}>
|
<label className={styles.sectionTitle}>
|
||||||
Backstage Info
|
Backstage info
|
||||||
<Textarea
|
<Textarea
|
||||||
variant='ontime-filled-on-light'
|
variant='ontime-filled-on-light'
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -151,7 +151,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.entryRow}>
|
<div className={styles.entryRow}>
|
||||||
<label className={styles.sectionTitle}>
|
<label className={styles.sectionTitle}>
|
||||||
Backstage QR Code Url
|
Backstage QR code Url
|
||||||
<Input
|
<Input
|
||||||
variant='ontime-filled-on-light'
|
variant='ontime-filled-on-light'
|
||||||
size='sm'
|
size='sm'
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export default function AppSettingsModal() {
|
|||||||
<ModalSplitInput
|
<ModalSplitInput
|
||||||
field='serverPort'
|
field='serverPort'
|
||||||
title='Ontime is available on port'
|
title='Ontime is available on port'
|
||||||
description='Default 4001'
|
description='Default 4001 (needs app restart to change)'
|
||||||
error={errors.serverPort?.message}
|
error={errors.serverPort?.message}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
@@ -69,7 +69,6 @@ export default function AppSettingsModal() {
|
|||||||
size='sm'
|
size='sm'
|
||||||
textAlign='right'
|
textAlign='right'
|
||||||
maxLength={5}
|
maxLength={5}
|
||||||
disabled
|
|
||||||
variant='ontime-filled-on-light'
|
variant='ontime-filled-on-light'
|
||||||
{...register('serverPort', {
|
{...register('serverPort', {
|
||||||
required: { value: true, message: 'Required field' },
|
required: { value: true, message: 'Required field' },
|
||||||
|
|||||||
+15
-15
@@ -1,11 +1,11 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { Input, Textarea } from '@chakra-ui/react';
|
import { Input, Textarea } from '@chakra-ui/react';
|
||||||
import { EventData } from 'ontime-types';
|
import { ProjectData } from 'ontime-types';
|
||||||
|
|
||||||
import { logAxiosError } from '../../../common/api/apiUtils';
|
import { logAxiosError } from '../../../common/api/apiUtils';
|
||||||
import { postEventData } from '../../../common/api/eventDataApi';
|
import { postProjectData } from '../../../common/api/projectDataApi';
|
||||||
import useEventData from '../../../common/hooks-query/useEventData';
|
import useProjectData from '../../../common/hooks-query/useProjectData';
|
||||||
import ModalLoader from '../modal-loader/ModalLoader';
|
import ModalLoader from '../modal-loader/ModalLoader';
|
||||||
import { inputProps } from '../modalHelper';
|
import { inputProps } from '../modalHelper';
|
||||||
import ModalInput from '../ModalInput';
|
import ModalInput from '../ModalInput';
|
||||||
@@ -13,14 +13,14 @@ import OntimeModalFooter from '../OntimeModalFooter';
|
|||||||
|
|
||||||
import style from './SettingsModal.module.scss';
|
import style from './SettingsModal.module.scss';
|
||||||
|
|
||||||
export default function EventDataForm() {
|
export default function ProjectDataForm() {
|
||||||
const { data, status, isFetching, refetch } = useEventData();
|
const { data, status, isFetching, refetch } = useProjectData();
|
||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
register,
|
register,
|
||||||
reset,
|
reset,
|
||||||
formState: { errors, isSubmitting, isDirty, isValid },
|
formState: { errors, isSubmitting, isDirty, isValid },
|
||||||
} = useForm<EventData>({
|
} = useForm<ProjectData>({
|
||||||
defaultValues: data,
|
defaultValues: data,
|
||||||
values: data,
|
values: data,
|
||||||
resetOptions: {
|
resetOptions: {
|
||||||
@@ -34,11 +34,11 @@ export default function EventDataForm() {
|
|||||||
}
|
}
|
||||||
}, [data, reset]);
|
}, [data, reset]);
|
||||||
|
|
||||||
const onSubmit = async (formData: EventData) => {
|
const onSubmit = async (formData: ProjectData) => {
|
||||||
try {
|
try {
|
||||||
await postEventData(formData);
|
await postProjectData(formData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error saving event settings', error);
|
logAxiosError('Error saving project data', error);
|
||||||
} finally {
|
} finally {
|
||||||
await refetch();
|
await refetch();
|
||||||
}
|
}
|
||||||
@@ -55,10 +55,10 @@ export default function EventDataForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(onSubmit)} id='event-data' className={style.sectionContainer}>
|
<form onSubmit={handleSubmit(onSubmit)} id='project-data' className={style.sectionContainer}>
|
||||||
<ModalInput
|
<ModalInput
|
||||||
field='title'
|
field='title'
|
||||||
title='Event title'
|
title='Project title'
|
||||||
description='Shown in overview screens'
|
description='Shown in overview screens'
|
||||||
error={errors.title?.message}
|
error={errors.title?.message}
|
||||||
>
|
>
|
||||||
@@ -73,7 +73,7 @@ export default function EventDataForm() {
|
|||||||
</ModalInput>
|
</ModalInput>
|
||||||
<ModalInput
|
<ModalInput
|
||||||
field='description'
|
field='description'
|
||||||
title='Event description'
|
title='Project description'
|
||||||
description='Free field, shown in editor'
|
description='Free field, shown in editor'
|
||||||
error={errors.description?.message}
|
error={errors.description?.message}
|
||||||
>
|
>
|
||||||
@@ -87,7 +87,7 @@ export default function EventDataForm() {
|
|||||||
/>
|
/>
|
||||||
</ModalInput>
|
</ModalInput>
|
||||||
<div style={{ height: '16px' }} />
|
<div style={{ height: '16px' }} />
|
||||||
<ModalInput field='publicInfo' title='Public Info' description='Information shown in public screens'>
|
<ModalInput field='publicInfo' title='Public info' description='Information shown in public screens'>
|
||||||
<Textarea
|
<Textarea
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
variant='ontime-filled-on-light'
|
variant='ontime-filled-on-light'
|
||||||
@@ -107,7 +107,7 @@ export default function EventDataForm() {
|
|||||||
/>
|
/>
|
||||||
</ModalInput>
|
</ModalInput>
|
||||||
<div style={{ height: '16px' }} />
|
<div style={{ height: '16px' }} />
|
||||||
<ModalInput field='backstageInfo' title='Backstage Info' description='Information shown in public screens'>
|
<ModalInput field='backstageInfo' title='Backstage info' description='Information shown in public screens'>
|
||||||
<Textarea
|
<Textarea
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
variant='ontime-filled-on-light'
|
variant='ontime-filled-on-light'
|
||||||
@@ -128,7 +128,7 @@ export default function EventDataForm() {
|
|||||||
/>
|
/>
|
||||||
</ModalInput>
|
</ModalInput>
|
||||||
<OntimeModalFooter
|
<OntimeModalFooter
|
||||||
formId='event-data'
|
formId='project-data'
|
||||||
handleRevert={onReset}
|
handleRevert={onReset}
|
||||||
isDirty={isDirty}
|
isDirty={isDirty}
|
||||||
isValid={isValid}
|
isValid={isValid}
|
||||||
@@ -6,7 +6,7 @@ import AliasesForm from './AliasesForm';
|
|||||||
import AppSettingsModal from './AppSettings';
|
import AppSettingsModal from './AppSettings';
|
||||||
import CuesheetSettingsForm from './CuesheetSettingsForm';
|
import CuesheetSettingsForm from './CuesheetSettingsForm';
|
||||||
import EditorSettings from './EditorSettings';
|
import EditorSettings from './EditorSettings';
|
||||||
import EventDataForm from './EventDataForm';
|
import ProjectDataForm from './ProjectDataForm';
|
||||||
import ViewSettingsForm from './ViewSettingsForm';
|
import ViewSettingsForm from './ViewSettingsForm';
|
||||||
|
|
||||||
interface ModalManagerProps {
|
interface ModalManagerProps {
|
||||||
@@ -22,7 +22,7 @@ export default function SettingsModal(props: ModalManagerProps) {
|
|||||||
<Tabs variant='ontime' size='sm' isLazy>
|
<Tabs variant='ontime' size='sm' isLazy>
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab>App</Tab>
|
<Tab>App</Tab>
|
||||||
<Tab>Event Data</Tab>
|
<Tab>Project Data</Tab>
|
||||||
<Tab>Editor</Tab>
|
<Tab>Editor</Tab>
|
||||||
<Tab>Cuesheet</Tab>
|
<Tab>Cuesheet</Tab>
|
||||||
<Tab>Views</Tab>
|
<Tab>Views</Tab>
|
||||||
@@ -33,7 +33,7 @@ export default function SettingsModal(props: ModalManagerProps) {
|
|||||||
<AppSettingsModal />
|
<AppSettingsModal />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
<EventDataForm />
|
<ProjectDataForm />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
<EditorSettings />
|
<EditorSettings />
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
@use '../../../src/theme/v2Styles' as *;
|
||||||
|
@use '../../../src/theme/ontimeColours' as *;
|
||||||
|
|
||||||
|
.operatorContainer {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
|
||||||
|
color: $ui-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operatorEvents {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
min-height: 95vh;
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { isOntimeEvent, OntimeEvent, SupportedEvent, UserFields } from 'ontime-types';
|
||||||
|
import { getFirstEvent, getLastEvent } from 'ontime-utils';
|
||||||
|
|
||||||
|
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
|
||||||
|
import Empty from '../../common/components/state/Empty';
|
||||||
|
import { getOperatorOptions } from '../../common/components/view-params-editor/constants';
|
||||||
|
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
|
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||||
|
import { useOperator } from '../../common/hooks/useSocket';
|
||||||
|
import useProjectData from '../../common/hooks-query/useProjectData';
|
||||||
|
import useRundown from '../../common/hooks-query/useRundown';
|
||||||
|
import useUserFields from '../../common/hooks-query/useUserFields';
|
||||||
|
import { isStringBoolean } from '../../common/utils/viewUtils';
|
||||||
|
|
||||||
|
import FollowButton from './follow-button/FollowButton';
|
||||||
|
import OperatorBlock from './operator-block/OperatorBlock';
|
||||||
|
import OperatorEvent from './operator-event/OperatorEvent';
|
||||||
|
import StatusBar from './status-bar/StatusBar';
|
||||||
|
|
||||||
|
import style from './Operator.module.scss';
|
||||||
|
|
||||||
|
const selectedOffset = 50;
|
||||||
|
|
||||||
|
type TitleFields = Pick<OntimeEvent, 'title' | 'subtitle' | 'presenter'>;
|
||||||
|
export default function Operator() {
|
||||||
|
const { data, status } = useRundown();
|
||||||
|
const { data: userFields, status: userFieldsStatus } = useUserFields();
|
||||||
|
const { data: projectData, status: projectDataStatus } = useProjectData();
|
||||||
|
|
||||||
|
const featureData = useOperator();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const isAutomatedScroll = useRef(false);
|
||||||
|
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
||||||
|
const selectedRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const scrollToComponent = useFollowComponent({
|
||||||
|
followRef: selectedRef,
|
||||||
|
scrollRef: scrollRef,
|
||||||
|
doFollow: !lockAutoScroll,
|
||||||
|
topOffset: selectedOffset,
|
||||||
|
setScrollFlag: () => (isAutomatedScroll.current = true),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set window title
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = 'ontime - Operator';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// reset scroll if nothing is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (!featureData?.selectedEventId) {
|
||||||
|
if (!lockAutoScroll) {
|
||||||
|
scrollRef.current?.scrollTo(0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [featureData?.selectedEventId, lockAutoScroll, scrollRef]);
|
||||||
|
|
||||||
|
const handleOffset = () => {
|
||||||
|
if (featureData.selectedEventId) {
|
||||||
|
scrollToComponent();
|
||||||
|
}
|
||||||
|
setLockAutoScroll(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
// prevent considering automated scrolls as user scrolls
|
||||||
|
if (isAutomatedScroll.current) {
|
||||||
|
isAutomatedScroll.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedRef?.current && scrollRef?.current) {
|
||||||
|
const selectedRect = selectedRef.current.getBoundingClientRect();
|
||||||
|
const scrollerRect = scrollRef.current.getBoundingClientRect();
|
||||||
|
if (selectedRect && scrollerRect) {
|
||||||
|
const distanceFromTop = selectedRect.top - scrollerRect.top;
|
||||||
|
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > selectedOffset;
|
||||||
|
setLockAutoScroll(hasScrolledOutOfThreshold);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const missingData = !data || !userFields || !projectData;
|
||||||
|
const isLoading = status === 'loading' || userFieldsStatus === 'loading' || projectDataStatus === 'loading';
|
||||||
|
|
||||||
|
if (missingData || isLoading) {
|
||||||
|
return <Empty text='Loading...' />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get fields which the user subscribed to
|
||||||
|
const subscribe = searchParams.get('subscribe') as keyof UserFields | null;
|
||||||
|
const main = searchParams.get('main') as keyof TitleFields | null;
|
||||||
|
const secondary = searchParams.get('secondary') as keyof TitleFields | null;
|
||||||
|
const subscribedAlias = subscribe ? userFields[subscribe] : '';
|
||||||
|
const showSeconds = isStringBoolean(searchParams.get('showseconds'));
|
||||||
|
|
||||||
|
const operatorOptions = getOperatorOptions(userFields);
|
||||||
|
let isPast = Boolean(featureData.selectedEventId);
|
||||||
|
const hidePast = isStringBoolean(searchParams.get('hidepast'));
|
||||||
|
|
||||||
|
const firstEvent = getFirstEvent(data);
|
||||||
|
const lastEvent = getLastEvent(data);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.operatorContainer}>
|
||||||
|
<NavigationMenu />
|
||||||
|
<ViewParamsEditor paramFields={operatorOptions} />
|
||||||
|
|
||||||
|
<StatusBar
|
||||||
|
projectTitle={projectData.title}
|
||||||
|
playback={featureData.playback}
|
||||||
|
selectedEventId={featureData.selectedEventId}
|
||||||
|
firstStart={firstEvent?.timeStart}
|
||||||
|
firstId={firstEvent?.id}
|
||||||
|
lastEnd={lastEvent?.timeEnd}
|
||||||
|
lastId={lastEvent?.id}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={style.operatorEvents} onScroll={handleScroll} ref={scrollRef}>
|
||||||
|
{data.map((entry) => {
|
||||||
|
if (isOntimeEvent(entry)) {
|
||||||
|
const isSelected = featureData.selectedEventId === entry.id;
|
||||||
|
if (isSelected) {
|
||||||
|
isPast = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// hide past events (if setting) and skipped events
|
||||||
|
if ((hidePast && isPast) || entry.skip) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainField = main ? entry?.[main] || entry.title : entry.title;
|
||||||
|
const secondaryField = secondary ? entry?.[secondary] || entry.subtitle : entry.subtitle;
|
||||||
|
const subscribedData = (subscribe ? entry?.[subscribe] : undefined) || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OperatorEvent
|
||||||
|
key={entry.id}
|
||||||
|
colour={entry.colour}
|
||||||
|
cue={entry.cue}
|
||||||
|
main={mainField}
|
||||||
|
secondary={secondaryField}
|
||||||
|
timeStart={entry.timeStart}
|
||||||
|
timeEnd={entry.timeEnd}
|
||||||
|
duration={entry.duration}
|
||||||
|
delay={entry.delay}
|
||||||
|
isSelected={isSelected}
|
||||||
|
subscribed={subscribedData}
|
||||||
|
subscribedAlias={subscribedAlias}
|
||||||
|
showSeconds={showSeconds}
|
||||||
|
isPast={isPast}
|
||||||
|
selectedRef={isSelected ? selectedRef : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.type === SupportedEvent.Block) {
|
||||||
|
return <OperatorBlock key={entry.id} title={entry.title} />;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
<div className={style.spacer} />
|
||||||
|
</div>
|
||||||
|
<FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
@use '../../../../src/theme/ontimeColours' as *;
|
||||||
|
@use '../../../../src/theme/v2Styles' as *;
|
||||||
|
|
||||||
|
.followButton {
|
||||||
|
position: relative;
|
||||||
|
bottom: 12rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.25rem 1rem;
|
||||||
|
background-color: $blue-700;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
transition: bottom 1s;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transition: background-color $transition-time-action;
|
||||||
|
background-color: $blue-900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
transition: bottom 1s;
|
||||||
|
bottom: -50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// tablet
|
||||||
|
@media (min-width: $min-tablet) {
|
||||||
|
.followButton {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.25rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
|
||||||
|
|
||||||
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
|
|
||||||
|
import style from './FollowButton.module.scss';
|
||||||
|
|
||||||
|
interface FollowButtonProps {
|
||||||
|
isVisible: boolean;
|
||||||
|
onClickHandler: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FollowButton(props: FollowButtonProps) {
|
||||||
|
const { isVisible, onClickHandler } = props;
|
||||||
|
|
||||||
|
const classes = cx([style.followButton, !isVisible && style.hidden]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className={classes} onClick={onClickHandler} type='button'>
|
||||||
|
<IoLocate />
|
||||||
|
Follow
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
@use '../../../../src/theme/ontimeColours' as *;
|
||||||
|
@use '../../../../src/theme/v2Styles' as *;
|
||||||
|
|
||||||
|
.block {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background-color: $gray-1350;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// tablet
|
||||||
|
@media (min-width: $min-tablet) {
|
||||||
|
.block {
|
||||||
|
padding: 0.25rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
@use '../../../../src/theme/v2Styles' as *;
|
||||||
|
@use '../../../../src/theme/ontimeColours' as *;
|
||||||
|
@import '../Operator.module.scss';
|
||||||
|
|
||||||
|
@mixin clock-size {
|
||||||
|
font-size: calc(1rem - 2px);
|
||||||
|
@media (min-width: $min-tablet) {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.event {
|
||||||
|
opacity: 1;
|
||||||
|
border-top: 1px solid $white-1;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
color: $white-90;
|
||||||
|
background-color: $gray-1300;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
grid-template-columns: 1.25rem 1fr auto;
|
||||||
|
grid-template-rows: auto auto auto;
|
||||||
|
column-gap: 0.5rem;
|
||||||
|
grid-template-areas:
|
||||||
|
"binder main schedule"
|
||||||
|
"binder secondary running"
|
||||||
|
"binder fields fields";
|
||||||
|
|
||||||
|
&.subscribed {
|
||||||
|
background-color: $gray-1250;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.running {
|
||||||
|
border-top: 1px solid $gray-1300;
|
||||||
|
background-color: var(--operator-running-bg-override, $red-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.past {
|
||||||
|
border-top: 1px solid transparent;
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.binder {
|
||||||
|
grid-area: binder;
|
||||||
|
color: $section-white;
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
position: relative;
|
||||||
|
background-color: $gray-1050; // to override inline
|
||||||
|
|
||||||
|
.cue {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
text-align: center;
|
||||||
|
width: 6em;
|
||||||
|
|
||||||
|
rotate: -90deg;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mainField {
|
||||||
|
grid-area: main;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: $ui-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondaryField {
|
||||||
|
grid-area: secondary;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule {
|
||||||
|
@include clock-size;
|
||||||
|
grid-area: schedule;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.running {
|
||||||
|
@include clock-size;
|
||||||
|
grid-area: running;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields {
|
||||||
|
grid-area: fields;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: $ui-black;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
|
||||||
|
.field {
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
background-color: var(--operator-highlight-override, $orange-600);
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
color: $orange-500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields::after {
|
||||||
|
content: '\200b';
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { memo, RefObject } from 'react';
|
||||||
|
|
||||||
|
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
|
||||||
|
import { useTimer } from '../../../common/hooks/useSocket';
|
||||||
|
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||||
|
import { formatTime } from '../../../common/utils/time';
|
||||||
|
|
||||||
|
import style from './OperatorEvent.module.scss';
|
||||||
|
|
||||||
|
interface OperatorEventProps {
|
||||||
|
colour: string;
|
||||||
|
cue: string;
|
||||||
|
main: string;
|
||||||
|
secondary: string;
|
||||||
|
timeStart: number;
|
||||||
|
timeEnd: number;
|
||||||
|
duration: number;
|
||||||
|
delay?: number;
|
||||||
|
isSelected: boolean;
|
||||||
|
subscribed?: string;
|
||||||
|
subscribedAlias: string;
|
||||||
|
showSeconds: boolean;
|
||||||
|
isPast: boolean;
|
||||||
|
selectedRef?: RefObject<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// extract this to contain re-renders
|
||||||
|
function RollingTime() {
|
||||||
|
const timer = useTimer();
|
||||||
|
return <>{formatTime(timer.current, { showSeconds: true, format: 'hh:mm:ss' })}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OperatorEvent(props: OperatorEventProps) {
|
||||||
|
const {
|
||||||
|
colour,
|
||||||
|
cue,
|
||||||
|
main,
|
||||||
|
secondary,
|
||||||
|
timeStart,
|
||||||
|
timeEnd,
|
||||||
|
duration,
|
||||||
|
delay,
|
||||||
|
isSelected,
|
||||||
|
subscribed,
|
||||||
|
subscribedAlias,
|
||||||
|
showSeconds,
|
||||||
|
isPast,
|
||||||
|
selectedRef,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const start = formatTime(timeStart, { showSeconds });
|
||||||
|
const end = formatTime(timeEnd, { showSeconds });
|
||||||
|
|
||||||
|
const cueColours = colour && getAccessibleColour(colour);
|
||||||
|
|
||||||
|
const operatorClasses = cx([
|
||||||
|
style.event,
|
||||||
|
isSelected ? style.running : null,
|
||||||
|
subscribed ? style.subscribed : null,
|
||||||
|
isPast ? style.past : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={operatorClasses} ref={selectedRef}>
|
||||||
|
<div className={style.binder} style={{ ...cueColours }}>
|
||||||
|
<span className={style.cue}>{cue}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className={style.mainField}>{main}</span>
|
||||||
|
<span className={style.schedule}>
|
||||||
|
{start} - {end}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className={style.secondaryField}>{secondary}</span>
|
||||||
|
<span className={style.running}>
|
||||||
|
<DelayIndicator delayValue={delay} />
|
||||||
|
{isSelected ? <RollingTime /> : formatTime(duration, { showSeconds: true, format: 'hh:mm:ss' })}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className={style.fields}>
|
||||||
|
{subscribed && (
|
||||||
|
<>
|
||||||
|
<span className={style.field}>{subscribedAlias}</span>
|
||||||
|
<span className={style.value}>{subscribed}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(OperatorEvent);
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
@use '../../../../src/theme/ontimeColours' as *;
|
||||||
|
@use '../../../../src/theme/v2Styles' as *;
|
||||||
|
|
||||||
|
@mixin column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
background-color: $gray-1350;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-bottom: 1px solid $white-10;
|
||||||
|
box-shadow: $large-top-drawer-shadow;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-areas:
|
||||||
|
"playback timer1B timer2B timer3B";
|
||||||
|
grid-template-columns: 1fr auto auto auto;
|
||||||
|
column-gap: 1.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playbackIcon {
|
||||||
|
grid-area: playback;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: $gray-700;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: $ui-white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeNow {
|
||||||
|
grid-area: timer1B;
|
||||||
|
@include column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.elapsedTime {
|
||||||
|
grid-area: timer2B;
|
||||||
|
@include column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runningTime {
|
||||||
|
grid-area: timer3B;
|
||||||
|
@include column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
grid-area: title;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
display: none;
|
||||||
|
line-height: 1.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.startTime {
|
||||||
|
grid-area: timer2A;
|
||||||
|
@include column;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endTime {
|
||||||
|
grid-area: timer3A;
|
||||||
|
@include column;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer {
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// tablet
|
||||||
|
@media (min-width: $min-tablet) {
|
||||||
|
.statusBar {
|
||||||
|
grid-template-areas:
|
||||||
|
"playback timer1B timer2A timer3A"
|
||||||
|
"title title timer2B timer3B";
|
||||||
|
row-gap: 0.25rem;
|
||||||
|
column-gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.startTime {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endTime {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Playback } from 'ontime-types';
|
||||||
|
import { millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
|
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
|
||||||
|
import { useTimer } from '../../../common/hooks/useSocket';
|
||||||
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
|
import { formatTime } from '../../../common/utils/time';
|
||||||
|
|
||||||
|
import styles from './StatusBar.module.scss';
|
||||||
|
|
||||||
|
interface StatusBarProps {
|
||||||
|
projectTitle: string;
|
||||||
|
playback: Playback;
|
||||||
|
selectedEventId: string | null;
|
||||||
|
firstStart?: number;
|
||||||
|
firstId?: string;
|
||||||
|
lastEnd?: number;
|
||||||
|
lastId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusBar(props: StatusBarProps) {
|
||||||
|
const { projectTitle, playback, selectedEventId, firstStart, firstId, lastEnd, lastId } = props;
|
||||||
|
|
||||||
|
const timer = useTimer();
|
||||||
|
|
||||||
|
const getTimeStart = () => {
|
||||||
|
if (firstStart === undefined) {
|
||||||
|
return '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedEventId) {
|
||||||
|
if (firstId === selectedEventId) {
|
||||||
|
return millisToString(timer.expectedFinish);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return millisToString(firstStart);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTimeEnd = () => {
|
||||||
|
if (lastEnd === undefined) {
|
||||||
|
return '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedEventId) {
|
||||||
|
if (lastId === selectedEventId) {
|
||||||
|
return millisToString(timer.expectedFinish);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return millisToString(lastEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
// use user defined format
|
||||||
|
const timeNow = formatTime(timer.clock, {
|
||||||
|
showSeconds: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const runningTime = millisToString(timer.current);
|
||||||
|
const elapsedTime = millisToString(timer.elapsed);
|
||||||
|
|
||||||
|
const PlaybackIconComponent = useMemo(() => {
|
||||||
|
const isPlaying = playback === Playback.Play || playback === Playback.Roll;
|
||||||
|
const classes = cx([styles.playbackIcon, isPlaying ? styles.active : null]);
|
||||||
|
return <PlaybackIcon state={playback} skipTooltip className={classes} />;
|
||||||
|
}, [playback]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.statusBar}>
|
||||||
|
{PlaybackIconComponent}
|
||||||
|
<div className={styles.timeNow}>
|
||||||
|
<span className={styles.label}>Time now</span>
|
||||||
|
<span className={styles.timer}>{timeNow}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.elapsedTime}>
|
||||||
|
<span className={styles.label}>Elapsed time</span>
|
||||||
|
<span className={styles.timer}>{elapsedTime}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.runningTime}>
|
||||||
|
<span className={styles.label}>Running timer</span>
|
||||||
|
<span className={styles.timer}>{runningTime}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className={styles.title}>{projectTitle}</span>
|
||||||
|
<div className={styles.startTime}>
|
||||||
|
<span className={styles.label}>Scheduled start</span>
|
||||||
|
<span className={styles.timer}>{getTimeStart()}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.endTime}>
|
||||||
|
<span className={styles.label}>Scheduled end</span>
|
||||||
|
<span className={styles.timer}>{getTimeEnd()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
|
@use '../../theme/v2Styles' as *;
|
||||||
|
|
||||||
.eventContainer {
|
.eventContainer {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
margin-top: 1em;
|
margin-top: 1em;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 8px 4px 8px 0;
|
padding: 0 4px;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
-ms-overflow-style: -ms-autohiding-scrollbar;
|
-ms-overflow-style: -ms-autohiding-scrollbar;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -22,11 +26,29 @@
|
|||||||
.alignCenter {
|
.alignCenter {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
.spaceTop {
|
.spaceTop {
|
||||||
margin-top: 24px;
|
margin-top: 1.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.spacer {
|
.spacer {
|
||||||
min-height: 50vh;
|
min-height: 50vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entryWrapper {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entryIndex {
|
||||||
|
text-align: right;
|
||||||
|
min-width: 2em;
|
||||||
|
color: $label-gray;
|
||||||
|
font-size: calc(1rem - 3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { lazy, MutableRefObject, useCallback, useEffect, useRef, useState } from 'react';
|
import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||||
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
import { OntimeRundown, Playback, SupportedEvent } from 'ontime-types';
|
import { OntimeRundown, Playback, SupportedEvent } from 'ontime-types';
|
||||||
|
import { getFirst, getNext, getPrevious } from 'ontime-utils';
|
||||||
|
|
||||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||||
|
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||||
import { useRundownEditor } from '../../common/hooks/useSocket';
|
import { useRundownEditor } from '../../common/hooks/useSocket';
|
||||||
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
|
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
|
||||||
import { useEditorSettings } from '../../common/stores/editorSettings';
|
import { useEditorSettings } from '../../common/stores/editorSettings';
|
||||||
import { cloneEvent, getFirstEvent, getNextEvent, getPreviousEvent } from '../../common/utils/eventsManager';
|
import { cloneEvent } from '../../common/utils/eventsManager';
|
||||||
|
|
||||||
import QuickAddBlock from './quick-add-block/QuickAddBlock';
|
import QuickAddBlock from './quick-add-block/QuickAddBlock';
|
||||||
import RundownEmpty from './RundownEmpty';
|
import RundownEmpty from './RundownEmpty';
|
||||||
@@ -40,6 +42,7 @@ export default function Rundown(props: RundownProps) {
|
|||||||
const moveCursorTo = useAppMode((state) => state.setCursor);
|
const moveCursorTo = useAppMode((state) => state.setCursor);
|
||||||
const cursorRef = useRef<HTMLDivElement | null>(null);
|
const cursorRef = useRef<HTMLDivElement | null>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
useFollowComponent({ followRef: cursorRef, scrollRef: scrollRef, doFollow: true });
|
||||||
|
|
||||||
// DND KIT
|
// DND KIT
|
||||||
const sensors = useSensors(useSensor(PointerSensor));
|
const sensors = useSensors(useSensor(PointerSensor));
|
||||||
@@ -59,8 +62,7 @@ export default function Rundown(props: RundownProps) {
|
|||||||
if (type === 'clone') {
|
if (type === 'clone') {
|
||||||
const cursorEvent = entries.find((event) => event.id === cursor);
|
const cursorEvent = entries.find((event) => event.id === cursor);
|
||||||
if (cursorEvent?.type === SupportedEvent.Event) {
|
if (cursorEvent?.type === SupportedEvent.Event) {
|
||||||
const newEvent = cloneEvent(cursorEvent);
|
const newEvent = cloneEvent(cursorEvent, cursorEvent.id);
|
||||||
newEvent.after = cursorEvent.id;
|
|
||||||
addEvent(newEvent);
|
addEvent(newEvent);
|
||||||
}
|
}
|
||||||
} else if (type === SupportedEvent.Event) {
|
} else if (type === SupportedEvent.Event) {
|
||||||
@@ -93,7 +95,7 @@ export default function Rundown(props: RundownProps) {
|
|||||||
if (entries.length < 1) {
|
if (entries.length < 1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextEvent = cursor == null ? getFirstEvent(entries) : getNextEvent(entries, cursor);
|
const nextEvent = cursor == null ? getFirst(entries) : getNext(entries, cursor);
|
||||||
if (nextEvent) {
|
if (nextEvent) {
|
||||||
moveCursorTo(nextEvent.id, nextEvent.type === SupportedEvent.Event);
|
moveCursorTo(nextEvent.id, nextEvent.type === SupportedEvent.Event);
|
||||||
}
|
}
|
||||||
@@ -104,7 +106,7 @@ export default function Rundown(props: RundownProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we check for this before
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we check for this before
|
||||||
const previousEvent = cursor == null ? getFirstEvent(entries) : getPreviousEvent(entries, cursor);
|
const previousEvent = cursor == null ? getFirst(entries) : getPrevious(entries, cursor);
|
||||||
if (previousEvent) {
|
if (previousEvent) {
|
||||||
moveCursorTo(previousEvent.id, previousEvent.type === SupportedEvent.Event);
|
moveCursorTo(previousEvent.id, previousEvent.type === SupportedEvent.Event);
|
||||||
}
|
}
|
||||||
@@ -153,28 +155,6 @@ export default function Rundown(props: RundownProps) {
|
|||||||
};
|
};
|
||||||
}, [handleKeyPress]);
|
}, [handleKeyPress]);
|
||||||
|
|
||||||
// when cursor moves, view should follow
|
|
||||||
useEffect(() => {
|
|
||||||
function scrollToComponent(
|
|
||||||
componentRef: MutableRefObject<HTMLDivElement>,
|
|
||||||
scrollRef: MutableRefObject<HTMLDivElement>,
|
|
||||||
) {
|
|
||||||
const componentRect = componentRef.current.getBoundingClientRect();
|
|
||||||
const scrollRect = scrollRef.current.getBoundingClientRect();
|
|
||||||
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - 100;
|
|
||||||
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cursorRef.current && scrollRef.current) {
|
|
||||||
// Use requestAnimationFrame to ensure the component is fully loaded
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
scrollToComponent(cursorRef as MutableRefObject<HTMLDivElement>, scrollRef as MutableRefObject<HTMLDivElement>);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line -- the prompt seems incorrect
|
|
||||||
}, [cursorRef?.current, scrollRef]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// in run mode, we follow selection
|
// in run mode, we follow selection
|
||||||
if (!viewFollowsCursor || !featureData?.selectedEventId) {
|
if (!viewFollowsCursor || !featureData?.selectedEventId) {
|
||||||
@@ -206,7 +186,7 @@ export default function Rundown(props: RundownProps) {
|
|||||||
let previousEnd = 0;
|
let previousEnd = 0;
|
||||||
let thisEnd = 0;
|
let thisEnd = 0;
|
||||||
let previousEventId: string | undefined;
|
let previousEventId: string | undefined;
|
||||||
let eventIndex = -1;
|
let eventIndex = 0;
|
||||||
let isPast = Boolean(featureData?.selectedEventId);
|
let isPast = Boolean(featureData?.selectedEventId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -216,7 +196,7 @@ export default function Rundown(props: RundownProps) {
|
|||||||
<div className={style.list}>
|
<div className={style.list}>
|
||||||
{statefulEntries.map((entry, index) => {
|
{statefulEntries.map((entry, index) => {
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
eventIndex = -1;
|
eventIndex = 0;
|
||||||
}
|
}
|
||||||
if (entry.type === SupportedEvent.Event) {
|
if (entry.type === SupportedEvent.Event) {
|
||||||
eventIndex++;
|
eventIndex++;
|
||||||
@@ -233,21 +213,25 @@ export default function Rundown(props: RundownProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={entry.id} ref={hasCursor ? cursorRef : undefined}>
|
<Fragment key={entry.id}>
|
||||||
<RundownEntry
|
<div className={style.entryWrapper} data-testid={`entry-${eventIndex}`}>
|
||||||
type={entry.type}
|
{entry.type === SupportedEvent.Event && <div className={style.entryIndex}>{eventIndex}</div>}
|
||||||
eventIndex={eventIndex}
|
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
|
||||||
isPast={isPast}
|
<RundownEntry
|
||||||
data={entry}
|
type={entry.type}
|
||||||
selected={isSelected}
|
isPast={isPast}
|
||||||
hasCursor={hasCursor}
|
data={entry}
|
||||||
next={isNext}
|
selected={isSelected}
|
||||||
previousEnd={previousEnd}
|
hasCursor={hasCursor}
|
||||||
previousEventId={previousEventId}
|
next={isNext}
|
||||||
playback={isSelected ? featureData.playback : undefined}
|
previousEnd={previousEnd}
|
||||||
isRolling={featureData.playback === Playback.Roll}
|
previousEventId={previousEventId}
|
||||||
disableEdit={isExtracted}
|
playback={isSelected ? featureData.playback : undefined}
|
||||||
/>
|
isRolling={featureData.playback === Playback.Roll}
|
||||||
|
disableEdit={isExtracted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{((showQuickEntry && hasCursor) || isLast) && (
|
{((showQuickEntry && hasCursor) || isLast) && (
|
||||||
<QuickAddBlock
|
<QuickAddBlock
|
||||||
showKbd={hasCursor}
|
showKbd={hasCursor}
|
||||||
@@ -257,7 +241,7 @@ export default function Rundown(props: RundownProps) {
|
|||||||
disableAddBlock={entry.type === SupportedEvent.Block}
|
disableAddBlock={entry.type === SupportedEvent.Block}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div className={style.spacer} />
|
<div className={style.spacer} />
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types';
|
import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types';
|
||||||
import { calculateDuration } from 'ontime-utils';
|
import { calculateDuration, getCueCandidate } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { RUNDOWN_TABLE } from '../../common/api/apiConstants';
|
||||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||||
|
import { ontimeQueryClient } from '../../common/queryClient';
|
||||||
import { useAppMode } from '../../common/stores/appModeStore';
|
import { useAppMode } from '../../common/stores/appModeStore';
|
||||||
import { useEditorSettings } from '../../common/stores/editorSettings';
|
import { useEditorSettings } from '../../common/stores/editorSettings';
|
||||||
import { useEmitLog } from '../../common/stores/logger';
|
import { useEmitLog } from '../../common/stores/logger';
|
||||||
@@ -12,11 +14,10 @@ import BlockBlock from './block-block/BlockBlock';
|
|||||||
import DelayBlock from './delay-block/DelayBlock';
|
import DelayBlock from './delay-block/DelayBlock';
|
||||||
import EventBlock from './event-block/EventBlock';
|
import EventBlock from './event-block/EventBlock';
|
||||||
|
|
||||||
export type EventItemActions = 'set-cursor' | 'event' | 'delay' | 'block' | 'delete' | 'clone' | 'update';
|
export type EventItemActions = 'set-cursor' | 'event' | 'delay' | 'block' | 'delete' | 'clone' | 'update' | 'swap';
|
||||||
|
|
||||||
interface RundownEntryProps {
|
interface RundownEntryProps {
|
||||||
type: SupportedEvent;
|
type: SupportedEvent;
|
||||||
eventIndex: number;
|
|
||||||
isPast: boolean;
|
isPast: boolean;
|
||||||
data: OntimeRundownEntry;
|
data: OntimeRundownEntry;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
@@ -30,21 +31,10 @@ interface RundownEntryProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RundownEntry(props: RundownEntryProps) {
|
export default function RundownEntry(props: RundownEntryProps) {
|
||||||
const {
|
const { isPast, data, selected, hasCursor, next, previousEnd, previousEventId, playback, isRolling, disableEdit } =
|
||||||
eventIndex,
|
props;
|
||||||
isPast,
|
|
||||||
data,
|
|
||||||
selected,
|
|
||||||
hasCursor,
|
|
||||||
next,
|
|
||||||
previousEnd,
|
|
||||||
previousEventId,
|
|
||||||
playback,
|
|
||||||
isRolling,
|
|
||||||
disableEdit,
|
|
||||||
} = props;
|
|
||||||
const { emitError } = useEmitLog();
|
const { emitError } = useEmitLog();
|
||||||
const { addEvent, updateEvent, deleteEvent } = useEventAction();
|
const { addEvent, updateEvent, deleteEvent, swapEvents } = useEventAction();
|
||||||
|
|
||||||
const cursor = useAppMode((state) => state.cursor);
|
const cursor = useAppMode((state) => state.cursor);
|
||||||
const setCursor = useAppMode((state) => state.setCursor);
|
const setCursor = useAppMode((state) => state.setCursor);
|
||||||
@@ -95,6 +85,12 @@ export default function RundownEntry(props: RundownEntryProps) {
|
|||||||
addEvent({ type: SupportedEvent.Block }, { after: data.id });
|
addEvent({ type: SupportedEvent.Block }, { after: data.id });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'swap': {
|
||||||
|
const { value } = payload as FieldValue;
|
||||||
|
swapEvents({ from: value as string, to: data.id });
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'delete': {
|
case 'delete': {
|
||||||
if (openId === data.id) {
|
if (openId === data.id) {
|
||||||
removeOpenEvent();
|
removeOpenEvent();
|
||||||
@@ -104,6 +100,7 @@ export default function RundownEntry(props: RundownEntryProps) {
|
|||||||
}
|
}
|
||||||
case 'clone': {
|
case 'clone': {
|
||||||
const newEvent = cloneEvent(data as OntimeEvent, data.id);
|
const newEvent = cloneEvent(data as OntimeEvent, data.id);
|
||||||
|
newEvent.cue = getCueCandidate(ontimeQueryClient.getQueryData(RUNDOWN_TABLE) || [], data.id);
|
||||||
addEvent(newEvent);
|
addEvent(newEvent);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -149,16 +146,17 @@ export default function RundownEntry(props: RundownEntryProps) {
|
|||||||
removeOpenEvent,
|
removeOpenEvent,
|
||||||
startTimeIsLastEnd,
|
startTimeIsLastEnd,
|
||||||
updateEvent,
|
updateEvent,
|
||||||
|
swapEvents,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (data.type === SupportedEvent.Event) {
|
if (data.type === SupportedEvent.Event) {
|
||||||
return (
|
return (
|
||||||
<EventBlock
|
<EventBlock
|
||||||
|
cue={data.cue}
|
||||||
timeStart={data.timeStart}
|
timeStart={data.timeStart}
|
||||||
timeEnd={data.timeEnd}
|
timeEnd={data.timeEnd}
|
||||||
duration={data.duration}
|
duration={data.duration}
|
||||||
eventIndex={eventIndex + 1}
|
|
||||||
eventId={data.id}
|
eventId={data.id}
|
||||||
isPublic={data.isPublic}
|
isPublic={data.isPublic}
|
||||||
endAction={data.endAction}
|
endAction={data.endAction}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ $block-binder-width: 2rem;
|
|||||||
$block-clearance: 0.5rem;
|
$block-clearance: 0.5rem;
|
||||||
$block-border-radius: 0.5rem;
|
$block-border-radius: 0.5rem;
|
||||||
$block-text-color: $gray-50;
|
$block-text-color: $gray-50;
|
||||||
$block-bg: $gray-1200;
|
$block-bg: $gray-1250;
|
||||||
$block-bg2: $gray-1050; // for delay and blocks
|
$block-bg2: $gray-1050; // for delay and blocks
|
||||||
$block-box-shadow: rgba(0, 0, 0, 0.5) 0 0 3px 2px;
|
$block-box-shadow: rgba(0, 0, 0, 0.5) 0 0 3px 2px;
|
||||||
$secondary-block-height: 2.5rem;
|
$secondary-block-height: 2.5rem;
|
||||||
|
|||||||
@@ -6,11 +6,12 @@
|
|||||||
|
|
||||||
background-color: $block-bg2;
|
background-color: $block-bg2;
|
||||||
|
|
||||||
|
min-width: 34rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 32px 1fr auto;
|
grid-template-columns: 2rem 1fr auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: $secondary-block-height;
|
height: $secondary-block-height;
|
||||||
gap: 8px;
|
gap: 0.5rem;
|
||||||
|
|
||||||
&.hasCursor {
|
&.hasCursor {
|
||||||
outline: 1px solid $block-cursor-color;
|
outline: 1px solid $block-cursor-color;
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export default function BlockBlock(props: BlockBlockProps) {
|
|||||||
<IoReorderTwo />
|
<IoReorderTwo />
|
||||||
</span>
|
</span>
|
||||||
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' />
|
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' />
|
||||||
<BlockActionMenu className={style.actionMenu} showAdd showDelay enableDelete actionHandler={actionHandler} />
|
<BlockActionMenu className={style.actionMenu} enableDelete actionHandler={actionHandler} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,13 @@
|
|||||||
|
|
||||||
background-color: $block-bg2;
|
background-color: $block-bg2;
|
||||||
|
|
||||||
|
min-width: 34rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 32px 1fr auto;
|
grid-template-columns: 2rem 1fr auto;
|
||||||
grid-template-areas: 'drag inpt btns';
|
grid-template-areas: 'drag inpt btns';
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: $secondary-block-height;
|
height: $secondary-block-height;
|
||||||
gap: 8px;
|
gap: 0.5rem;
|
||||||
|
|
||||||
&.hasCursor {
|
&.hasCursor {
|
||||||
outline: 1px solid $block-cursor-color;
|
outline: 1px solid $block-cursor-color;
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export default function DelayBlock(props: DelayBlockProps) {
|
|||||||
<Button onClick={cancelDelayHandler} size='sm' leftIcon={<IoClose />} variant='ontime-subtle-white'>
|
<Button onClick={cancelDelayHandler} size='sm' leftIcon={<IoClose />} variant='ontime-subtle-white'>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<BlockActionMenu showAdd enableDelete actionHandler={actionHandler} />
|
<BlockActionMenu enableDelete actionHandler={actionHandler} />
|
||||||
</HStack>
|
</HStack>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -90,6 +90,18 @@ $skip-opacity: 0.1;
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cue {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
width: 5.5rem;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
rotate: -90deg;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.playbackActions {
|
.playbackActions {
|
||||||
@@ -116,6 +128,9 @@ $skip-opacity: 0.1;
|
|||||||
|
|
||||||
.eventTitle {
|
.eventTitle {
|
||||||
grid-area: title;
|
grid-area: title;
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: calc(2.5em + 2px);
|
||||||
|
line-height: 1.25em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.eventActions {
|
.eventActions {
|
||||||
|
|||||||
@@ -1,27 +1,29 @@
|
|||||||
import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||||
import { IoCopyOutline } from '@react-icons/all-files/io5/IoCopyOutline';
|
import { IoCopyOutline } from '@react-icons/all-files/io5/IoCopyOutline';
|
||||||
import { IoPeopleOutline } from '@react-icons/all-files/io5/IoPeopleOutline';
|
import { IoPeopleOutline } from '@react-icons/all-files/io5/IoPeopleOutline';
|
||||||
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
|
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
|
||||||
|
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
||||||
import { EndAction, OntimeEvent, Playback, TimerType } from 'ontime-types';
|
import { EndAction, OntimeEvent, Playback, TimerType } from 'ontime-types';
|
||||||
|
|
||||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
|
||||||
import { useAppMode } from '../../../common/stores/appModeStore';
|
import { useAppMode } from '../../../common/stores/appModeStore';
|
||||||
import copyToClipboard from '../../../common/utils/copyToClipboard';
|
import copyToClipboard from '../../../common/utils/copyToClipboard';
|
||||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||||
import type { EventItemActions } from '../RundownEntry';
|
import type { EventItemActions } from '../RundownEntry';
|
||||||
|
import { useEventIdSwapping } from '../useEventIdSwapping';
|
||||||
|
|
||||||
import EventBlockInner from './EventBlockInner';
|
import EventBlockInner from './EventBlockInner';
|
||||||
|
|
||||||
import style from './EventBlock.module.scss';
|
import style from './EventBlock.module.scss';
|
||||||
|
|
||||||
interface EventBlockProps {
|
interface EventBlockProps {
|
||||||
|
cue: string;
|
||||||
timeStart: number;
|
timeStart: number;
|
||||||
timeEnd: number;
|
timeEnd: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
eventIndex: number;
|
|
||||||
eventId: string;
|
eventId: string;
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
endAction: EndAction;
|
endAction: EndAction;
|
||||||
@@ -52,11 +54,11 @@ interface EventBlockProps {
|
|||||||
|
|
||||||
export default function EventBlock(props: EventBlockProps) {
|
export default function EventBlock(props: EventBlockProps) {
|
||||||
const {
|
const {
|
||||||
|
eventId,
|
||||||
|
cue,
|
||||||
timeStart,
|
timeStart,
|
||||||
timeEnd,
|
timeEnd,
|
||||||
duration,
|
duration,
|
||||||
eventIndex,
|
|
||||||
eventId,
|
|
||||||
isPublic = true,
|
isPublic = true,
|
||||||
endAction,
|
endAction,
|
||||||
timerType,
|
timerType,
|
||||||
@@ -75,22 +77,37 @@ export default function EventBlock(props: EventBlockProps) {
|
|||||||
actionHandler,
|
actionHandler,
|
||||||
disableEdit,
|
disableEdit,
|
||||||
} = props;
|
} = props;
|
||||||
const { updateEvent } = useEventAction();
|
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
|
||||||
const moveCursorTo = useAppMode((state) => state.setCursor);
|
const moveCursorTo = useAppMode((state) => state.setCursor);
|
||||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const openId = useAppMode((state) => state.editId);
|
const openId = useAppMode((state) => state.editId);
|
||||||
const [onContextMenu] = useContextMenu<HTMLDivElement>([
|
const [onContextMenu] = useContextMenu<HTMLDivElement>([
|
||||||
{ label: `Copy ID: ${eventId}}`, icon: IoCopyOutline, onClick: () => copyToClipboard(eventId) },
|
{ label: `Copy ID: ${eventId}`, icon: IoCopyOutline, onClick: () => copyToClipboard(eventId) },
|
||||||
{
|
{
|
||||||
label: 'Toggle public',
|
label: 'Toggle public',
|
||||||
icon: IoPeopleOutline,
|
icon: IoPeopleOutline,
|
||||||
onClick: () =>
|
onClick: () =>
|
||||||
updateEvent({
|
actionHandler('update', {
|
||||||
id: eventId,
|
field: 'isPublic',
|
||||||
isPublic: !isPublic,
|
value: !isPublic,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Add to swap',
|
||||||
|
icon: IoAdd,
|
||||||
|
onClick: () => setSelectedEventId(eventId),
|
||||||
|
withDivider: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `Swap this event with ${selectedEventId ?? ''}`,
|
||||||
|
icon: IoSwapVertical,
|
||||||
|
onClick: () => {
|
||||||
|
actionHandler('swap', { field: 'id', value: selectedEventId });
|
||||||
|
clearSelectedEventId();
|
||||||
|
},
|
||||||
|
isDisabled: selectedEventId == null || selectedEventId === eventId,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -170,7 +187,7 @@ export default function EventBlock(props: EventBlockProps) {
|
|||||||
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
|
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
|
||||||
<IoReorderTwo />
|
<IoReorderTwo />
|
||||||
</span>
|
</span>
|
||||||
{eventIndex}
|
<span className={style.cue}>{cue}</span>
|
||||||
</div>
|
</div>
|
||||||
{isVisible && (
|
{isVisible && (
|
||||||
<EventBlockInner
|
<EventBlockInner
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user