mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
Compare commits
96 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f80e957036 | |||
| fb93063b47 | |||
| 24d3bf05ca | |||
| a4b15970de | |||
| 2a5b053d97 | |||
| 02c30c9681 | |||
| 2de7221653 | |||
| 2498e59156 | |||
| f3b4ea0155 | |||
| aebf949883 | |||
| 33ac05ebee | |||
| 421183fe55 | |||
| b66c19769d | |||
| 7b8445597f | |||
| 0b719c485f | |||
| 17deeddb87 | |||
| 2546159a94 | |||
| 2f26f20db5 | |||
| 11e0215530 | |||
| ea54953298 | |||
| d21f132674 | |||
| c7b2f0e89c | |||
| a1505606cf | |||
| 9edff55bd5 | |||
| e287215179 | |||
| 776649c997 | |||
| 9de5e0ac48 | |||
| c7d99073f6 | |||
| 5c1694d802 | |||
| 7cd319a6b3 | |||
| 2c9b6918e6 | |||
| a50cfad7ef | |||
| 66cd1decde | |||
| c9bf6d9812 | |||
| 8557f64382 | |||
| 8e994fb0df | |||
| 5c47a834b5 | |||
| f356afef43 | |||
| eba5252e40 | |||
| 72c4ad789a | |||
| 3118c20719 | |||
| 3754c374c3 | |||
| dd6d74121c | |||
| 6fb42b989e | |||
| 82dc1bc56b | |||
| d2c7b34142 | |||
| 918f8d1b08 | |||
| fcbcf70e01 | |||
| 5c6b06a0d4 | |||
| fc4c1bf22e | |||
| 84fac739f0 | |||
| ef73c87e2f | |||
| 26ee5768a5 | |||
| 27dcf06c73 | |||
| d48b2ae506 | |||
| 14a7c04d3b | |||
| abd9b127db | |||
| 0a80d6db31 | |||
| 977c99e587 | |||
| fe8bbd5003 | |||
| c6e38a2b4a | |||
| 56f18fe71c | |||
| 0b9306de82 | |||
| 3b1e6cf33d | |||
| dda219ebbe | |||
| 8bb771e5c6 | |||
| 8723bcbd33 | |||
| 0dcea4f2d7 | |||
| 696c016c90 | |||
| eed6373dbf | |||
| ac61f257c0 | |||
| 8d8625aa77 | |||
| 1e1aa4bca5 | |||
| 1f0cde6d0d | |||
| c4b66a9ed3 | |||
| 133b3aa819 | |||
| 9566fadbbb | |||
| 62570899e8 | |||
| c1f25f8dba | |||
| 37a91026d8 | |||
| c62ce213f5 | |||
| 529751578d | |||
| dce87c3a81 | |||
| a94b10cb0f | |||
| 6817263123 | |||
| ddd629d7db | |||
| 9d2a3d5a12 | |||
| 4bc271208e | |||
| dbd535dd6c | |||
| 57b7fe214a | |||
| b136e2856a | |||
| 03e68c03a9 | |||
| 44048a889d | |||
| f72db62e08 | |||
| 333f2623a5 | |||
| 3ebb3451e4 |
@@ -14,12 +14,12 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -56,12 +56,12 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -89,12 +89,12 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -20,13 +20,13 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -17,12 +17,12 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -73,12 +73,12 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@ Ontime consists of 3 distinct parts
|
||||
The steps below will assume you have locally installed the necessary dependencies.
|
||||
Other dependencies will be installed as part of the setup
|
||||
|
||||
- __node__ (~20)
|
||||
- __pnpm__ (~9)
|
||||
- __node__ (~22)
|
||||
- __pnpm__ (~10)
|
||||
- __docker__ (only necessary to run and build docker images)
|
||||
|
||||
## LOCAL DEVELOPMENT
|
||||
|
||||
+3
-3
@@ -1,13 +1,13 @@
|
||||
FROM node:20-bullseye AS builder
|
||||
FROM node:22-bullseye AS builder
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN npm install -g pnpm@9.5.0
|
||||
RUN npm install -g pnpm@10.11.0
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile
|
||||
RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker
|
||||
|
||||
FROM node:20-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
# Set environment variables
|
||||
# Environment Variable to signal that we are running production
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "3.14.3",
|
||||
"version": "3.15.2",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
+18
-12
@@ -1,28 +1,31 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "3.14.3",
|
||||
"version": "3.15.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^2.7.0",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/is-prop-valid": "^1.3.1",
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@emotion/styled": "^11.10.6",
|
||||
"@fontsource/open-sans": "^5.0.28",
|
||||
"@mantine/hooks": "^7.13.3",
|
||||
"@lexical/react": "^0.16.1",
|
||||
"@lexical/rich-text": "^0.16.1",
|
||||
"@mantine/hooks": "^7.17.2",
|
||||
"@sentry/react": "^8.43.0",
|
||||
"@table-nav/react": "^0.0.7",
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"@tanstack/react-query-devtools": "^5.62.7",
|
||||
"@tanstack/react-table": "^8.20.5",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.2.0",
|
||||
"axios": "^1.9.0",
|
||||
"color": "^4.2.3",
|
||||
"csv-stringify": "^6.4.5",
|
||||
"framer-motion": "^10.10.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.3.1",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -31,8 +34,10 @@
|
||||
"react-icons": "5.4.0",
|
||||
"react-qr-code": "^2.0.12",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"web-vitals": "^3.1.1",
|
||||
"zustand": "^5.0.3"
|
||||
"zustand": "^5.0.3",
|
||||
"lexical": "^0.16.1"
|
||||
},
|
||||
"scripts": {
|
||||
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
||||
@@ -65,11 +70,12 @@
|
||||
"@sentry/vite-plugin": "^2.16.1",
|
||||
"@tanstack/eslint-plugin-query": "^5.8.4",
|
||||
"@types/color": "^3.0.3",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||
"@typescript-eslint/parser": "catalog:",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@vitejs/plugin-react": "4.5.1",
|
||||
"eslint": "catalog:",
|
||||
"eslint-config-prettier": "catalog:",
|
||||
"eslint-plugin-jest": "^28.6.0",
|
||||
@@ -83,10 +89,10 @@
|
||||
"prettier": "catalog:",
|
||||
"sass": "^1.57.1",
|
||||
"typescript": "catalog:",
|
||||
"vite": "^5.2.11",
|
||||
"vite-plugin-compression2": "^1.3.3",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vite-tsconfig-paths": "^4.3.1",
|
||||
"vite": "6.3.1",
|
||||
"vite-plugin-compression2": "1.4.0",
|
||||
"vite-plugin-svgr": "4.3.0",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import axios from 'axios';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const assetsPath = `${apiEntryUrl}/assets`;
|
||||
|
||||
/**
|
||||
* HTTP request to get css contents
|
||||
*/
|
||||
export async function getCSSContents(): Promise<string> {
|
||||
const res = await axios.get(`${assetsPath}/css`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to post css contents
|
||||
*/
|
||||
export async function postCSSContents(css: string): Promise<void> {
|
||||
await axios.post(`${assetsPath}/css`, {
|
||||
css,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to restore default css
|
||||
*/
|
||||
export async function restoreCSSContents(): Promise<string> {
|
||||
const res = await axios.post(`${assetsPath}/css/restore`);
|
||||
return res.data;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import { CustomField, CustomFieldLabel, CustomFields } from 'ontime-types';
|
||||
import { CustomField, CustomFieldKey, CustomFields } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
@@ -24,15 +24,15 @@ export async function postCustomField(newField: CustomField): Promise<CustomFiel
|
||||
/**
|
||||
* Edits single custom field
|
||||
*/
|
||||
export async function editCustomField(label: CustomFieldLabel, newField: CustomField): Promise<CustomFields> {
|
||||
const res = await axios.put(`${customFieldsPath}/${label}`, { ...newField });
|
||||
export async function editCustomField(key: CustomFieldKey, newField: CustomField): Promise<CustomFields> {
|
||||
const res = await axios.put(`${customFieldsPath}/${key}`, { ...newField });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes single custom field
|
||||
*/
|
||||
export async function deleteCustomField(label: CustomFieldLabel): Promise<CustomFields> {
|
||||
const res = await axios.delete(`${customFieldsPath}/${label}`);
|
||||
export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFields> {
|
||||
const res = await axios.delete(`${customFieldsPath}/${key}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export type HasUpdate = {
|
||||
* HTTP request to get the latest version and url from github
|
||||
*/
|
||||
export async function getLatestVersion(): Promise<HasUpdate> {
|
||||
const res = await axios.get(`${apiRepoLatest}`);
|
||||
const res = await axios.get(apiRepoLatest);
|
||||
return {
|
||||
url: res.data.html_url as string,
|
||||
version: res.data.tag_name as string,
|
||||
|
||||
@@ -11,7 +11,7 @@ export const reportUrl = `${apiEntryUrl}/report`;
|
||||
* HTTP request to fetch all reports
|
||||
*/
|
||||
export async function fetchReport(): Promise<OntimeReport> {
|
||||
const res = await axios.get(`${reportUrl}/`);
|
||||
const res = await axios.get(reportUrl);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import {
|
||||
EntryId,
|
||||
MessageResponse,
|
||||
OntimeEntry,
|
||||
OntimeEvent,
|
||||
@@ -16,7 +17,7 @@ const rundownPath = `${apiEntryUrl}/rundown`;
|
||||
* HTTP request to fetch a list of existing rundowns
|
||||
*/
|
||||
export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
|
||||
const res = await axios.get(`${rundownPath}/`);
|
||||
const res = await axios.get(rundownPath);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
@@ -29,16 +30,16 @@ export async function fetchCurrentRundown(): Promise<Rundown> {
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to post new event
|
||||
* HTTP request to post new entry
|
||||
*/
|
||||
export async function requestPostEvent(data: TransientEventPayload): Promise<AxiosResponse<OntimeEntry>> {
|
||||
export async function postAddEntry(data: TransientEventPayload): Promise<AxiosResponse<OntimeEntry>> {
|
||||
return axios.post(rundownPath, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to put new event
|
||||
* HTTP request to edit an entry
|
||||
*/
|
||||
export async function requestPutEvent(data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
|
||||
export async function putEditEntry(data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
|
||||
return axios.put(rundownPath, data);
|
||||
}
|
||||
|
||||
@@ -48,22 +49,22 @@ type BatchEditEntry = {
|
||||
};
|
||||
|
||||
/**
|
||||
* HTTP request to put multiple events
|
||||
* HTTP request to edit multiple events
|
||||
*/
|
||||
export async function requestBatchPutEvents(data: BatchEditEntry): Promise<AxiosResponse<MessageResponse>> {
|
||||
export async function putBatchEditEvents(data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.put(`${rundownPath}/batch`, data);
|
||||
}
|
||||
|
||||
export type ReorderEntry = {
|
||||
eventId: string;
|
||||
from: number;
|
||||
to: number;
|
||||
entryId: EntryId;
|
||||
destinationId: EntryId;
|
||||
order: 'before' | 'after' | 'insert';
|
||||
};
|
||||
|
||||
/**
|
||||
* HTTP request to reorder events
|
||||
* HTTP request to reorder an entry
|
||||
*/
|
||||
export async function requestReorderEvent(data: ReorderEntry): Promise<AxiosResponse<OntimeEntry>> {
|
||||
export async function patchReorderEntry(data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.patch(`${rundownPath}/reorder`, data);
|
||||
}
|
||||
|
||||
@@ -82,15 +83,36 @@ export async function requestEventSwap(data: SwapEntry): Promise<AxiosResponse<M
|
||||
/**
|
||||
* HTTP request to request application of delay
|
||||
*/
|
||||
export async function requestApplyDelay(eventId: string): Promise<AxiosResponse<MessageResponse>> {
|
||||
return axios.patch(`${rundownPath}/applydelay/${eventId}`);
|
||||
export async function requestApplyDelay(delayId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.patch(`${rundownPath}/applydelay/${delayId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to delete given event
|
||||
* HTTP request for cloning an entry
|
||||
*/
|
||||
export async function requestDelete(eventIds: string[]): Promise<AxiosResponse<MessageResponse>> {
|
||||
return axios.delete(rundownPath, { data: { ids: eventIds } });
|
||||
export async function postCloneEntry(entryId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.post(`${rundownPath}/clone/${entryId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request for dissolving of a block
|
||||
*/
|
||||
export async function requestUngroup(blockId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.post(`${rundownPath}/ungroup/${blockId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request for grouping a list of entries into a block
|
||||
*/
|
||||
export async function requestGroupEntries(entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.post(`${rundownPath}/group`, { ids: entryIds });
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to delete entries
|
||||
*/
|
||||
export async function deleteEntries(entryIds: EntryId[]): Promise<AxiosResponse<MessageResponse>> {
|
||||
return axios.delete(rundownPath, { data: { ids: entryIds } });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,7 +7,7 @@ import { addLog } from '../stores/logger';
|
||||
import { nowInMillis } from '../utils/time';
|
||||
|
||||
/**
|
||||
* Utility unrwap a potential axios error
|
||||
* Utility unwrap a potential axios error
|
||||
* @param error
|
||||
* @returns
|
||||
*/
|
||||
@@ -34,7 +34,7 @@ export function maybeAxiosError(error: unknown) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility unrwaps a potential axios error and sends to logger
|
||||
* Utility unwraps a potential axios error and sends to logger
|
||||
* @param prepend
|
||||
* @param error
|
||||
*/
|
||||
|
||||
@@ -4,12 +4,13 @@ import { IoCopy } from 'react-icons/io5';
|
||||
import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react';
|
||||
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
import { Size } from '../../models/Util.type';
|
||||
import copyToClipboard from '../../utils/copyToClipboard';
|
||||
|
||||
interface CopyTagProps {
|
||||
copyValue: string;
|
||||
label: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
size?: Size;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { KeyboardEvent, useEffect, useRef, useState } from 'react';
|
||||
import { Input, Radio, RadioGroup } from '@chakra-ui/react';
|
||||
import { millisToString, parseUserTime } from 'ontime-utils';
|
||||
|
||||
import { useEventAction } from '../../../hooks/useEventAction';
|
||||
import { useEntryActions } from '../../../hooks/useEntryAction';
|
||||
|
||||
import style from './DelayInput.module.scss';
|
||||
|
||||
@@ -13,7 +13,7 @@ interface DelayInputProps {
|
||||
|
||||
export default function DelayInput(props: DelayInputProps) {
|
||||
const { eventId, duration } = props;
|
||||
const { updateEvent } = useEventAction();
|
||||
const { updateEntry } = useEntryActions();
|
||||
|
||||
const [value, setValue] = useState<string>('');
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
@@ -54,7 +54,7 @@ export default function DelayInput(props: DelayInputProps) {
|
||||
};
|
||||
|
||||
const submitChange = (value: number) => {
|
||||
updateEvent({
|
||||
updateEntry({
|
||||
id: eventId,
|
||||
duration: value,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer';
|
||||
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
|
||||
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
|
||||
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
|
||||
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
|
||||
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary';
|
||||
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
|
||||
import { ListPlugin } from '@lexical/react/LexicalListPlugin';
|
||||
import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin';
|
||||
import { TRANSFORMERS } from '@lexical/markdown';
|
||||
import { $getRoot, $getSelection, EditorState, FORMAT_TEXT_COMMAND, LexicalEditor, $isRangeSelection, SELECTION_CHANGE_COMMAND, COMMAND_PRIORITY_LOW, $createParagraphNode, $createTextNode, $patchStyleText } from 'lexical';
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { mergeRegister } from '@lexical/utils';
|
||||
import { LinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link';
|
||||
import { ListItemNode, ListNode } from '@lexical/list';
|
||||
import { HeadingNode, QuoteNode } from '@lexical/rich-text';
|
||||
import { CodeNode, CodeHighlightNode } from '@lexical/code';
|
||||
import { TableNode, TableCellNode, TableRowNode } from '@lexical/table';
|
||||
|
||||
// Define initial editor configuration
|
||||
const editorConfig = {
|
||||
namespace: 'MyEditor',
|
||||
theme: {
|
||||
ltr: 'ltr',
|
||||
rtl: 'rtl',
|
||||
placeholder: 'editor-placeholder',
|
||||
paragraph: 'editor-paragraph',
|
||||
quote: 'editor-quote',
|
||||
heading: {
|
||||
h1: 'editor-heading-h1',
|
||||
h2: 'editor-heading-h2',
|
||||
h3: 'editor-heading-h3',
|
||||
h4: 'editor-heading-h4',
|
||||
h5: 'editor-heading-h5',
|
||||
},
|
||||
list: {
|
||||
nested: {
|
||||
listitem: 'editor-nested-listitem',
|
||||
},
|
||||
ol: 'editor-list-ol',
|
||||
ul: 'editor-list-ul',
|
||||
listitem: 'editor-listitem',
|
||||
},
|
||||
link: 'editor-link',
|
||||
text: {
|
||||
bold: 'editor-text-bold',
|
||||
italic: 'editor-text-italic',
|
||||
underline: 'editor-text-underline',
|
||||
strikethrough: 'editor-text-strikethrough',
|
||||
underlineStrikethrough: 'editor-text-underlineStrikethrough',
|
||||
code: 'editor-text-code',
|
||||
},
|
||||
code: 'editor-code',
|
||||
codeHighlight: {
|
||||
atrule: 'editor-tokenAttr',
|
||||
attr: 'editor-tokenAttr',
|
||||
boolean: 'editor-tokenProperty',
|
||||
builtin: 'editor-tokenSelector',
|
||||
cdata: 'editor-tokenComment',
|
||||
char: 'editor-tokenSelector',
|
||||
clike: 'editor-tokenComment',
|
||||
comment: 'editor-tokenComment',
|
||||
contentType: 'editor-tokenComment',
|
||||
coord: 'editor-tokenComment',
|
||||
deleted: 'editor-tokenProperty',
|
||||
doctype: 'editor-tokenComment',
|
||||
doi: 'editor-tokenComment',
|
||||
entity: 'editor-tokenOperator',
|
||||
function: 'editor-tokenFunction',
|
||||
important: 'editor-tokenVariable',
|
||||
inserted: 'editor-tokenSelector',
|
||||
keyword: 'editor-tokenAttr',
|
||||
markup: 'editor-tokenComment',
|
||||
merged: 'editor-tokenComment',
|
||||
namespace: 'editor-tokenVariable',
|
||||
number: 'editor-tokenProperty',
|
||||
operator: 'editor-tokenOperator',
|
||||
prolog: 'editor-tokenComment',
|
||||
property: 'editor-tokenProperty',
|
||||
punctuation: 'editor-tokenPunctuation',
|
||||
regex: 'editor-tokenVariable',
|
||||
selector: 'editor-tokenSelector',
|
||||
string: 'editor-tokenSelector',
|
||||
style: 'editor-tokenComment',
|
||||
symbol: 'editor-tokenProperty',
|
||||
tag: 'editor-tokenProperty',
|
||||
url: 'editor-tokenOperator',
|
||||
variable: 'editor-tokenVariable',
|
||||
},
|
||||
},
|
||||
onError(error: Error) {
|
||||
throw error;
|
||||
},
|
||||
nodes: [
|
||||
HeadingNode,
|
||||
ListNode,
|
||||
ListItemNode,
|
||||
QuoteNode,
|
||||
CodeNode,
|
||||
CodeHighlightNode,
|
||||
TableNode,
|
||||
TableCellNode,
|
||||
TableRowNode,
|
||||
LinkNode,
|
||||
],
|
||||
};
|
||||
|
||||
interface RichTextEditorProps {
|
||||
initialValue?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function RichTextEditor({ initialValue, onChange }: RichTextEditorProps) {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const editorRef = useRef<LexicalEditor | null>(null); // Use ref for editor instance
|
||||
const editorWrapperRef = useRef<HTMLDivElement>(null); // Ref for the entire editor + toolbar wrapper
|
||||
|
||||
const handleEditorFocus = () => {
|
||||
setIsFocused(true);
|
||||
};
|
||||
|
||||
// This handleBlur is for the wrapper around the editor and toolbar
|
||||
const handleWrapperBlur = (event: React.FocusEvent<HTMLDivElement>) => {
|
||||
// Check if the new focused element is still within the editor wrapper
|
||||
if (editorWrapperRef.current && !editorWrapperRef.current.contains(event.relatedTarget as Node)) {
|
||||
setIsFocused(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = useCallback((editorState: EditorState) => {
|
||||
if (onChange) {
|
||||
editorState.read(() => {
|
||||
const root = $getRoot();
|
||||
// For now, sending plain text. Could be HTML or Lexical's format.
|
||||
onChange(root.getTextContent());
|
||||
});
|
||||
}
|
||||
}, [onChange]);
|
||||
|
||||
// Placeholder for initial value loading
|
||||
useEffect(() => {
|
||||
const currentEditor = editorRef.current;
|
||||
if (currentEditor && initialValue && isFocused) { // only update if focused and has initial value
|
||||
currentEditor.update(() => {
|
||||
const root = $getRoot();
|
||||
root.clear();
|
||||
const paragraph = $createParagraphNode();
|
||||
paragraph.append($createTextNode(initialValue));
|
||||
root.append(paragraph);
|
||||
root.selectEnd(); // Move cursor to the end
|
||||
});
|
||||
}
|
||||
}, [initialValue, isFocused]); // Rerun when isFocused changes or initialValue changes (though initialValue should be stable)
|
||||
|
||||
|
||||
if (!isFocused) {
|
||||
return (
|
||||
<div
|
||||
onClick={handleEditorFocus}
|
||||
onFocus={handleEditorFocus}
|
||||
tabIndex={0}
|
||||
style={{ border: '1px solid #ccc', padding: '10px', minHeight: '50px', cursor: 'text' }}
|
||||
role="textbox" // for accessibility
|
||||
aria-placeholder="Click to edit..."
|
||||
>
|
||||
{initialValue || 'Click to edit...'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Prepare initial config for when the editor mounts
|
||||
const localEditorConfig = {
|
||||
...editorConfig,
|
||||
editorState: initialValue && !editorRef.current // Only set initial state if editor hasn't been initialized yet with a value
|
||||
? () => {
|
||||
const root = $getRoot();
|
||||
const paragraph = $createParagraphNode();
|
||||
paragraph.append($createTextNode(initialValue));
|
||||
root.append(paragraph);
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={editorWrapperRef} onBlur={handleWrapperBlur} tabIndex={-1} style={{ border: '1px solid #ccc', position: 'relative', marginTop: '5px' }}>
|
||||
<LexicalComposer initialConfig={localEditorConfig}>
|
||||
<EditorInitializer editorRef={editorRef} />
|
||||
<ToolbarPlugin />
|
||||
<div style={{position: 'relative'}}> {/* Container for ContentEditable and Placeholder */}
|
||||
<RichTextPlugin
|
||||
contentEditable={<ContentEditable style={{ minHeight: '150px', padding: '10px', outline: 'none', resize: 'vertical', userSelect: 'text' }} />}
|
||||
placeholder={<div style={{ position: 'absolute', top: '10px', left: '10px', color: '#aaa', pointerEvents: 'none', userSelect: 'none' }}>Enter text...</div>}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
</div>
|
||||
<HistoryPlugin />
|
||||
<OnChangePlugin onChange={handleChange} />
|
||||
<LinkPlugin /> {/* LinkPlugin provides TOGGLE_LINK_COMMAND */}
|
||||
<ListPlugin />
|
||||
<MarkdownShortcutPlugin transformers={TRANSFORMERS} />
|
||||
</LexicalComposer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper component to get editor instance and attach it to a ref
|
||||
function EditorInitializer({ editorRef }: { editorRef: React.MutableRefObject<LexicalEditor | null> }) {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
useEffect(() => {
|
||||
editorRef.current = editor;
|
||||
return () => {
|
||||
editorRef.current = null;
|
||||
};
|
||||
}, [editor, editorRef]);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
function ToolbarPlugin() {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const [isLink, setIsLink] = useState(false);
|
||||
const [isBold, setIsBold] = useState(false);
|
||||
// Add states for other formats if needed, e.g., selectedTextColor, selectedBgColor
|
||||
|
||||
const updateToolbar = useCallback(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
setIsBold(selection.hasFormat('bold'));
|
||||
// Update link state
|
||||
const node = selection.anchor.getNode();
|
||||
const parent = node.getParent();
|
||||
setIsLink(parent?.getType() === 'link' || node.getType() === 'link');
|
||||
// Could also get current text color/bg color here if needed
|
||||
// const style = selection.style;
|
||||
// setSelectedTextColor(style.color);
|
||||
// setSelectedBgColor(style.backgroundColor);
|
||||
}
|
||||
}, [editor]); // editor dependency is implicit via useLexicalComposerContext
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
() => {
|
||||
updateToolbar();
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
// Potentially other listeners here, e.g., for text format changes
|
||||
);
|
||||
}, [editor, updateToolbar]);
|
||||
|
||||
|
||||
const toggleBold = () => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
|
||||
};
|
||||
|
||||
const toggleLink = useCallback(() => {
|
||||
if (isLink) {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); // Remove link
|
||||
} else {
|
||||
const url = prompt('Enter link URL:');
|
||||
if (url) {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, url);
|
||||
}
|
||||
}
|
||||
}, [editor, isLink]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '8px', borderBottom: '1px solid #ccc', display: 'flex', gap: '8px', userSelect: 'none' }}
|
||||
onMouseDown={(e) => e.preventDefault()} // Prevent editor blur when clicking toolbar
|
||||
>
|
||||
<button onClick={toggleBold} style={{ fontWeight: isBold ? 'bold' : 'normal' }}>B</button>
|
||||
<button onClick={toggleLink} style={{ fontWeight: isLink ? 'bold' : 'normal' }}>{isLink ? 'Unlink' : 'Link'}</button>
|
||||
<button onClick={applyTextColor}>Color</button>
|
||||
<button onClick={applyBackgroundColor}>BG Color</button>
|
||||
{/* Add more buttons here */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function applyStyleToSelection(editor: LexicalEditor, styleKey: string, styleValue: string) {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
$patchStyleText(selection, { [styleKey]: styleValue });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyTextColor() {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const color = prompt('Enter text color (e.g., red, #FF0000):');
|
||||
if (color) {
|
||||
applyStyleToSelection(editor, 'color', color);
|
||||
}
|
||||
}
|
||||
|
||||
function applyBackgroundColor() {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const color = prompt('Enter background color (e.g., yellow, #FFFF00):');
|
||||
if (color) {
|
||||
// For background color, Lexical often uses 'highlight' format or direct background-color style
|
||||
// Using FORMAT_TEXT_COMMAND for 'highlight' might be one way if a plugin handles it.
|
||||
// Or, apply direct style:
|
||||
applyStyleToSelection(editor, 'background-color', color);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,9 @@
|
||||
font-size: $text-body-size;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
color: $blue-400;
|
||||
|
||||
&:hover {
|
||||
color: $blue-400;
|
||||
color: $blue-500;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import type { MultiselectOptions, ParamField } from './types';
|
||||
|
||||
export const makeOptionsFromCustomFields = (
|
||||
customFields: CustomFields,
|
||||
additionalOptions: Record<string, string> = {},
|
||||
additionalOptions: Readonly<Record<string, string>> = {},
|
||||
filterImageType = true,
|
||||
) => {
|
||||
const options = structuredClone(additionalOptions);
|
||||
const options = { ...additionalOptions };
|
||||
for (const [key, value] of Object.entries(customFields)) {
|
||||
if (filterImageType && value.type === 'image') {
|
||||
continue;
|
||||
|
||||
@@ -13,6 +13,7 @@ const cachedRundownPlaceholder: Rundown = {
|
||||
id: 'default',
|
||||
title: '',
|
||||
order: [],
|
||||
flatOrder: [],
|
||||
entries: {},
|
||||
revision: -1,
|
||||
};
|
||||
|
||||
+279
-140
@@ -1,10 +1,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
EntryId,
|
||||
isOntimeBlock,
|
||||
isOntimeEvent,
|
||||
MaybeString,
|
||||
OntimeBlock,
|
||||
OntimeDelay,
|
||||
OntimeEntry,
|
||||
OntimeEvent,
|
||||
Rundown,
|
||||
@@ -12,19 +13,23 @@ import {
|
||||
TimeStrategy,
|
||||
TransientEventPayload,
|
||||
} from 'ontime-types';
|
||||
import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils';
|
||||
import { dayInMs, generateId, MILLIS_PER_SECOND, parseUserTime, swapEventData } from 'ontime-utils';
|
||||
|
||||
import { moveDown, moveUp } from '../../features/rundown/rundown.utils';
|
||||
import { RUNDOWN } from '../api/constants';
|
||||
import {
|
||||
deleteEntries,
|
||||
patchReorderEntry,
|
||||
postAddEntry,
|
||||
postCloneEntry,
|
||||
putBatchEditEvents,
|
||||
putEditEntry,
|
||||
ReorderEntry,
|
||||
requestApplyDelay,
|
||||
requestBatchPutEvents,
|
||||
requestDelete,
|
||||
requestDeleteAll,
|
||||
requestEventSwap,
|
||||
requestPostEvent,
|
||||
requestPutEvent,
|
||||
requestReorderEvent,
|
||||
requestGroupEntries,
|
||||
requestUngroup,
|
||||
SwapEntry,
|
||||
} from '../api/rundown';
|
||||
import { logAxiosError } from '../api/utils';
|
||||
@@ -41,9 +46,9 @@ export type EventOptions = Partial<{
|
||||
}>;
|
||||
|
||||
/**
|
||||
* @description Set of utilities for events //TODO: should this be called useEntryAction and so on
|
||||
* Gather utilities for actions on entries
|
||||
*/
|
||||
export const useEventAction = () => {
|
||||
export const useEntryActions = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
defaultPublic,
|
||||
@@ -56,8 +61,8 @@ export const useEventAction = () => {
|
||||
defaultEndAction,
|
||||
} = useEditorSettings();
|
||||
|
||||
const getEventById = useCallback(
|
||||
(eventId: string) => {
|
||||
const getEntryById = useCallback(
|
||||
(eventId: string): OntimeEntry | undefined => {
|
||||
const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||
if (!cachedRundown?.entries) {
|
||||
return;
|
||||
@@ -68,26 +73,24 @@ export const useEventAction = () => {
|
||||
);
|
||||
|
||||
/**
|
||||
* Calls mutation to add new event
|
||||
* Calls mutation to add new entry
|
||||
* @private
|
||||
*/
|
||||
const _addEventMutation = useMutation({
|
||||
mutationFn: requestPostEvent,
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||
},
|
||||
networkMode: 'always',
|
||||
const _addEntryMutation = useMutation({
|
||||
// TODO(v4): optimistic create entry
|
||||
mutationFn: postAddEntry,
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Adds an event to rundown
|
||||
* Adds an entry to rundown
|
||||
*/
|
||||
const addEvent = useCallback(
|
||||
async (event: Partial<OntimeEvent | OntimeDelay | OntimeBlock>, options?: EventOptions) => {
|
||||
const newEvent: TransientEventPayload = { ...event };
|
||||
const addEntry = useCallback(
|
||||
async (entry: Partial<OntimeEntry>, options?: EventOptions) => {
|
||||
const newEntry: TransientEventPayload = { ...entry, id: generateId() };
|
||||
|
||||
// ************* CHECK OPTIONS specific to events
|
||||
if (isOntimeEvent(newEvent)) {
|
||||
if (isOntimeEvent(newEntry)) {
|
||||
// merge creation time options with event settings
|
||||
const applicationOptions = {
|
||||
after: options?.after,
|
||||
@@ -97,65 +100,60 @@ export const useEventAction = () => {
|
||||
linkPrevious: options?.linkPrevious ?? linkPrevious,
|
||||
};
|
||||
|
||||
if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) {
|
||||
newEvent.linkStart = applicationOptions.lastEventId;
|
||||
} else if (applicationOptions?.lastEventId) {
|
||||
if (applicationOptions?.lastEventId) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
|
||||
const rundownData = queryClient.getQueryData<Rundown>(RUNDOWN)!;
|
||||
const previousEvent = rundownData.entries[applicationOptions.lastEventId];
|
||||
if (isOntimeEvent(previousEvent)) {
|
||||
newEvent.timeStart = previousEvent.timeEnd;
|
||||
newEntry.timeStart = previousEvent.timeEnd;
|
||||
}
|
||||
}
|
||||
|
||||
// Override event with options from editor settings
|
||||
if (applicationOptions.defaultPublic) {
|
||||
newEvent.isPublic = true;
|
||||
newEntry.linkStart = applicationOptions.linkPrevious;
|
||||
newEntry.isPublic = applicationOptions.defaultPublic;
|
||||
|
||||
if (newEntry.duration === undefined && newEntry.timeEnd === undefined) {
|
||||
newEntry.duration = parseUserTime(defaultDuration);
|
||||
}
|
||||
|
||||
if (newEvent.duration === undefined && newEvent.timeEnd === undefined) {
|
||||
newEvent.duration = parseUserTime(defaultDuration);
|
||||
if (newEntry.timeDanger === undefined) {
|
||||
newEntry.timeDanger = parseUserTime(defaultDangerTime);
|
||||
}
|
||||
|
||||
if (newEvent.timeDanger === undefined) {
|
||||
newEvent.timeDanger = parseUserTime(defaultDangerTime);
|
||||
if (newEntry.timeWarning === undefined) {
|
||||
newEntry.timeWarning = parseUserTime(defaultWarnTime);
|
||||
}
|
||||
|
||||
if (newEvent.timeWarning === undefined) {
|
||||
newEvent.timeWarning = parseUserTime(defaultWarnTime);
|
||||
if (newEntry.timerType === undefined) {
|
||||
newEntry.timerType = defaultTimerType;
|
||||
}
|
||||
|
||||
if (newEvent.timerType === undefined) {
|
||||
newEvent.timerType = defaultTimerType;
|
||||
if (newEntry.endAction === undefined) {
|
||||
newEntry.endAction = defaultEndAction;
|
||||
}
|
||||
|
||||
if (newEvent.endAction === undefined) {
|
||||
newEvent.endAction = defaultEndAction;
|
||||
}
|
||||
|
||||
if (newEvent.timeStrategy === undefined) {
|
||||
newEvent.timeStrategy = defaultTimeStrategy;
|
||||
if (newEntry.timeStrategy === undefined) {
|
||||
newEntry.timeStrategy = defaultTimeStrategy;
|
||||
}
|
||||
}
|
||||
|
||||
// handle adding options that concern all event type
|
||||
if (options?.after) {
|
||||
// @ts-expect-error -- not sure how to type this, <after> is a transient property
|
||||
newEvent.after = options.after;
|
||||
(newEntry as TransientEventPayload).after = options.after;
|
||||
}
|
||||
if (options?.before) {
|
||||
// @ts-expect-error -- not sure how to type this, <before> is a transient property
|
||||
newEvent.before = options.before;
|
||||
(newEntry as TransientEventPayload).before = options.before;
|
||||
}
|
||||
|
||||
try {
|
||||
await _addEventMutation.mutateAsync(newEvent as TransientEventPayload);
|
||||
await _addEntryMutation.mutateAsync(newEntry);
|
||||
} catch (error) {
|
||||
logAxiosError('Failed adding event', error);
|
||||
}
|
||||
},
|
||||
[
|
||||
_addEventMutation,
|
||||
_addEntryMutation,
|
||||
defaultDangerTime,
|
||||
defaultDuration,
|
||||
defaultEndAction,
|
||||
@@ -169,11 +167,34 @@ export const useEventAction = () => {
|
||||
);
|
||||
|
||||
/**
|
||||
* Calls mutation to update existing event
|
||||
* Calls mutation to clone a selection
|
||||
* @private
|
||||
*/
|
||||
const _updateEventMutation = useMutation({
|
||||
mutationFn: requestPutEvent,
|
||||
const _cloneMutation = useMutation({
|
||||
mutationFn: postCloneEntry,
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Clone a selection
|
||||
*/
|
||||
const clone = useCallback(
|
||||
async (entryId: EntryId) => {
|
||||
try {
|
||||
await _cloneMutation.mutateAsync(entryId);
|
||||
} catch (error) {
|
||||
logAxiosError('Error cloning entry', error);
|
||||
}
|
||||
},
|
||||
[_cloneMutation],
|
||||
);
|
||||
|
||||
/**
|
||||
* Calls mutation to update existing entry
|
||||
* @private
|
||||
*/
|
||||
const _updateEntryMutation = useMutation({
|
||||
mutationFn: putEditEntry,
|
||||
// we optimistically update here
|
||||
onMutate: async (newEvent) => {
|
||||
// cancel ongoing queries
|
||||
@@ -192,6 +213,7 @@ export const useEventAction = () => {
|
||||
id: previousData.id,
|
||||
title: previousData.title,
|
||||
order: previousData.order,
|
||||
flatOrder: previousData.flatOrder,
|
||||
entries: newRundown,
|
||||
revision: -1,
|
||||
});
|
||||
@@ -209,39 +231,38 @@ export const useEventAction = () => {
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||
},
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
/**
|
||||
* Updates existing event
|
||||
* Updates existing entry
|
||||
*/
|
||||
const updateEvent = useCallback(
|
||||
const updateEntry = useCallback(
|
||||
async (event: Partial<OntimeEntry>) => {
|
||||
try {
|
||||
await _updateEventMutation.mutateAsync(event);
|
||||
await _updateEntryMutation.mutateAsync(event);
|
||||
} catch (error) {
|
||||
logAxiosError('Error updating event', error);
|
||||
}
|
||||
},
|
||||
[_updateEventMutation],
|
||||
[_updateEntryMutation],
|
||||
);
|
||||
|
||||
const updateCustomField = useCallback(
|
||||
async (eventId: string, field: string, value: string) => {
|
||||
updateEvent({ id: eventId, custom: { [field]: value } });
|
||||
async (entryId: EntryId, field: string, value: string) => {
|
||||
updateEntry({ id: entryId, custom: { [field]: value } });
|
||||
},
|
||||
[updateEvent],
|
||||
[updateEntry],
|
||||
);
|
||||
|
||||
/**
|
||||
* Updates time of existing event
|
||||
* @param eventId {string} - id of the event
|
||||
* @param eventId {EntryId} - id of the event
|
||||
* @param field {TimeField} - field to update
|
||||
* @param value {string} - new value string to be parsed
|
||||
* @param lockOnUpdate {boolean} - whether we will apply the lock / release on update
|
||||
*/
|
||||
const updateTimer = useCallback(
|
||||
async (eventId: string, field: TimeField, value: string, lockOnUpdate?: boolean) => {
|
||||
async (eventId: EntryId, field: TimeField, value: string, lockOnUpdate?: boolean) => {
|
||||
// an empty value with no lock has no domain validity
|
||||
if (!lockOnUpdate && value === '') {
|
||||
return;
|
||||
@@ -263,7 +284,7 @@ export const useEventAction = () => {
|
||||
newEvent.duration = value === '' ? undefined : calculateNewValue();
|
||||
} else if (field === 'timeStart') {
|
||||
// an empty values means we should link to the previous
|
||||
newEvent.linkStart = value === '' ? 'true' : null;
|
||||
newEvent.linkStart = value === '';
|
||||
newEvent.timeStart = value === '' ? undefined : calculateNewValue();
|
||||
}
|
||||
} else {
|
||||
@@ -271,7 +292,7 @@ export const useEventAction = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
await _updateEventMutation.mutateAsync(newEvent);
|
||||
await _updateEntryMutation.mutateAsync(newEvent);
|
||||
} catch (error) {
|
||||
logAxiosError('Error updating event', error);
|
||||
}
|
||||
@@ -323,7 +344,7 @@ export const useEventAction = () => {
|
||||
return previousEnd;
|
||||
}
|
||||
},
|
||||
[_updateEventMutation, queryClient],
|
||||
[_updateEntryMutation, queryClient],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -331,7 +352,7 @@ export const useEventAction = () => {
|
||||
* @private
|
||||
*/
|
||||
const _batchUpdateEventsMutation = useMutation({
|
||||
mutationFn: requestBatchPutEvents,
|
||||
mutationFn: putBatchEditEvents,
|
||||
onMutate: async ({ ids, data }) => {
|
||||
// cancel ongoing queries
|
||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||
@@ -359,20 +380,31 @@ export const useEventAction = () => {
|
||||
id: previousRundown.id,
|
||||
title: previousRundown.title,
|
||||
order: previousRundown.order,
|
||||
flatOrder: previousRundown.flatOrder,
|
||||
entries: newRundown,
|
||||
revision: -1,
|
||||
});
|
||||
}
|
||||
|
||||
// Return a context with the previous rundown
|
||||
return { previousRundown };
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||
onSuccess: (response) => {
|
||||
if (!response.data) return;
|
||||
|
||||
const { id, title, order, flatOrder, entries, revision } = response.data;
|
||||
queryClient.setQueryData<Rundown>(RUNDOWN, {
|
||||
id,
|
||||
title,
|
||||
order,
|
||||
flatOrder,
|
||||
entries,
|
||||
revision,
|
||||
});
|
||||
},
|
||||
onError: (_error, _newEvent, context) => {
|
||||
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousRundown);
|
||||
},
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
const batchUpdateEvents = useCallback(
|
||||
@@ -387,13 +419,13 @@ export const useEventAction = () => {
|
||||
);
|
||||
|
||||
/**
|
||||
* Calls mutation to delete an event
|
||||
* Calls mutation to delete an entry
|
||||
* @private
|
||||
*/
|
||||
const _deleteEventMutation = useMutation({
|
||||
mutationFn: requestDelete,
|
||||
const _deleteEntryMutation = useMutation({
|
||||
mutationFn: deleteEntries,
|
||||
// we optimistically update here
|
||||
onMutate: async (eventIds: string[]) => {
|
||||
onMutate: async (entryIds: EntryId[]) => {
|
||||
// cancel ongoing queries
|
||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||
|
||||
@@ -402,17 +434,14 @@ export const useEventAction = () => {
|
||||
|
||||
if (previousData) {
|
||||
// optimistically update object
|
||||
const newOrder = previousData.order.filter((id) => !eventIds.includes(id));
|
||||
const newRundown = { ...previousData.entries };
|
||||
for (const eventId of eventIds) {
|
||||
delete newRundown[eventId];
|
||||
}
|
||||
const { entries, order, flatOrder } = optimisticDeleteEntries(entryIds, previousData);
|
||||
|
||||
queryClient.setQueryData<Rundown>(RUNDOWN, {
|
||||
id: previousData.id,
|
||||
title: previousData.title,
|
||||
order: newOrder,
|
||||
entries: newRundown,
|
||||
order,
|
||||
flatOrder,
|
||||
entries,
|
||||
revision: -1,
|
||||
});
|
||||
}
|
||||
@@ -422,7 +451,7 @@ export const useEventAction = () => {
|
||||
},
|
||||
|
||||
// Mutation fails, rollback undoes optimist update
|
||||
onError: (_error, _eventId, context) => {
|
||||
onError: (_error, _entryIds, context) => {
|
||||
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
|
||||
},
|
||||
// Mutation finished, failed or successful
|
||||
@@ -430,28 +459,27 @@ export const useEventAction = () => {
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||
},
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
/**
|
||||
* Deletes an event form the list
|
||||
* Deletes an event entry from the rundown
|
||||
*/
|
||||
const deleteEvent = useCallback(
|
||||
async (eventIds: string[]) => {
|
||||
const deleteEntry = useCallback(
|
||||
async (entryIds: EntryId[]) => {
|
||||
try {
|
||||
await _deleteEventMutation.mutateAsync(eventIds);
|
||||
await _deleteEntryMutation.mutateAsync(entryIds);
|
||||
} catch (error) {
|
||||
logAxiosError('Error deleting event', error);
|
||||
}
|
||||
},
|
||||
[_deleteEventMutation],
|
||||
[_deleteEntryMutation],
|
||||
);
|
||||
|
||||
/**
|
||||
* Calls mutation to delete all events
|
||||
* @private
|
||||
*/
|
||||
const _deleteAllEventsMutation = useMutation({
|
||||
const _deleteAllEntriesMutation = useMutation({
|
||||
mutationFn: requestDeleteAll,
|
||||
// we optimistically update here
|
||||
onMutate: async () => {
|
||||
@@ -465,8 +493,9 @@ export const useEventAction = () => {
|
||||
queryClient.setQueryData<Rundown>(RUNDOWN, {
|
||||
id: previousData?.id ?? 'default',
|
||||
title: previousData?.title ?? '',
|
||||
entries: {},
|
||||
order: [],
|
||||
flatOrder: [],
|
||||
entries: {},
|
||||
revision: -1,
|
||||
});
|
||||
|
||||
@@ -474,8 +503,8 @@ export const useEventAction = () => {
|
||||
return { previousData };
|
||||
},
|
||||
|
||||
// Mutation fails, rollback undos optimist update
|
||||
onError: (_error, _eventId, context) => {
|
||||
// Mutation fails, rollback optimist update
|
||||
onError: (_error, _, context) => {
|
||||
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
|
||||
},
|
||||
// Mutation finished, failed or successful
|
||||
@@ -483,19 +512,18 @@ export const useEventAction = () => {
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||
},
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
/**
|
||||
* Deletes all events from list
|
||||
* Deletes all entries in the rundown
|
||||
*/
|
||||
const deleteAllEvents = useCallback(async () => {
|
||||
const deleteAllEntries = useCallback(async () => {
|
||||
try {
|
||||
await _deleteAllEventsMutation.mutateAsync();
|
||||
await _deleteAllEntriesMutation.mutateAsync();
|
||||
} catch (error) {
|
||||
logAxiosError('Error deleting events', error);
|
||||
}
|
||||
}, [_deleteAllEventsMutation]);
|
||||
}, [_deleteAllEntriesMutation]);
|
||||
|
||||
/**
|
||||
* Calls mutation to apply a delay
|
||||
@@ -503,18 +531,30 @@ export const useEventAction = () => {
|
||||
*/
|
||||
const _applyDelayMutation = useMutation({
|
||||
mutationFn: requestApplyDelay,
|
||||
onSuccess: (response) => {
|
||||
if (!response.data) return;
|
||||
|
||||
const { id, title, order, flatOrder, entries, revision } = response.data;
|
||||
queryClient.setQueryData<Rundown>(RUNDOWN, {
|
||||
id,
|
||||
title,
|
||||
order,
|
||||
flatOrder,
|
||||
entries,
|
||||
revision,
|
||||
});
|
||||
},
|
||||
// Mutation finished, failed or successful
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||
},
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
/**
|
||||
* Applies a given delay block
|
||||
* Applies a given delay
|
||||
*/
|
||||
const applyDelay = useCallback(
|
||||
async (delayEventId: string) => {
|
||||
async (delayEventId: EntryId) => {
|
||||
try {
|
||||
await _applyDelayMutation.mutateAsync(delayEventId);
|
||||
} catch (error) {
|
||||
@@ -525,66 +565,133 @@ export const useEventAction = () => {
|
||||
);
|
||||
|
||||
/**
|
||||
* Calls mutation to reorder an event
|
||||
* Calls mutation to dissolve a block
|
||||
* @private
|
||||
*/
|
||||
const _reorderEventMutation = useMutation({
|
||||
mutationFn: requestReorderEvent,
|
||||
// we optimistically update here
|
||||
onMutate: async (data) => {
|
||||
// cancel ongoing queries
|
||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||
const _ungroupMutation = useMutation({
|
||||
mutationFn: requestUngroup,
|
||||
onSuccess: (response) => {
|
||||
if (!response.data) return;
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||
const { id, title, order, flatOrder, entries, revision } = response.data;
|
||||
queryClient.setQueryData<Rundown>(RUNDOWN, {
|
||||
id,
|
||||
title,
|
||||
order,
|
||||
flatOrder,
|
||||
entries,
|
||||
revision,
|
||||
});
|
||||
},
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
|
||||
});
|
||||
|
||||
if (previousData) {
|
||||
// optimistically update object
|
||||
const newOrder = reorderArray(previousData.order, data.from, data.to);
|
||||
queryClient.setQueryData<Rundown>(RUNDOWN, {
|
||||
id: previousData.id,
|
||||
title: previousData.title,
|
||||
order: newOrder,
|
||||
entries: previousData.entries,
|
||||
revision: -1,
|
||||
});
|
||||
/**
|
||||
* Deletes a block and moves its events to the top level
|
||||
*/
|
||||
const ungroup = useCallback(
|
||||
async (blockId: EntryId) => {
|
||||
try {
|
||||
await _ungroupMutation.mutateAsync(blockId);
|
||||
} catch (error) {
|
||||
logAxiosError('Error dissolving block', error);
|
||||
}
|
||||
|
||||
// Return a context with the previous and new events
|
||||
return { previousData };
|
||||
},
|
||||
[_ungroupMutation],
|
||||
);
|
||||
|
||||
// Mutation fails, rollback undoes optimist update
|
||||
onError: (_error, _eventId, context) => {
|
||||
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
|
||||
/**
|
||||
* Calls mutation to create a block with a selection
|
||||
* @private
|
||||
*/
|
||||
const _groupEntriesMutation = useMutation({
|
||||
mutationFn: requestGroupEntries,
|
||||
onSuccess: (response) => {
|
||||
if (!response.data) return;
|
||||
|
||||
const { id, title, order, flatOrder, entries, revision } = response.data;
|
||||
queryClient.setQueryData<Rundown>(RUNDOWN, {
|
||||
id,
|
||||
title,
|
||||
order,
|
||||
flatOrder,
|
||||
entries,
|
||||
revision,
|
||||
});
|
||||
},
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a block with a selection
|
||||
*/
|
||||
const groupEntries = useCallback(
|
||||
async (entryIds: EntryId[]) => {
|
||||
try {
|
||||
await _groupEntriesMutation.mutateAsync(entryIds);
|
||||
} catch (error) {
|
||||
logAxiosError('Error grouping entries', error);
|
||||
}
|
||||
},
|
||||
[_groupEntriesMutation],
|
||||
);
|
||||
|
||||
/**
|
||||
* Calls mutation to reorder an entry
|
||||
* @private
|
||||
*/
|
||||
const _reorderEntryMutation = useMutation({
|
||||
mutationFn: patchReorderEntry,
|
||||
// Mutation finished, failed or successful
|
||||
// Fetch anyway, just to be sure
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||
},
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
/**
|
||||
* Reorders a given event
|
||||
* Reorders a given entry
|
||||
*/
|
||||
const reorderEvent = useCallback(
|
||||
async (eventId: string, from: number, to: number) => {
|
||||
const reorderEntry = useCallback(
|
||||
async (entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') => {
|
||||
try {
|
||||
const reorderObject: ReorderEntry = {
|
||||
eventId,
|
||||
from,
|
||||
to,
|
||||
entryId,
|
||||
destinationId,
|
||||
order,
|
||||
};
|
||||
await _reorderEventMutation.mutateAsync(reorderObject);
|
||||
await _reorderEntryMutation.mutateAsync(reorderObject);
|
||||
} catch (error) {
|
||||
logAxiosError('Error re-ordering event', error);
|
||||
}
|
||||
},
|
||||
[_reorderEventMutation],
|
||||
[_reorderEntryMutation],
|
||||
);
|
||||
|
||||
const move = useCallback(async (entryId: EntryId, direction: 'up' | 'down') => {
|
||||
const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||
if (!cachedRundown?.order) {
|
||||
return;
|
||||
}
|
||||
const { destinationId, order } =
|
||||
direction === 'up'
|
||||
? moveUp(entryId, cachedRundown.order, cachedRundown.entries)
|
||||
: moveDown(entryId, cachedRundown.order, cachedRundown.entries);
|
||||
|
||||
if (destinationId) {
|
||||
try {
|
||||
const reorderObject: ReorderEntry = {
|
||||
entryId,
|
||||
destinationId,
|
||||
order: order as 'before' | 'after' | 'insert',
|
||||
};
|
||||
await _reorderEntryMutation.mutateAsync(reorderObject);
|
||||
} catch (error) {
|
||||
logAxiosError('Error re-ordering event', error);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Calls mutation to swap events
|
||||
* @private
|
||||
@@ -616,6 +723,7 @@ export const useEventAction = () => {
|
||||
id: previousData.id,
|
||||
title: previousData.title,
|
||||
order: previousData.order,
|
||||
flatOrder: previousData.flatOrder,
|
||||
entries: newRundown,
|
||||
revision: -1,
|
||||
});
|
||||
@@ -634,7 +742,6 @@ export const useEventAction = () => {
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||
},
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -652,16 +759,48 @@ export const useEventAction = () => {
|
||||
);
|
||||
|
||||
return {
|
||||
addEvent,
|
||||
addEntry,
|
||||
applyDelay,
|
||||
batchUpdateEvents,
|
||||
deleteEvent,
|
||||
deleteAllEvents,
|
||||
getEventById,
|
||||
reorderEvent,
|
||||
clone,
|
||||
deleteEntry,
|
||||
deleteAllEntries,
|
||||
ungroup,
|
||||
getEntryById,
|
||||
groupEntries,
|
||||
move,
|
||||
reorderEntry,
|
||||
swapEvents,
|
||||
updateEvent,
|
||||
updateEntry,
|
||||
updateTimer,
|
||||
updateCustomField,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility to optimistically delete entries from client cache
|
||||
*/
|
||||
function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
|
||||
const entries = { ...rundown.entries };
|
||||
let order = [...rundown.order];
|
||||
let flatOrder = [...rundown.flatOrder];
|
||||
|
||||
for (let i = 0; i < entryIds.length; i++) {
|
||||
const entry = entries[entryIds[i]];
|
||||
deleteEntry(entry);
|
||||
}
|
||||
|
||||
function deleteEntry(entry: OntimeEntry) {
|
||||
if (isOntimeBlock(entry) || !entry.parent) {
|
||||
order = order.filter((id) => id !== entry.id);
|
||||
} else {
|
||||
const parent = entries[entry.parent] as OntimeBlock;
|
||||
parent.events = parent.events.filter((event) => event !== entry.id);
|
||||
}
|
||||
|
||||
delete entries[entry.id];
|
||||
flatOrder = flatOrder.filter((id) => id !== entry.id);
|
||||
}
|
||||
|
||||
return { entries, order, flatOrder };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
|
||||
import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
|
||||
|
||||
import { useRuntimeStore } from '../stores/runtime';
|
||||
import { socketSendJson } from '../utils/socket';
|
||||
@@ -150,7 +150,7 @@ export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) =
|
||||
|
||||
numEvents: state.runtime.numEvents,
|
||||
selectedEventIndex: state.runtime.selectedEventIndex,
|
||||
offset: state.runtime.offset,
|
||||
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
|
||||
|
||||
currentBlock: state.currentBlock,
|
||||
}));
|
||||
@@ -162,8 +162,11 @@ export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
|
||||
|
||||
export const useTimeUntilData = createSelector((state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
offset: state.runtime.offset,
|
||||
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
|
||||
offsetMode: state.runtime.offsetMode,
|
||||
currentDay: state.eventNow?.dayOffset ?? 0, //The day of the currently running event
|
||||
actualStart: state.runtime.actualStart,
|
||||
plannedStart: state.runtime.plannedStart,
|
||||
}));
|
||||
|
||||
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
|
||||
@@ -179,6 +182,12 @@ export const useIsOnline = createSelector((state: RuntimeStore) => ({
|
||||
isOnline: state.ping > 0,
|
||||
}));
|
||||
|
||||
export const useOffsetMode = createSelector((state: RuntimeStore) => ({
|
||||
offsetMode: state.runtime.offsetMode,
|
||||
}));
|
||||
|
||||
export const setOffsetMode = (payload: OffsetMode) => socketSendJson('offsetmode', payload);
|
||||
|
||||
export const usePlayback = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
playback: state.timer.playback,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { GetInfo } from 'ontime-types';
|
||||
|
||||
export const ontimePlaceholderInfo: GetInfo = {
|
||||
networkInterfaces: [],
|
||||
version: '2.0.0',
|
||||
version: '4.0.0',
|
||||
serverPort: 4001,
|
||||
publicDir: '',
|
||||
};
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Settings } from 'ontime-types';
|
||||
|
||||
export const ontimePlaceholderSettings: Settings = {
|
||||
app: 'ontime',
|
||||
version: '2.0.0',
|
||||
version: '4.0.0',
|
||||
serverPort: 4001,
|
||||
editorKey: null,
|
||||
operatorKey: null,
|
||||
|
||||
@@ -8,4 +8,5 @@ export const projectDataPlaceholder: ProjectData = {
|
||||
backstageUrl: '',
|
||||
backstageInfo: '',
|
||||
projectLogo: null,
|
||||
custom: [],
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export type Size = 'xs' | 'sm' | 'md' | 'lg';
|
||||
@@ -1,9 +1,20 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { isOntimeCloud } from '../externals';
|
||||
|
||||
export const ontimeQueryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
gcTime: 1000 * 60 * 10, // 10 min
|
||||
},
|
||||
mutations: {
|
||||
/**
|
||||
* React Query detects whether the client is online
|
||||
* However, web access is not required for the clients when deployed locally
|
||||
* - use 'always' for clients that may be online
|
||||
* - use 'online' for clients that are connected to the cloud
|
||||
*/
|
||||
networkMode: isOntimeCloud ? 'online' : 'always',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -14,18 +14,6 @@ export const runtimeStore = createWithEqualityFn<RuntimeStore>(
|
||||
export const useRuntimeStore = <T>(selector: (state: RuntimeStore) => T) =>
|
||||
useStoreWithEqualityFn(runtimeStore, selector, deepCompare);
|
||||
|
||||
let batchStore: Partial<RuntimeStore> = {};
|
||||
|
||||
export function addToBatchUpdates<K extends keyof RuntimeStore>(key: K, value: RuntimeStore[K]) {
|
||||
batchStore[key] = value;
|
||||
}
|
||||
|
||||
export function flushBatchUpdates() {
|
||||
const state = runtimeStore.getState();
|
||||
runtimeStore.setState({ ...state, ...batchStore });
|
||||
batchStore = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows patching a property of the runtime store
|
||||
*/
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
exports[`cx() > ignores falsy values 1`] = `""`;
|
||||
|
||||
exports[`cx() > merges styles 1`] = `"_test_98a1e0 _another_98a1e0"`;
|
||||
exports[`cx() > merges styles 1`] = `"_test_d5d33f _another_d5d33f"`;
|
||||
|
||||
+11
-5
@@ -1,12 +1,12 @@
|
||||
import { EndAction, EntryCustomFields, OntimeEvent, SupportedEvent, TimerType, TimeStrategy } from 'ontime-types';
|
||||
import { EndAction, EntryCustomFields, OntimeEvent, SupportedEntry, TimerType, TimeStrategy } from 'ontime-types';
|
||||
|
||||
import { cloneEvent } from '../eventsManager';
|
||||
import { cloneEvent } from '../clone';
|
||||
|
||||
describe('cloneEvent()', () => {
|
||||
it('creates a stem from a given event', () => {
|
||||
const original: OntimeEvent = {
|
||||
id: 'unique',
|
||||
type: SupportedEvent.Event,
|
||||
type: SupportedEntry.Event,
|
||||
title: 'title',
|
||||
cue: 'cue',
|
||||
note: 'note',
|
||||
@@ -15,7 +15,8 @@ describe('cloneEvent()', () => {
|
||||
timeEnd: 10,
|
||||
timerType: TimerType.CountDown,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
linkStart: null,
|
||||
parent: 'test',
|
||||
linkStart: false,
|
||||
countToEnd: false,
|
||||
endAction: EndAction.None,
|
||||
isPublic: false,
|
||||
@@ -27,6 +28,7 @@ describe('cloneEvent()', () => {
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
triggers: [],
|
||||
custom: {
|
||||
lighting: '3',
|
||||
} as EntryCustomFields,
|
||||
@@ -35,9 +37,10 @@ describe('cloneEvent()', () => {
|
||||
const cloned = cloneEvent(original);
|
||||
expect(cloned).not.toBe(original);
|
||||
expect(cloned.custom).not.toBe(original.custom);
|
||||
expect(cloned.triggers).not.toBe(original.triggers);
|
||||
|
||||
expect(cloned).toMatchObject({
|
||||
type: SupportedEvent.Event,
|
||||
type: SupportedEntry.Event,
|
||||
title: original.title,
|
||||
note: original.note,
|
||||
timeStart: original.timeStart,
|
||||
@@ -45,6 +48,7 @@ describe('cloneEvent()', () => {
|
||||
timeEnd: original.timeEnd,
|
||||
timerType: original.timerType,
|
||||
timeStrategy: original.timeStrategy,
|
||||
parent: 'test',
|
||||
countToEnd: original.countToEnd,
|
||||
linkStart: original.linkStart,
|
||||
endAction: original.endAction,
|
||||
@@ -57,6 +61,8 @@ describe('cloneEvent()', () => {
|
||||
gap: 0,
|
||||
timeWarning: original.timeWarning,
|
||||
timeDanger: original.timeDanger,
|
||||
triggers: original.triggers,
|
||||
custom: original.custom,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ describe('aggregateRundowns()', () => {
|
||||
title: '',
|
||||
revision: 0,
|
||||
order: ['1', '2'],
|
||||
flatOrder: ['1', '2'],
|
||||
entries: {
|
||||
'1': { id: '1' } as OntimeEntry,
|
||||
'2': { id: '2' } as OntimeEntry,
|
||||
@@ -32,6 +33,7 @@ describe('aggregateRundowns()', () => {
|
||||
title: '',
|
||||
revision: 0,
|
||||
order: ['3', '4'],
|
||||
flatOrder: ['3', '4'],
|
||||
entries: {
|
||||
'3': { id: '3' } as OntimeEntry,
|
||||
'4': { id: '4' } as OntimeEntry,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { OffsetMode } from 'ontime-types';
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
|
||||
import { calculateTimeUntilStart, formatTime, nowInMillis } from '../time';
|
||||
|
||||
describe('nowInMillis()', () => {
|
||||
@@ -40,82 +43,255 @@ describe('formatTime()', () => {
|
||||
});
|
||||
|
||||
describe('calculateTimeUntilStart()', () => {
|
||||
test('ontime', () => {
|
||||
describe('Absolute offset mode', () => {
|
||||
test('ontime', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
clock: 90,
|
||||
offset: 0,
|
||||
offsetMode: OffsetMode.Absolute,
|
||||
actualStart: null,
|
||||
plannedStart: null,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(10);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
|
||||
});
|
||||
|
||||
test('running behind', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
clock: 90,
|
||||
offset: -20,
|
||||
offsetMode: OffsetMode.Absolute,
|
||||
actualStart: null,
|
||||
plannedStart: null,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(30);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(30);
|
||||
});
|
||||
|
||||
test('running ahead', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
clock: 80,
|
||||
offset: 10,
|
||||
offsetMode: OffsetMode.Absolute,
|
||||
actualStart: null,
|
||||
plannedStart: null,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // <-- when running ahead the unlinked timer stays put
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
|
||||
});
|
||||
|
||||
test('running behind with enough gaps', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 20,
|
||||
clock: 50,
|
||||
offset: -20,
|
||||
offsetMode: OffsetMode.Absolute,
|
||||
actualStart: null,
|
||||
plannedStart: null,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(50); // <-- when gap is enough to compensate for the running behind
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
|
||||
});
|
||||
|
||||
test('running behind with too little gaps', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 10,
|
||||
clock: 50,
|
||||
offset: -20,
|
||||
offsetMode: OffsetMode.Absolute,
|
||||
actualStart: 0,
|
||||
plannedStart: 0,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(60); // <-- when gap is not enough to compensate for the running behind it absorbs at much as possible
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
|
||||
});
|
||||
});
|
||||
|
||||
describe('Relative offset mode', () => {
|
||||
test('basic function', () => {
|
||||
const test = {
|
||||
timeStart: 0,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
clock: 100,
|
||||
actualStart: 100,
|
||||
plannedStart: 0,
|
||||
offset: 0,
|
||||
offsetMode: OffsetMode.Relative,
|
||||
};
|
||||
|
||||
const timeStartEvent2 = 10;
|
||||
const timeStartEvent3 = 20;
|
||||
|
||||
//event 1 is the currently running event
|
||||
|
||||
//event 2
|
||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(10);
|
||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(10);
|
||||
|
||||
//event 3
|
||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(20);
|
||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(20);
|
||||
|
||||
// When clock advances by 5ms, time until start should decrease by 5ms
|
||||
test.clock = 105;
|
||||
|
||||
//event 2
|
||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(5);
|
||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(5);
|
||||
|
||||
//event 3
|
||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(15);
|
||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(15);
|
||||
});
|
||||
|
||||
test('gaps', () => {
|
||||
const test = {
|
||||
timeStart: 20,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 10,
|
||||
clock: 100,
|
||||
actualStart: 100,
|
||||
plannedStart: 0,
|
||||
offset: 0,
|
||||
offsetMode: OffsetMode.Relative,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
|
||||
|
||||
// When clock advances by 5ms, time until start should decrease by 5ms
|
||||
test.clock = 105;
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(15);
|
||||
});
|
||||
|
||||
test('added/remove time', () => {
|
||||
const test = {
|
||||
timeStart: 20,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
clock: 100,
|
||||
actualStart: 100,
|
||||
plannedStart: 0,
|
||||
offset: 0,
|
||||
offsetMode: OffsetMode.Relative,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
|
||||
|
||||
test.offset = 5; // remove 5 with addtime - we are ahead of time
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // unlocked evets will stay on schedule
|
||||
|
||||
test.offset = -5; // add 5 with addtime - we are behind
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(25);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(25);
|
||||
});
|
||||
|
||||
test('next day', () => {
|
||||
const test = {
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
clock: 100,
|
||||
actualStart: 100,
|
||||
plannedStart: 0,
|
||||
offset: 0,
|
||||
offsetMode: OffsetMode.Relative,
|
||||
};
|
||||
|
||||
// this event will start the current day
|
||||
expect(
|
||||
calculateTimeUntilStart({
|
||||
...test,
|
||||
timeStart: 10,
|
||||
dayOffset: 0,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
}),
|
||||
).toBe(10);
|
||||
|
||||
// this event will start the next day
|
||||
// in absolute mode this would start in dayInMs - 100 since the gap would compensate
|
||||
// but in relative mode with and actual start that is 100 offset it starts in dayInMs
|
||||
expect(
|
||||
calculateTimeUntilStart({
|
||||
...test,
|
||||
timeStart: 0,
|
||||
dayOffset: 1,
|
||||
totalGap: dayInMs - 20,
|
||||
isLinkedToLoaded: false,
|
||||
}),
|
||||
).toBe(dayInMs);
|
||||
|
||||
// advancing 100ms
|
||||
test.clock = 200;
|
||||
|
||||
expect(
|
||||
calculateTimeUntilStart({
|
||||
...test,
|
||||
timeStart: 0,
|
||||
dayOffset: 1,
|
||||
totalGap: dayInMs - 20,
|
||||
isLinkedToLoaded: false,
|
||||
}),
|
||||
).toBe(dayInMs - 100);
|
||||
});
|
||||
});
|
||||
|
||||
test('overlap with negative total gap', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
clock: 90,
|
||||
clock: 100,
|
||||
actualStart: 100,
|
||||
plannedStart: 0,
|
||||
offset: 0,
|
||||
offsetMode: OffsetMode.Relative,
|
||||
isLinkedToLoaded: false,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(10);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
|
||||
// the overlap will be pushed out to the expected available time
|
||||
expect(calculateTimeUntilStart({ ...test, timeStart: 5, totalGap: -5 })).toBe(10);
|
||||
|
||||
test.clock = 105;
|
||||
});
|
||||
|
||||
test('running behind', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
clock: 90,
|
||||
offset: -20,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(30);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(30);
|
||||
});
|
||||
|
||||
test('running ahead', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
clock: 80,
|
||||
offset: 10,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // <-- when running ahead the unlinked timer stays put
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
|
||||
});
|
||||
|
||||
test('running behind with enough gaps', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 20,
|
||||
clock: 50,
|
||||
offset: -20,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(50);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
|
||||
});
|
||||
|
||||
test('running behind with to little gaps', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 10,
|
||||
clock: 50,
|
||||
offset: -20,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(60);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
|
||||
});
|
||||
|
||||
//TODO: more indepth testing,
|
||||
// including day offset handling
|
||||
// and more?
|
||||
});
|
||||
|
||||
+5
-4
@@ -1,4 +1,4 @@
|
||||
import { OntimeEvent, SupportedEvent } from 'ontime-types';
|
||||
import { OntimeEvent, SupportedEntry } from 'ontime-types';
|
||||
|
||||
/**
|
||||
* @description Creates a safe duplicate of an event
|
||||
@@ -9,7 +9,7 @@ import { OntimeEvent, SupportedEvent } from 'ontime-types';
|
||||
type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>;
|
||||
export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
|
||||
return {
|
||||
type: SupportedEvent.Event,
|
||||
type: SupportedEntry.Event,
|
||||
title: event.title,
|
||||
note: event.note,
|
||||
timeStart: event.timeStart,
|
||||
@@ -23,13 +23,14 @@ export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
|
||||
isPublic: event.isPublic,
|
||||
skip: event.skip,
|
||||
colour: event.colour,
|
||||
currentBlock: event.currentBlock,
|
||||
parent: event.parent,
|
||||
revision: 0,
|
||||
delay: event.delay, // the events will be collocated, so having the same metadata is a good start
|
||||
dayOffset: event.dayOffset,
|
||||
gap: 0,
|
||||
timeWarning: event.timeWarning,
|
||||
timeDanger: event.timeDanger,
|
||||
custom: { ...event.custom },
|
||||
triggers: structuredClone(event.triggers),
|
||||
custom: structuredClone(event.custom),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { MaybeString } from 'ontime-types';
|
||||
|
||||
export default function safeParseNumber(value: MaybeString, defaultValue: number = 0): number {
|
||||
if (!value) return defaultValue;
|
||||
const number = Number(value);
|
||||
if (isNaN(number)) return defaultValue;
|
||||
return number;
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from '../stores/clientStore';
|
||||
import { addDialog } from '../stores/dialogStore';
|
||||
import { addLog } from '../stores/logger';
|
||||
import { addToBatchUpdates, flushBatchUpdates, patchRuntime, patchRuntimeProperty } from '../stores/runtime';
|
||||
import { patchRuntime, patchRuntimeProperty } from '../stores/runtime';
|
||||
|
||||
let websocket: WebSocket | null = null;
|
||||
let reconnectTimeout: NodeJS.Timeout | null = null;
|
||||
@@ -47,10 +47,12 @@ export const connectSocket = () => {
|
||||
|
||||
websocket.onclose = () => {
|
||||
console.warn('WebSocket disconnected');
|
||||
setOnlineStatus(false);
|
||||
|
||||
// we decide to allows reconnect
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
if (reconnectAttempts > 2) {
|
||||
setOnlineStatus(false);
|
||||
}
|
||||
console.warn('WebSocket: attempting reconnect');
|
||||
if (websocket && websocket.readyState === WebSocket.CLOSED) {
|
||||
reconnectAttempts += 1;
|
||||
@@ -139,59 +141,10 @@ export const connectSocket = () => {
|
||||
updateDevTools(serverPayload);
|
||||
break;
|
||||
}
|
||||
case 'ontime-clock': {
|
||||
addToBatchUpdates('clock', payload);
|
||||
updateDevTools({ clock: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-timer': {
|
||||
addToBatchUpdates('timer', payload);
|
||||
updateDevTools({ timer: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-onAir': {
|
||||
addToBatchUpdates('onAir', payload);
|
||||
updateDevTools({ onAir: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-message': {
|
||||
addToBatchUpdates('message', payload);
|
||||
updateDevTools({ message: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-runtime': {
|
||||
addToBatchUpdates('runtime', payload);
|
||||
updateDevTools({ runtime: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-eventNow': {
|
||||
addToBatchUpdates('eventNow', payload);
|
||||
updateDevTools({ eventNow: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-currentBlock': {
|
||||
addToBatchUpdates('currentBlock', payload);
|
||||
updateDevTools({ currentBlock: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-publicEventNow': {
|
||||
addToBatchUpdates('publicEventNow', payload);
|
||||
updateDevTools({ publicEventNow: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-eventNext': {
|
||||
addToBatchUpdates('eventNext', payload);
|
||||
updateDevTools({ eventNext: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-publicEventNext': {
|
||||
addToBatchUpdates('publicEventNext', payload);
|
||||
updateDevTools({ publicEventNext: payload });
|
||||
break;
|
||||
}
|
||||
case 'ontime-auxtimer1': {
|
||||
addToBatchUpdates('auxtimer1', payload);
|
||||
updateDevTools({ auxtimer1: payload });
|
||||
case 'ontime-patch': {
|
||||
const patch = payload as Partial<RuntimeStore>;
|
||||
patchRuntime(patch);
|
||||
updateDevTools(patch);
|
||||
break;
|
||||
}
|
||||
case 'ontime-refetch': {
|
||||
@@ -211,8 +164,8 @@ export const connectSocket = () => {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'ontime-flush': {
|
||||
flushBatchUpdates();
|
||||
default: {
|
||||
console.log('unknown WS message', type);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MaybeNumber, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
|
||||
import { MaybeNumber, OffsetMode, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
|
||||
import { dayInMs, formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||
|
||||
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
|
||||
@@ -119,7 +119,7 @@ export function formatDuration(duration: number, hideSeconds = true): string {
|
||||
}
|
||||
|
||||
if (!hideSeconds) {
|
||||
const seconds = Math.floor((duration % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND);
|
||||
const seconds = Math.ceil((duration % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND);
|
||||
if (seconds > 0) {
|
||||
result += `${seconds}s`;
|
||||
}
|
||||
@@ -140,8 +140,8 @@ export function useTimeUntilStart(
|
||||
isLinkedToLoaded: boolean;
|
||||
},
|
||||
): number {
|
||||
const { offset, clock, currentDay } = useTimeUntilData();
|
||||
return calculateTimeUntilStart({ ...data, currentDay, clock, offset });
|
||||
const { offset, clock, currentDay, offsetMode, actualStart, plannedStart } = useTimeUntilData();
|
||||
return calculateTimeUntilStart({ ...data, currentDay, clock, offset, offsetMode, actualStart, plannedStart });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,9 +160,24 @@ export function calculateTimeUntilStart(
|
||||
isLinkedToLoaded: boolean;
|
||||
clock: number;
|
||||
offset: number;
|
||||
offsetMode: OffsetMode;
|
||||
actualStart: MaybeNumber;
|
||||
plannedStart: MaybeNumber;
|
||||
},
|
||||
): number {
|
||||
const { timeStart, dayOffset, currentDay, totalGap, isLinkedToLoaded, clock, offset, delay } = data;
|
||||
const {
|
||||
timeStart,
|
||||
dayOffset,
|
||||
currentDay,
|
||||
totalGap,
|
||||
isLinkedToLoaded,
|
||||
clock,
|
||||
offset,
|
||||
delay,
|
||||
offsetMode,
|
||||
actualStart,
|
||||
plannedStart,
|
||||
} = data;
|
||||
|
||||
//How many days from the currently running event to this one
|
||||
const relativeDayOffset = dayOffset - currentDay;
|
||||
@@ -172,20 +187,23 @@ export function calculateTimeUntilStart(
|
||||
//The normalised start time of this event relative to the currently running event
|
||||
const normalisedTimeStart = delayedStart + relativeDayOffset * dayInMs;
|
||||
|
||||
const offsetTimestart = normalisedTimeStart - offset;
|
||||
const offsetTimeUntil = offsetTimestart - clock;
|
||||
let relativeStartOffset = 0;
|
||||
|
||||
if (offsetMode === OffsetMode.Relative) {
|
||||
relativeStartOffset = (actualStart ?? 0) - (plannedStart ?? 0);
|
||||
}
|
||||
|
||||
const scheduledTimeUntil = normalisedTimeStart - clock + relativeStartOffset;
|
||||
|
||||
const offsetTimeUntil = scheduledTimeUntil - offset;
|
||||
|
||||
if (isLinkedToLoaded) {
|
||||
//if we are directly linked back to the loaded event we just follow the offset
|
||||
return offsetTimeUntil;
|
||||
}
|
||||
|
||||
const scheduledTimeUntil = normalisedTimeStart - clock;
|
||||
|
||||
const isAheadOfSchedule = offset >= 0;
|
||||
const gapsCanCompensadeForOffset = totalGap + offset >= 0;
|
||||
|
||||
if (isAheadOfSchedule || gapsCanCompensadeForOffset) {
|
||||
const gapsCanCompensateForOffset = totalGap + offset >= 0;
|
||||
if (gapsCanCompensateForOffset) {
|
||||
// if we are ahead of schedule or the gap can compensate for the amount we are behind then expect to start at the scheduled time
|
||||
return scheduledTimeUntil;
|
||||
}
|
||||
|
||||
@@ -49,10 +49,7 @@ function resolveUrl(protocol: 'http' | 'ws', path: string) {
|
||||
url.pathname = baseURI ? `${baseURI}/${path}` : path;
|
||||
|
||||
// in development mode, we use the React port for UI, but need the requests to target the server
|
||||
if (isDev) {
|
||||
// this is used as a fallback port for development
|
||||
url.port = '4001';
|
||||
}
|
||||
// this is done with a proxy in the vite config to avoid CORS issues in the dev environment
|
||||
|
||||
const result = url.toString();
|
||||
|
||||
|
||||
@@ -207,6 +207,9 @@ $inner-padding: 1rem;
|
||||
&.end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
&.apart {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes animloader {
|
||||
|
||||
@@ -113,8 +113,8 @@ export function BlockQuote({ children }: { children: ReactNode }) {
|
||||
return <blockquote className={style.blockquote}>{children}</blockquote>;
|
||||
}
|
||||
|
||||
export function Error({ children }: { children: ReactNode }) {
|
||||
return <div className={style.fieldError}>{children}</div>;
|
||||
export function Error({ children, className }: { children: ReactNode } & JSX.IntrinsicElements['div']) {
|
||||
return <div className={cx([style.fieldError, className])}>{children}</div>;
|
||||
}
|
||||
|
||||
export function Divider() {
|
||||
@@ -136,7 +136,7 @@ type AllowedInlineTags = 'div' | 'td';
|
||||
type InlineProps<C extends AllowedInlineTags> = {
|
||||
as?: C;
|
||||
relation?: 'inner' | 'component' | 'section';
|
||||
align?: 'start' | 'end';
|
||||
align?: 'start' | 'end' | 'apart';
|
||||
className?: string;
|
||||
};
|
||||
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { IoPencil, IoTrash } from 'react-icons/io5';
|
||||
import { IconButton } from '@chakra-ui/react';
|
||||
import { CustomField, CustomFieldLabel } from 'ontime-types';
|
||||
import { CustomField, CustomFieldKey } from 'ontime-types';
|
||||
|
||||
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
|
||||
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
|
||||
@@ -17,8 +17,8 @@ interface CustomFieldEntryProps {
|
||||
label: string;
|
||||
fieldKey: string;
|
||||
type: 'string' | 'image';
|
||||
onEdit: (label: CustomFieldLabel, patch: CustomField) => Promise<void>;
|
||||
onDelete: (label: CustomFieldLabel) => Promise<void>;
|
||||
onEdit: (key: CustomFieldKey, patch: CustomField) => Promise<void>;
|
||||
onDelete: (key: CustomFieldKey) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
||||
|
||||
+5
-5
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { IoAdd } from 'react-icons/io5';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { CustomField, CustomFieldLabel } from 'ontime-types';
|
||||
import { CustomField, CustomFieldKey } from 'ontime-types';
|
||||
|
||||
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
|
||||
import Info from '../../../../../common/components/info/Info';
|
||||
@@ -31,14 +31,14 @@ export default function CustomFields() {
|
||||
setIsAdding(false);
|
||||
};
|
||||
|
||||
const handleEditField = async (label: CustomFieldLabel, customField: CustomField) => {
|
||||
await editCustomField(label, customField);
|
||||
const handleEditField = async (key: CustomFieldKey, customField: CustomField) => {
|
||||
await editCustomField(key, customField);
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleDelete = async (label: string) => {
|
||||
const handleDelete = async (key: CustomFieldKey) => {
|
||||
try {
|
||||
await deleteCustomField(label);
|
||||
await deleteCustomField(key);
|
||||
refetch();
|
||||
} catch (_error) {
|
||||
/** we do not handle errors here */
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.wrapper {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { forwardRef, memo, useEffect, useImperativeHandle, useState } from 'react';
|
||||
import Editor from 'react-simple-code-editor';
|
||||
import Prism from 'prismjs/components/prism-core';
|
||||
|
||||
import 'prismjs/components/prism-css';
|
||||
import 'prismjs/themes/prism-tomorrow.min.css';
|
||||
import style from './StyleEditor.module.scss';
|
||||
|
||||
interface CodeEditorProps {
|
||||
language: string;
|
||||
initialValue: string;
|
||||
isDirty: boolean;
|
||||
setIsDirty: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const CodeEditor = forwardRef((props: CodeEditorProps, cssRef) => {
|
||||
const { language, initialValue, isDirty, setIsDirty } = props;
|
||||
|
||||
const [code, setCode] = useState(initialValue);
|
||||
|
||||
const highlight = (code: string) => {
|
||||
const grammar = Prism.languages[language];
|
||||
return grammar ? Prism.highlight(code, grammar, language) : code;
|
||||
};
|
||||
|
||||
const handleChange = (newCode: string) => {
|
||||
setCode(newCode);
|
||||
};
|
||||
|
||||
useImperativeHandle(cssRef, () => {
|
||||
return {
|
||||
getCss: () => code,
|
||||
};
|
||||
});
|
||||
|
||||
// add contents to editor on mount and any change in initialValue
|
||||
useEffect(() => {
|
||||
setCode(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
// handle dirty state on change
|
||||
useEffect(() => {
|
||||
if (initialValue.trim() !== code.trim() && !isDirty && code.length !== 0) {
|
||||
setIsDirty(true);
|
||||
}
|
||||
|
||||
if (initialValue.trim() === code.trim() && isDirty) {
|
||||
setIsDirty(false);
|
||||
}
|
||||
}, [initialValue, code, isDirty, setIsDirty]);
|
||||
|
||||
return (
|
||||
<div className={style.wrapper}>
|
||||
<Editor
|
||||
value={code}
|
||||
padding={15}
|
||||
onValueChange={handleChange}
|
||||
highlight={highlight}
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
minHeight: 500,
|
||||
background: '#2d2d2d', // Background of tomorrow theme
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
CodeEditor.displayName = 'StyleEditor';
|
||||
|
||||
export default memo(CodeEditor);
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
.editorActions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.right {
|
||||
align-self: end;
|
||||
text-align: right
|
||||
}
|
||||
|
||||
.column {
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { lazy, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { getCSSContents, postCSSContents, restoreCSSContents } from '../../../../common/api/assets';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './StyleEditorModal.module.scss';
|
||||
|
||||
const CodeEditor = lazy(() => import('./StyleEditor'));
|
||||
|
||||
interface CodeEditorModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface CSSRef {
|
||||
getCss: () => string;
|
||||
}
|
||||
|
||||
export default function CodeEditorModal(props: CodeEditorModalProps) {
|
||||
const { isOpen, onClose } = props;
|
||||
|
||||
const [css, setCSS] = useState('');
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [saveLoading, setSaveLoading] = useState(false);
|
||||
const [resetLoading, setResetLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const cssRef = useRef<CSSRef>(null);
|
||||
|
||||
const handleRestore = async () => {
|
||||
try {
|
||||
setResetLoading(true);
|
||||
const defaultCss = await restoreCSSContents();
|
||||
setCSS(defaultCss);
|
||||
} catch (_error) {
|
||||
/** no error handling for now */
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaveLoading(true);
|
||||
if (cssRef.current) {
|
||||
await postCSSContents(cssRef.current.getCss());
|
||||
setCSS(cssRef.current.getCss());
|
||||
setIsDirty(false);
|
||||
}
|
||||
} catch (_error) {
|
||||
/** no error handling for now */
|
||||
} finally {
|
||||
setSaveLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clear = () => setCSS('');
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchServerCSS() {
|
||||
// check for isOpen to fetch recent css
|
||||
if (isOpen) {
|
||||
try {
|
||||
const css = await getCSSContents();
|
||||
setCSS(css);
|
||||
} catch (_error) {
|
||||
setError('Failed to load CSS from server');
|
||||
/** no error handling for now */
|
||||
}
|
||||
}
|
||||
}
|
||||
fetchServerCSS();
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} variant='ontime' isCentered>
|
||||
<ModalOverlay />
|
||||
<ModalContent maxWidth='max(800px, 40vw)'>
|
||||
<ModalHeader>Edit CSS override</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<CodeEditor ref={cssRef} initialValue={css} language='css' isDirty={isDirty} setIsDirty={setIsDirty} />
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter className={style.column}>
|
||||
<Info>Invalid CSS will be refused by the browser</Info>
|
||||
{error && <Panel.Error className={style.right}>{`Error: ${error}`}</Panel.Error>}
|
||||
<Panel.InlineElements align='apart' className={style.editorActions}>
|
||||
<Button
|
||||
variant='ontime-ghosted'
|
||||
onClick={handleRestore}
|
||||
isDisabled={saveLoading || resetLoading}
|
||||
isLoading={resetLoading}
|
||||
>
|
||||
Reset to example
|
||||
</Button>
|
||||
<Panel.InlineElements>
|
||||
<Button variant='ontime-ghosted' onClick={clear}>
|
||||
Clear
|
||||
</Button>
|
||||
<Button variant='ontime-subtle' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='ontime-filled'
|
||||
onClick={handleSave}
|
||||
isDisabled={saveLoading || resetLoading || !isDirty}
|
||||
isLoading={saveLoading}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.InlineElements>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { Button, Input, Switch } from '@chakra-ui/react';
|
||||
import { Button, Input, Switch, useDisclosure } from '@chakra-ui/react';
|
||||
import { ViewSettings } from 'ontime-types';
|
||||
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
@@ -14,11 +14,14 @@ import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { isOntimeCloud } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import CodeEditorModal from './StyleEditorModal';
|
||||
|
||||
const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/';
|
||||
|
||||
export default function ViewSettingsForm() {
|
||||
const { data, status, refetch } = useViewSettings();
|
||||
const { data: info, status: infoStatus } = useInfo();
|
||||
const { isOpen: isCodeEditorOpen, onOpen: onCodeEditorOpen, onClose: onCodeEditorClose } = useDisclosure();
|
||||
|
||||
const {
|
||||
control,
|
||||
@@ -103,6 +106,7 @@ export default function ViewSettingsForm() {
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={isLoading} />
|
||||
<Panel.ListGroup>
|
||||
<CodeEditorModal isOpen={isCodeEditorOpen} onClose={onCodeEditorClose} />
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Override CSS styles'
|
||||
@@ -115,6 +119,15 @@ export default function ViewSettingsForm() {
|
||||
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
onClick={onCodeEditorOpen}
|
||||
variant='ontime-subtle'
|
||||
size='sm'
|
||||
isDisabled={isSubmitting}
|
||||
width='fit-content'
|
||||
>
|
||||
Edit CSS override
|
||||
</Button>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
<Panel.ListGroup>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
declare module 'prismjs/components/prism-core' {
|
||||
export * from 'prismjs';
|
||||
}
|
||||
|
||||
declare module 'prismjs/components/prism-css';
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { IoTrash } from 'react-icons/io5';
|
||||
import { Button, Input, Textarea } from '@chakra-ui/react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
@@ -23,6 +24,7 @@ type ProjectCreateFormValues = {
|
||||
publicUrl?: string;
|
||||
backstageInfo?: string;
|
||||
backstageUrl?: string;
|
||||
custom?: { title: string; value: string }[];
|
||||
};
|
||||
|
||||
export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
||||
@@ -34,6 +36,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
control,
|
||||
formState: { isSubmitting, isValid },
|
||||
setFocus,
|
||||
} = useForm<ProjectCreateFormValues>({
|
||||
@@ -44,6 +47,11 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: 'custom',
|
||||
});
|
||||
|
||||
// set focus to first field
|
||||
useEffect(() => {
|
||||
setFocus('title');
|
||||
@@ -59,6 +67,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
||||
...values,
|
||||
filename,
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: PROJECT_LIST });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
@@ -66,6 +75,10 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCustom = () => {
|
||||
append({ title: '', value: '' });
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Section
|
||||
as='form'
|
||||
@@ -151,6 +164,42 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
||||
{...register('backstageUrl')}
|
||||
/>
|
||||
</label>
|
||||
<Panel.Section>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Custom data' description='Add custom data for your project' />
|
||||
<Button variant='ontime-subtle' onClick={handleAddCustom}>
|
||||
+
|
||||
</Button>
|
||||
</Panel.ListItem>
|
||||
{fields.map((field, idx) => (
|
||||
<div key={field.id} className={style.customDataItem}>
|
||||
<Panel.Paragraph>{idx + 1}.</Panel.Paragraph>
|
||||
<label>
|
||||
Title
|
||||
<Input
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder={field.title}
|
||||
autoComplete='off'
|
||||
{...register(`custom.${idx}.title` as const)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Value
|
||||
<Input
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder={field.value}
|
||||
autoComplete='off'
|
||||
{...register(`custom.${idx}.value` as const)}
|
||||
/>
|
||||
</label>
|
||||
<Button variant='ontime-ghosted' onClick={() => remove(idx)}>
|
||||
<IoTrash />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</Panel.Section>
|
||||
</Panel.Section>
|
||||
</Panel.Section>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ChangeEvent, useEffect, useRef } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { IoDownloadOutline, IoTrash } from 'react-icons/io5';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { IoAdd, IoDownloadOutline, IoTrash } from 'react-icons/io5';
|
||||
import { Button, Input, Textarea } from '@chakra-ui/react';
|
||||
import { type ProjectData } from 'ontime-types';
|
||||
|
||||
@@ -25,6 +25,7 @@ export default function ProjectData() {
|
||||
formState: { isSubmitting, isValid, isDirty, errors },
|
||||
setError,
|
||||
watch,
|
||||
control,
|
||||
setValue,
|
||||
} = useForm({
|
||||
defaultValues: data,
|
||||
@@ -32,6 +33,12 @@ export default function ProjectData() {
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
mode: 'onChange',
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: 'custom',
|
||||
});
|
||||
|
||||
// reset form values if data changes
|
||||
@@ -77,6 +84,10 @@ export default function ProjectData() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddCustom = () => {
|
||||
append({ title: '', value: '' });
|
||||
};
|
||||
|
||||
const onSubmit = async (formData: ProjectData) => {
|
||||
try {
|
||||
await postProjectData(formData);
|
||||
@@ -231,6 +242,69 @@ export default function ProjectData() {
|
||||
{...register('backstageUrl')}
|
||||
/>
|
||||
</label>
|
||||
<Panel.Section style={{ marginTop: 0 }}>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Custom data' description='' />
|
||||
<Button leftIcon={<IoAdd />} size='sm' variant='ontime-subtle' onClick={handleAddCustom}>
|
||||
Add
|
||||
</Button>
|
||||
</Panel.ListItem>
|
||||
{fields.length > 0 &&
|
||||
fields.map((field, idx) => {
|
||||
const rowErrors = errors.custom?.[idx] as
|
||||
| {
|
||||
title?: { message?: string };
|
||||
value?: { message?: string };
|
||||
}
|
||||
| undefined;
|
||||
return (
|
||||
<div key={field.id} className={style.customDataItem}>
|
||||
<div>
|
||||
<div className={style.titleRow}>
|
||||
<label>
|
||||
Title
|
||||
<Input
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
defaultValue={field.title}
|
||||
placeholder='Title of your custom data'
|
||||
autoComplete='off'
|
||||
{...register(`custom.${idx}.title`, {
|
||||
required: { value: true, message: 'Field cannot be empty' },
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='ontime-subtle'
|
||||
color='#FA5656' // $red-500
|
||||
onClick={() => remove(idx)}
|
||||
leftIcon={<IoTrash />}
|
||||
>
|
||||
Delete Entry
|
||||
</Button>
|
||||
</div>
|
||||
{rowErrors?.title?.message && <Panel.Error>{rowErrors.title.message}</Panel.Error>}
|
||||
</div>
|
||||
<label>
|
||||
Value
|
||||
<Textarea
|
||||
variant='ontime-filled'
|
||||
resize='none'
|
||||
size='sm'
|
||||
defaultValue={field.value}
|
||||
autoComplete='off'
|
||||
placeholder='Text of your custom data'
|
||||
{...register(`custom.${idx}.value`, {
|
||||
required: { value: true, message: 'Field cannot be empty' },
|
||||
})}
|
||||
/>
|
||||
{rowErrors?.value?.message && <Panel.Error>{rowErrors.value.message}</Panel.Error>}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Panel.Section>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
|
||||
@@ -57,3 +57,18 @@
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.customDataItem {
|
||||
display: contents;
|
||||
width: 100%;
|
||||
|
||||
.titleRow{
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: end;
|
||||
|
||||
label {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ export default function SourcesPanel() {
|
||||
};
|
||||
|
||||
const handleSubmitImportPreview = async (importMap: ImportMap) => {
|
||||
setError(''); // to clear previous error
|
||||
if (importFlow === 'excel') {
|
||||
try {
|
||||
const previewData = await importRundownPreviewExcel(importMap);
|
||||
|
||||
+10
-1
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { IoAdd, IoTrash } from 'react-icons/io5';
|
||||
import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react';
|
||||
@@ -28,6 +28,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
|
||||
control,
|
||||
handleSubmit,
|
||||
register,
|
||||
setValue,
|
||||
formState: { errors, isValid },
|
||||
} = useForm<NamedImportMap>({
|
||||
mode: 'onChange',
|
||||
@@ -45,6 +46,14 @@ export default function ImportMapForm(props: ImportMapFormProps) {
|
||||
|
||||
const [loading, setLoading] = useState<'' | 'export' | 'import'>('');
|
||||
|
||||
// Set first sheet as default worksheet when 'event schedule' sheet is not there
|
||||
useEffect(() => {
|
||||
if (!worksheetNames || worksheetNames.length === 0) return;
|
||||
if (!worksheetNames.includes(namedImportMap.Worksheet)) {
|
||||
setValue('Worksheet', worksheetNames[0], { shouldValidate: true, shouldDirty: true });
|
||||
}
|
||||
}, [worksheetNames, setValue, namedImportMap.Worksheet]);
|
||||
|
||||
const handleExport = async (values: NamedImportMap) => {
|
||||
setLoading('export');
|
||||
const importMap = convertToImportMap(values);
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { IoEye, IoEyeOffOutline } from 'react-icons/io5';
|
||||
import { PropsWithChildren, useEffect, useRef, useState } from 'react';
|
||||
import { Input } from '@chakra-ui/react';
|
||||
|
||||
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
|
||||
import style from './InputRow.module.scss';
|
||||
|
||||
@@ -12,50 +10,53 @@ interface InputRowProps {
|
||||
placeholder: string;
|
||||
text: string;
|
||||
visible: boolean;
|
||||
actionHandler: () => void;
|
||||
changeHandler: (newValue: string) => void;
|
||||
}
|
||||
|
||||
export default function InputRow(props: InputRowProps) {
|
||||
const { label, placeholder, text, visible, actionHandler, changeHandler } = props;
|
||||
export default function InputRow(props: PropsWithChildren<InputRowProps>) {
|
||||
const { label, placeholder, text, visible, changeHandler, children } = props;
|
||||
|
||||
const [value, setValue] = useState(text);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const cursorPositionRef = useRef(0);
|
||||
|
||||
// sync cursor position with text
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
if (inputRef.current && inputRef.current !== document.activeElement) {
|
||||
inputRef.current.selectionStart = cursorPositionRef.current;
|
||||
inputRef.current.selectionEnd = cursorPositionRef.current;
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
// synchronise external text
|
||||
useEffect(() => {
|
||||
if (inputRef.current !== document.activeElement) {
|
||||
setValue(text);
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
cursorPositionRef.current = event.target.selectionStart ?? 0;
|
||||
setValue(event.target.value);
|
||||
changeHandler(event.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.inputRow}>
|
||||
<label className={`${style.label} ${visible ? style.active : ''}`}>{label}</label>
|
||||
<label className={cx([style.label, visible ?? style.active])} htmlFor={label}>
|
||||
{label}
|
||||
</label>
|
||||
<div className={style.inputItems}>
|
||||
<Input
|
||||
id={label}
|
||||
ref={inputRef}
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
value={text}
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<TooltipActionBtn
|
||||
clickHandler={actionHandler}
|
||||
tooltip={visible ? 'Make invisible' : 'Make visible'}
|
||||
aria-label={`Toggle ${label}`}
|
||||
openDelay={tooltipDelayMid}
|
||||
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
|
||||
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
|
||||
size='sm'
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { IoEye, IoEyeOffOutline } from 'react-icons/io5';
|
||||
|
||||
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
|
||||
import { setMessage, useExternalMessageInput, useTimerMessageInput } from '../../../common/hooks/useSocket';
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
|
||||
import InputRow from './InputRow';
|
||||
import TimerControlsPreview from './TimerViewControl';
|
||||
@@ -23,8 +27,17 @@ function TimerMessageInput() {
|
||||
text={text}
|
||||
visible={visible}
|
||||
changeHandler={(newValue) => setMessage.timerText(newValue)}
|
||||
actionHandler={() => setMessage.timerVisible(!visible)}
|
||||
/>
|
||||
>
|
||||
<TooltipActionBtn
|
||||
clickHandler={() => setMessage.timerVisible(!visible)}
|
||||
tooltip={visible ? 'Make invisible' : 'Make visible'}
|
||||
aria-label='Toggle timer message visibility'
|
||||
openDelay={tooltipDelayMid}
|
||||
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
|
||||
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
|
||||
size='sm'
|
||||
/>
|
||||
</InputRow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,7 +59,16 @@ function ExternalInput() {
|
||||
text={text}
|
||||
visible={visible}
|
||||
changeHandler={(newValue) => setMessage.externalText(newValue)}
|
||||
actionHandler={toggleExternal}
|
||||
/>
|
||||
>
|
||||
<TooltipActionBtn
|
||||
clickHandler={toggleExternal}
|
||||
tooltip={visible ? 'Make invisible' : 'Make visible'}
|
||||
aria-label='Toggle external message visibility'
|
||||
openDelay={tooltipDelayMid}
|
||||
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
|
||||
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
|
||||
size='sm'
|
||||
/>
|
||||
</InputRow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { IoAdd, IoRemove } from 'react-icons/io5';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import { Playback } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, MILLIS_PER_SECOND, parseUserTime } from 'ontime-utils';
|
||||
import { MILLIS_PER_HOUR, parseUserTime } from 'ontime-utils';
|
||||
|
||||
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
||||
import { setPlayback } from '../../../../common/hooks/useSocket';
|
||||
@@ -17,29 +17,29 @@ interface AddTimeProps {
|
||||
|
||||
export default function AddTime(props: AddTimeProps) {
|
||||
const { playback } = props;
|
||||
const [time, setTime] = useLocalStorage({ key: 'add-time', defaultValue: 300_000 }); // 5 minutes
|
||||
const [timeInMs, setTime] = useLocalStorage({ key: 'add-time', defaultValue: 300_000 }); // 5 minutes
|
||||
|
||||
const handleTimeChange = (_field: string, value: string) => {
|
||||
const newTime = parseUserTime(value);
|
||||
const newTimeInMs = parseUserTime(value);
|
||||
// cap add time to 1 hour
|
||||
setTime(Math.min(newTime, MILLIS_PER_HOUR));
|
||||
setTime(Math.min(newTimeInMs, MILLIS_PER_HOUR));
|
||||
};
|
||||
|
||||
const handleAddTime = (direction: 'add' | 'remove') => {
|
||||
// API expects input in seconds
|
||||
// API expects input in milliseconds
|
||||
if (direction === 'add') {
|
||||
setPlayback.addTime(time / MILLIS_PER_SECOND);
|
||||
setPlayback.addTime(timeInMs);
|
||||
} else {
|
||||
setPlayback.addTime((-1 * time) / MILLIS_PER_SECOND);
|
||||
setPlayback.addTime(-1 * timeInMs);
|
||||
}
|
||||
};
|
||||
|
||||
const canAddTime = playback === Playback.Play || playback === Playback.Pause;
|
||||
const doDisableButtons = !canAddTime || time === 0;
|
||||
const doDisableButtons = !canAddTime || timeInMs === 0;
|
||||
|
||||
return (
|
||||
<div className={style.addTime}>
|
||||
<TimeInput name='addtime' submitHandler={handleTimeChange} time={time} placeholder='Add time' />
|
||||
<TimeInput name='addtime' submitHandler={handleTimeChange} time={timeInMs} placeholder='Add time' />
|
||||
<div className={style.addButtons}>
|
||||
<Tooltip label='Remove time' openDelay={tooltipDelayMid} shouldWrapChildren>
|
||||
<TapButton onClick={() => handleAddTime('remove')} disabled={doDisableButtons} className={style.tallButtons}>
|
||||
|
||||
@@ -59,15 +59,20 @@ export function AuxTimer() {
|
||||
}
|
||||
|
||||
function AuxTimerInput() {
|
||||
const time = useAuxTimerTime();
|
||||
const newTimeInMs = useAuxTimerTime();
|
||||
const { setDuration } = setAuxTimer;
|
||||
|
||||
const handleTimeUpdate = (_field: string, value: string) => {
|
||||
const newTime = parseUserTime(value);
|
||||
setDuration(newTime / 1000); // frontend api is seconds based
|
||||
const newTimeInMs = parseUserTime(value);
|
||||
setDuration(newTimeInMs);
|
||||
};
|
||||
|
||||
return (
|
||||
<TimeInput<'auxTimer'> submitHandler={handleTimeUpdate} name='auxTimer' time={time} placeholder='Aux Timer 1' />
|
||||
<TimeInput<'auxTimer'>
|
||||
submitHandler={handleTimeUpdate}
|
||||
name='auxTimer'
|
||||
time={newTimeInMs}
|
||||
placeholder='Aux Timer 1'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import AppSettings from '../app-settings/AppSettings';
|
||||
import useAppSettingsNavigation from '../app-settings/useAppSettingsNavigation';
|
||||
import { EditorOverview } from '../overview/Overview';
|
||||
|
||||
import Finder from './finder/Finder';
|
||||
import WelcomePlacement from './welcome/WelcomePlacement';
|
||||
|
||||
import styles from './Editor.module.scss';
|
||||
@@ -22,7 +21,6 @@ const MessageControl = lazy(() => import('../control/message/MessageControlExpor
|
||||
export default function Editor() {
|
||||
const { isOpen: isSettingsOpen, setLocation, close } = useAppSettingsNavigation();
|
||||
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
|
||||
const { isOpen: isFinderOpen, onToggle: onFinderToggle, onClose: onFinderClose } = useDisclosure();
|
||||
|
||||
useWindowTitle('Editor');
|
||||
|
||||
@@ -46,16 +44,11 @@ export default function Editor() {
|
||||
}
|
||||
}, [close, isSettingsOpen, setLocation]);
|
||||
|
||||
useHotkeys([
|
||||
['mod + ,', toggleSettings],
|
||||
['mod + f', onFinderToggle],
|
||||
['Escape', onFinderClose],
|
||||
]);
|
||||
useHotkeys([['mod + ,', toggleSettings]]);
|
||||
|
||||
return (
|
||||
<div className={styles.mainContainer} data-testid='event-editor'>
|
||||
<WelcomePlacement />
|
||||
<Finder isOpen={isFinderOpen} onClose={onFinderClose} />
|
||||
<NavigationMenu isOpen={isMenuOpen} onClose={onClose} />
|
||||
<EditorOverview>
|
||||
<IconButton
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { KeyboardEvent, useState } from 'react';
|
||||
import { Input, Modal, ModalBody, ModalContent, ModalFooter, ModalOverlay } from '@chakra-ui/react';
|
||||
import { useDebouncedCallback } from '@mantine/hooks';
|
||||
import { SupportedEvent } from 'ontime-types';
|
||||
import { SupportedEntry } from 'ontime-types';
|
||||
|
||||
import { useEventSelection } from '../../rundown/useEventSelection';
|
||||
|
||||
@@ -67,9 +67,9 @@ export default function Finder(props: FinderProps) {
|
||||
{results.length > 0 &&
|
||||
results.map((entry, index) => {
|
||||
const isSelected = selected === index;
|
||||
const displayIndex = entry.type === SupportedEvent.Event ? entry.eventIndex : '-';
|
||||
const displayCue = entry.type === SupportedEvent.Event ? entry.cue : '';
|
||||
const colour = entry.type === SupportedEvent.Event ? entry.colour : '';
|
||||
const displayIndex = entry.type === SupportedEntry.Event ? entry.eventIndex : '-';
|
||||
const displayCue = entry.type === SupportedEntry.Event ? entry.cue : '';
|
||||
const colour = entry.type === SupportedEntry.Event ? entry.colour : '';
|
||||
|
||||
return (
|
||||
<li
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { ChangeEvent, useEffect, useRef, useState } from 'react';
|
||||
import { isOntimeBlock, isOntimeEvent, MaybeString, SupportedEvent } from 'ontime-types';
|
||||
import { isOntimeBlock, isOntimeEvent, MaybeString, SupportedEntry } from 'ontime-types';
|
||||
|
||||
import { useFlatRundown } from '../../../common/hooks-query/useRundown';
|
||||
|
||||
const maxResults = 12;
|
||||
|
||||
type FilterableBlock = {
|
||||
type: SupportedEvent.Block;
|
||||
type: SupportedEntry.Block;
|
||||
id: string;
|
||||
index: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type FilterableEvent = {
|
||||
type: SupportedEvent.Event;
|
||||
type: SupportedEntry.Event;
|
||||
id: string;
|
||||
index: number;
|
||||
eventIndex: number;
|
||||
@@ -59,7 +59,7 @@ export default function useFinder() {
|
||||
if (isOntimeEvent(event)) {
|
||||
if (eventIndex === searchIndex) {
|
||||
results.push({
|
||||
type: SupportedEvent.Event,
|
||||
type: SupportedEntry.Event,
|
||||
id: event.id,
|
||||
index: i,
|
||||
eventIndex,
|
||||
@@ -93,7 +93,7 @@ export default function useFinder() {
|
||||
if (event.cue.toLowerCase().includes(searchString)) {
|
||||
remaining--;
|
||||
results.push({
|
||||
type: SupportedEvent.Event,
|
||||
type: SupportedEntry.Event,
|
||||
id: event.id,
|
||||
index: i,
|
||||
eventIndex,
|
||||
@@ -126,7 +126,7 @@ export default function useFinder() {
|
||||
if (event.title.toLowerCase().includes(searchString)) {
|
||||
remaining--;
|
||||
results.push({
|
||||
type: SupportedEvent.Event,
|
||||
type: SupportedEntry.Event,
|
||||
id: event.id,
|
||||
index: i,
|
||||
eventIndex,
|
||||
@@ -141,7 +141,7 @@ export default function useFinder() {
|
||||
if (event.title.toLowerCase().includes(searchString)) {
|
||||
remaining--;
|
||||
results.push({
|
||||
type: SupportedEvent.Block,
|
||||
type: SupportedEntry.Block,
|
||||
id: event.id,
|
||||
index: i,
|
||||
title: event.title,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types';
|
||||
import { isOntimeEvent, OntimeEvent, SupportedEntry } from 'ontime-types';
|
||||
import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils';
|
||||
|
||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||
@@ -197,7 +197,7 @@ export default function Operator() {
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.type === SupportedEvent.Block) {
|
||||
if (entry.type === SupportedEntry.Block) {
|
||||
return <OperatorBlock key={entry.id} title={entry.title} />;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useRef, useState } from 'react';
|
||||
import { Button, Textarea } from '@chakra-ui/react';
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||
import type { EditEvent } from '../Operator';
|
||||
|
||||
import style from './EditModal.module.scss';
|
||||
@@ -15,7 +15,7 @@ interface EditModalProps {
|
||||
export default function EditModal(props: EditModalProps) {
|
||||
const { event, onClose } = props;
|
||||
|
||||
const { updateEvent } = useEventAction();
|
||||
const { updateEntry } = useEntryActions();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const inputRef = useRef<HTMLTextAreaElement[]>(new Array<HTMLTextAreaElement>());
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function EditModal(props: EditModalProps) {
|
||||
});
|
||||
|
||||
if (patchObject.custom) {
|
||||
await updateEvent(patchObject);
|
||||
await updateEntry(patchObject);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
|
||||
@@ -45,6 +45,10 @@
|
||||
@include ellipsis-overflow;
|
||||
}
|
||||
|
||||
.offset {
|
||||
color: $muted-gray;
|
||||
}
|
||||
|
||||
.ahead {
|
||||
color: $playback-ahead;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, PropsWithChildren, ReactNode, useMemo } from 'react';
|
||||
import { Playback } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
|
||||
@@ -163,14 +164,14 @@ function ProgressOverview() {
|
||||
}
|
||||
|
||||
function RuntimeOverview() {
|
||||
const { clock, offset } = useRuntimePlaybackOverview();
|
||||
const { clock, offset, playback } = useRuntimePlaybackOverview();
|
||||
|
||||
const offsetText = getOffsetText(offset);
|
||||
const offsetClasses = offset === null ? undefined : offset <= 0 ? style.behind : style.ahead;
|
||||
const offsetClasses = cx([style.offset, playback !== Playback.Stop && (offset < 0 ? style.behind : style.ahead)]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TimeColumn label='Offset' value={offsetText} className={offsetClasses} />
|
||||
<TimeColumn label='Offset' value={offsetText} className={offsetClasses} testId='offset' />
|
||||
<TimeColumn label='Time now' value={formatedTime(clock)} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -10,13 +10,16 @@ interface TimeLayoutProps {
|
||||
muted?: boolean;
|
||||
daySpan?: number;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function TimeColumn({ label, value, muted, className }: TimeLayoutProps) {
|
||||
export function TimeColumn({ label, value, muted, className, testId }: TimeLayoutProps) {
|
||||
return (
|
||||
<div className={style.column}>
|
||||
<span className={style.label}>{label}</span>
|
||||
<span className={cx([style.clock, muted && style.muted, className])}>{value}</span>
|
||||
<span className={cx([style.clock, muted && style.muted, className])} data-testid={testId}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.empty {
|
||||
padding-block: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
@@ -23,15 +23,6 @@
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.alignCenter {
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
|
||||
.spaceTop {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.spacer {
|
||||
min-height: 50vh;
|
||||
}
|
||||
@@ -40,6 +31,7 @@
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
background-color: color-mix(in srgb, var(--user-bg, transparent) 10%, transparent 90%);
|
||||
}
|
||||
|
||||
.entryIndex {
|
||||
|
||||
@@ -1,38 +1,48 @@
|
||||
import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
DragStartEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { useHotkeys } from '@mantine/hooks';
|
||||
import { useHotkeys, useSessionStorage } from '@mantine/hooks';
|
||||
import {
|
||||
type EntryId,
|
||||
type MaybeString,
|
||||
type PlayableEvent,
|
||||
type Rundown,
|
||||
isOntimeBlock,
|
||||
isOntimeEvent,
|
||||
isPlayableEvent,
|
||||
OntimeBlock,
|
||||
OntimeEntry,
|
||||
Playback,
|
||||
SupportedEvent,
|
||||
SupportedEntry,
|
||||
} from 'ontime-types';
|
||||
import {
|
||||
checkIsNextDay,
|
||||
getFirstNormal,
|
||||
getLastNormal,
|
||||
getNextBlockNormal,
|
||||
getNextNormal,
|
||||
getPreviousBlockNormal,
|
||||
getPreviousNormal,
|
||||
isNewLatest,
|
||||
reorderArray,
|
||||
} from 'ontime-utils';
|
||||
|
||||
import { type EventOptions, useEventAction } from '../../common/hooks/useEventAction';
|
||||
import { type EventOptions, useEntryActions } from '../../common/hooks/useEntryAction';
|
||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||
import { useRundownEditor } from '../../common/hooks/useSocket';
|
||||
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
|
||||
import { useEntryCopy } from '../../common/stores/entryCopyStore';
|
||||
import { cloneEvent } from '../../common/utils/eventsManager';
|
||||
import { cloneEvent } from '../../common/utils/clone';
|
||||
|
||||
import BlockBlock from './block-block/BlockBlock';
|
||||
import BlockEnd from './block-block/BlockEnd';
|
||||
import QuickAddBlock from './quick-add-block/QuickAddBlock';
|
||||
import { makeRundownMetadata, makeSortableList, moveDown, moveUp } from './rundown.utils';
|
||||
import RundownEmpty from './RundownEmpty';
|
||||
import { useEventSelection } from './useEventSelection';
|
||||
|
||||
@@ -45,11 +55,17 @@ interface RundownProps {
|
||||
}
|
||||
|
||||
export default function Rundown({ data }: RundownProps) {
|
||||
const { order, entries } = data;
|
||||
const [statefulEntries, setStatefulEntries] = useState<EntryId[]>(order);
|
||||
|
||||
const { order, entries, id } = data;
|
||||
// we create a copy of the rundown with a data structured aligned with what dnd-kit needs
|
||||
const featureData = useRundownEditor();
|
||||
const { addEvent, reorderEvent, deleteEvent } = useEventAction();
|
||||
const [sortableData, setSortableData] = useState<EntryId[]>(() => makeSortableList(order, entries));
|
||||
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
|
||||
// we ensure that this is unique to the rundown
|
||||
key: `rundown.${id}-editor-collapsed-groups`,
|
||||
defaultValue: [],
|
||||
});
|
||||
|
||||
const { addEntry, reorderEntry, deleteEntry } = useEntryActions();
|
||||
|
||||
const { entryCopyId, setEntryCopyId } = useEntryCopy();
|
||||
|
||||
@@ -62,18 +78,18 @@ export default function Rundown({ data }: RundownProps) {
|
||||
useFollowComponent({ followRef: cursorRef, scrollRef, doFollow: appMode === AppMode.Run });
|
||||
|
||||
// DND KIT
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 10 } }));
|
||||
|
||||
const deleteAtCursor = useCallback(
|
||||
(cursor: string | null) => {
|
||||
if (!cursor) return;
|
||||
const { entry, index } = getPreviousNormal(entries, order, cursor);
|
||||
deleteEvent([cursor]);
|
||||
deleteEntry([cursor]);
|
||||
if (entry && index !== null) {
|
||||
setSelectedEvents({ id: entry.id, selectMode: 'click', index });
|
||||
}
|
||||
},
|
||||
[entries, order, deleteEvent, setSelectedEvents],
|
||||
[entries, order, deleteEntry, setSelectedEvents],
|
||||
);
|
||||
|
||||
const insertCopyAtId = useCallback(
|
||||
@@ -84,17 +100,17 @@ export default function Rundown({ data }: RundownProps) {
|
||||
return;
|
||||
}
|
||||
const cloneEntry = entries[copyId];
|
||||
if (cloneEntry?.type === SupportedEvent.Event) {
|
||||
if (cloneEntry?.type === SupportedEntry.Event) {
|
||||
//if we don't have a cursor add the new event on top
|
||||
const newEvent = cloneEvent(cloneEntry);
|
||||
addEvent(newEvent, { after: adjustedCursor ?? undefined });
|
||||
addEntry(newEvent, { after: adjustedCursor ?? undefined });
|
||||
}
|
||||
},
|
||||
[addEvent, order, entries],
|
||||
[addEntry, order, entries],
|
||||
);
|
||||
|
||||
const insertAtId = useCallback(
|
||||
(type: SupportedEvent, id: MaybeString, above = false) => {
|
||||
(patch: Partial<OntimeEntry> & { type: SupportedEntry }, id: MaybeString, above = false) => {
|
||||
const options: EventOptions =
|
||||
id === null
|
||||
? {}
|
||||
@@ -103,19 +119,12 @@ export default function Rundown({ data }: RundownProps) {
|
||||
before: above ? id : undefined,
|
||||
};
|
||||
|
||||
if (type === SupportedEvent.Event) {
|
||||
const newEvent = {
|
||||
type: SupportedEvent.Event,
|
||||
};
|
||||
if (!above && id) {
|
||||
options.lastEventId = id;
|
||||
}
|
||||
addEvent(newEvent, options);
|
||||
} else {
|
||||
addEvent({ type }, options);
|
||||
if (!above && id) {
|
||||
options.lastEventId = id;
|
||||
}
|
||||
addEntry(patch, options);
|
||||
},
|
||||
[addEvent],
|
||||
[addEntry],
|
||||
);
|
||||
|
||||
const selectBlock = useCallback(
|
||||
@@ -179,19 +188,26 @@ export default function Rundown({ data }: RundownProps) {
|
||||
);
|
||||
|
||||
const moveEntry = useCallback(
|
||||
(cursor: string | null, direction: 'up' | 'down') => {
|
||||
if (order.length < 2 || cursor == null) {
|
||||
(cursor: EntryId | null, direction: 'up' | 'down') => {
|
||||
if (sortableData.length < 2 || cursor == null) {
|
||||
return;
|
||||
}
|
||||
const { index } =
|
||||
direction === 'up' ? getPreviousNormal(entries, order, cursor) : getNextNormal(entries, order, cursor);
|
||||
|
||||
if (index !== null) {
|
||||
const offsetIndex = direction === 'up' ? index + 1 : index - 1;
|
||||
reorderEvent(cursor, offsetIndex, index);
|
||||
const { destinationId, order, isBlock } =
|
||||
direction === 'up' ? moveUp(cursor, sortableData, entries) : moveDown(cursor, sortableData, entries);
|
||||
|
||||
if (!destinationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if we are moving into a block, we need to make sure it is expanded
|
||||
if (isBlock) {
|
||||
handleCollapseGroup(false, destinationId);
|
||||
}
|
||||
|
||||
reorderEntry(cursor, destinationId, order as 'before' | 'after' | 'insert');
|
||||
},
|
||||
[order, reorderEvent, entries],
|
||||
[sortableData, reorderEntry],
|
||||
);
|
||||
|
||||
// shortcuts
|
||||
@@ -209,14 +225,14 @@ export default function Rundown({ data }: RundownProps) {
|
||||
|
||||
['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
|
||||
|
||||
['alt + E', () => insertAtId(SupportedEvent.Event, cursor), { preventDefault: true }],
|
||||
['alt + shift + E', () => insertAtId(SupportedEvent.Event, cursor, true), { preventDefault: true }],
|
||||
['alt + E', () => insertAtId({ type: SupportedEntry.Event }, cursor), { preventDefault: true }],
|
||||
['alt + shift + E', () => insertAtId({ type: SupportedEntry.Event }, cursor, true), { preventDefault: true }],
|
||||
|
||||
['alt + B', () => insertAtId(SupportedEvent.Block, cursor), { preventDefault: true }],
|
||||
['alt + shift + B', () => insertAtId(SupportedEvent.Block, cursor, true), { preventDefault: true }],
|
||||
['alt + B', () => insertAtId({ type: SupportedEntry.Block }, cursor), { preventDefault: true }],
|
||||
['alt + shift + B', () => insertAtId({ type: SupportedEntry.Block }, cursor, true), { preventDefault: true }],
|
||||
|
||||
['alt + D', () => insertAtId(SupportedEvent.Delay, cursor), { preventDefault: true }],
|
||||
['alt + shift + D', () => insertAtId(SupportedEvent.Delay, cursor, true), { preventDefault: true }],
|
||||
['alt + D', () => insertAtId({ type: SupportedEntry.Delay }, cursor), { preventDefault: true }],
|
||||
['alt + shift + D', () => insertAtId({ type: SupportedEntry.Delay }, cursor, true), { preventDefault: true }],
|
||||
|
||||
['mod + C', () => setEntryCopyId(cursor)],
|
||||
['mod + V', () => insertCopyAtId(cursor, entryCopyId)],
|
||||
@@ -228,11 +244,11 @@ export default function Rundown({ data }: RundownProps) {
|
||||
// we copy the state from the store here
|
||||
// to workaround async updates on the drag mutations
|
||||
useEffect(() => {
|
||||
setStatefulEntries(order);
|
||||
}, [order]);
|
||||
setSortableData(makeSortableList(order, entries));
|
||||
}, [order, entries]);
|
||||
|
||||
// in run mode, we follow selection
|
||||
useEffect(() => {
|
||||
// in run mode, we follow selection
|
||||
if (appMode !== AppMode.Run || !featureData?.selectedEventId) {
|
||||
return;
|
||||
}
|
||||
@@ -240,115 +256,235 @@ export default function Rundown({ data }: RundownProps) {
|
||||
setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index });
|
||||
}, [appMode, featureData.selectedEventId, order, setSelectedEvents]);
|
||||
|
||||
/**
|
||||
* Checks whether a block is collapsed
|
||||
*/
|
||||
const getIsCollapsed = useCallback(
|
||||
(blockId: EntryId): boolean => {
|
||||
return Boolean(collapsedGroups.find((id) => id === blockId));
|
||||
},
|
||||
[collapsedGroups],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handles logic for collapsing groups
|
||||
*/
|
||||
const handleCollapseGroup = useCallback(
|
||||
(collapsed: boolean, groupId: EntryId) => {
|
||||
setCollapsedGroups((prev) => {
|
||||
const isCollapsed = getIsCollapsed(groupId);
|
||||
if (collapsed && !isCollapsed) {
|
||||
const newSet = new Set(prev).add(groupId);
|
||||
return [...newSet];
|
||||
}
|
||||
if (!collapsed && isCollapsed) {
|
||||
return [...prev].filter((id) => id !== groupId);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
},
|
||||
[getIsCollapsed, setCollapsedGroups],
|
||||
);
|
||||
|
||||
/**
|
||||
* On drag end, we reorder the events
|
||||
*/
|
||||
const handleOnDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over?.id) {
|
||||
if (active.id !== over?.id) {
|
||||
const fromIndex = active.data.current?.sortable.index;
|
||||
const toIndex = over.data.current?.sortable.index;
|
||||
if (!over?.id || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates
|
||||
setStatefulEntries((currentEntries) => {
|
||||
return reorderArray(currentEntries, fromIndex, toIndex);
|
||||
});
|
||||
reorderEvent(String(active.id), fromIndex, toIndex);
|
||||
}
|
||||
const fromIndex = active.data.current?.sortable.index;
|
||||
const toIndex = over.data.current?.sortable.index;
|
||||
|
||||
// we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates
|
||||
setSortableData((currentEntries) => {
|
||||
return reorderArray(currentEntries, fromIndex, toIndex);
|
||||
});
|
||||
|
||||
let destinationId = over.id as EntryId;
|
||||
let order: 'before' | 'after' | 'insert' = fromIndex < toIndex ? 'after' : 'before';
|
||||
|
||||
/**
|
||||
* We need to specially handle the end blocks
|
||||
* Dragging before and end block will add the entry to the end of the block
|
||||
* Dragging after an end block will add the event after the block itself
|
||||
*/
|
||||
if (destinationId.startsWith('end-')) {
|
||||
destinationId = destinationId.replace('end-', '');
|
||||
// if we are moving before the end, we use the insert operation
|
||||
order = 'insert';
|
||||
}
|
||||
|
||||
reorderEntry(active.id as EntryId, destinationId, order);
|
||||
};
|
||||
|
||||
/**
|
||||
* When we drag a block, we force collapse it
|
||||
* This avoids strange scenarios like dropping a block inside itself
|
||||
*/
|
||||
const collapseDraggedBlocks = (event: DragStartEvent) => {
|
||||
const isBlock = event.active.data.current?.type === 'block';
|
||||
if (isBlock) {
|
||||
handleCollapseGroup(true, event.active.id as EntryId);
|
||||
}
|
||||
};
|
||||
|
||||
if (statefulEntries.length < 1) {
|
||||
return <RundownEmpty handleAddNew={() => insertAtId(SupportedEvent.Event, cursor)} />;
|
||||
/**
|
||||
* When we drag over a block, we expand it if it is collapsed
|
||||
*/
|
||||
const expandOverBlock = (event: DragOverEvent) => {
|
||||
// if we are dragging a block, the drop operation is invalid so we dont expand
|
||||
if (event.active.data.current?.type === 'block') {
|
||||
return;
|
||||
}
|
||||
if (event.over?.data.current?.type !== 'block') {
|
||||
return;
|
||||
}
|
||||
const blockId = event.over?.id as EntryId;
|
||||
const isCollapsed = getIsCollapsed(blockId);
|
||||
if (isCollapsed) {
|
||||
handleCollapseGroup(false, blockId);
|
||||
}
|
||||
};
|
||||
|
||||
if (sortableData.length < 1) {
|
||||
return <RundownEmpty handleAddNew={(type: SupportedEntry) => addEntry({ type })} />;
|
||||
}
|
||||
|
||||
// last event is used to calculate relative timings
|
||||
let lastEvent: PlayableEvent | null = null; // used by indicators
|
||||
let thisEvent: PlayableEvent | null = null;
|
||||
// previous entry is used to infer position in the rundown for new events
|
||||
let previousEntryId: MaybeString = null;
|
||||
let thisId: MaybeString = null;
|
||||
|
||||
let eventIndex = 0;
|
||||
// all events before the current selected are in the past
|
||||
let isPast = Boolean(featureData?.selectedEventId);
|
||||
let isNextDay = false;
|
||||
let totalGap = 0;
|
||||
// 1. gather presentation options
|
||||
const isEditMode = appMode === AppMode.Edit;
|
||||
let isLinkedToLoaded = true; //check if the event can link all the way back to the currently playing event
|
||||
|
||||
// 2. initialise rundown metadata
|
||||
const { metadata, process } = makeRundownMetadata(featureData?.selectedEventId);
|
||||
// keep a single reference to the metadata which we override for every entry
|
||||
let rundownMetadata = metadata;
|
||||
|
||||
return (
|
||||
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
|
||||
<DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}>
|
||||
<SortableContext items={statefulEntries} strategy={verticalListSortingStrategy}>
|
||||
<DndContext
|
||||
onDragEnd={handleOnDragEnd}
|
||||
onDragStart={collapseDraggedBlocks}
|
||||
onDragOver={expandOverBlock}
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
>
|
||||
<SortableContext items={sortableData} strategy={verticalListSortingStrategy}>
|
||||
<div className={style.list}>
|
||||
{statefulEntries.map((entryId, index) => {
|
||||
// we iterate through a stateful copy of order to make the operations smoother
|
||||
{sortableData.map((entryId, index) => {
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === sortableData.length - 1;
|
||||
|
||||
// the entry might be a pseudo block-end which does not generate metadata and should not be processed
|
||||
if (entryId.startsWith('end-')) {
|
||||
const parentId = entryId.split('end-')[1];
|
||||
const isBlockCollapsed = getIsCollapsed(parentId);
|
||||
|
||||
if (isBlockCollapsed && isEditMode && isLast) {
|
||||
return <QuickAddBlock key={entryId} previousEventId={parentId} parentBlock={null} />;
|
||||
} else if (isBlockCollapsed) {
|
||||
return null;
|
||||
} else {
|
||||
const parentColour = (entries[parentId] as OntimeBlock | undefined)?.colour;
|
||||
// if the previous element is selected, it will have its own QuickAddBlock
|
||||
// we use thisId instead of previousEntryId because the block end does not process
|
||||
// and it does not cause the reassignment of the iteration id to the previous entry
|
||||
const showPrependingQuickAdd = isEditMode && cursor !== rundownMetadata.thisId;
|
||||
return (
|
||||
<Fragment key={entryId}>
|
||||
{showPrependingQuickAdd && (
|
||||
<QuickAddBlock
|
||||
previousEventId={rundownMetadata.thisId}
|
||||
parentBlock={parentId}
|
||||
backgroundColor={parentColour}
|
||||
/>
|
||||
)}
|
||||
<BlockEnd key={entryId} id={entryId} colour={parentColour} />
|
||||
{isEditMode && isLast && <QuickAddBlock previousEventId={parentId} parentBlock={null} />}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// we iterate through a stateful copy of order to make the dnd operations smoother
|
||||
// this means that this can be out of sync with order until the useEffect runs
|
||||
// instead of writing all the logic guards, we simply short circuit rendering here
|
||||
const entry = entries[entryId];
|
||||
if (!entry) {
|
||||
if (!entry) return null;
|
||||
rundownMetadata = process(entry);
|
||||
|
||||
// if the entry has a parent, and it is collapsed, render nothing
|
||||
if (
|
||||
entry.type !== SupportedEntry.Block &&
|
||||
rundownMetadata.groupId !== null &&
|
||||
getIsCollapsed(rundownMetadata.groupId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (index === 0) {
|
||||
eventIndex = 0;
|
||||
}
|
||||
isNextDay = false;
|
||||
previousEntryId = thisId;
|
||||
thisId = entryId;
|
||||
if (isOntimeEvent(entry)) {
|
||||
// event indexes are 1 based in frontend
|
||||
eventIndex++;
|
||||
lastEvent = thisEvent;
|
||||
|
||||
if (isPlayableEvent(entry)) {
|
||||
isNextDay = checkIsNextDay(entry, lastEvent);
|
||||
if (!isPast) {
|
||||
totalGap += entry.gap;
|
||||
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart !== null;
|
||||
}
|
||||
if (isNewLatest(entry, lastEvent)) {
|
||||
// populate previous entry
|
||||
thisEvent = entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === order.length - 1;
|
||||
const isLoaded = featureData?.selectedEventId === entry.id;
|
||||
const isNext = featureData?.nextEventId === entry.id;
|
||||
const hasCursor = entry.id === cursor;
|
||||
if (isLoaded) {
|
||||
isPast = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outside a block, the value will be undefined
|
||||
* If the colour is empty string ''
|
||||
* ie: we are inside a block, but there is no defined colour
|
||||
* we default to $gray-1050 #303030
|
||||
*/
|
||||
const blockColour = rundownMetadata.groupColour === '' ? '#303030' : rundownMetadata.groupColour;
|
||||
|
||||
return (
|
||||
<Fragment key={entry.id}>
|
||||
{isEditMode && (hasCursor || isFirst) && <QuickAddBlock previousEventId={previousEntryId} />}
|
||||
<div className={style.entryWrapper} data-testid={`entry-${eventIndex}`}>
|
||||
{isOntimeEvent(entry) && <div className={style.entryIndex}>{eventIndex}</div>}
|
||||
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
|
||||
<RundownEntry
|
||||
type={entry.type}
|
||||
isPast={isPast}
|
||||
eventIndex={eventIndex}
|
||||
data={entry}
|
||||
loaded={isLoaded}
|
||||
hasCursor={hasCursor}
|
||||
isNext={isNext}
|
||||
previousEntryId={previousEntryId}
|
||||
previousEventId={lastEvent?.id}
|
||||
playback={isLoaded ? featureData.playback : undefined}
|
||||
isRolling={featureData.playback === Playback.Roll}
|
||||
isNextDay={isNextDay}
|
||||
totalGap={totalGap}
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
/>
|
||||
{isEditMode && (hasCursor || isFirst) && (
|
||||
<QuickAddBlock
|
||||
previousEventId={rundownMetadata.previousEntryId}
|
||||
parentBlock={isFirst ? null : rundownMetadata.groupId}
|
||||
backgroundColor={isFirst ? undefined : blockColour}
|
||||
/>
|
||||
)}
|
||||
{isOntimeBlock(entry) ? (
|
||||
<BlockBlock
|
||||
data={entry}
|
||||
hasCursor={hasCursor}
|
||||
collapsed={getIsCollapsed(entry.id)}
|
||||
onCollapse={handleCollapseGroup}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={style.entryWrapper}
|
||||
data-testid={`entry-${rundownMetadata.eventIndex}`}
|
||||
style={blockColour ? { '--user-bg': blockColour } : {}}
|
||||
>
|
||||
{isOntimeEvent(entry) && <div className={style.entryIndex}>{rundownMetadata.eventIndex}</div>}
|
||||
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
|
||||
<RundownEntry
|
||||
type={entry.type}
|
||||
isPast={rundownMetadata.isPast}
|
||||
eventIndex={rundownMetadata.eventIndex}
|
||||
data={entry}
|
||||
loaded={rundownMetadata.isLoaded}
|
||||
hasCursor={hasCursor}
|
||||
isNext={isNext}
|
||||
previousEntryId={rundownMetadata.previousEntryId}
|
||||
previousEventId={rundownMetadata.previousEvent?.id}
|
||||
playback={rundownMetadata.isLoaded ? featureData.playback : undefined}
|
||||
isRolling={featureData.playback === Playback.Roll}
|
||||
isNextDay={rundownMetadata.isNextDay}
|
||||
totalGap={rundownMetadata.totalGap}
|
||||
isLinkedToLoaded={rundownMetadata.isLinkedToLoaded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isEditMode && (hasCursor || isLast) && <QuickAddBlock previousEventId={entry.id} />}
|
||||
)}
|
||||
{isEditMode && (hasCursor || isLast) && (
|
||||
<QuickAddBlock
|
||||
previousEventId={entry.id}
|
||||
parentBlock={rundownMetadata.groupId}
|
||||
backgroundColor={blockColour}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import { IoAdd } from 'react-icons/io5';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { SupportedEntry } from 'ontime-types';
|
||||
|
||||
import Empty from '../../common/components/state/Empty';
|
||||
|
||||
import style from './Rundown.module.scss';
|
||||
import style from './Empty.module.scss';
|
||||
|
||||
interface RundownEmptyProps {
|
||||
handleAddNew: () => void;
|
||||
handleAddNew: (type: SupportedEntry) => void;
|
||||
}
|
||||
|
||||
export default function RundownEmpty(props: RundownEmptyProps) {
|
||||
const { handleAddNew } = props;
|
||||
|
||||
return (
|
||||
<div className={style.alignCenter}>
|
||||
<Empty style={{ marginTop: '7vh' }} />
|
||||
<Button onClick={handleAddNew} variant='ontime-filled' className={style.spaceTop} leftIcon={<IoAdd />}>
|
||||
Create Event
|
||||
</Button>
|
||||
<div className={style.empty}>
|
||||
<Empty style={{ marginTop: '5vh', marginBottom: '3rem' }} />
|
||||
<div className={style.inline}>
|
||||
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='ontime-filled' leftIcon={<IoAdd />}>
|
||||
Create Event
|
||||
</Button>
|
||||
|
||||
<Button onClick={() => handleAddNew(SupportedEntry.Block)} variant='ontime-filled' leftIcon={<IoAdd />}>
|
||||
Create Block
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,41 +1,38 @@
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
isOntimeBlock,
|
||||
isOntimeDelay,
|
||||
isOntimeEvent,
|
||||
MaybeString,
|
||||
OntimeEntry,
|
||||
OntimeEvent,
|
||||
Playback,
|
||||
SupportedEvent,
|
||||
SupportedEntry,
|
||||
} from 'ontime-types';
|
||||
|
||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||
import { useEntryActions } from '../../common/hooks/useEntryAction';
|
||||
import useMemoisedFn from '../../common/hooks/useMemoisedFn';
|
||||
import { useEmitLog } from '../../common/stores/logger';
|
||||
import { cloneEvent } from '../../common/utils/eventsManager';
|
||||
import { cloneEvent } from '../../common/utils/clone';
|
||||
|
||||
import BlockBlock from './block-block/BlockBlock';
|
||||
import DelayBlock from './delay-block/DelayBlock';
|
||||
import EventBlock from './event-block/EventBlock';
|
||||
import { useEventSelection } from './useEventSelection';
|
||||
|
||||
export type EventItemActions =
|
||||
| 'set-cursor'
|
||||
| 'event'
|
||||
| 'event-before'
|
||||
| 'delay'
|
||||
| 'delay-before'
|
||||
| 'block'
|
||||
| 'block-before'
|
||||
| 'swap'
|
||||
| 'delete'
|
||||
| 'clone'
|
||||
| 'update'
|
||||
| 'swap'
|
||||
| 'clear-report';
|
||||
| 'group'
|
||||
| 'update';
|
||||
|
||||
interface RundownEntryProps {
|
||||
type: SupportedEvent;
|
||||
type: SupportedEntry;
|
||||
isPast: boolean;
|
||||
data: OntimeEntry;
|
||||
loaded: boolean;
|
||||
@@ -68,7 +65,7 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
isLinkedToLoaded,
|
||||
} = props;
|
||||
const { emitError } = useEmitLog();
|
||||
const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction();
|
||||
const { addEntry, updateEntry, batchUpdateEvents, deleteEntry, groupEntries, swapEvents } = useEntryActions();
|
||||
const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection();
|
||||
|
||||
const removeOpenEvent = useCallback(() => {
|
||||
@@ -88,31 +85,31 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
const actionHandler = useMemoisedFn((action: EventItemActions, payload?: number | FieldValue) => {
|
||||
switch (action) {
|
||||
case 'event': {
|
||||
const newEvent = { type: SupportedEvent.Event };
|
||||
const newEvent = { type: SupportedEntry.Event };
|
||||
const options = {
|
||||
after: data.id,
|
||||
lastEventId: previousEventId,
|
||||
};
|
||||
return addEvent(newEvent, options);
|
||||
return addEntry(newEvent, options);
|
||||
}
|
||||
case 'event-before': {
|
||||
const newEvent = { type: SupportedEvent.Event };
|
||||
const newEvent = { type: SupportedEntry.Event };
|
||||
const options = {
|
||||
after: previousEntryId,
|
||||
};
|
||||
return addEvent(newEvent, options);
|
||||
return addEntry(newEvent, options);
|
||||
}
|
||||
case 'delay': {
|
||||
return addEvent({ type: SupportedEvent.Delay }, { after: data.id });
|
||||
return addEntry({ type: SupportedEntry.Delay }, { after: data.id });
|
||||
}
|
||||
case 'delay-before': {
|
||||
return addEvent({ type: SupportedEvent.Delay }, { after: previousEntryId });
|
||||
return addEntry({ type: SupportedEntry.Delay }, { after: previousEntryId });
|
||||
}
|
||||
case 'block': {
|
||||
return addEvent({ type: SupportedEvent.Block }, { after: data.id });
|
||||
return addEntry({ type: SupportedEntry.Block }, { after: data.id });
|
||||
}
|
||||
case 'block-before': {
|
||||
return addEvent({ type: SupportedEvent.Block }, { after: previousEntryId });
|
||||
return addEntry({ type: SupportedEntry.Block }, { after: previousEntryId });
|
||||
}
|
||||
case 'swap': {
|
||||
const { value } = payload as FieldValue;
|
||||
@@ -121,14 +118,21 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
case 'delete': {
|
||||
if (selectedEvents.size > 1) {
|
||||
clearMultiSelection();
|
||||
return deleteEvent(Array.from(selectedEvents));
|
||||
return deleteEntry(Array.from(selectedEvents));
|
||||
}
|
||||
removeOpenEvent();
|
||||
return deleteEvent([data.id]);
|
||||
return deleteEntry([data.id]);
|
||||
}
|
||||
case 'clone': {
|
||||
const newEvent = cloneEvent(data as OntimeEvent);
|
||||
addEvent(newEvent, { after: data.id });
|
||||
addEntry(newEvent, { after: data.id });
|
||||
break;
|
||||
}
|
||||
case 'group': {
|
||||
if (selectedEvents.size > 1) {
|
||||
clearMultiSelection();
|
||||
return groupEntries(Array.from(selectedEvents));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'update': {
|
||||
@@ -149,7 +153,7 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
if (field in data) {
|
||||
// @ts-expect-error -- not sure how to type this
|
||||
newData[field] = value;
|
||||
return updateEvent(newData);
|
||||
return updateEntry(newData);
|
||||
}
|
||||
|
||||
return emitError(`Unknown field: ${field}`);
|
||||
@@ -181,6 +185,7 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
isPast={isPast}
|
||||
isNext={isNext}
|
||||
skip={data.skip}
|
||||
parent={data.parent}
|
||||
loaded={loaded}
|
||||
hasCursor={hasCursor}
|
||||
playback={playback}
|
||||
@@ -193,14 +198,6 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
actionHandler={actionHandler}
|
||||
/>
|
||||
);
|
||||
} else if (isOntimeBlock(data)) {
|
||||
return (
|
||||
<BlockBlock data={data} hasCursor={hasCursor}>
|
||||
{data.events.map((eventId) => {
|
||||
return <div key={eventId}>{eventId}</div>;
|
||||
})}
|
||||
</BlockBlock>
|
||||
);
|
||||
} else if (isOntimeDelay(data)) {
|
||||
return <DelayBlock data={data} hasCursor={hasCursor} />;
|
||||
}
|
||||
|
||||
@@ -8,11 +8,14 @@ import { cx } from '../../common/utils/styleUtils';
|
||||
import { Corner } from '../editors/editor-utils/EditorUtils';
|
||||
|
||||
import RundownEventEditor from './event-editor/RundownEventEditor';
|
||||
import FinderPlacement from './placements/FinderPlacement';
|
||||
import RundownWrapper from './RundownWrapper';
|
||||
|
||||
import style from './RundownExport.module.scss';
|
||||
|
||||
const RundownExport = () => {
|
||||
export default memo(RundownExport);
|
||||
|
||||
function RundownExport() {
|
||||
const isExtracted = window.location.pathname.includes('/rundown');
|
||||
const appMode = useAppMode((state) => state.mode);
|
||||
const hideSideBar = isExtracted && appMode === 'run';
|
||||
@@ -21,6 +24,7 @@ const RundownExport = () => {
|
||||
|
||||
return (
|
||||
<div className={classes} data-testid='panel-rundown'>
|
||||
<FinderPlacement />
|
||||
<div className={style.rundown}>
|
||||
<div className={style.list}>
|
||||
<ErrorBoundary>
|
||||
@@ -40,6 +44,4 @@ const RundownExport = () => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(RundownExport);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
import { EntryId, OntimeBlock, OntimeDelay, OntimeEvent, RundownEntries, SupportedEntry } from 'ontime-types';
|
||||
|
||||
import { makeRundownMetadata, makeSortableList, moveDown, moveUp } from '../rundown.utils';
|
||||
|
||||
describe('makeRundownMetadata()', () => {
|
||||
it('processes nested rundown data', () => {
|
||||
const selectedEventId = '12';
|
||||
const demoEvents = {
|
||||
'1': {
|
||||
id: '1',
|
||||
type: SupportedEntry.Event,
|
||||
parent: null,
|
||||
timeStart: 0,
|
||||
timeEnd: 1,
|
||||
duration: 1,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
skip: false,
|
||||
linkStart: false,
|
||||
} as OntimeEvent,
|
||||
block: {
|
||||
id: 'block',
|
||||
type: SupportedEntry.Block,
|
||||
events: ['11', 'delay', '12', '13'],
|
||||
colour: 'red',
|
||||
} as OntimeBlock,
|
||||
'11': {
|
||||
id: '11',
|
||||
type: SupportedEntry.Event,
|
||||
parent: 'block',
|
||||
timeStart: 10,
|
||||
timeEnd: 11,
|
||||
duration: 1,
|
||||
dayOffset: 0,
|
||||
gap: 10,
|
||||
skip: false,
|
||||
linkStart: false,
|
||||
} as OntimeEvent,
|
||||
delay: {
|
||||
id: 'delay',
|
||||
type: SupportedEntry.Delay,
|
||||
parent: 'block',
|
||||
duration: 0,
|
||||
} as OntimeDelay,
|
||||
'12': {
|
||||
id: '12',
|
||||
type: SupportedEntry.Event,
|
||||
parent: 'block',
|
||||
timeStart: 11,
|
||||
timeEnd: 12,
|
||||
duration: 1,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
skip: false,
|
||||
linkStart: true,
|
||||
} as OntimeEvent,
|
||||
'13': {
|
||||
id: '13',
|
||||
type: SupportedEntry.Event,
|
||||
parent: 'block',
|
||||
timeStart: 12,
|
||||
timeEnd: 13,
|
||||
duration: 1,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
skip: false,
|
||||
linkStart: true,
|
||||
} as OntimeEvent,
|
||||
'2': {
|
||||
id: '2',
|
||||
type: SupportedEntry.Event,
|
||||
parent: null,
|
||||
timeStart: 20,
|
||||
timeEnd: 21,
|
||||
duration: 1,
|
||||
dayOffset: 0,
|
||||
gap: 7,
|
||||
skip: false,
|
||||
linkStart: false,
|
||||
} as OntimeEvent,
|
||||
};
|
||||
|
||||
const { metadata, process } = makeRundownMetadata(selectedEventId);
|
||||
|
||||
expect(metadata).toStrictEqual({
|
||||
previousEvent: null,
|
||||
latestEvent: null,
|
||||
previousEntryId: null,
|
||||
thisId: null,
|
||||
eventIndex: 0,
|
||||
isPast: true,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: null,
|
||||
groupColour: undefined,
|
||||
});
|
||||
|
||||
expect(process(demoEvents['1'])).toStrictEqual({
|
||||
previousEvent: null,
|
||||
latestEvent: demoEvents['1'],
|
||||
previousEntryId: null,
|
||||
thisId: demoEvents['1'].id,
|
||||
eventIndex: 1, // UI indexes are 1 based
|
||||
isPast: true,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: null,
|
||||
groupColour: undefined,
|
||||
});
|
||||
|
||||
expect(process(demoEvents['block'])).toMatchObject({
|
||||
previousEvent: demoEvents['1'],
|
||||
latestEvent: demoEvents['1'],
|
||||
previousEntryId: demoEvents['1'].id,
|
||||
thisId: demoEvents['block'].id,
|
||||
eventIndex: 1,
|
||||
isPast: true,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: 'block',
|
||||
groupColour: 'red',
|
||||
});
|
||||
|
||||
expect(process(demoEvents['11'])).toMatchObject({
|
||||
previousEvent: demoEvents['1'],
|
||||
latestEvent: demoEvents['11'],
|
||||
previousEntryId: demoEvents['block'].id,
|
||||
thisId: demoEvents['11'].id,
|
||||
eventIndex: 2,
|
||||
isPast: true,
|
||||
isNextDay: false,
|
||||
totalGap: 10,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: 'block',
|
||||
groupColour: 'red',
|
||||
});
|
||||
|
||||
expect(process(demoEvents['delay'])).toMatchObject({
|
||||
previousEvent: demoEvents['11'],
|
||||
latestEvent: demoEvents['11'],
|
||||
previousEntryId: demoEvents['11'].id,
|
||||
thisId: demoEvents['delay'].id,
|
||||
eventIndex: 2,
|
||||
isPast: true,
|
||||
isNextDay: false,
|
||||
totalGap: 10,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: 'block',
|
||||
groupColour: 'red',
|
||||
});
|
||||
|
||||
expect(process(demoEvents['12'])).toMatchObject({
|
||||
previousEvent: demoEvents['11'],
|
||||
latestEvent: demoEvents['12'],
|
||||
previousEntryId: demoEvents['delay'].id,
|
||||
thisId: demoEvents['12'].id,
|
||||
eventIndex: 3,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
totalGap: 10,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: true,
|
||||
groupId: 'block',
|
||||
groupColour: 'red',
|
||||
});
|
||||
|
||||
expect(process(demoEvents['13'])).toMatchObject({
|
||||
previousEvent: demoEvents['12'],
|
||||
latestEvent: demoEvents['13'],
|
||||
previousEntryId: demoEvents['12'].id,
|
||||
thisId: demoEvents['13'].id,
|
||||
eventIndex: 4,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
totalGap: 10,
|
||||
isLinkedToLoaded: true,
|
||||
isLoaded: false,
|
||||
groupId: 'block',
|
||||
groupColour: 'red',
|
||||
});
|
||||
|
||||
expect(process(demoEvents['2'])).toMatchObject({
|
||||
previousEvent: demoEvents['13'],
|
||||
latestEvent: demoEvents['2'],
|
||||
previousEntryId: demoEvents['13'].id,
|
||||
thisId: demoEvents['2'].id,
|
||||
eventIndex: 5,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
totalGap: 17,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: null,
|
||||
groupColour: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('populates previousEntries in blocks', () => {
|
||||
const rundownStartsWithBlock = {
|
||||
block: {
|
||||
id: 'block',
|
||||
type: SupportedEntry.Block,
|
||||
colour: 'red',
|
||||
events: ['1', '2'],
|
||||
} as OntimeBlock,
|
||||
'1': {
|
||||
id: '1',
|
||||
type: SupportedEntry.Event,
|
||||
parent: 'block',
|
||||
timeStart: 1,
|
||||
timeEnd: 2,
|
||||
duration: 1,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
skip: false,
|
||||
linkStart: false,
|
||||
} as OntimeEvent,
|
||||
'2': {
|
||||
id: '2',
|
||||
type: SupportedEntry.Event,
|
||||
parent: 'block',
|
||||
timeStart: 2,
|
||||
timeEnd: 3,
|
||||
duration: 1,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
skip: false,
|
||||
linkStart: false,
|
||||
} as OntimeEvent,
|
||||
};
|
||||
const { process } = makeRundownMetadata(null);
|
||||
|
||||
expect(process(rundownStartsWithBlock.block)).toStrictEqual({
|
||||
previousEvent: null,
|
||||
latestEvent: null,
|
||||
previousEntryId: null,
|
||||
thisId: rundownStartsWithBlock.block.id,
|
||||
eventIndex: 0,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: rundownStartsWithBlock.block.id,
|
||||
groupColour: 'red',
|
||||
});
|
||||
|
||||
expect(process(rundownStartsWithBlock['1'])).toStrictEqual({
|
||||
previousEvent: null,
|
||||
latestEvent: rundownStartsWithBlock['1'],
|
||||
previousEntryId: rundownStartsWithBlock.block.id,
|
||||
thisId: rundownStartsWithBlock['1'].id,
|
||||
eventIndex: 1,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: rundownStartsWithBlock.block.id,
|
||||
groupColour: 'red',
|
||||
});
|
||||
expect(process(rundownStartsWithBlock['2'])).toStrictEqual({
|
||||
previousEvent: rundownStartsWithBlock['1'],
|
||||
latestEvent: rundownStartsWithBlock['2'],
|
||||
previousEntryId: rundownStartsWithBlock['1'].id,
|
||||
thisId: rundownStartsWithBlock['2'].id,
|
||||
eventIndex: 2,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: rundownStartsWithBlock.block.id,
|
||||
groupColour: 'red',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeSortableList()', () => {
|
||||
it('generates a list with block ends', () => {
|
||||
const order = ['block-1', '2', 'block-3', 'block-4'];
|
||||
const entries: RundownEntries = {
|
||||
'block-1': { type: SupportedEntry.Block, id: 'block-1', events: ['11'] } as OntimeBlock,
|
||||
'11': { type: SupportedEntry.Event, id: '11', parent: 'block-1' } as OntimeEvent,
|
||||
'2': { type: SupportedEntry.Event, id: '2', parent: null } as OntimeEvent,
|
||||
'block-3': { type: SupportedEntry.Block, id: 'block-3', events: ['31'] } as OntimeBlock,
|
||||
'31': { type: SupportedEntry.Event, id: '31', parent: 'block-3' } as OntimeEvent,
|
||||
'block-4': { type: SupportedEntry.Block, id: 'block-4', events: [] as string[] } as OntimeBlock,
|
||||
};
|
||||
|
||||
const sortableList = makeSortableList(order, entries);
|
||||
expect(sortableList).toStrictEqual([
|
||||
'block-1',
|
||||
'11',
|
||||
'end-block-1',
|
||||
'2',
|
||||
'block-3',
|
||||
'31',
|
||||
'end-block-3',
|
||||
'block-4',
|
||||
'end-block-4',
|
||||
]);
|
||||
});
|
||||
|
||||
it('closes dangling blocks', () => {
|
||||
const order = ['block'];
|
||||
const entries: RundownEntries = {
|
||||
block: { type: SupportedEntry.Block, id: 'block-1', events: ['11', '12'] } as OntimeBlock,
|
||||
'11': { type: SupportedEntry.Event, id: '11', parent: 'block-1' } as OntimeEvent,
|
||||
'12': { type: SupportedEntry.Event, id: '12', parent: 'block-1' } as OntimeEvent,
|
||||
};
|
||||
|
||||
const sortableList = makeSortableList(order, entries);
|
||||
expect(sortableList).toStrictEqual(['block-1', '11', '12', 'end-block-1']);
|
||||
});
|
||||
|
||||
it('handles a list with a with just blocks', () => {
|
||||
const order = ['block-1', 'block-2'];
|
||||
const entries: RundownEntries = {
|
||||
'block-1': { type: SupportedEntry.Block, id: 'block-1', events: [] as string[] } as OntimeBlock,
|
||||
'block-2': { type: SupportedEntry.Block, id: 'block-2', events: [] as string[] } as OntimeBlock,
|
||||
};
|
||||
|
||||
const sortableList = makeSortableList(order, entries);
|
||||
expect(sortableList).toStrictEqual(['block-1', 'end-block-1', 'block-2', 'end-block-2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveUp()', () => {
|
||||
const sortableData = ['event1', 'event2', 'block1', 'event11', 'end-block1', 'block2', 'end-block2', 'event3'];
|
||||
const entries = {
|
||||
event1: { type: 'event', id: 'event1', parent: null } as OntimeEvent,
|
||||
event2: { type: 'event', id: 'event2', parent: null }as OntimeEvent,
|
||||
block1: { type: 'block', id: 'block1', events: ['event3'] } as OntimeBlock,
|
||||
event11: { type: 'event', id: 'event11', parent: 'block1' } as OntimeEvent,
|
||||
block2: { type: 'block', id: 'block2', events: [] as EntryId[] } as OntimeBlock,
|
||||
event3: { type: 'event', id: 'event3', parent: null } as OntimeEvent,
|
||||
};
|
||||
|
||||
it('moves an event up in the list', () => {
|
||||
const result = moveUp('event2', sortableData, entries);
|
||||
expect(result).toStrictEqual({ destinationId: 'event1', order: 'before', isBlock: false });
|
||||
})
|
||||
|
||||
it.todo('disallows nesting blocks', () => {
|
||||
const result = moveUp('block2', sortableData, entries);
|
||||
expect(result).toStrictEqual({ destinationId: 'block1', order: 'before', isBlock: false });
|
||||
})
|
||||
|
||||
it('moves an event into a block', () => {
|
||||
const result = moveUp('event3', sortableData, entries);
|
||||
expect(result).toStrictEqual({ destinationId: 'block2', order: 'insert', isBlock: true });
|
||||
})
|
||||
|
||||
it('moving up from top is noop', () => {
|
||||
const result = moveUp('event1', sortableData, entries);
|
||||
expect(result).toMatchObject({ destinationId: null });
|
||||
})
|
||||
});
|
||||
|
||||
describe('moveDown()', () => {
|
||||
const sortableData = ['event1', 'event2', 'block1', 'event11', 'end-block1', 'block2', 'end-block2', 'event3'];
|
||||
const entries = {
|
||||
event1: { type: 'event', id: 'event1', parent: null } as OntimeEvent,
|
||||
event2: { type: 'event', id: 'event2', parent: null }as OntimeEvent,
|
||||
block1: { type: 'block', id: 'block1', events: ['event11'] } as OntimeBlock,
|
||||
event11: { type: 'event', id: 'event11', parent: 'block1' } as OntimeEvent,
|
||||
block2: { type: 'block', id: 'block2', events: [] as EntryId[] } as OntimeBlock,
|
||||
event3: { type: 'event', id: 'event3', parent: null } as OntimeEvent,
|
||||
};
|
||||
|
||||
it('moves an event down in the list', () => {
|
||||
const result = moveDown('event1', sortableData, entries);
|
||||
expect(result).toStrictEqual({ destinationId: 'event2', order: 'after', isBlock: false });
|
||||
})
|
||||
|
||||
it.todo('disallows nesting blocks', () => {
|
||||
const result = moveDown('block1', sortableData, entries);
|
||||
expect(result).toStrictEqual({ destinationId: 'block2', order: 'before', isBlock: false });
|
||||
})
|
||||
|
||||
it('moves an event into a block', () => {
|
||||
const result = moveDown('event2', sortableData, entries);
|
||||
expect(result).toStrictEqual({ destinationId: 'event11', order: 'before', isBlock: true });
|
||||
})
|
||||
|
||||
it('moving down from bottom is noop', () => {
|
||||
const result = moveDown('event3', sortableData, entries);
|
||||
expect(result).toMatchObject({ destinationId: null });
|
||||
})
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
@use '../../theme/ontimeColours' as *;
|
||||
@use '../../theme/ontimeStyles' as *;
|
||||
|
||||
$block-width: 33rem;
|
||||
$block-width: 32rem;
|
||||
|
||||
$block-gap: 0.25rem;
|
||||
$block-element-spacing: 0.25rem;
|
||||
@@ -20,7 +20,6 @@ $block-cursor-color: $orange-400;
|
||||
box-sizing: content-box;
|
||||
border: 1px solid $white-7;
|
||||
border-radius: $block-border-radius;
|
||||
margin-block: 0.25rem;
|
||||
position: relative;
|
||||
color: $block-text-color;
|
||||
|
||||
@@ -33,9 +32,11 @@ $block-cursor-color: $orange-400;
|
||||
opacity: 0.3;
|
||||
cursor: grab;
|
||||
transition: opacity 0.3s;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
|
||||
@@ -2,21 +2,73 @@
|
||||
|
||||
.block {
|
||||
@include block-styling;
|
||||
overflow: hidden;
|
||||
|
||||
background-color: $block-bg2;
|
||||
|
||||
min-width: 34rem;
|
||||
display: grid;
|
||||
grid-template-columns: 2rem 1fr auto;
|
||||
grid-template-columns: 2rem 1fr;
|
||||
grid-template-areas: 'binder header';
|
||||
align-items: center;
|
||||
height: $secondary-block-height;
|
||||
gap: 0.5rem;
|
||||
// TODO(style fix): groups have an extra bottom margin which interrupt colour
|
||||
margin-block: 0.25rem;
|
||||
|
||||
&.hasCursor {
|
||||
outline: 1px solid $block-cursor-color;
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
border-radius: $block-border-radius $block-border-radius 0 0;
|
||||
}
|
||||
|
||||
.binder {
|
||||
grid-area: binder;
|
||||
height: 100%;
|
||||
background-color: var(--block-color, $gray-1050);
|
||||
color: $section-white;
|
||||
font-size: 1rem;
|
||||
display: grid;
|
||||
justify-content: center;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
grid-area: header;
|
||||
padding-inline: 0.5rem;
|
||||
background-color: $block-bg2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.metaRow {
|
||||
display: flex;
|
||||
gap: 3rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.metaEntry {
|
||||
width: 4.5em;
|
||||
|
||||
:first-child {
|
||||
font-size: calc(1rem - 3px);
|
||||
color: $label-gray;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.drag {
|
||||
@include drag-style;
|
||||
|
||||
&.isDragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&.notAllowed {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,57 @@
|
||||
import { PropsWithChildren, useRef } from 'react';
|
||||
import { IoReorderTwo } from 'react-icons/io5';
|
||||
import { useRef } from 'react';
|
||||
import {
|
||||
IoChevronDown,
|
||||
IoChevronUp,
|
||||
IoDuplicateOutline,
|
||||
IoFolderOpenOutline,
|
||||
IoReorderTwo,
|
||||
IoTrash,
|
||||
} from 'react-icons/io5';
|
||||
import { IconButton } from '@chakra-ui/react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { OntimeBlock } from 'ontime-types';
|
||||
import { EntryId, OntimeBlock } from 'ontime-types';
|
||||
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { formatDuration, formatTime } from '../../../common/utils/time';
|
||||
import EditableBlockTitle from '../common/EditableBlockTitle';
|
||||
import { canDrop } from '../rundown.utils';
|
||||
|
||||
import style from './BlockBlock.module.scss';
|
||||
|
||||
interface BlockBlockProps {
|
||||
data: OntimeBlock;
|
||||
hasCursor: boolean;
|
||||
collapsed: boolean;
|
||||
onCollapse: (collapsed: boolean, groupId: EntryId) => void;
|
||||
}
|
||||
|
||||
export default function BlockBlock(props: PropsWithChildren<BlockBlockProps>) {
|
||||
const { data, hasCursor, children } = props;
|
||||
|
||||
export default function BlockBlock(props: BlockBlockProps) {
|
||||
const { data, hasCursor, collapsed, onCollapse } = props;
|
||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||
const { clone, ungroup, deleteEntry } = useEntryActions();
|
||||
|
||||
const [onContextMenu] = useContextMenu<HTMLDivElement>([
|
||||
{
|
||||
label: 'Clone Block',
|
||||
icon: IoDuplicateOutline,
|
||||
onClick: () => clone(data.id),
|
||||
},
|
||||
{
|
||||
label: 'Ungroup',
|
||||
icon: IoFolderOpenOutline,
|
||||
onClick: () => ungroup(data.id),
|
||||
isDisabled: data.events.length === 0,
|
||||
},
|
||||
{
|
||||
label: 'Delete Block',
|
||||
icon: IoTrash,
|
||||
onClick: () => deleteEntry([data.id]),
|
||||
withDivider: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const {
|
||||
attributes: dragAttributes,
|
||||
@@ -25,26 +59,79 @@ export default function BlockBlock(props: PropsWithChildren<BlockBlockProps>) {
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
isOver,
|
||||
over,
|
||||
} = useSortable({
|
||||
id: data.id,
|
||||
data: {
|
||||
type: 'block',
|
||||
},
|
||||
animateLayoutChanges: () => false,
|
||||
});
|
||||
|
||||
const binderColours = data.colour && getAccessibleColour(data.colour);
|
||||
const isValidDrop = over?.id && canDrop(over.data.current?.type, over.data.current?.parent);
|
||||
|
||||
const dragStyle = {
|
||||
zIndex: isDragging ? 2 : 'inherit',
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
cursor: isOver ? (isValidDrop ? 'grabbing' : 'no-drop') : 'default',
|
||||
};
|
||||
|
||||
const blockClasses = cx([style.block, hasCursor ? style.hasCursor : null]);
|
||||
|
||||
return (
|
||||
<div className={blockClasses} ref={setNodeRef} style={dragStyle}>
|
||||
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
|
||||
<IoReorderTwo />
|
||||
</span>
|
||||
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' />
|
||||
<button>+++</button>
|
||||
<div>{children}</div>
|
||||
<div
|
||||
className={cx([style.block, hasCursor && style.hasCursor, !collapsed && style.expanded])}
|
||||
ref={setNodeRef}
|
||||
onContextMenu={onContextMenu}
|
||||
style={{
|
||||
...(binderColours ? { '--user-bg': binderColours.backgroundColor } : {}),
|
||||
...dragStyle,
|
||||
}}
|
||||
>
|
||||
<div className={style.binder} style={{ ...binderColours }} tabIndex={-1}>
|
||||
<span
|
||||
className={cx([style.drag, isDragging && style.isDragging, isDragging && !isValidDrop && style.notAllowed])}
|
||||
ref={handleRef}
|
||||
{...dragAttributes}
|
||||
{...dragListeners}
|
||||
>
|
||||
<IoReorderTwo />
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.header}>
|
||||
<div className={style.titleRow}>
|
||||
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' />
|
||||
<IconButton
|
||||
aria-label='Collapse'
|
||||
onClick={() => onCollapse(!collapsed, data.id)}
|
||||
color='#e2e2e2' // $gray-200
|
||||
variant='ontime-ghosted'
|
||||
size='sm'
|
||||
>
|
||||
{collapsed ? <IoChevronUp /> : <IoChevronDown />}
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className={style.metaRow}>
|
||||
<div className={style.metaEntry}>
|
||||
<div>Start</div>
|
||||
<div>{formatTime(data.startTime)}</div>
|
||||
</div>
|
||||
<div className={style.metaEntry}>
|
||||
<div>End</div>
|
||||
<div>{formatTime(data.endTime)}</div>
|
||||
</div>
|
||||
<div className={style.metaEntry}>
|
||||
<div>Duration</div>
|
||||
<div>{formatDuration(data.duration)}</div>
|
||||
</div>
|
||||
<div className={style.metaEntry}>
|
||||
<div>Events</div>
|
||||
<div>{data.events.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
@use '../blockMixins' as *;
|
||||
|
||||
.blockEnd {
|
||||
cursor: default;
|
||||
height: 0.5rem;
|
||||
background-color: var(--user-bg, $gray-1050);
|
||||
|
||||
border-radius: 0 0 $block-border-radius $block-border-radius;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import style from './BlockEnd.module.scss';
|
||||
|
||||
interface BlockEndProps {
|
||||
id: string;
|
||||
colour?: string;
|
||||
}
|
||||
|
||||
export default function BlockEnd(props: BlockEndProps) {
|
||||
const { id, colour } = props;
|
||||
const {
|
||||
attributes: dragAttributes,
|
||||
listeners: dragListeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({
|
||||
id,
|
||||
animateLayoutChanges: () => false,
|
||||
disabled: true, // we do not want to drag end blocks
|
||||
});
|
||||
|
||||
const dragStyle = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={style.blockEnd}
|
||||
ref={setNodeRef}
|
||||
{...dragAttributes}
|
||||
{...dragListeners}
|
||||
style={{
|
||||
...dragStyle,
|
||||
...(colour ? { '--user-bg': colour } : {}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useRef } from 'react';
|
||||
import { Input } from '@chakra-ui/react';
|
||||
|
||||
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
|
||||
import style from './TitleEditor.module.scss';
|
||||
@@ -16,7 +16,7 @@ interface TitleEditorProps {
|
||||
|
||||
export default function EditableBlockTitle(props: TitleEditorProps) {
|
||||
const { title, eventId, placeholder, className } = props;
|
||||
const { updateEvent } = useEventAction();
|
||||
const { updateEntry } = useEntryActions();
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
const submitCallback = useCallback(
|
||||
(text: string) => {
|
||||
@@ -25,9 +25,9 @@ export default function EditableBlockTitle(props: TitleEditorProps) {
|
||||
}
|
||||
|
||||
const cleanVal = text.trim();
|
||||
updateEvent({ id: eventId, title: cleanVal });
|
||||
updateEntry({ id: eventId, title: cleanVal });
|
||||
},
|
||||
[title, updateEvent, eventId],
|
||||
[title, updateEntry, eventId],
|
||||
);
|
||||
|
||||
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(title, submitCallback, ref, {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
.delay {
|
||||
@include block-styling;
|
||||
|
||||
margin-block: 0.25rem;
|
||||
background-color: $block-bg2;
|
||||
padding-right: 0.5rem;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { CSS } from '@dnd-kit/utilities';
|
||||
import { OntimeDelay } from 'ontime-types';
|
||||
|
||||
import DelayInput from '../../../common/components/input/delay-input/DelayInput';
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
|
||||
import style from './DelayBlock.module.scss';
|
||||
@@ -18,21 +18,26 @@ interface DelayBlockProps {
|
||||
|
||||
export default function DelayBlock(props: DelayBlockProps) {
|
||||
const { data, hasCursor } = props;
|
||||
const { applyDelay, deleteEvent } = useEventAction();
|
||||
const { applyDelay, deleteEntry } = useEntryActions();
|
||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||
|
||||
const {
|
||||
attributes: dragAttributes,
|
||||
listeners: dragListeners,
|
||||
setNodeRef,
|
||||
isDragging,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({
|
||||
id: data.id,
|
||||
data: {
|
||||
type: 'delay',
|
||||
},
|
||||
animateLayoutChanges: () => false,
|
||||
});
|
||||
|
||||
const dragStyle = {
|
||||
zIndex: isDragging ? 2 : 'inherit',
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
};
|
||||
@@ -48,7 +53,7 @@ export default function DelayBlock(props: DelayBlockProps) {
|
||||
};
|
||||
|
||||
const cancelDelayHandler = () => {
|
||||
deleteEvent([data.id]);
|
||||
deleteEntry([data.id]);
|
||||
};
|
||||
|
||||
const blockClasses = cx([style.delay, hasCursor ? style.hasCursor : null]);
|
||||
|
||||
@@ -5,6 +5,7 @@ $skip-opacity: 0.2;
|
||||
.eventBlock {
|
||||
@include block-styling;
|
||||
background-color: $block-bg;
|
||||
margin-block: 0.25rem;
|
||||
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
|
||||
@@ -2,6 +2,7 @@ import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
IoAdd,
|
||||
IoDuplicateOutline,
|
||||
IoFolder,
|
||||
IoLink,
|
||||
IoPeople,
|
||||
IoPeopleOutline,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
} from 'react-icons/io5';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { EndAction, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||
import { EndAction, EntryId, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||
|
||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
@@ -26,13 +27,13 @@ import RundownIndicators from './RundownIndicators';
|
||||
import style from './EventBlock.module.scss';
|
||||
|
||||
interface EventBlockProps {
|
||||
eventId: string;
|
||||
eventId: EntryId;
|
||||
cue: string;
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
duration: number;
|
||||
timeStrategy: TimeStrategy;
|
||||
linkStart: MaybeString;
|
||||
linkStart: boolean;
|
||||
countToEnd: boolean;
|
||||
eventIndex: number;
|
||||
isPublic: boolean;
|
||||
@@ -45,6 +46,7 @@ interface EventBlockProps {
|
||||
isPast: boolean;
|
||||
isNext: boolean;
|
||||
skip: boolean;
|
||||
parent: EntryId | null;
|
||||
loaded: boolean;
|
||||
hasCursor: boolean;
|
||||
playback?: Playback;
|
||||
@@ -86,6 +88,7 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
isPast,
|
||||
isNext,
|
||||
skip = false,
|
||||
parent,
|
||||
loaded,
|
||||
hasCursor,
|
||||
playback,
|
||||
@@ -142,6 +145,7 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
value: false,
|
||||
}),
|
||||
},
|
||||
{ withDivider: true, label: 'Group', icon: IoFolder, onClick: () => actionHandler('group') },
|
||||
{ withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
|
||||
]
|
||||
: [
|
||||
@@ -151,7 +155,7 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
onClick: () =>
|
||||
actionHandler('update', {
|
||||
field: 'linkStart',
|
||||
value: linkStart ? null : 'true',
|
||||
value: linkStart,
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -193,6 +197,10 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
transition,
|
||||
} = useSortable({
|
||||
id: eventId,
|
||||
data: {
|
||||
type: 'event',
|
||||
parent,
|
||||
},
|
||||
animateLayoutChanges: () => false,
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
IoTime,
|
||||
} from 'react-icons/io5';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { EndAction, MaybeString, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||
import { EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
@@ -30,7 +30,7 @@ interface EventBlockInnerProps {
|
||||
timeEnd: number;
|
||||
duration: number;
|
||||
timeStrategy: TimeStrategy;
|
||||
linkStart: MaybeString;
|
||||
linkStart: boolean;
|
||||
countToEnd: boolean;
|
||||
eventIndex: number;
|
||||
isPublic: boolean;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { memo, MouseEvent } from 'react';
|
||||
import { IoPause, IoPlay, IoReload, IoRemoveCircle, IoRemoveCircleOutline } from 'react-icons/io5';
|
||||
|
||||
import TooltipActionBtn from '../../../../common/components/buttons/TooltipActionBtn';
|
||||
import { useEventAction } from '../../../../common/hooks/useEventAction';
|
||||
import { useEntryActions } from '../../../../common/hooks/useEntryAction';
|
||||
import { setEventPlayback } from '../../../../common/hooks/useSocket';
|
||||
import { tooltipDelayMid } from '../../../../ontimeConfig';
|
||||
|
||||
@@ -34,11 +34,11 @@ interface EventBlockPlaybackProps {
|
||||
|
||||
const EventBlockPlayback = (props: EventBlockPlaybackProps) => {
|
||||
const { eventId, skip, isPlaying, isPaused, loaded, disablePlayback } = props;
|
||||
const { updateEvent } = useEventAction();
|
||||
const { updateEntry } = useEntryActions();
|
||||
|
||||
const toggleSkip = (event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
updateEvent({ id: eventId, skip: !skip });
|
||||
updateEntry({ id: eventId, skip: !skip });
|
||||
};
|
||||
|
||||
const actionHandler = (event: MouseEvent) => {
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import { CSSProperties, useCallback } from 'react';
|
||||
import { CustomFieldLabel, OntimeEvent } from 'ontime-types';
|
||||
import { useCallback } from 'react';
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
|
||||
import AppLink from '../../../common/components/link/app-link/AppLink';
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import * as Editor from '../../editors/editor-utils/EditorUtils';
|
||||
|
||||
import EventEditorImage from './composite/EventEditorImage';
|
||||
import EventCustom from './composite/EventEditorCustom';
|
||||
import EventEditorTimes from './composite/EventEditorTimes';
|
||||
import EventEditorTitles from './composite/EventEditorTitles';
|
||||
import EventTextArea from './composite/EventTextArea';
|
||||
import EventTextInput from './composite/EventTextInput';
|
||||
import EventEditorTriggers from './composite/EventEditorTriggers';
|
||||
import EventEditorEmpty from './EventEditorEmpty';
|
||||
|
||||
import style from './EventEditor.module.scss';
|
||||
|
||||
export type EditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | CustomFieldLabel;
|
||||
// any of the titles + custom field labels
|
||||
export type EditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | string;
|
||||
|
||||
interface EventEditorProps {
|
||||
event: OntimeEvent;
|
||||
@@ -25,7 +24,7 @@ interface EventEditorProps {
|
||||
export default function EventEditor(props: EventEditorProps) {
|
||||
const { event } = props;
|
||||
const { data: customFields } = useCustomFields();
|
||||
const { updateEvent } = useEventAction();
|
||||
const { updateEntry } = useEntryActions();
|
||||
|
||||
const isEditor = window.location.pathname.includes('editor');
|
||||
|
||||
@@ -33,12 +32,12 @@ export default function EventEditor(props: EventEditorProps) {
|
||||
(field: EditorUpdateFields, value: string) => {
|
||||
if (field.startsWith('custom-')) {
|
||||
const fieldLabel = field.split('custom-')[1];
|
||||
updateEvent({ id: event?.id, custom: { [fieldLabel]: value } });
|
||||
updateEntry({ id: event?.id, custom: { [fieldLabel]: value } });
|
||||
} else {
|
||||
updateEvent({ id: event?.id, [field]: value });
|
||||
updateEntry({ id: event?.id, [field]: value });
|
||||
}
|
||||
},
|
||||
[event?.id, updateEvent],
|
||||
[event?.id, updateEntry],
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
@@ -75,52 +74,16 @@ export default function EventEditor(props: EventEditorProps) {
|
||||
<div className={style.column}>
|
||||
<Editor.Title>
|
||||
Custom Fields
|
||||
{isEditor && <AppLink search='settings=feature_settings__custom'>Manage</AppLink>}
|
||||
{isEditor && <AppLink search='settings=feature_settings__custom'>Manage Custom Fields</AppLink>}
|
||||
</Editor.Title>
|
||||
|
||||
{Object.keys(customFields).map((fieldKey) => {
|
||||
const key = `${event.id}-${fieldKey}`;
|
||||
const fieldName = `custom-${fieldKey}`;
|
||||
const initialValue = event.custom[fieldKey] ?? '';
|
||||
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
|
||||
const labelText = customFields[fieldKey].label;
|
||||
|
||||
if (customFields[fieldKey].type === 'string') {
|
||||
return (
|
||||
<EventTextArea
|
||||
key={key}
|
||||
field={fieldName}
|
||||
label={labelText}
|
||||
initialValue={initialValue}
|
||||
submitHandler={handleSubmit}
|
||||
className={style.decorated}
|
||||
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (customFields[fieldKey].type === 'image') {
|
||||
return (
|
||||
<div key={key} className={style.customImage}>
|
||||
<EventTextInput
|
||||
key={key}
|
||||
field={fieldName}
|
||||
label={labelText}
|
||||
initialValue={initialValue}
|
||||
placeholder='Paste image URL'
|
||||
submitHandler={handleSubmit}
|
||||
className={style.decorated}
|
||||
maxLength={255}
|
||||
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
|
||||
/>
|
||||
<EventEditorImage src={initialValue} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// we should have exhausted all types by now
|
||||
return null;
|
||||
})}
|
||||
<EventCustom fields={customFields} handleSubmit={handleSubmit} event={event} />
|
||||
</div>
|
||||
<div className={style.column}>
|
||||
<Editor.Title>
|
||||
Automations
|
||||
{isEditor && <AppLink search='settings=automation__automations'>Manage Automations</AppLink>}
|
||||
</Editor.Title>
|
||||
<EventEditorTriggers triggers={event.triggers} eventId={event.id} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function RundownEventEditor() {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedEventId = data.order.find((entryId) => selectedEvents.has(entryId));
|
||||
const selectedEventId = Array.from(selectedEvents).at(0);
|
||||
if (!selectedEventId) {
|
||||
setEvent(null);
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { CSSProperties, Fragment } from 'react';
|
||||
import { CustomFields, OntimeEvent } from 'ontime-types';
|
||||
|
||||
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||
import { EditorUpdateFields } from '../EventEditor';
|
||||
|
||||
import EventEditorImage from './EventEditorImage';
|
||||
import EventTextArea from './EventTextArea';
|
||||
import EventTextInput from './EventTextInput';
|
||||
|
||||
import style from '../EventEditor.module.scss';
|
||||
|
||||
interface EventEditorCustomProps {
|
||||
fields: CustomFields;
|
||||
event: OntimeEvent;
|
||||
handleSubmit: (field: EditorUpdateFields, value: string) => void;
|
||||
}
|
||||
|
||||
export default function EventEditorCustom(props: EventEditorCustomProps) {
|
||||
const { fields: customFields, handleSubmit, event } = props;
|
||||
return (
|
||||
<Fragment>
|
||||
{Object.keys(customFields).map((fieldKey) => {
|
||||
const key = `${event.id}-${fieldKey}`;
|
||||
const fieldName = `custom-${fieldKey}`;
|
||||
const initialValue = event.custom[fieldKey] ?? '';
|
||||
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
|
||||
const labelText = customFields[fieldKey].label;
|
||||
|
||||
if (customFields[fieldKey].type === 'string') {
|
||||
return (
|
||||
<EventTextArea
|
||||
key={key}
|
||||
field={fieldName}
|
||||
label={labelText}
|
||||
initialValue={initialValue}
|
||||
submitHandler={handleSubmit}
|
||||
className={style.decorated}
|
||||
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (customFields[fieldKey].type === 'image') {
|
||||
return (
|
||||
<div key={key} className={style.customImage}>
|
||||
<EventTextInput
|
||||
key={key}
|
||||
field={fieldName}
|
||||
label={labelText}
|
||||
initialValue={initialValue}
|
||||
placeholder='Paste image URL'
|
||||
submitHandler={handleSubmit}
|
||||
className={style.decorated}
|
||||
maxLength={255}
|
||||
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
|
||||
/>
|
||||
<EventEditorImage src={initialValue} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// we should have exhausted all types by now
|
||||
return null;
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { memo } from 'react';
|
||||
import { IoInformationCircle } from 'react-icons/io5';
|
||||
import { Select, Switch, Tooltip } from '@chakra-ui/react';
|
||||
import { EndAction, MaybeString, TimerType, TimeStrategy } from 'ontime-types';
|
||||
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
|
||||
import { millisToString, parseUserTime } from 'ontime-utils';
|
||||
|
||||
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
||||
import { useEventAction } from '../../../../common/hooks/useEventAction';
|
||||
import { useEntryActions } from '../../../../common/hooks/useEntryAction';
|
||||
import { millisToDelayString } from '../../../../common/utils/dateConfig';
|
||||
import * as Editor from '../../../editors/editor-utils/EditorUtils';
|
||||
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
|
||||
@@ -18,7 +18,7 @@ interface EventEditorTimesProps {
|
||||
timeEnd: number;
|
||||
duration: number;
|
||||
timeStrategy: TimeStrategy;
|
||||
linkStart: MaybeString;
|
||||
linkStart: boolean;
|
||||
countToEnd: boolean;
|
||||
delay: number;
|
||||
isPublic: boolean;
|
||||
@@ -46,27 +46,27 @@ function EventEditorTimes(props: EventEditorTimesProps) {
|
||||
timeWarning,
|
||||
timeDanger,
|
||||
} = props;
|
||||
const { updateEvent } = useEventAction();
|
||||
const { updateEntry } = useEntryActions();
|
||||
|
||||
const handleSubmit = (field: HandledActions, value: string | boolean) => {
|
||||
if (field === 'isPublic') {
|
||||
updateEvent({ id: eventId, isPublic: !(value as boolean) });
|
||||
updateEntry({ id: eventId, isPublic: !(value as boolean) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (field === 'countToEnd') {
|
||||
updateEvent({ id: eventId, countToEnd: !(value as boolean) });
|
||||
updateEntry({ id: eventId, countToEnd: !(value as boolean) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (field === 'timeWarning' || field === 'timeDanger') {
|
||||
const newTime = parseUserTime(value as string);
|
||||
updateEvent({ id: eventId, [field]: newTime });
|
||||
updateEntry({ id: eventId, [field]: newTime });
|
||||
return;
|
||||
}
|
||||
|
||||
if (field === 'timerType' || field === 'endAction') {
|
||||
updateEvent({ id: eventId, [field]: value });
|
||||
updateEntry({ id: eventId, [field]: value });
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,8 +5,8 @@ import { sanitiseCue } from 'ontime-utils';
|
||||
import SwatchSelect from '../../../../common/components/input/colour-input/SwatchSelect';
|
||||
import * as Editor from '../../../editors/editor-utils/EditorUtils';
|
||||
import { type EditorUpdateFields } from '../EventEditor';
|
||||
import RichTextEditor from '../../../../common/components/input/rich-text-editor/RichTextEditor';
|
||||
|
||||
import EventTextArea from './EventTextArea';
|
||||
import EventTextInput from './EventTextInput';
|
||||
|
||||
import style from '../EventEditor.module.scss';
|
||||
@@ -49,7 +49,10 @@ const EventEditorTitles = (props: EventEditorTitlesProps) => {
|
||||
<SwatchSelect name='colour' value={colour} handleChange={handleSubmit} />
|
||||
</div>
|
||||
<EventTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} />
|
||||
<EventTextArea field='note' label='Note' initialValue={note} submitHandler={handleSubmit} />
|
||||
<div>
|
||||
<Editor.Label>Note</Editor.Label>
|
||||
<RichTextEditor initialValue={note} onChange={(newNote) => handleSubmit('note', newNote)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
.triggerForm {
|
||||
padding-block: 0.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto auto;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
padding: 0.25rem 0.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
align-items: center;
|
||||
|
||||
&:nth-child(even) {
|
||||
background-color: $white-1;
|
||||
}
|
||||
|
||||
& > span {
|
||||
width: fit-content;
|
||||
}
|
||||
}
|
||||
|
||||
.errorLabel {
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: $green-500;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Fragment, useCallback, useState } from 'react';
|
||||
import { IoAlertCircle, IoCheckmarkCircle, IoTrash } from 'react-icons/io5';
|
||||
import { Button, IconButton, Select, Tooltip } from '@chakra-ui/react';
|
||||
import { TimerLifeCycle, timerLifecycleValues, Trigger } from 'ontime-types';
|
||||
import { generateId } from 'ontime-utils';
|
||||
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import { useEntryActions } from '../../../../common/hooks/useEntryAction';
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
|
||||
import { eventTriggerOptions } from './eventTrigger.constants';
|
||||
|
||||
import style from './EventEditorTriggers.module.scss';
|
||||
|
||||
interface EventEditorTriggersProps {
|
||||
eventId: string;
|
||||
triggers: Trigger[];
|
||||
}
|
||||
|
||||
export default function EventEditorTriggers(props: EventEditorTriggersProps) {
|
||||
const { triggers, eventId } = props;
|
||||
const showTriggers = triggers.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showTriggers && <ExistingEventTriggers triggers={triggers} eventId={eventId} />}
|
||||
<EventTriggerForm triggers={triggers} eventId={eventId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface EventTriggerFormProps {
|
||||
eventId: string;
|
||||
triggers?: Trigger[];
|
||||
}
|
||||
|
||||
function EventTriggerForm(props: EventTriggerFormProps) {
|
||||
const { eventId, triggers } = props;
|
||||
const { data: automationSettings } = useAutomationSettings();
|
||||
const { updateEntry } = useEntryActions();
|
||||
const [automationId, setAutomationId] = useState<string | undefined>(undefined);
|
||||
const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart);
|
||||
|
||||
const handleSubmit = (triggerLifeCycle: TimerLifeCycle, automationId: string) => {
|
||||
const newTriggers = triggers ?? new Array<Trigger>();
|
||||
const id = generateId();
|
||||
newTriggers.push({ id, title: '', trigger: triggerLifeCycle, automationId });
|
||||
updateEntry({ id: eventId, triggers: newTriggers });
|
||||
};
|
||||
|
||||
const getValidationError = (cycle: TimerLifeCycle, automationId?: string): string | undefined => {
|
||||
if (automationId === undefined) {
|
||||
return 'Select an automation';
|
||||
}
|
||||
if (!Object.keys(automationSettings.automations).includes(automationId)) {
|
||||
return 'This automation does not exist';
|
||||
}
|
||||
if (triggers === undefined) {
|
||||
return;
|
||||
}
|
||||
return Object.values(triggers).some((t) => t.automationId === automationId && t.trigger === cycle)
|
||||
? 'Automation can only be used once'
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const validationError = getValidationError(cycleValue, automationId);
|
||||
|
||||
return (
|
||||
<div className={style.triggerForm}>
|
||||
<Select
|
||||
size='sm'
|
||||
variant='ontime'
|
||||
value={cycleValue}
|
||||
onChange={(e) => setCycleValue(e.target.value as TimerLifeCycle)}
|
||||
>
|
||||
<option disabled>Lifecycle Trigger</option>
|
||||
{eventTriggerOptions.map((cycle) => (
|
||||
<option key={cycle} value={cycle}>
|
||||
{cycle}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
size='sm'
|
||||
variant='ontime'
|
||||
value={automationId}
|
||||
defaultValue='«invalid»'
|
||||
onChange={(e) => setAutomationId(e.target.value)}
|
||||
>
|
||||
<option disabled value='«invalid»'>
|
||||
Automation
|
||||
</option>
|
||||
{Object.values(automationSettings.automations).map(({ id, title }) => (
|
||||
<option key={id} value={id}>
|
||||
{title}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
size='sm'
|
||||
isDisabled={validationError !== undefined}
|
||||
onClick={() => automationId && handleSubmit(cycleValue, automationId)}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
{validationError !== undefined ? (
|
||||
<Tooltip label={validationError} shouldWrapChildren>
|
||||
<IoAlertCircle className={style.errorLabel} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<IoCheckmarkCircle className={style.success} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExistingEventTriggersProps {
|
||||
eventId: string;
|
||||
triggers: Trigger[];
|
||||
}
|
||||
|
||||
function ExistingEventTriggers(props: ExistingEventTriggersProps) {
|
||||
const { eventId, triggers } = props;
|
||||
const { updateEntry } = useEntryActions();
|
||||
const { data: automationSettings } = useAutomationSettings();
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(triggerId: string) => {
|
||||
const newTriggers = triggers.filter((trigger) => trigger.id !== triggerId);
|
||||
updateEntry({ id: eventId, triggers: newTriggers });
|
||||
},
|
||||
[eventId, triggers, updateEntry],
|
||||
);
|
||||
|
||||
const filteredTriggers: Record<string, Trigger[]> = {};
|
||||
|
||||
// sort triggers out into groups by the Lifecycle they are on
|
||||
timerLifecycleValues.forEach((triggerType) => {
|
||||
const thisTriggerType = triggers.filter((trigger) => trigger.trigger === triggerType);
|
||||
if (thisTriggerType.length) {
|
||||
Object.assign(filteredTriggers, { [triggerType]: thisTriggerType });
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{Object.entries(filteredTriggers).map(([triggerLifeCycle, triggerGroup]) => (
|
||||
<Fragment key={triggerLifeCycle}>
|
||||
{triggerGroup.map((trigger) => {
|
||||
const { id, automationId } = trigger;
|
||||
const automationTitle = automationSettings.automations[automationId]?.title ?? '<MISSING AUTOMATION>';
|
||||
return (
|
||||
<div key={id} className={style.trigger}>
|
||||
<Tag>{triggerLifeCycle}</Tag>
|
||||
<Tag>{automationTitle}</Tag>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='ontime-ghosted'
|
||||
color='#FA5656' // $red-500
|
||||
icon={<IoTrash />}
|
||||
aria-label='Delete entry'
|
||||
onClick={() => handleDelete(id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { type CSSProperties, useCallback, useRef } from 'react';
|
||||
|
||||
import { AutoTextArea } from '../../../../common/components/input/auto-text-area/AutoTextArea';
|
||||
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
|
||||
import * as Editor from '../../../editors/editor-utils/EditorUtils';
|
||||
import { EditorUpdateFields } from '../EventEditor';
|
||||
|
||||
interface CountedTextAreaProps {
|
||||
className?: string;
|
||||
field: EditorUpdateFields;
|
||||
label: string;
|
||||
initialValue: string;
|
||||
style?: CSSProperties;
|
||||
submitHandler: (field: EditorUpdateFields, value: string) => void;
|
||||
}
|
||||
|
||||
export default function EventTextArea(props: CountedTextAreaProps) {
|
||||
const { className, field, label, initialValue, style: givenStyles, submitHandler } = props;
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
|
||||
|
||||
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
|
||||
submitOnCtrlEnter: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
|
||||
{label}
|
||||
</Editor.Label>
|
||||
<AutoTextArea
|
||||
id={field}
|
||||
inputref={ref}
|
||||
rows={1}
|
||||
size='sm'
|
||||
resize='none'
|
||||
variant='ontime-filled'
|
||||
data-testid='input-textarea'
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { TimerLifeCycle } from 'ontime-types';
|
||||
|
||||
export const eventTriggerOptions: TimerLifeCycle[] = [
|
||||
TimerLifeCycle.onLoad,
|
||||
TimerLifeCycle.onStart,
|
||||
TimerLifeCycle.onPause,
|
||||
TimerLifeCycle.onFinish,
|
||||
TimerLifeCycle.onWarning,
|
||||
TimerLifeCycle.onDanger,
|
||||
];
|
||||
@@ -0,0 +1,22 @@
|
||||
import { memo } from 'react';
|
||||
import { useDisclosure } from '@chakra-ui/react';
|
||||
import { useHotkeys } from '@mantine/hooks';
|
||||
|
||||
import Finder from '../../editors/finder/Finder';
|
||||
|
||||
export default memo(FinderPlacement);
|
||||
|
||||
function FinderPlacement() {
|
||||
const { isOpen, onToggle, onClose } = useDisclosure();
|
||||
|
||||
useHotkeys([
|
||||
['mod + f', onToggle],
|
||||
['Escape', onClose],
|
||||
]);
|
||||
|
||||
if (isOpen) {
|
||||
return <Finder isOpen={isOpen} onClose={onClose} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -3,8 +3,10 @@
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
margin: 0.25rem 0;
|
||||
padding-block: 0.5rem;
|
||||
font-size: calc(1rem - 3px);
|
||||
padding-left: calc(2em + 0.5rem);
|
||||
background-color: color-mix(in srgb, var(--user-bg, transparent) 10%, transparent 90%);
|
||||
}
|
||||
|
||||
.quickBtn {
|
||||
|
||||
@@ -1,73 +1,76 @@
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
import { memo, useRef } from 'react';
|
||||
import { IoAdd } from 'react-icons/io5';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { MaybeString, SupportedEvent } from 'ontime-types';
|
||||
import { MaybeString, SupportedEntry } from 'ontime-types';
|
||||
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import { useEmitLog } from '../../../common/stores/logger';
|
||||
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||
|
||||
import style from './QuickAddBlock.module.scss';
|
||||
|
||||
interface QuickAddBlockProps {
|
||||
previousEventId: MaybeString;
|
||||
parentBlock: MaybeString;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export default memo(QuickAddBlock);
|
||||
|
||||
function QuickAddBlock(props: QuickAddBlockProps) {
|
||||
const { previousEventId } = props;
|
||||
const { addEvent } = useEventAction();
|
||||
const { emitError } = useEmitLog();
|
||||
const { previousEventId, parentBlock, backgroundColor } = props;
|
||||
const { addEntry } = useEntryActions();
|
||||
|
||||
const doLinkPrevious = useRef<HTMLInputElement | null>(null);
|
||||
const doPublic = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const handleCreateEvent = useCallback(
|
||||
(eventType: SupportedEvent) => {
|
||||
switch (eventType) {
|
||||
case 'event': {
|
||||
const defaultPublic = doPublic?.current?.checked;
|
||||
const linkPrevious = doLinkPrevious?.current?.checked;
|
||||
const addEvent = () => {
|
||||
addEntry(
|
||||
{
|
||||
type: SupportedEntry.Event,
|
||||
parent: parentBlock,
|
||||
},
|
||||
{
|
||||
after: previousEventId,
|
||||
defaultPublic: doPublic?.current?.checked,
|
||||
lastEventId: previousEventId,
|
||||
linkPrevious: doLinkPrevious?.current?.checked,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const newEvent = { type: SupportedEvent.Event };
|
||||
const options = {
|
||||
after: previousEventId,
|
||||
defaultPublic,
|
||||
lastEventId: previousEventId,
|
||||
linkPrevious,
|
||||
};
|
||||
addEvent(newEvent, options);
|
||||
break;
|
||||
}
|
||||
case 'delay': {
|
||||
const options = {
|
||||
lastEventId: previousEventId,
|
||||
after: previousEventId,
|
||||
};
|
||||
addEvent({ type: SupportedEvent.Delay }, options);
|
||||
break;
|
||||
}
|
||||
case 'block': {
|
||||
const options = {
|
||||
lastEventId: previousEventId,
|
||||
after: previousEventId,
|
||||
};
|
||||
addEvent({ type: SupportedEvent.Block }, options);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
emitError(`Cannot create unknown event type: ${eventType}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[previousEventId, addEvent, emitError],
|
||||
);
|
||||
const addDelay = () => {
|
||||
addEntry(
|
||||
{ type: SupportedEntry.Delay, parent: parentBlock },
|
||||
{
|
||||
lastEventId: previousEventId,
|
||||
after: previousEventId,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const addBlock = () => {
|
||||
if (parentBlock !== null) {
|
||||
return;
|
||||
}
|
||||
addEntry(
|
||||
{ type: SupportedEntry.Block },
|
||||
{
|
||||
lastEventId: previousEventId,
|
||||
after: previousEventId,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* If the colour is empty string ''
|
||||
* ie: we are inside a block, but there is no defined colour
|
||||
* we default to $gray-1050 #303030
|
||||
*/
|
||||
const blockColour = backgroundColor === '' ? '#303030' : backgroundColor;
|
||||
|
||||
return (
|
||||
<div className={style.quickAdd}>
|
||||
<div className={style.quickAdd} style={blockColour ? { '--user-bg': blockColour } : {}}>
|
||||
<Button
|
||||
onClick={() => handleCreateEvent(SupportedEvent.Event)}
|
||||
onClick={addEvent}
|
||||
size='xs'
|
||||
variant='ontime-subtle-white'
|
||||
className={style.quickBtn}
|
||||
@@ -77,7 +80,7 @@ function QuickAddBlock(props: QuickAddBlockProps) {
|
||||
Event
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleCreateEvent(SupportedEvent.Delay)}
|
||||
onClick={addDelay}
|
||||
size='xs'
|
||||
variant='ontime-subtle-white'
|
||||
className={style.quickBtn}
|
||||
@@ -86,16 +89,18 @@ function QuickAddBlock(props: QuickAddBlockProps) {
|
||||
>
|
||||
Delay
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleCreateEvent(SupportedEvent.Block)}
|
||||
size='xs'
|
||||
variant='ontime-subtle-white'
|
||||
className={style.quickBtn}
|
||||
leftIcon={<IoAdd />}
|
||||
color='#b1b1b1' // $gray-400
|
||||
>
|
||||
Block
|
||||
</Button>
|
||||
{parentBlock === null && (
|
||||
<Button
|
||||
onClick={addBlock}
|
||||
size='xs'
|
||||
variant='ontime-subtle-white'
|
||||
className={style.quickBtn}
|
||||
leftIcon={<IoAdd />}
|
||||
color='#b1b1b1' // $gray-400
|
||||
>
|
||||
Block
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Button, ButtonGroup } from '@chakra-ui/react';
|
||||
import { OffsetMode } from 'ontime-types';
|
||||
|
||||
import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket';
|
||||
import { AppMode, useAppMode } from '../../../common/stores/appModeStore';
|
||||
|
||||
import RundownMenu from './RundownMenu';
|
||||
@@ -12,6 +14,8 @@ export default function RundownHeader() {
|
||||
const setRunMode = () => setAppMode(AppMode.Run);
|
||||
const setEditMode = () => setAppMode(AppMode.Edit);
|
||||
|
||||
const { offsetMode } = useOffsetMode();
|
||||
|
||||
return (
|
||||
<div className={style.header}>
|
||||
<ButtonGroup isAttached>
|
||||
@@ -22,6 +26,22 @@ export default function RundownHeader() {
|
||||
Edit
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<ButtonGroup isAttached>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={offsetMode === OffsetMode.Absolute ? 'ontime-filled' : 'ontime-subtle'}
|
||||
onClick={() => setOffsetMode(OffsetMode.Absolute)}
|
||||
>
|
||||
Absolute
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={offsetMode === OffsetMode.Relative ? 'ontime-filled' : 'ontime-subtle'}
|
||||
onClick={() => setOffsetMode(OffsetMode.Relative)}
|
||||
>
|
||||
Relative
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<RundownMenu />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,23 +11,23 @@ import {
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||
import { useAppMode } from '../../../common/stores/appModeStore';
|
||||
import { useEventSelection } from '../useEventSelection';
|
||||
|
||||
export default function RundownMenu() {
|
||||
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
|
||||
const appMode = useAppMode((state) => state.mode);
|
||||
const { deleteAllEvents } = useEventAction();
|
||||
const { deleteAllEntries } = useEntryActions();
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const cancelRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const deleteAll = useCallback(() => {
|
||||
deleteAllEvents();
|
||||
deleteAllEntries();
|
||||
clearSelectedEvents();
|
||||
onClose();
|
||||
}, [clearSelectedEvents, deleteAllEvents, onClose]);
|
||||
}, [clearSelectedEvents, deleteAllEntries, onClose]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import {
|
||||
EntryId,
|
||||
isOntimeBlock,
|
||||
isOntimeEvent,
|
||||
isPlayableEvent,
|
||||
MaybeString,
|
||||
OntimeDelay,
|
||||
OntimeEntry,
|
||||
OntimeEvent,
|
||||
PlayableEvent,
|
||||
RundownEntries,
|
||||
SupportedEntry,
|
||||
} from 'ontime-types';
|
||||
import { checkIsNextDay, isNewLatest } from 'ontime-utils';
|
||||
|
||||
type RundownMetadata = {
|
||||
previousEvent: PlayableEvent | null; // The playableEvent from the previous iteration, used by indicators
|
||||
latestEvent: PlayableEvent | null; // The playableEvent most forwards in time processed so far
|
||||
previousEntryId: MaybeString; // previous entry is used to infer position in the rundown for new events
|
||||
thisId: MaybeString;
|
||||
eventIndex: number;
|
||||
isPast: boolean;
|
||||
isNextDay: boolean;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean; // check if the event can link all the way back to the currently playing event
|
||||
isLoaded: boolean;
|
||||
groupId: MaybeString;
|
||||
groupColour: string | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a process function which aggregates the rundown metadata and event metadata
|
||||
*/
|
||||
export function makeRundownMetadata(selectedEventId: MaybeString) {
|
||||
let rundownMeta: RundownMetadata = {
|
||||
previousEvent: null,
|
||||
latestEvent: null,
|
||||
previousEntryId: null,
|
||||
thisId: null,
|
||||
eventIndex: 0,
|
||||
isPast: Boolean(selectedEventId), // all events before the current selected are in the past
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: null,
|
||||
groupColour: undefined,
|
||||
};
|
||||
|
||||
function process(entry: OntimeEntry): Readonly<RundownMetadata> {
|
||||
const processedRundownMetadata = processEntry(rundownMeta, selectedEventId, entry);
|
||||
rundownMeta = processedRundownMetadata;
|
||||
return rundownMeta;
|
||||
}
|
||||
|
||||
return { metadata: rundownMeta, process };
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives a rundown entry and processes its place in the rundown
|
||||
*/
|
||||
function processEntry(
|
||||
rundownMetadata: RundownMetadata,
|
||||
selectedEventId: MaybeString,
|
||||
entry: Readonly<OntimeEntry>,
|
||||
): Readonly<RundownMetadata> {
|
||||
const processedData = { ...rundownMetadata };
|
||||
// initialise data to be overridden below
|
||||
processedData.isNextDay = false;
|
||||
processedData.isLoaded = false;
|
||||
|
||||
processedData.previousEntryId = processedData.thisId; // thisId comes from the previous iteration
|
||||
processedData.thisId = entry.id; // we reassign thisId
|
||||
processedData.previousEvent = processedData.latestEvent;
|
||||
|
||||
if (entry.id === selectedEventId) {
|
||||
processedData.isLoaded = true;
|
||||
processedData.isPast = false;
|
||||
}
|
||||
|
||||
if (isOntimeBlock(entry)) {
|
||||
processedData.groupId = entry.id;
|
||||
processedData.groupColour = entry.colour;
|
||||
} else {
|
||||
// for delays and blocks, we insert the group metadata
|
||||
if ((entry as OntimeEvent | OntimeDelay).parent !== processedData.groupId) {
|
||||
// if the parent is not the current group, we need to update the groupId
|
||||
processedData.groupId = (entry as OntimeEvent | OntimeDelay).parent;
|
||||
if ((entry as OntimeEvent | OntimeDelay).parent === null) {
|
||||
// if the entry has no parent, it cannot have a group colour
|
||||
processedData.groupColour = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (isOntimeEvent(entry)) {
|
||||
// event indexes are 1 based in UI
|
||||
processedData.eventIndex += 1;
|
||||
|
||||
if (isPlayableEvent(entry)) {
|
||||
processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
|
||||
processedData.totalGap += entry.gap;
|
||||
|
||||
if (!processedData.isPast && !processedData.isLoaded) {
|
||||
/**
|
||||
* isLinkToLoaded is a chain value that we maintain until we
|
||||
* a) find an unlinked event
|
||||
* b) find a countToEnd event
|
||||
*/
|
||||
processedData.isLinkedToLoaded = entry.linkStart && !processedData.previousEvent?.countToEnd;
|
||||
}
|
||||
|
||||
if (isNewLatest(entry, processedData.latestEvent)) {
|
||||
// this event is the forward most event in rundown, for next iteration
|
||||
processedData.latestEvent = entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return processedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a sortable list of entries
|
||||
* ------------------------------------
|
||||
* Due to limitations in dnd-kit we need to flatten the list of entries
|
||||
* This list should also be aware of any elements that are sortable (ie: block ends)
|
||||
*/
|
||||
export function makeSortableList(order: EntryId[], entries: RundownEntries): EntryId[] {
|
||||
const flatIds: EntryId[] = [];
|
||||
|
||||
for (let i = 0; i < order.length; i++) {
|
||||
const entry = entries[order[i]];
|
||||
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isOntimeBlock(entry)) {
|
||||
// inside a block there are delays and events
|
||||
// there is no need for special handling
|
||||
flatIds.push(entry.id);
|
||||
flatIds.push(...entry.events);
|
||||
|
||||
// close the block
|
||||
flatIds.push(`end-${entry.id}`);
|
||||
} else {
|
||||
flatIds.push(entry.id);
|
||||
}
|
||||
}
|
||||
return flatIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a drop operation is valid
|
||||
* Currently only used for validating dropping blocks
|
||||
*/
|
||||
export function canDrop(targetType?: SupportedEntry, targetParent?: EntryId | null): boolean {
|
||||
if (targetType === 'event' || targetType === 'delay') {
|
||||
return targetParent === null;
|
||||
}
|
||||
// remaining events will be block or end-block
|
||||
// we can swap places with other blocks
|
||||
return targetType == 'block';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates destinations for an entry moving one position up in the rundown
|
||||
* - Handles noops
|
||||
* - Handles moving in and out of blocks
|
||||
* TODO: handle moving blocks
|
||||
*/
|
||||
export function moveUp(entryId: EntryId, sortableData: EntryId[], entries: RundownEntries) {
|
||||
const previousEntryId = getPreviousId(entryId, sortableData);
|
||||
|
||||
// the user is moving up at the top of the list
|
||||
if (!previousEntryId) {
|
||||
return { destinationId: null, order: 'before', isBlock: false };
|
||||
}
|
||||
|
||||
if (previousEntryId.startsWith('end-')) {
|
||||
const entry = entries[entryId];
|
||||
if (isOntimeBlock(entry)) {
|
||||
// if we are moving a block, we cannot insert it
|
||||
return { destinationId: previousEntryId.replace('end-', ''), order: 'before', isBlock: false };
|
||||
}
|
||||
// insert in the block ID will add to the end of the block events
|
||||
return { destinationId: previousEntryId.replace('end-', ''), order: 'insert', isBlock: true };
|
||||
}
|
||||
|
||||
// @ts-expect-error -- we safeguard the entry not having a parent property
|
||||
return { destinationId: previousEntryId, order: 'before', isBlock: Boolean(entries[previousEntryId]?.parent) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates destinations for an entry moving one position down in the rundown
|
||||
* - Handles noops
|
||||
* - Handles moving in and out of blocks
|
||||
* TODO: handle moving blocks
|
||||
*/
|
||||
export function moveDown(entryId: EntryId, sortableData: EntryId[], entries: RundownEntries) {
|
||||
const nextEntryId = getNextId(entryId, sortableData);
|
||||
|
||||
// the user is moving down at the end of the list
|
||||
if (!nextEntryId) {
|
||||
return { destinationId: null, order: 'after', isBlock: false };
|
||||
}
|
||||
|
||||
if (nextEntryId.startsWith('end-')) {
|
||||
// move outside the block
|
||||
return { destinationId: nextEntryId.replace('end-', ''), order: 'after', isBlock: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* If the next entry is a block
|
||||
* - 1. blocks need to skip over it
|
||||
* - 2. if the block has children, we insert before the first child
|
||||
* - 3. if the block is empty, we insert into the block
|
||||
*/
|
||||
if (isOntimeBlock(entries[nextEntryId])) {
|
||||
const entry = entries[entryId];
|
||||
|
||||
if (isOntimeBlock(entry)) {
|
||||
// 1. if we are moving a block, we cannot insert it
|
||||
return { destinationId: nextEntryId, order: 'after', isBlock: false };
|
||||
}
|
||||
|
||||
const firstBlockChild = entries[nextEntryId].events.at(0);
|
||||
if (firstBlockChild) {
|
||||
// 2. add before the first child of the block
|
||||
return { destinationId: firstBlockChild, order: 'before', isBlock: true };
|
||||
} else {
|
||||
// 3. or insert into an empty block
|
||||
return { destinationId: nextEntryId, order: 'insert', isBlock: true };
|
||||
}
|
||||
}
|
||||
|
||||
return { destinationId: nextEntryId, order: 'after', isBlock: Boolean(entries[nextEntryId]?.parent) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function gets the ID if the next entry in the list
|
||||
* returns null if none is found
|
||||
*/
|
||||
function getNextId(entryId: EntryId, sortableData: EntryId[]): EntryId | null {
|
||||
const currentIndex = sortableData.indexOf(entryId);
|
||||
if (currentIndex === -1 || currentIndex === sortableData.length - 1) {
|
||||
// No next ID if not found or at the end
|
||||
return null;
|
||||
}
|
||||
return sortableData[currentIndex + 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function gets the ID if the previous entry in the list
|
||||
* returns null if none is found
|
||||
*/
|
||||
function getPreviousId(entryId: EntryId, sortableData: EntryId[]): EntryId | null {
|
||||
const currentIndex = sortableData.indexOf(entryId);
|
||||
if (currentIndex < 1) {
|
||||
// No previous ID found or at the beginning
|
||||
return null;
|
||||
}
|
||||
return sortableData[currentIndex - 1];
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { memo } from 'react';
|
||||
import { IoAlertCircleOutline, IoLink, IoLockClosed, IoLockOpenOutline, IoUnlink } from 'react-icons/io5';
|
||||
import { InputRightElement, Tooltip } from '@chakra-ui/react';
|
||||
import { MaybeString, TimeField, TimeStrategy } from 'ontime-types';
|
||||
import { TimeField, TimeStrategy } from 'ontime-types';
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
|
||||
import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton';
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { tooltipDelayFast, tooltipDelayMid } from '../../../ontimeConfig';
|
||||
import * as Editor from '../../editors/editor-utils/EditorUtils';
|
||||
@@ -19,14 +19,14 @@ interface EventBlockTimerProps {
|
||||
timeEnd: number;
|
||||
duration: number;
|
||||
timeStrategy: TimeStrategy;
|
||||
linkStart: MaybeString;
|
||||
linkStart: boolean;
|
||||
delay: number;
|
||||
showLabels?: boolean;
|
||||
}
|
||||
|
||||
function TimeInputFlow(props: EventBlockTimerProps) {
|
||||
const { eventId, countToEnd, timeStart, timeEnd, duration, timeStrategy, linkStart, delay, showLabels } = props;
|
||||
const { updateEvent, updateTimer } = useEventAction();
|
||||
const { updateEntry, updateTimer } = useEntryActions();
|
||||
|
||||
// In sync with EventEditorTimes
|
||||
const handleSubmit = (field: TimeField, value: string) => {
|
||||
@@ -34,11 +34,11 @@ function TimeInputFlow(props: EventBlockTimerProps) {
|
||||
};
|
||||
|
||||
const handleChangeStrategy = (timeStrategy: TimeStrategy) => {
|
||||
updateEvent({ id: eventId, timeStrategy });
|
||||
updateEntry({ id: eventId, timeStrategy });
|
||||
};
|
||||
|
||||
const handleLink = (doLink: boolean) => {
|
||||
updateEvent({ id: eventId, linkStart: doLink ? 'true' : null });
|
||||
updateEntry({ id: eventId, linkStart: doLink });
|
||||
};
|
||||
|
||||
const warnings = [];
|
||||
@@ -55,9 +55,9 @@ function TimeInputFlow(props: EventBlockTimerProps) {
|
||||
const isLockedEnd = timeStrategy === TimeStrategy.LockEnd;
|
||||
const isLockedDuration = timeStrategy === TimeStrategy.LockDuration;
|
||||
|
||||
const activeStart = cx([style.timeAction, linkStart ? style.active : null]);
|
||||
const activeEnd = cx([style.timeAction, isLockedEnd ? style.active : null]);
|
||||
const activeDuration = cx([style.timeAction, isLockedDuration ? style.active : null]);
|
||||
const activeStart = cx([style.timeAction, linkStart && style.active]);
|
||||
const activeEnd = cx([style.timeAction, isLockedEnd && style.active]);
|
||||
const activeDuration = cx([style.timeAction, isLockedDuration && style.active]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -69,7 +69,7 @@ function TimeInputFlow(props: EventBlockTimerProps) {
|
||||
time={timeStart}
|
||||
hasDelay={hasDelay}
|
||||
placeholder='Start'
|
||||
disabled={Boolean(linkStart)}
|
||||
disabled={linkStart}
|
||||
>
|
||||
<Tooltip label='Link start to previous end' openDelay={tooltipDelayMid}>
|
||||
<InputRightElement className={activeStart} onClick={() => handleLink(!linkStart)}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MouseEvent } from 'react';
|
||||
import { isOntimeEvent, MaybeNumber, MaybeString, OntimeEvent, Rundown } from 'ontime-types';
|
||||
import { EntryId, isOntimeEvent, MaybeNumber, MaybeString, Rundown } from 'ontime-types';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { RUNDOWN } from '../../common/api/constants';
|
||||
@@ -9,13 +9,13 @@ import { isMacOS } from '../../common/utils/deviceUtils';
|
||||
type SelectionMode = 'shift' | 'click' | 'ctrl';
|
||||
|
||||
interface EventSelectionStore {
|
||||
selectedEvents: Set<string>;
|
||||
selectedEvents: Set<EntryId>;
|
||||
anchoredIndex: MaybeNumber;
|
||||
cursor: MaybeString;
|
||||
setSelectedEvents: (selectionArgs: { id: string; index: number; selectMode: SelectionMode }) => void;
|
||||
setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void;
|
||||
clearSelectedEvents: () => void;
|
||||
clearMultiSelect: () => void;
|
||||
unselect: (id: string) => void;
|
||||
unselect: (id: EntryId) => void;
|
||||
}
|
||||
|
||||
export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
|
||||
@@ -66,11 +66,11 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
|
||||
if (!rundownData) return;
|
||||
|
||||
// get list of rundown with only ontime events
|
||||
const events: OntimeEvent[] = [];
|
||||
rundownData.order.forEach((eventId) => {
|
||||
const eventIds: EntryId[] = [];
|
||||
rundownData.flatOrder.forEach((eventId) => {
|
||||
const event = rundownData.entries[eventId];
|
||||
if (isOntimeEvent(event)) {
|
||||
events.push(event);
|
||||
eventIds.push(event.id);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -78,7 +78,7 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
|
||||
const end = anchoredIndex === null ? index : Math.max(anchoredIndex, index + 1);
|
||||
|
||||
// create new set with range of ids from start to end
|
||||
const selectedEventIds = events.slice(start, end).map((event) => event.id);
|
||||
const selectedEventIds = eventIds.slice(start, end);
|
||||
|
||||
return set({
|
||||
selectedEvents: new Set([...selectedEvents, ...selectedEventIds]),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user