mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-03 06:28:01 +00:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0057b4a3f1 | |||
| ac8c1e333e | |||
| 321297b685 | |||
| 67e67d6a5d | |||
| 4385e0b9c9 | |||
| 02243242f1 | |||
| 1af42b766d | |||
| 9d6ec3758b | |||
| 9e78ff4d1c | |||
| 2037aef080 | |||
| 48b9cf042f | |||
| 1866e5e78d | |||
| bc1b3800c8 | |||
| a2505c838b | |||
| bffa863fb4 | |||
| d00d7677bc | |||
| 3ae73be562 | |||
| 763080cd4d | |||
| 6ff06b21a8 | |||
| adebde1b43 | |||
| 89922fe394 | |||
| f219ebb8f8 | |||
| eace97ec7a | |||
| ec1df8b958 | |||
| 8d2d8ef979 | |||
| ae15f3cdc5 | |||
| 3c41b40c5f | |||
| 3b38756dda | |||
| 5a4494ba85 | |||
| 02ad852dd4 | |||
| 7ce1097826 | |||
| 7ee0de5341 | |||
| 0db249871b |
@@ -34,6 +34,10 @@ e2e/tests/fixtures/tmp/*
|
||||
build/
|
||||
dist/
|
||||
|
||||
# bundled assets
|
||||
translations.json
|
||||
override.css
|
||||
|
||||
# working stuff
|
||||
**/TODO.md
|
||||
|
||||
|
||||
+13
-19
@@ -20,19 +20,20 @@ development.
|
||||
Locally, we would need to run both the React client and the node.js server in development mode
|
||||
|
||||
From the project root, run the following commands
|
||||
|
||||
- __Install the project dependencies__ by running `pnpm i`
|
||||
- __Run dev mode__ by running `turbo dev`
|
||||
- __Run dev mode__ by running `pnpm turbo dev`
|
||||
|
||||
|
||||
### Debugging backend
|
||||
|
||||
To debug backend code in Node.js:
|
||||
The previous command will start the development servers for both the client, server and electron applications.
|
||||
Typically in dev mode we prefer to start these in separate terminals to help with error tracking and debugging.
|
||||
|
||||
- Open two separate terminals and navigate to the `apps/client` and `apps/server` directories.
|
||||
- In each terminal, run the command `pnpm dev` to start the development servers for both the client and server
|
||||
applications.
|
||||
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by
|
||||
running `pnpm dev:inspect`.
|
||||
We do that by creating two terminals an running
|
||||
- __Run the React UI__ by running `pnpm turbo dev --filter=ontime-ui`
|
||||
- __Run the nodejs server__ by running `pnpm turbo dev --filter=ontime-server`
|
||||
|
||||
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by running `pnpm turbo dev:inspect --filter=ontime-server`.
|
||||
|
||||
## TESTING
|
||||
|
||||
@@ -45,7 +46,7 @@ Generally we have 2 types of tests.
|
||||
|
||||
Unit tests are contained in mostly all the apps and packages (client, server and utils)
|
||||
|
||||
You can run unit tests by running `turbo run test:pipeline` from the project root.
|
||||
You can run unit tests by running `pnpm turbo test:pipeline` from the project root.
|
||||
This will run all tests and close test runner.
|
||||
|
||||
Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode
|
||||
@@ -76,13 +77,13 @@ You can generate a distribution for your OS by running the following steps.
|
||||
From the project root, run the following commands
|
||||
|
||||
- __Install the project dependencies__ by running `pnpm i`
|
||||
- __Build the UI and server__ by running `turbo run build:electron`
|
||||
- __Create the package__ by running `turbo run dist-win`, `turbo run dist-mac` or `turbo run dist-linux`
|
||||
- __Build the UI and server__ by running `pnpm turbo run build:electron`
|
||||
- __Create the package__ by running `pnpm turbo run dist-win`, `pnpm turbo run dist-mac` or `pnpm turbo run dist-linux`
|
||||
|
||||
The build distribution assets will be at `.apps/electron/dist`
|
||||
|
||||
Note: The MacOS build will only work in CI, locally it will fail due to notarisation issues.
|
||||
Use the `turbo run dist-mac:local` command to build a MacOS distribution locally.
|
||||
Use the `pnpm turbo run dist-mac:local` command to build a MacOS distribution locally and skip the notary process.
|
||||
|
||||
## DOCKER
|
||||
|
||||
@@ -98,10 +99,3 @@ Other useful commands
|
||||
|
||||
- __List running processes__ by running `docker ps`
|
||||
- __Kill running process__ by running `docker kill <process-id>`
|
||||
|
||||
## General Info
|
||||
|
||||
# APP Building
|
||||
|
||||
We build the app from app.js for almost all applications. The output file will still be named index.cjs. This is because of Electron.
|
||||
Building the app from index.ts only applies for applications that don't use electron. index.ts will take over the initialization of the server and UI when electron isn't present.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "4.0.0-alpha.3",
|
||||
"version": "4.0.0-beta.2",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
+12
-11
@@ -1,23 +1,23 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "4.0.0-alpha.3",
|
||||
"version": "4.0.0-beta.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@base-ui-components/react": "1.0.0-beta.2",
|
||||
"@base-ui-components/react": "1.0.0-beta.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fontsource/open-sans": "^5.0.28",
|
||||
"@mantine/hooks": "^8.2.4",
|
||||
"@fontsource/open-sans": "^5.2.6",
|
||||
"@mantine/hooks": "^8.2.8",
|
||||
"@sentry/react": "^10.2.0",
|
||||
"@table-nav/react": "^0.0.7",
|
||||
"@tanstack/react-query": "^5.84.1",
|
||||
"@tanstack/react-query-devtools": "^5.84.1",
|
||||
"@tanstack/react-query": "^5.85.9",
|
||||
"@tanstack/react-query-devtools": "^5.85.9",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.11.0",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
||||
"csv-stringify": "^6.6.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^19.1.1",
|
||||
@@ -27,10 +27,11 @@
|
||||
"react-hook-form": "^7.62.0",
|
||||
"react-icons": "5.5.0",
|
||||
"react-qr-code": "^2.0.18",
|
||||
"react-router": "^7.8.0",
|
||||
"react-router": "^7.8.2",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"react-virtuoso": "^4.14.0",
|
||||
"web-vitals": "^5.1.0",
|
||||
"zustand": "^5.0.7"
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"scripts": {
|
||||
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
||||
@@ -63,8 +64,8 @@
|
||||
"@sentry/vite-plugin": "^2.16.1",
|
||||
"@tanstack/eslint-plugin-query": "^5.8.4",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||
"@typescript-eslint/parser": "catalog:",
|
||||
"@vitejs/plugin-react": "4.5.1",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import axios from 'axios';
|
||||
import { TranslationObject } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import { ontimeQueryClient } from '../../common/queryClient';
|
||||
|
||||
import { apiEntryUrl, customTranslationsURL, TRANSLATION } from './constants';
|
||||
|
||||
const assetsPath = `${apiEntryUrl}/assets`;
|
||||
|
||||
@@ -28,3 +31,21 @@ export async function restoreCSSContents(): Promise<string> {
|
||||
const res = await axios.post(`${assetsPath}/css/restore`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to get user translation
|
||||
*/
|
||||
export async function getUserTranslation(): Promise<TranslationObject> {
|
||||
const res = await axios.get(customTranslationsURL);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to post user translation
|
||||
*/
|
||||
export async function postUserTranslation(translation: TranslationObject): Promise<void> {
|
||||
await axios.post(`${assetsPath}/translations`, {
|
||||
translation,
|
||||
});
|
||||
await ontimeQueryClient.invalidateQueries({ queryKey: TRANSLATION });
|
||||
}
|
||||
|
||||
@@ -15,12 +15,15 @@ export const URL_PRESETS = ['urlpresets'];
|
||||
export const VIEW_SETTINGS = ['viewSettings'];
|
||||
export const CLIENT_LIST = ['clientList'];
|
||||
export const REPORT = ['report'];
|
||||
export const TRANSLATION = ['translation'];
|
||||
|
||||
// API URLs
|
||||
export const apiEntryUrl = `${serverURL}/data`;
|
||||
|
||||
const userAssetsPath = 'user';
|
||||
const cssOverridePath = 'styles/override.css';
|
||||
const customTranslationsPath = 'translations/translations.json';
|
||||
|
||||
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
|
||||
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
|
||||
export const customTranslationsURL = `${serverURL}/${userAssetsPath}/${customTranslationsPath}`;
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import {
|
||||
EntryId,
|
||||
MessageResponse,
|
||||
OntimeEntry,
|
||||
OntimeEvent,
|
||||
ProjectRundownsList,
|
||||
Rundown,
|
||||
TransientEventPayload,
|
||||
} from 'ontime-types';
|
||||
import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, TransientEventPayload } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const rundownPath = `${apiEntryUrl}/rundown`;
|
||||
const rundownPath = `${apiEntryUrl}/rundowns`;
|
||||
|
||||
// #region operations on project rundowns =========================
|
||||
|
||||
/**
|
||||
* HTTP request to fetch a list of existing rundowns
|
||||
@@ -22,37 +16,64 @@ export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to fetch all events
|
||||
* HTTP request to fetch all entries in the currently loaded rundown
|
||||
*/
|
||||
export async function fetchCurrentRundown(): Promise<Rundown> {
|
||||
const res = await axios.get(`${rundownPath}/current`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to switch the currently loaded rundown
|
||||
*/
|
||||
export async function loadRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
|
||||
return axios.post(`${rundownPath}/${id}/load`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to create a new rundown
|
||||
*/
|
||||
export async function createRundown(title: string): Promise<AxiosResponse<ProjectRundownsList>> {
|
||||
return axios.post(rundownPath, { title });
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to delete a rundown
|
||||
*/
|
||||
export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
|
||||
return axios.delete(`${rundownPath}/${id}`);
|
||||
}
|
||||
|
||||
// #endregion operations on project rundowns ======================
|
||||
// #region operations on rundown entries ==========================
|
||||
|
||||
/**
|
||||
* HTTP request to post new entry
|
||||
*/
|
||||
export async function postAddEntry(data: TransientEventPayload): Promise<AxiosResponse<OntimeEntry>> {
|
||||
return axios.post(rundownPath, data);
|
||||
export async function postAddEntry(
|
||||
rundownId: string,
|
||||
data: TransientEventPayload,
|
||||
): Promise<AxiosResponse<OntimeEntry>> {
|
||||
return axios.post(`${rundownPath}/${rundownId}/entry`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to edit an entry
|
||||
*/
|
||||
export async function putEditEntry(data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
|
||||
return axios.put(rundownPath, data);
|
||||
export async function putEditEntry(rundownId: string, data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
|
||||
return axios.put(`${rundownPath}/${rundownId}/entry`, data);
|
||||
}
|
||||
|
||||
type BatchEditEntry = {
|
||||
export type BatchEditEntry = {
|
||||
data: Partial<OntimeEvent>;
|
||||
ids: string[];
|
||||
ids: EntryId[];
|
||||
};
|
||||
|
||||
/**
|
||||
* HTTP request to edit multiple events
|
||||
*/
|
||||
export async function putBatchEditEvents(data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.put(`${rundownPath}/batch`, data);
|
||||
export async function putBatchEditEvents(rundownId: string, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.put(`${rundownPath}/${rundownId}/batch`, data);
|
||||
}
|
||||
|
||||
export type ReorderEntry = {
|
||||
@@ -64,60 +85,57 @@ export type ReorderEntry = {
|
||||
/**
|
||||
* HTTP request to reorder an entry
|
||||
*/
|
||||
export async function patchReorderEntry(data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.patch(`${rundownPath}/reorder`, data);
|
||||
export async function patchReorderEntry(rundownId: string, data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.patch(`${rundownPath}/${rundownId}/reorder`, data);
|
||||
}
|
||||
|
||||
export type SwapEntry = {
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* HTTP request to swap two events
|
||||
*/
|
||||
export async function requestEventSwap(data: SwapEntry): Promise<AxiosResponse<MessageResponse>> {
|
||||
return axios.patch(`${rundownPath}/swap`, data);
|
||||
export async function requestEventSwap(rundownId: string, from: EntryId, to: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to });
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to request application of delay
|
||||
*/
|
||||
export async function requestApplyDelay(delayId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.patch(`${rundownPath}/applydelay/${delayId}`);
|
||||
export async function requestApplyDelay(rundownId: string, delayId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.patch(`${rundownPath}/${rundownId}/applydelay/${delayId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request for cloning an entry
|
||||
*/
|
||||
export async function postCloneEntry(entryId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.post(`${rundownPath}/clone/${entryId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request for dissolving of a group
|
||||
*/
|
||||
export async function requestUngroup(groupId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.post(`${rundownPath}/ungroup/${groupId}`);
|
||||
export async function postCloneEntry(rundownId: string, entryId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request for grouping a list of entries into a group
|
||||
*/
|
||||
export async function requestGroupEntries(entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.post(`${rundownPath}/group`, { ids: entryIds });
|
||||
export async function requestGroupEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.post(`${rundownPath}/${rundownId}/group`, { ids: entryIds });
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to delete entries
|
||||
* HTTP request for dissolving of a group
|
||||
*/
|
||||
export async function deleteEntries(entryIds: EntryId[]): Promise<AxiosResponse<MessageResponse>> {
|
||||
return axios.delete(rundownPath, { data: { ids: entryIds } });
|
||||
export async function requestUngroup(rundownId: string, groupId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.post(`${rundownPath}/${rundownId}/ungroup/${groupId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to delete all events
|
||||
* HTTP request to delete entries of a given rundown
|
||||
*/
|
||||
export async function requestDeleteAll(): Promise<AxiosResponse<MessageResponse>> {
|
||||
return axios.delete(`${rundownPath}/all`);
|
||||
export async function deleteEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.delete(`${rundownPath}/${rundownId}/entries`, { data: { ids: entryIds } });
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to delete all entries of a given rundown
|
||||
*/
|
||||
export async function requestDeleteAll(rundownId: string): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.delete(`${rundownPath}/${rundownId}/all`);
|
||||
}
|
||||
|
||||
// #endregion operations on rundown entries =======================
|
||||
|
||||
@@ -33,7 +33,13 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
||||
if (newPath === '/' || newPath === currentPath) {
|
||||
return;
|
||||
}
|
||||
setRedirect({ target: id, redirect: newPath });
|
||||
|
||||
if (newPath.startsWith('preset-')) {
|
||||
setRedirect({ target: id, redirect: newPath.slice(7) });
|
||||
} else {
|
||||
setRedirect({ target: id, redirect: newPath });
|
||||
}
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -45,7 +51,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
||||
label: view.label,
|
||||
})),
|
||||
...enabledPresets.map((preset) => ({
|
||||
value: preset.search,
|
||||
value: `preset-${preset.alias}`,
|
||||
label: `URL Preset: ${preset.alias}`,
|
||||
})),
|
||||
];
|
||||
@@ -61,32 +67,11 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
||||
<>
|
||||
<Info>
|
||||
Remotely redirect the client to a different URL. <br />
|
||||
Either by selecting a URL Preset or entering a custom path.
|
||||
Either by entering a custom path or selecting a URL Preset.
|
||||
<br />
|
||||
<br />
|
||||
<AppLink search='settings=sharing__presets'>Manage URL Presets</AppLink>
|
||||
</Info>
|
||||
<div>
|
||||
<span className={style.label}>Select View or URL Preset</span>
|
||||
<div className={style.textEntry}>
|
||||
<Select
|
||||
fluid
|
||||
options={viewOptions}
|
||||
defaultValue={viewOptions[0].value}
|
||||
onValueChange={(value) => setSelected(value)}
|
||||
disabled={enabledPresets.length === 0}
|
||||
/>
|
||||
<Button
|
||||
variant='primary'
|
||||
aria-label='Redirect to preset'
|
||||
className={style.redirect}
|
||||
disabled={enabledPresets.length === 0 || selected === '/'}
|
||||
onClick={() => handleRedirect(selected)}
|
||||
>
|
||||
Redirect <IoArrowForward />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.inlineEntry}>
|
||||
<span className={style.label}>Enter custom path</span>
|
||||
<label className={style.textEntry}>
|
||||
@@ -100,9 +85,34 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
||||
className={style.redirect}
|
||||
onClick={() => handleRedirect(path)}
|
||||
>
|
||||
Redirect <IoArrowForward />
|
||||
Redirect
|
||||
<IoArrowForward />
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<span className={style.label}>Select View or URL Preset</span>
|
||||
<div className={style.inlineEntry}>
|
||||
<label className={style.textEntry}>
|
||||
{origin}
|
||||
<Select
|
||||
fluid
|
||||
options={viewOptions}
|
||||
defaultValue={viewOptions[0].value}
|
||||
onValueChange={(value) => setSelected(value)}
|
||||
disabled={enabledPresets.length === 0}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
variant='primary'
|
||||
aria-label='Redirect to preset'
|
||||
className={style.redirect}
|
||||
disabled={enabledPresets.length === 0 || selected === '/'}
|
||||
onClick={() => handleRedirect(selected)}
|
||||
>
|
||||
Redirect <IoArrowForward />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
.list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 99px;
|
||||
border: 2px solid $gray-1200;
|
||||
color: $ui-white;
|
||||
|
||||
+2
-1
@@ -14,7 +14,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 1rem;
|
||||
padding: 0.5em;
|
||||
padding-top: 0.75rem;
|
||||
padding-left: 0.5em;
|
||||
|
||||
position: fixed;
|
||||
left: 0;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import SwatchPicker from '../input/colour-input/SwatchPicker';
|
||||
|
||||
@@ -16,10 +16,13 @@ const ensureHex = (value: string) => {
|
||||
return value;
|
||||
};
|
||||
|
||||
export default function InlineColourPicker(props: InlineColourPickerProps) {
|
||||
const { name, value } = props;
|
||||
export default function InlineColourPicker({ name, value }: InlineColourPickerProps) {
|
||||
const [colour, setColour] = useState(() => ensureHex(value));
|
||||
|
||||
useEffect(() => {
|
||||
setColour(ensureHex(value));
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className={style.inline}>
|
||||
<SwatchPicker color={colour} onChange={setColour} alwaysDisplayColor />
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { ComponentProps, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
import Checkbox from '../checkbox/Checkbox';
|
||||
import Input from '../input/input/Input';
|
||||
import Select from '../select/Select';
|
||||
import Select, { SelectOption } from '../select/Select';
|
||||
import Switch from '../switch/Switch';
|
||||
|
||||
import InlineColourPicker from './InlineColourPicker';
|
||||
@@ -35,15 +35,22 @@ export default function ParamInput({ paramField }: ParamInputProps) {
|
||||
return <span className={style.empty}>No options available</span>;
|
||||
}
|
||||
|
||||
return <Select size='large' name={id} defaultValue={defaultOptionValue} options={paramField.values} />;
|
||||
return <ControlledSelect id={id} initialValue={defaultOptionValue} options={paramField.values} />;
|
||||
}
|
||||
|
||||
if (type === 'multi-option') {
|
||||
return <MultiOption paramField={paramField} />;
|
||||
const optionFromParams = searchParams.getAll(id);
|
||||
|
||||
return (
|
||||
<MultiOption
|
||||
paramField={paramField}
|
||||
options={optionFromParams.length ? optionFromParams : paramField.defaultValue ?? ['']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'boolean') {
|
||||
return <ControlledSwitch id={id} initialValue={isStringBoolean(searchParams.get(id)) || defaultValue} />;
|
||||
return <ControlledSwitch id={id} initialValue={isStringBoolean(searchParams.get(id)) ?? defaultValue} />;
|
||||
}
|
||||
|
||||
if (type === 'number') {
|
||||
@@ -63,27 +70,28 @@ export default function ParamInput({ paramField }: ParamInputProps) {
|
||||
}
|
||||
|
||||
if (type === 'colour') {
|
||||
const currentvalue = `#${searchParams.get(id) ?? defaultValue}`;
|
||||
|
||||
return <InlineColourPicker name={id} value={currentvalue} />;
|
||||
return <InlineColourPicker name={id} value={searchParams.get(id) ?? defaultValue} />;
|
||||
}
|
||||
|
||||
const defaultStringValue = searchParams.get(id) ?? defaultValue;
|
||||
const defaultStringValue = searchParams.get(id) ?? defaultValue ?? '';
|
||||
const { placeholder } = paramField;
|
||||
|
||||
return <Input height='large' name={id} defaultValue={defaultStringValue} placeholder={placeholder} />;
|
||||
return <ControlledInput id={id} initialValue={defaultStringValue} placeholder={placeholder} />;
|
||||
}
|
||||
|
||||
interface EditFormMultiOptionProps {
|
||||
paramField: ParamField & { type: 'multi-option' };
|
||||
options: string[];
|
||||
}
|
||||
|
||||
function MultiOption({ paramField }: EditFormMultiOptionProps) {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { id, values, defaultValue = [''] } = paramField;
|
||||
function MultiOption({ paramField, options }: EditFormMultiOptionProps) {
|
||||
const { id, values } = paramField;
|
||||
const [paramState, setParamState] = useState<string[]>(options);
|
||||
|
||||
const optionFromParams = searchParams.getAll(id);
|
||||
const [paramState, setParamState] = useState<string[]>(optionFromParams || defaultValue);
|
||||
// synchronise options
|
||||
useEffect(() => {
|
||||
setParamState(options);
|
||||
}, [options]);
|
||||
|
||||
const toggleValue = (value: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
@@ -129,5 +137,52 @@ interface ControlledSwitchProps {
|
||||
}
|
||||
function ControlledSwitch({ id, initialValue }: ControlledSwitchProps) {
|
||||
const [checked, setChecked] = useState(initialValue);
|
||||
|
||||
// synchronise checked state
|
||||
useEffect(() => {
|
||||
setChecked(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
return <Switch size='large' name={id} checked={checked} onCheckedChange={setChecked} />;
|
||||
}
|
||||
|
||||
interface ControlledSelectProps {
|
||||
id: string;
|
||||
initialValue?: string;
|
||||
options: SelectOption[];
|
||||
}
|
||||
function ControlledSelect({ id, initialValue, options }: ControlledSelectProps) {
|
||||
const [selected, setSelected] = useState(initialValue);
|
||||
|
||||
// synchronise selected state
|
||||
useEffect(() => {
|
||||
setSelected(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
return (
|
||||
<Select size='large' name={id} options={options} value={selected} onValueChange={(value) => setSelected(value)} />
|
||||
);
|
||||
}
|
||||
|
||||
interface ControlledInputProps<T extends number | string> extends ComponentProps<typeof Input> {
|
||||
id: string;
|
||||
initialValue: T;
|
||||
}
|
||||
function ControlledInput<T extends number | string>({ id, initialValue, ...inputProps }: ControlledInputProps<T>) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
|
||||
// synchronise selected state
|
||||
useEffect(() => {
|
||||
setValue(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
height='large'
|
||||
name={id}
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value as T)}
|
||||
{...inputProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FormEvent, memo, useReducer } from 'react';
|
||||
import { FormEvent, memo } from 'react';
|
||||
import { IoClose } from 'react-icons/io5';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { Dialog } from '@base-ui-components/react/dialog';
|
||||
@@ -12,7 +12,7 @@ import Info from '../info/Info';
|
||||
import { ViewOption } from './viewParams.types';
|
||||
import { getURLSearchParamsFromObj } from './viewParams.utils';
|
||||
import { useViewParamsEditorStore } from './viewParamsEditor.store';
|
||||
import { ViewParamsShare } from './ViewParamShare';
|
||||
import { ViewParamsPresets } from './ViewParamsPresets';
|
||||
import ViewParamsSection from './ViewParamsSection';
|
||||
|
||||
import style from './ViewParamsEditor.module.scss';
|
||||
@@ -24,12 +24,9 @@ interface EditFormDrawerProps {
|
||||
|
||||
export default memo(ViewParamsEditor);
|
||||
function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
||||
// TODO: can we ensure that the options update when the user loads an alias?
|
||||
const [_, setSearchParams] = useSearchParams();
|
||||
const { data: viewSettings } = useViewSettings();
|
||||
const { isOpen, close } = useViewParamsEditorStore();
|
||||
// TODO: we dont want this as a permanent option
|
||||
const forceRender = useReducer((x) => x + 1, 0)[1];
|
||||
|
||||
const handleClose = () => {
|
||||
close();
|
||||
@@ -37,7 +34,6 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
||||
|
||||
const resetParams = () => {
|
||||
setSearchParams();
|
||||
forceRender();
|
||||
};
|
||||
|
||||
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
|
||||
@@ -46,7 +42,6 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
||||
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
|
||||
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
|
||||
setSearchParams(newSearchParams);
|
||||
forceRender();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -71,7 +66,7 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
||||
{viewSettings.overrideStyles && (
|
||||
<Info className={style.info}>This view style is being modified by a custom CSS file.</Info>
|
||||
)}
|
||||
<ViewParamsShare target={target} />
|
||||
<ViewParamsPresets target={target} />
|
||||
<form id='edit-params-form' onSubmit={onParamsFormSubmit} className={style.sectionList}>
|
||||
{viewOptions.map((section) => (
|
||||
<ViewParamsSection
|
||||
|
||||
+3
@@ -5,6 +5,9 @@
|
||||
gap: 0.25rem;
|
||||
padding: 1rem 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
max-height: 10rem;
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.preset {
|
||||
+8
-6
@@ -5,17 +5,19 @@ import { useViewUrlPresets } from '../../hooks-query/useUrlPresets';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import Button from '../buttons/Button';
|
||||
|
||||
import style from './ViewParamsShare.module.scss';
|
||||
import style from './ViewParamsPresets.module.scss';
|
||||
|
||||
/**
|
||||
* Shows a list of presets for the current view
|
||||
*/
|
||||
export function ViewParamsShare({ target }: { target: OntimeView }) {
|
||||
export function ViewParamsPresets({ target }: { target: OntimeView }) {
|
||||
const { viewPresets } = useViewUrlPresets(target);
|
||||
const [_, setSearchParams] = useSearchParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const handleRecall = (preset: URLPreset) => {
|
||||
setSearchParams(`${preset.search}&alias=${preset.alias}`);
|
||||
const newSearch = new URLSearchParams(preset.search);
|
||||
newSearch.set('alias', preset.alias);
|
||||
setSearchParams(newSearch);
|
||||
};
|
||||
|
||||
if (viewPresets.length === 0) {
|
||||
@@ -25,12 +27,12 @@ export function ViewParamsShare({ target }: { target: OntimeView }) {
|
||||
return (
|
||||
<div className={style.presetSection}>
|
||||
{viewPresets.map((preset) => {
|
||||
const active = window.location.search.includes(`alias=${preset.alias}`);
|
||||
const active = searchParams.get('alias') === preset.alias;
|
||||
return (
|
||||
<div key={preset.alias} className={cx([style.preset, active && style.active])}>
|
||||
<div>{preset.alias}</div>
|
||||
<Button
|
||||
variant='subtle-white'
|
||||
variant={active ? 'ghosted' : 'subtle-white'}
|
||||
onClick={() => handleRecall(preset)}
|
||||
disabled={active}
|
||||
className={style.presetActions}
|
||||
@@ -44,3 +44,7 @@
|
||||
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
|
||||
rotate: 180deg;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
@@ -47,15 +47,11 @@ interface SectionContentsProps {
|
||||
}
|
||||
|
||||
function SectionContents({ options, collapsed }: SectionContentsProps) {
|
||||
if (collapsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{options.map((option) => {
|
||||
return (
|
||||
<label key={option.title} className={style.label}>
|
||||
<label key={option.title} className={cx([style.label, collapsed && style.hidden])}>
|
||||
<span className={style.title}>{option.title}</span>
|
||||
<span className={style.description}>{option.description}</span>
|
||||
<ParamInput paramField={option} />
|
||||
|
||||
@@ -16,7 +16,7 @@ export type MultiselectOption = { value: string; label: string; colour: string }
|
||||
type MultiOptionsField = {
|
||||
type: 'multi-option';
|
||||
values: MultiselectOption[];
|
||||
defaultValue?: string;
|
||||
defaultValue?: string[];
|
||||
};
|
||||
|
||||
type StringField = { type: 'string'; defaultValue?: string; placeholder?: string };
|
||||
|
||||
@@ -56,13 +56,18 @@ export function makeCustomFieldSelectOptions(customFields: CustomFields, filterI
|
||||
/**
|
||||
* Creates data for a select element that displays project custom data
|
||||
*/
|
||||
export function makeProjectDataOptions(projectData: ProjectData): SelectOption[] {
|
||||
return projectData.custom.map((entry, index) => {
|
||||
export function makeProjectDataOptions(
|
||||
projectData: ProjectData,
|
||||
additionalOptions: SelectOption[] = [],
|
||||
): SelectOption[] {
|
||||
const generatedOptions = projectData.custom.map((entry, index) => {
|
||||
return {
|
||||
value: `${index}-${entry.title}`,
|
||||
label: entry.title,
|
||||
};
|
||||
});
|
||||
|
||||
return [...additionalOptions, ...generatedOptions];
|
||||
}
|
||||
|
||||
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { langEn } from 'ontime-types';
|
||||
|
||||
import { getUserTranslation } from '../../common/api/assets';
|
||||
import { TRANSLATION } from '../../common/api/constants';
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
|
||||
export function useCustomTranslation() {
|
||||
const { data, status, refetch } = useQuery({
|
||||
queryKey: TRANSLATION,
|
||||
queryFn: getUserTranslation,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
});
|
||||
return { data: data ?? langEn, status, refetch };
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ProjectRundownsList } from 'ontime-types';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { PROJECT_RUNDOWNS } from '../api/constants';
|
||||
import { fetchProjectRundownList } from '../api/rundown';
|
||||
import { createRundown, deleteRundown, fetchProjectRundownList, loadRundown } from '../api/rundown';
|
||||
|
||||
/**
|
||||
* Project rundowns
|
||||
@@ -13,10 +13,43 @@ export function useProjectRundowns() {
|
||||
queryKey: PROJECT_RUNDOWNS,
|
||||
queryFn: fetchProjectRundownList,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
return { data: data ?? { loaded: '', rundowns: [] }, status, isError, refetch, isFetching };
|
||||
}
|
||||
|
||||
export function useMutateProjectRundowns() {
|
||||
const ontimeQueryClient = useQueryClient();
|
||||
|
||||
const { mutateAsync: create } = useMutation({
|
||||
mutationFn: createRundown,
|
||||
onMutate: () => {
|
||||
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: remove } = useMutation({
|
||||
mutationFn: deleteRundown,
|
||||
onMutate: () => {
|
||||
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: load } = useMutation({
|
||||
mutationFn: loadRundown,
|
||||
onMutate: () => {
|
||||
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
|
||||
},
|
||||
});
|
||||
|
||||
return { create, remove, load };
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { RUNDOWN } from '../api/constants';
|
||||
import { fetchCurrentRundown } from '../api/rundown';
|
||||
import { useSelectedEventId } from '../hooks/useSocket';
|
||||
import { getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
|
||||
|
||||
import useProjectData from './useProjectData';
|
||||
|
||||
@@ -26,14 +28,18 @@ export default function useRundown() {
|
||||
queryKey: RUNDOWN,
|
||||
queryFn: fetchCurrentRundown,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
|
||||
}
|
||||
|
||||
export function useRundownWithMetadata() {
|
||||
const { data, status } = useRundown();
|
||||
const { selectedEventId } = useSelectedEventId();
|
||||
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
|
||||
return { data, status, rundownMetadata };
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides access to a flat rundown
|
||||
* built from the order and rundown fields
|
||||
@@ -68,6 +74,14 @@ export function useFlatRundown() {
|
||||
return { data: flatRundown, rundownId: data.id, status };
|
||||
}
|
||||
|
||||
export function useFlatRundownWithMetadata() {
|
||||
const { data, status } = useRundown();
|
||||
const { selectedEventId } = useSelectedEventId();
|
||||
|
||||
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
|
||||
return { data: rundownWithMetadata, status };
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides access to a partial rundown based on a filter callback
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { unobfuscate } from 'ontime-utils';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { APP_SETTINGS } from '../api/constants';
|
||||
import { getSettings } from '../api/settings';
|
||||
import { ontimePlaceholderSettings } from '../models/OntimeSettings';
|
||||
@@ -11,10 +10,6 @@ export default function useSettings() {
|
||||
queryKey: APP_SETTINGS,
|
||||
queryFn: getSettings,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
select: (data) => {
|
||||
const unobfuscated = { ...data };
|
||||
if (data.editorKey) {
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
requestEventSwap,
|
||||
requestGroupEntries,
|
||||
requestUngroup,
|
||||
SwapEntry,
|
||||
} from '../api/rundown';
|
||||
import { logAxiosError } from '../api/utils';
|
||||
import { useEditorSettings } from '../stores/editorSettings';
|
||||
@@ -59,15 +58,25 @@ export const useEntryActions = () => {
|
||||
defaultEndAction,
|
||||
} = useEditorSettings();
|
||||
|
||||
/**
|
||||
* Returns the currently loaded rundown
|
||||
*/
|
||||
const getCurrentRundownData = useCallback(() => {
|
||||
return queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||
}, [queryClient]);
|
||||
|
||||
/**
|
||||
* Looks for an entry with a given ID in the currently loaded rundown
|
||||
*/
|
||||
const getEntryById = useCallback(
|
||||
(eventId: string): OntimeEntry | undefined => {
|
||||
const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||
(eventId: EntryId): OntimeEntry | undefined => {
|
||||
const cachedRundown = getCurrentRundownData();
|
||||
if (!cachedRundown?.entries) {
|
||||
return;
|
||||
}
|
||||
return cachedRundown.entries[eventId];
|
||||
},
|
||||
[queryClient],
|
||||
[getCurrentRundownData],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -75,8 +84,8 @@ export const useEntryActions = () => {
|
||||
* @private
|
||||
*/
|
||||
const { mutateAsync: addEntryMutation } = useMutation({
|
||||
// TODO(v4): optimistic create entry
|
||||
mutationFn: postAddEntry,
|
||||
mutationFn: ([rundownId, entry]: Parameters<typeof postAddEntry>) => postAddEntry(rundownId, entry),
|
||||
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
|
||||
});
|
||||
|
||||
@@ -85,13 +94,19 @@ export const useEntryActions = () => {
|
||||
*/
|
||||
const addEntry = useCallback(
|
||||
async (entry: Partial<OntimeEntry>, options?: EventOptions) => {
|
||||
const rundownData = getCurrentRundownData();
|
||||
const rundownId = rundownData?.id;
|
||||
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
const newEntry: TransientEventPayload = { ...entry, id: generateId() };
|
||||
|
||||
// ************* CHECK OPTIONS specific to events
|
||||
if (isOntimeEvent(newEntry)) {
|
||||
if (options?.lastEventId) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
|
||||
const rundownData = queryClient.getQueryData<Rundown>(RUNDOWN)!;
|
||||
const previousEvent = rundownData.entries[options?.lastEventId];
|
||||
if (isOntimeEvent(previousEvent)) {
|
||||
newEntry.timeStart = previousEvent.timeEnd;
|
||||
@@ -135,21 +150,21 @@ export const useEntryActions = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
await addEntryMutation(newEntry);
|
||||
await addEntryMutation([rundownId, newEntry]);
|
||||
} catch (error) {
|
||||
logAxiosError('Failed adding event', error);
|
||||
}
|
||||
},
|
||||
[
|
||||
addEntryMutation,
|
||||
defaultDangerTime,
|
||||
defaultDuration,
|
||||
defaultEndAction,
|
||||
defaultTimerType,
|
||||
defaultTimeStrategy,
|
||||
defaultWarnTime,
|
||||
getCurrentRundownData,
|
||||
linkPrevious,
|
||||
queryClient,
|
||||
defaultDuration,
|
||||
defaultDangerTime,
|
||||
defaultWarnTime,
|
||||
defaultTimerType,
|
||||
defaultEndAction,
|
||||
defaultTimeStrategy,
|
||||
addEntryMutation,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -158,22 +173,28 @@ export const useEntryActions = () => {
|
||||
* @private
|
||||
*/
|
||||
const { mutateAsync: cloneEntryMutation } = useMutation({
|
||||
mutationFn: postCloneEntry,
|
||||
mutationFn: ([rundownId, entryId]: Parameters<typeof postCloneEntry>) => postCloneEntry(rundownId, entryId),
|
||||
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Clone a selection
|
||||
* Clone an entry
|
||||
*/
|
||||
const clone = useCallback(
|
||||
async (entryId: EntryId) => {
|
||||
try {
|
||||
await cloneEntryMutation(entryId);
|
||||
const rundownId = getCurrentRundownData()?.id;
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
await cloneEntryMutation([rundownId, entryId]);
|
||||
} catch (error) {
|
||||
logAxiosError('Error cloning entry', error);
|
||||
}
|
||||
},
|
||||
[cloneEntryMutation],
|
||||
[cloneEntryMutation, getCurrentRundownData],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -181,9 +202,9 @@ export const useEntryActions = () => {
|
||||
* @private
|
||||
*/
|
||||
const { mutateAsync: updateEntryMutation } = useMutation({
|
||||
mutationFn: putEditEntry,
|
||||
mutationFn: ([rundownId, newEvent]: Parameters<typeof putEditEntry>) => putEditEntry(rundownId, newEvent),
|
||||
// we optimistically update here
|
||||
onMutate: async (newEvent) => {
|
||||
onMutate: async ([_rundownId, newEvent]) => {
|
||||
// cancel ongoing queries
|
||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||
|
||||
@@ -211,7 +232,9 @@ export const useEntryActions = () => {
|
||||
},
|
||||
// Mutation fails, rollback undoes optimist update
|
||||
onError: (_error, _newEvent, context) => {
|
||||
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
|
||||
if (context?.previousData) {
|
||||
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
|
||||
}
|
||||
},
|
||||
// Mutation finished, failed or successful
|
||||
// Fetch anyway, just to be sure
|
||||
@@ -226,19 +249,17 @@ export const useEntryActions = () => {
|
||||
const updateEntry = useCallback(
|
||||
async (entry: Partial<OntimeEntry>) => {
|
||||
try {
|
||||
await updateEntryMutation(entry);
|
||||
const rundownId = getCurrentRundownData()?.id;
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
await updateEntryMutation([rundownId, entry]);
|
||||
} catch (error) {
|
||||
logAxiosError('Error updating event', error);
|
||||
}
|
||||
},
|
||||
[updateEntryMutation],
|
||||
);
|
||||
|
||||
const updateCustomField = useCallback(
|
||||
async (entryId: EntryId, field: string, value: string) => {
|
||||
updateEntry({ id: entryId, custom: { [field]: value } });
|
||||
},
|
||||
[updateEntry],
|
||||
[getCurrentRundownData, updateEntryMutation],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -250,6 +271,11 @@ export const useEntryActions = () => {
|
||||
*/
|
||||
const updateTimer = useCallback(
|
||||
async (eventId: EntryId, field: TimeField, value: string, lockOnUpdate?: boolean) => {
|
||||
const rundownId = getCurrentRundownData()?.id;
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
// an empty value with no lock has no domain validity
|
||||
if (!lockOnUpdate && value === '') {
|
||||
return;
|
||||
@@ -279,7 +305,7 @@ export const useEntryActions = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
await updateEntryMutation(newEvent);
|
||||
await updateEntryMutation([rundownId, newEvent]);
|
||||
} catch (error) {
|
||||
logAxiosError('Error updating event', error);
|
||||
}
|
||||
@@ -331,7 +357,7 @@ export const useEntryActions = () => {
|
||||
return previousEnd;
|
||||
}
|
||||
},
|
||||
[updateEntryMutation, queryClient],
|
||||
[getCurrentRundownData, updateEntryMutation, queryClient],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -339,8 +365,8 @@ export const useEntryActions = () => {
|
||||
* @private
|
||||
*/
|
||||
const { mutateAsync: batchUpdateEventsMutation } = useMutation({
|
||||
mutationFn: putBatchEditEvents,
|
||||
onMutate: async ({ ids, data }) => {
|
||||
mutationFn: ([rundownId, data]: Parameters<typeof putBatchEditEvents>) => putBatchEditEvents(rundownId, data),
|
||||
onMutate: async ([_rundownId, data]) => {
|
||||
// cancel ongoing queries
|
||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||
|
||||
@@ -348,7 +374,7 @@ export const useEntryActions = () => {
|
||||
const previousRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||
|
||||
if (previousRundown) {
|
||||
const eventIds = new Set(ids);
|
||||
const eventIds = new Set(data.ids);
|
||||
const newRundown = { ...previousRundown.entries };
|
||||
|
||||
eventIds.forEach((eventId) => {
|
||||
@@ -395,14 +421,19 @@ export const useEntryActions = () => {
|
||||
});
|
||||
|
||||
const batchUpdateEvents = useCallback(
|
||||
async (data: Partial<OntimeEvent>, eventIds: string[]) => {
|
||||
async (data: Partial<OntimeEvent>, eventIds: EntryId[]) => {
|
||||
try {
|
||||
await batchUpdateEventsMutation({ ids: eventIds, data });
|
||||
const rundownId = getCurrentRundownData()?.id;
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
await batchUpdateEventsMutation([rundownId, { data, ids: eventIds }]);
|
||||
} catch (error) {
|
||||
logAxiosError('Error updating events', error);
|
||||
}
|
||||
},
|
||||
[batchUpdateEventsMutation],
|
||||
[batchUpdateEventsMutation, getCurrentRundownData],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -410,9 +441,9 @@ export const useEntryActions = () => {
|
||||
* @private
|
||||
*/
|
||||
const { mutateAsync: deleteEntryMutation } = useMutation({
|
||||
mutationFn: deleteEntries,
|
||||
mutationFn: ([rundownId, entryIds]: Parameters<typeof deleteEntries>) => deleteEntries(rundownId, entryIds),
|
||||
// we optimistically update here
|
||||
onMutate: async (entryIds: EntryId[]) => {
|
||||
onMutate: async ([_rundownId, entryIds]) => {
|
||||
// cancel ongoing queries
|
||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||
|
||||
@@ -454,12 +485,17 @@ export const useEntryActions = () => {
|
||||
const deleteEntry = useCallback(
|
||||
async (entryIds: EntryId[]) => {
|
||||
try {
|
||||
await deleteEntryMutation(entryIds);
|
||||
const rundownId = getCurrentRundownData()?.id;
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
await deleteEntryMutation([rundownId, entryIds]);
|
||||
} catch (error) {
|
||||
logAxiosError('Error deleting event', error);
|
||||
}
|
||||
},
|
||||
[deleteEntryMutation],
|
||||
[deleteEntryMutation, getCurrentRundownData],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -467,7 +503,7 @@ export const useEntryActions = () => {
|
||||
* @private
|
||||
*/
|
||||
const { mutateAsync: deleteAllEntriesMutation } = useMutation({
|
||||
mutationFn: requestDeleteAll,
|
||||
mutationFn: ([rundownId]: Parameters<typeof requestDeleteAll>) => requestDeleteAll(rundownId),
|
||||
// we optimistically update here
|
||||
onMutate: async () => {
|
||||
// cancel ongoing queries
|
||||
@@ -506,18 +542,24 @@ export const useEntryActions = () => {
|
||||
*/
|
||||
const deleteAllEntries = useCallback(async () => {
|
||||
try {
|
||||
await deleteAllEntriesMutation();
|
||||
const rundownId = getCurrentRundownData()?.id;
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
await deleteAllEntriesMutation([rundownId]);
|
||||
} catch (error) {
|
||||
logAxiosError('Error deleting events', error);
|
||||
}
|
||||
}, [deleteAllEntriesMutation]);
|
||||
}, [deleteAllEntriesMutation, getCurrentRundownData]);
|
||||
|
||||
/**
|
||||
* Calls mutation to apply a delay
|
||||
* @private
|
||||
*/
|
||||
const { mutateAsync: applyDelayMutation } = useMutation({
|
||||
mutationFn: requestApplyDelay,
|
||||
mutationFn: ([rundownId, delayId]: Parameters<typeof requestApplyDelay>) => requestApplyDelay(rundownId, delayId),
|
||||
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
|
||||
onSuccess: (response) => {
|
||||
if (!response.data) return;
|
||||
|
||||
@@ -543,12 +585,17 @@ export const useEntryActions = () => {
|
||||
const applyDelay = useCallback(
|
||||
async (delayEventId: EntryId) => {
|
||||
try {
|
||||
await applyDelayMutation(delayEventId);
|
||||
const rundownId = getCurrentRundownData()?.id;
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
await applyDelayMutation([rundownId, delayEventId]);
|
||||
} catch (error) {
|
||||
logAxiosError('Error applying delay', error);
|
||||
}
|
||||
},
|
||||
[applyDelayMutation],
|
||||
[applyDelayMutation, getCurrentRundownData],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -556,7 +603,8 @@ export const useEntryActions = () => {
|
||||
* @private
|
||||
*/
|
||||
const { mutateAsync: ungroupMutation } = useMutation({
|
||||
mutationFn: requestUngroup,
|
||||
mutationFn: ([rundownId, groupId]: Parameters<typeof requestUngroup>) => requestUngroup(rundownId, groupId),
|
||||
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
|
||||
onSuccess: (response) => {
|
||||
if (!response.data) return;
|
||||
|
||||
@@ -579,12 +627,17 @@ export const useEntryActions = () => {
|
||||
const ungroup = useCallback(
|
||||
async (groupId: EntryId) => {
|
||||
try {
|
||||
await ungroupMutation(groupId);
|
||||
const rundownId = getCurrentRundownData()?.id;
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
await ungroupMutation([rundownId, groupId]);
|
||||
} catch (error) {
|
||||
logAxiosError('Error dissolving group', error);
|
||||
}
|
||||
},
|
||||
[ungroupMutation],
|
||||
[getCurrentRundownData, ungroupMutation],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -592,7 +645,9 @@ export const useEntryActions = () => {
|
||||
* @private
|
||||
*/
|
||||
const { mutateAsync: groupEntriesMutation } = useMutation({
|
||||
mutationFn: requestGroupEntries,
|
||||
mutationFn: ([rundownId, entryIds]: Parameters<typeof requestGroupEntries>) =>
|
||||
requestGroupEntries(rundownId, entryIds),
|
||||
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
|
||||
onSuccess: (response) => {
|
||||
if (!response.data) return;
|
||||
|
||||
@@ -617,19 +672,24 @@ export const useEntryActions = () => {
|
||||
if (entryIds.length === 0) return;
|
||||
|
||||
try {
|
||||
const rundownData = getCurrentRundownData();
|
||||
const rundownId = rundownData?.id;
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
if (entryIds.length === 1) {
|
||||
await groupEntriesMutation(entryIds);
|
||||
await groupEntriesMutation([rundownId, entryIds]);
|
||||
} else {
|
||||
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||
if (!rundown) return;
|
||||
const orderedIds = orderEntries(entryIds, rundown.flatOrder);
|
||||
await groupEntriesMutation(orderedIds);
|
||||
// the user selection may be out of order
|
||||
const orderedIds = orderEntries(entryIds, rundownData.flatOrder);
|
||||
await groupEntriesMutation([rundownId, orderedIds]);
|
||||
}
|
||||
} catch (error) {
|
||||
logAxiosError('Error grouping entries', error);
|
||||
}
|
||||
},
|
||||
[groupEntriesMutation, queryClient],
|
||||
[getCurrentRundownData, groupEntriesMutation],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -637,9 +697,8 @@ export const useEntryActions = () => {
|
||||
* @private
|
||||
*/
|
||||
const { mutateAsync: reorderEntryMutation } = useMutation({
|
||||
mutationFn: patchReorderEntry,
|
||||
// Mutation finished, failed or successful
|
||||
// Fetch anyway, just to be sure
|
||||
mutationFn: ([rundownId, data]: Parameters<typeof patchReorderEntry>) => patchReorderEntry(rundownId, data),
|
||||
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||
},
|
||||
@@ -650,34 +709,36 @@ export const useEntryActions = () => {
|
||||
*/
|
||||
const move = useCallback(
|
||||
async (entryId: EntryId, direction: 'up' | 'down') => {
|
||||
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||
if (!rundown) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { destinationId, order } =
|
||||
direction === 'up'
|
||||
? moveUp(entryId, rundown.flatOrder, rundown.entries)
|
||||
: moveDown(entryId, rundown.flatOrder, rundown.entries);
|
||||
|
||||
if (!destinationId) {
|
||||
return; // noop
|
||||
}
|
||||
|
||||
try {
|
||||
const rundownData = getCurrentRundownData();
|
||||
const rundownId = rundownData?.id;
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
const { destinationId, order } =
|
||||
direction === 'up'
|
||||
? moveUp(entryId, rundownData.flatOrder, rundownData.entries)
|
||||
: moveDown(entryId, rundownData.flatOrder, rundownData.entries);
|
||||
|
||||
if (!destinationId) {
|
||||
return; // noop
|
||||
}
|
||||
|
||||
const reorderObject: ReorderEntry = {
|
||||
entryId,
|
||||
destinationId,
|
||||
order,
|
||||
};
|
||||
await reorderEntryMutation(reorderObject);
|
||||
await reorderEntryMutation([rundownId, reorderObject]);
|
||||
// the rundown needs to know whether we moved into a group
|
||||
return rundownData.entries[destinationId]?.type === SupportedEntry.Group ? destinationId : undefined;
|
||||
} catch (error) {
|
||||
logAxiosError('Error re-ordering event', error);
|
||||
}
|
||||
// the rundown needs to know whether we moved into a group
|
||||
return rundown.entries[destinationId]?.type === SupportedEntry.Group ? destinationId : undefined;
|
||||
return undefined;
|
||||
},
|
||||
[queryClient, reorderEntryMutation],
|
||||
[getCurrentRundownData, reorderEntryMutation],
|
||||
);
|
||||
/**
|
||||
* Reorders a given entry
|
||||
@@ -685,18 +746,25 @@ export const useEntryActions = () => {
|
||||
const reorderEntry = useCallback(
|
||||
async (entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') => {
|
||||
try {
|
||||
const reorderObject: ReorderEntry = {
|
||||
entryId,
|
||||
destinationId,
|
||||
order,
|
||||
};
|
||||
await reorderEntryMutation(reorderObject);
|
||||
const rundownId = getCurrentRundownData()?.id;
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
await reorderEntryMutation([
|
||||
rundownId,
|
||||
{
|
||||
entryId,
|
||||
destinationId,
|
||||
order,
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
logAxiosError('Error re-ordering event', error);
|
||||
throw error; // rethrow to handle in the component
|
||||
}
|
||||
},
|
||||
[reorderEntryMutation],
|
||||
[getCurrentRundownData, reorderEntryMutation],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -704,9 +772,9 @@ export const useEntryActions = () => {
|
||||
* @private
|
||||
*/
|
||||
const { mutateAsync: swapEventsMutation } = useMutation({
|
||||
mutationFn: requestEventSwap,
|
||||
mutationFn: ([rundownId, from, to]: Parameters<typeof requestEventSwap>) => requestEventSwap(rundownId, from, to),
|
||||
// we optimistically update here
|
||||
onMutate: async ({ from, to }) => {
|
||||
onMutate: async ([_rundownId, from, to]) => {
|
||||
// cancel ongoing queries
|
||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||
|
||||
@@ -755,14 +823,19 @@ export const useEntryActions = () => {
|
||||
* Swaps the schedule of two events
|
||||
*/
|
||||
const swapEvents = useCallback(
|
||||
async ({ from, to }: SwapEntry) => {
|
||||
async (from: EntryId, to: EntryId) => {
|
||||
try {
|
||||
await swapEventsMutation({ from, to });
|
||||
const rundownId = getCurrentRundownData()?.id;
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
await swapEventsMutation([rundownId, from, to]);
|
||||
} catch (error) {
|
||||
logAxiosError('Error re-ordering event', error);
|
||||
}
|
||||
},
|
||||
[swapEventsMutation],
|
||||
[getCurrentRundownData, swapEventsMutation],
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -780,7 +853,6 @@ export const useEntryActions = () => {
|
||||
swapEvents,
|
||||
updateEntry,
|
||||
updateTimer,
|
||||
updateCustomField,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ export const useFadeOutOnInactivity = (initialState = false) => {
|
||||
|
||||
const throttledShowMenu = throttle(setShowMenuTrue, 1000);
|
||||
|
||||
// we call the function on mount, to make sure the menu is hidden
|
||||
throttledShowMenu();
|
||||
|
||||
document.addEventListener('mousemove', throttledShowMenu);
|
||||
document.addEventListener('keydown', throttledShowMenu);
|
||||
|
||||
|
||||
@@ -10,7 +10,10 @@ const createSelector =
|
||||
|
||||
export const setClientRemote = {
|
||||
setIdentify: (payload: { target: string; identify: boolean }) => sendSocket('client', payload),
|
||||
setRedirect: (payload: { target: string; redirect: string }) => sendSocket('client', payload),
|
||||
setRedirect: (payload: { target: string; redirect: string }) => {
|
||||
console.log('--- got', payload);
|
||||
sendSocket('client', payload);
|
||||
},
|
||||
setClientName: (payload: { target: string; rename: string }) => sendSocket('client', payload),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { OntimeDelay, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
|
||||
|
||||
import { initRundownMetadata } from '../rundownMetadata';
|
||||
|
||||
describe('initRundownMetadata()', () => {
|
||||
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,
|
||||
group: {
|
||||
id: 'group',
|
||||
type: SupportedEntry.Group,
|
||||
entries: ['11', 'delay', '12', '13'],
|
||||
colour: 'red',
|
||||
} as OntimeGroup,
|
||||
'11': {
|
||||
id: '11',
|
||||
type: SupportedEntry.Event,
|
||||
parent: 'group',
|
||||
timeStart: 10,
|
||||
timeEnd: 11,
|
||||
duration: 1,
|
||||
dayOffset: 0,
|
||||
gap: 10,
|
||||
skip: false,
|
||||
linkStart: false,
|
||||
} as OntimeEvent,
|
||||
delay: {
|
||||
id: 'delay',
|
||||
type: SupportedEntry.Delay,
|
||||
parent: 'group',
|
||||
duration: 0,
|
||||
} as OntimeDelay,
|
||||
'12': {
|
||||
id: '12',
|
||||
type: SupportedEntry.Event,
|
||||
parent: 'group',
|
||||
timeStart: 11,
|
||||
timeEnd: 12,
|
||||
duration: 1,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
skip: false,
|
||||
linkStart: true,
|
||||
} as OntimeEvent,
|
||||
'13': {
|
||||
id: '13',
|
||||
type: SupportedEntry.Event,
|
||||
parent: 'group',
|
||||
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 } = initRundownMetadata(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,
|
||||
groupEntries: undefined,
|
||||
isFirstAfterGroup: false,
|
||||
});
|
||||
|
||||
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,
|
||||
groupEntries: undefined,
|
||||
isFirstAfterGroup: false,
|
||||
});
|
||||
|
||||
expect(process(demoEvents['group'])).toMatchObject({
|
||||
previousEvent: demoEvents['1'],
|
||||
latestEvent: demoEvents['1'],
|
||||
previousEntryId: demoEvents['1'].id,
|
||||
thisId: demoEvents['group'].id,
|
||||
eventIndex: 1,
|
||||
isPast: true,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: 'group',
|
||||
groupColour: 'red',
|
||||
isFirstAfterGroup: false,
|
||||
});
|
||||
|
||||
expect(process(demoEvents['11'])).toMatchObject({
|
||||
previousEvent: demoEvents['1'],
|
||||
latestEvent: demoEvents['11'],
|
||||
previousEntryId: demoEvents['group'].id,
|
||||
thisId: demoEvents['11'].id,
|
||||
eventIndex: 2,
|
||||
isPast: true,
|
||||
isNextDay: false,
|
||||
totalGap: 10,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: 'group',
|
||||
groupColour: 'red',
|
||||
isFirstAfterGroup: false,
|
||||
});
|
||||
|
||||
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: 'group',
|
||||
groupColour: 'red',
|
||||
isFirstAfterGroup: false,
|
||||
});
|
||||
|
||||
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: true,
|
||||
isLoaded: true,
|
||||
groupId: 'group',
|
||||
groupColour: 'red',
|
||||
isFirstAfterGroup: false,
|
||||
});
|
||||
|
||||
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: 'group',
|
||||
groupColour: 'red',
|
||||
isFirstAfterGroup: false,
|
||||
});
|
||||
|
||||
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,
|
||||
isFirstAfterGroup: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('populates previousEntries in groups', () => {
|
||||
const rundownStartsWithGroup = {
|
||||
group: {
|
||||
id: 'group',
|
||||
type: SupportedEntry.Group,
|
||||
colour: 'red',
|
||||
entries: ['1', '2'],
|
||||
} as OntimeGroup,
|
||||
'1': {
|
||||
id: '1',
|
||||
type: SupportedEntry.Event,
|
||||
parent: 'group',
|
||||
timeStart: 1,
|
||||
timeEnd: 2,
|
||||
duration: 1,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
skip: false,
|
||||
linkStart: false,
|
||||
} as OntimeEvent,
|
||||
'2': {
|
||||
id: '2',
|
||||
type: SupportedEntry.Event,
|
||||
parent: 'group',
|
||||
timeStart: 2,
|
||||
timeEnd: 3,
|
||||
duration: 1,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
skip: false,
|
||||
linkStart: false,
|
||||
} as OntimeEvent,
|
||||
};
|
||||
const { process } = initRundownMetadata(null);
|
||||
|
||||
expect(process(rundownStartsWithGroup.group)).toStrictEqual({
|
||||
previousEvent: null,
|
||||
latestEvent: null,
|
||||
previousEntryId: null,
|
||||
thisId: rundownStartsWithGroup.group.id,
|
||||
eventIndex: 0,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: rundownStartsWithGroup.group.id,
|
||||
groupColour: 'red',
|
||||
groupEntries: 2,
|
||||
isFirstAfterGroup: false,
|
||||
});
|
||||
|
||||
expect(process(rundownStartsWithGroup['1'])).toStrictEqual({
|
||||
previousEvent: null,
|
||||
latestEvent: rundownStartsWithGroup['1'],
|
||||
previousEntryId: rundownStartsWithGroup.group.id,
|
||||
thisId: rundownStartsWithGroup['1'].id,
|
||||
eventIndex: 1,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: rundownStartsWithGroup.group.id,
|
||||
groupColour: 'red',
|
||||
groupEntries: 2,
|
||||
isFirstAfterGroup: false,
|
||||
});
|
||||
|
||||
expect(process(rundownStartsWithGroup['2'])).toStrictEqual({
|
||||
previousEvent: rundownStartsWithGroup['1'],
|
||||
latestEvent: rundownStartsWithGroup['2'],
|
||||
previousEntryId: rundownStartsWithGroup['1'].id,
|
||||
thisId: rundownStartsWithGroup['2'].id,
|
||||
eventIndex: 2,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: rundownStartsWithGroup.group.id,
|
||||
groupColour: 'red',
|
||||
groupEntries: 2,
|
||||
isFirstAfterGroup: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
isOntimeEvent,
|
||||
isOntimeGroup,
|
||||
isPlayableEvent,
|
||||
MaybeString,
|
||||
OntimeDelay,
|
||||
OntimeEntry,
|
||||
OntimeEvent,
|
||||
OntimeMilestone,
|
||||
PlayableEvent,
|
||||
Rundown,
|
||||
} from 'ontime-types';
|
||||
import { checkIsNextDay, isNewLatest } from 'ontime-utils';
|
||||
|
||||
export 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;
|
||||
groupEntries: number | undefined;
|
||||
isFirstAfterGroup: boolean;
|
||||
};
|
||||
|
||||
export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T & RundownMetadata;
|
||||
|
||||
export const lastMetadataKey = 'LAST';
|
||||
|
||||
export type RundownMetadataObject = Record<string, Readonly<RundownMetadata>>;
|
||||
|
||||
/**
|
||||
* Generates a Rundown Metadata object from a rundown
|
||||
*/
|
||||
export function getRundownMetadata(
|
||||
data: Pick<Rundown, 'entries' | 'flatOrder'>,
|
||||
selectedEventId: MaybeString,
|
||||
): RundownMetadataObject {
|
||||
const { metadata, process } = initRundownMetadata(selectedEventId);
|
||||
// keep a single reference to the metadata which we override for every entry
|
||||
let lastSnapshot = metadata;
|
||||
const rundownMetadata: RundownMetadataObject = {};
|
||||
|
||||
for (const id of data.flatOrder) {
|
||||
const entry = data.entries[id];
|
||||
lastSnapshot = process(entry);
|
||||
rundownMetadata[id] = lastSnapshot;
|
||||
}
|
||||
|
||||
// ensure some blank data even for empty rundowns
|
||||
rundownMetadata[lastMetadataKey] = lastSnapshot;
|
||||
|
||||
return rundownMetadata;
|
||||
}
|
||||
|
||||
export function getFlatRundownMetadata(
|
||||
data: Pick<Rundown, 'entries' | 'flatOrder'>,
|
||||
selectedEventId: MaybeString,
|
||||
): ExtendedEntry[] {
|
||||
const { process } = initRundownMetadata(selectedEventId);
|
||||
const flatRundown: ExtendedEntry[] = [];
|
||||
|
||||
for (const id of data.flatOrder) {
|
||||
const entry = data.entries[id];
|
||||
const extendedEntry = { ...entry, ...process(entry) };
|
||||
flatRundown.push(extendedEntry);
|
||||
}
|
||||
|
||||
return flatRundown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a process function which aggregates the rundown metadata and event metadata
|
||||
*/
|
||||
export function initRundownMetadata(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,
|
||||
groupEntries: undefined,
|
||||
isFirstAfterGroup: false,
|
||||
};
|
||||
|
||||
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;
|
||||
processedData.isLinkedToLoaded = true;
|
||||
}
|
||||
|
||||
if (isOntimeGroup(entry)) {
|
||||
processedData.groupId = entry.id;
|
||||
processedData.groupColour = entry.colour;
|
||||
processedData.groupEntries = entry.entries.length;
|
||||
} else {
|
||||
// for delays and groups, we insert the group metadata
|
||||
if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent !== processedData.groupId) {
|
||||
// if the parent is not the current group, we need to update the groupId
|
||||
processedData.groupId = (entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent;
|
||||
processedData.groupEntries = undefined;
|
||||
if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).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;
|
||||
processedData.isFirstAfterGroup = Boolean(processedData.previousEvent?.parent) && entry.parent === null;
|
||||
|
||||
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 && processedData.isLinkedToLoaded;
|
||||
}
|
||||
|
||||
if (isNewLatest(entry, processedData.latestEvent)) {
|
||||
// this event is the forward most event in rundown, for next iteration
|
||||
processedData.latestEvent = entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return processedData;
|
||||
}
|
||||
@@ -11,12 +11,14 @@ import {
|
||||
|
||||
import { isProduction, websocketUrl } from '../../externals';
|
||||
import {
|
||||
APP_SETTINGS,
|
||||
CLIENT_LIST,
|
||||
CUSTOM_FIELDS,
|
||||
PROJECT_DATA,
|
||||
REPORT,
|
||||
RUNDOWN,
|
||||
RUNTIME,
|
||||
TRANSLATION,
|
||||
URL_PRESETS,
|
||||
VIEW_SETTINGS,
|
||||
} from '../api/constants';
|
||||
@@ -173,6 +175,12 @@ export const connectSocket = () => {
|
||||
case RefetchKey.ViewSettings:
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: VIEW_SETTINGS });
|
||||
break;
|
||||
case RefetchKey.Translation:
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: TRANSLATION });
|
||||
break;
|
||||
case RefetchKey.Settings:
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: APP_SETTINGS });
|
||||
break;
|
||||
default: {
|
||||
target satisfies never;
|
||||
break;
|
||||
|
||||
@@ -12,12 +12,12 @@ interface PanelContentProps {
|
||||
export default function PanelContent({ onClose, children }: PropsWithChildren<PanelContentProps>) {
|
||||
return (
|
||||
<div className={style.contentWrapper}>
|
||||
<div className={style.content}>{children}</div>
|
||||
<div className={style.corner}>
|
||||
<Button size='large' onClick={onClose}>
|
||||
Close settings <IoClose />
|
||||
</Button>
|
||||
</div>
|
||||
<div className={style.content}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@ $inner-padding: 1rem;
|
||||
th,
|
||||
td {
|
||||
padding: 0.5rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
|
||||
@@ -6,14 +6,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fieldForm {
|
||||
padding: 1rem;
|
||||
background-color: $gray-1350;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.twoCols {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import { useMutateProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
type NewRundownFormState = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
interface ManageRundownForm {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ManageRundownForm({ onClose }: ManageRundownForm) {
|
||||
const { create } = useMutateProjectRundowns();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
setFocus,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<NewRundownFormState>({
|
||||
defaultValues: { title: '' },
|
||||
});
|
||||
|
||||
const createRundown = async (values: NewRundownFormState) => {
|
||||
try {
|
||||
await create(values.title || 'untitled');
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setError('root', { message: `Failed to create rundown. ${error}` });
|
||||
}
|
||||
};
|
||||
|
||||
// give initial focus to the title field
|
||||
useEffect(() => {
|
||||
setFocus('title');
|
||||
}, [setFocus]);
|
||||
|
||||
return (
|
||||
<Panel.Indent as='form' onSubmit={handleSubmit(createRundown)}>
|
||||
<Panel.Section>
|
||||
<label>
|
||||
<Panel.Description>Rundown title</Panel.Description>
|
||||
<Input {...register('title')} fluid placeholder='Your rundown name' />
|
||||
</label>
|
||||
</Panel.Section>
|
||||
<Panel.InlineElements relation='inner' align='end'>
|
||||
<Button variant='ghosted' disabled={isSubmitting} onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' variant='primary' disabled={isSubmitting}>
|
||||
Create rundown
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
</Panel.Indent>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,59 @@
|
||||
import { useState } from 'react';
|
||||
import { IoAdd } from 'react-icons/io5';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Dialog from '../../../../common/components/dialog/Dialog';
|
||||
import { useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import { ManageRundownForm } from './ManageRundownForm';
|
||||
|
||||
import style from './ManagePanel.module.scss';
|
||||
|
||||
export default function ManageRundowns() {
|
||||
const { data } = useProjectRundowns();
|
||||
const [deleteOpen, deleteHandlers] = useDisclosure();
|
||||
const [loadOpen, loadHandlers] = useDisclosure();
|
||||
const { remove, load } = useMutateProjectRundowns();
|
||||
const [isOpenDelete, deleteHandlers] = useDisclosure();
|
||||
const [isOpenLoad, loadHandlers] = useDisclosure();
|
||||
const [isNewLoad, newHandlers] = useDisclosure();
|
||||
const [targetRundown, setTargetRundown] = useState('');
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const openLoad = (id: string) => {
|
||||
setActionError(null);
|
||||
setTargetRundown(id);
|
||||
loadHandlers.open();
|
||||
};
|
||||
|
||||
const openDelete = (id: string) => {
|
||||
setActionError(null);
|
||||
setTargetRundown(id);
|
||||
deleteHandlers.open();
|
||||
};
|
||||
|
||||
const submitRundownLoad = async () => {
|
||||
try {
|
||||
await load(targetRundown);
|
||||
} catch (error) {
|
||||
setActionError(`Failed to load rundown. ${maybeAxiosError(error)}`);
|
||||
} finally {
|
||||
loadHandlers.close();
|
||||
}
|
||||
};
|
||||
|
||||
const submitRundownDelete = async () => {
|
||||
try {
|
||||
await remove(targetRundown);
|
||||
} catch (error) {
|
||||
setActionError(`Failed to delete rundown. ${maybeAxiosError(error)}`);
|
||||
} finally {
|
||||
deleteHandlers.close();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -21,49 +62,60 @@ export default function ManageRundowns() {
|
||||
<Panel.SubHeader>
|
||||
Manage project rundowns
|
||||
<Panel.InlineElements>
|
||||
<Button onClick={() => undefined} disabled>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setActionError(null);
|
||||
newHandlers.open();
|
||||
}}
|
||||
>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th># Entries</th>
|
||||
<th style={{ width: '100%' }}>Title</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.rundowns.map((rundown) => {
|
||||
const isLoaded = data.loaded === rundown.id;
|
||||
return (
|
||||
<tr key={rundown.id} className={cx([isLoaded && style.current])}>
|
||||
<td>{rundown.numEntries}</td>
|
||||
<td>{`${rundown.title}${isLoaded && ' (loaded)'}`}</td>
|
||||
<Panel.InlineElements as='td'>
|
||||
<Button size='small' onClick={() => loadHandlers.open()} disabled={isLoaded}>
|
||||
Load
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
variant='subtle-destructive'
|
||||
onClick={() => deleteHandlers.open()}
|
||||
disabled={isLoaded}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
<Panel.Section>
|
||||
{isNewLoad && <ManageRundownForm onClose={newHandlers.close} />}
|
||||
{actionError && <Panel.Error>{actionError}</Panel.Error>}
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th># Entries</th>
|
||||
<th style={{ width: '100%' }}>Title</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.rundowns?.map(({ id, numEntries, title }) => {
|
||||
const isLoaded = data.loaded === id;
|
||||
return (
|
||||
<tr key={id} className={cx([isLoaded && style.current])}>
|
||||
<td>{numEntries}</td>
|
||||
<td>
|
||||
{title} {isLoaded && <Tag>Loaded</Tag>}
|
||||
</td>
|
||||
<Panel.InlineElements as='td'>
|
||||
<Button size='small' onClick={() => openLoad(id)} disabled={isLoaded}>
|
||||
Load
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
variant='subtle-destructive'
|
||||
onClick={() => openDelete(id)}
|
||||
disabled={isLoaded}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
<Dialog
|
||||
isOpen={deleteOpen}
|
||||
isOpen={isOpenDelete}
|
||||
onClose={deleteHandlers.close}
|
||||
title='Load rundown'
|
||||
showBackdrop
|
||||
@@ -78,16 +130,16 @@ export default function ManageRundowns() {
|
||||
<Button size='large' onClick={deleteHandlers.close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='destructive' size='large' onClick={() => undefined}>
|
||||
<Button variant='destructive' size='large' onClick={submitRundownDelete}>
|
||||
Delete rundown
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Dialog
|
||||
isOpen={loadOpen}
|
||||
isOpen={isOpenLoad}
|
||||
onClose={loadHandlers.close}
|
||||
title='Delete rundown'
|
||||
title='Load rundown'
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
bodyElements={
|
||||
@@ -100,7 +152,7 @@ export default function ManageRundowns() {
|
||||
<Button size='large' onClick={loadHandlers.close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='primary' size='large' onClick={() => undefined}>
|
||||
<Button variant='primary' size='large' onClick={submitRundownLoad}>
|
||||
Load rundown
|
||||
</Button>
|
||||
</>
|
||||
|
||||
+11
-14
@@ -83,11 +83,7 @@ export default function CustomFieldForm({
|
||||
const isEditMode = initialKey !== undefined;
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(setupSubmit)}
|
||||
className={style.fieldForm}
|
||||
onKeyDown={(event) => preventEscape(event, onCancel)}
|
||||
>
|
||||
<Panel.Indent as='form' onSubmit={handleSubmit(setupSubmit)} onKeyDown={(event) => preventEscape(event, onCancel)}>
|
||||
<Info>
|
||||
Please note that images can quickly deteriorate your app's performance.
|
||||
<br />
|
||||
@@ -107,7 +103,7 @@ export default function CustomFieldForm({
|
||||
/>
|
||||
</div>
|
||||
<div className={style.twoCols}>
|
||||
<div>
|
||||
<label>
|
||||
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
|
||||
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
|
||||
<Input
|
||||
@@ -116,7 +112,8 @@ export default function CustomFieldForm({
|
||||
onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'),
|
||||
validate: (value) => {
|
||||
if (value.trim().length === 0) return 'Required field';
|
||||
if (!checkRegex.isAlphanumericWithSpace(value)) return 'Only alphanumeric characters and space are allowed';
|
||||
if (!checkRegex.isAlphanumericWithSpace(value))
|
||||
return 'Only alphanumeric characters and space are allowed';
|
||||
if (!isEditMode) {
|
||||
if (isEditMode && Object.keys(data).includes(value)) return 'Custom fields must be unique';
|
||||
}
|
||||
@@ -125,17 +122,17 @@ export default function CustomFieldForm({
|
||||
})}
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label>
|
||||
<Panel.Description>Key (use in Integrations and API)</Panel.Description>
|
||||
<Input {...register('key')} readOnly fluid />
|
||||
</div>
|
||||
<Input {...register('key')} variant='ghosted' readOnly fluid />
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
<Panel.Description>Colour</Panel.Description>
|
||||
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
|
||||
</div>
|
||||
</label>
|
||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Panel.InlineElements relation='inner' align='end'>
|
||||
<Button variant='ghosted' onClick={onCancel}>
|
||||
@@ -145,6 +142,6 @@ export default function CustomFieldForm({
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</form>
|
||||
</Panel.Indent>
|
||||
);
|
||||
}
|
||||
|
||||
+1
@@ -12,6 +12,7 @@ tr .secondaryRow {
|
||||
}
|
||||
|
||||
.linkStartActive {
|
||||
flex-shrink: 0;
|
||||
color: $active-indicator;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
+76
-12
@@ -1,6 +1,6 @@
|
||||
import { Fragment } from 'react';
|
||||
import { IoLink } from 'react-icons/io5';
|
||||
import { CustomFields, isOntimeEvent, isOntimeGroup, Rundown } from 'ontime-types';
|
||||
import { CustomFields, isOntimeEvent, isOntimeGroup, isOntimeMilestone, Rundown } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import Tag from '../../../../../../common/components/tag/Tag';
|
||||
@@ -53,9 +53,10 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rundown.order.map((entryId) => {
|
||||
{rundown.flatOrder.map((entryId) => {
|
||||
const entry = rundown.entries[entryId];
|
||||
if (isOntimeGroup(entry)) {
|
||||
const colour = entry.colour ? getAccessibleColour(entry.colour) : {};
|
||||
return (
|
||||
<tr key={entry.id}>
|
||||
<td className={style.center}>
|
||||
@@ -64,11 +65,75 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
||||
<td className={style.center}>
|
||||
<Tag>{entry.type}</Tag>
|
||||
</td>
|
||||
<td />
|
||||
<td colSpan={99}>{entry.title}</td>
|
||||
<td /> {/** CUE */}
|
||||
<td>{entry.title}</td>
|
||||
<td /> {/** Flag */}
|
||||
<td /> {/** Time Start */}
|
||||
<td /> {/** Time End */}
|
||||
<td /> {/** Duration */}
|
||||
<td /> {/** Warning Time */}
|
||||
<td /> {/** Danger Time */}
|
||||
<td /> {/** Count to end */}
|
||||
<td /> {/** Skip */}
|
||||
<td style={{ ...colour }}>{entry.colour}</td>
|
||||
<td /> {/** Timer Type */}
|
||||
<td /> {/** End Action */}
|
||||
{fieldKeys.map((field) => {
|
||||
let value = '';
|
||||
if (field in entry.custom) {
|
||||
value = entry.custom[field];
|
||||
}
|
||||
return <td key={field}>{value}</td>;
|
||||
})}
|
||||
<td className={style.center}>
|
||||
<Tag>{entry.id}</Tag>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
if (isOntimeMilestone(entry)) {
|
||||
const colour = entry.colour ? getAccessibleColour(entry.colour) : {};
|
||||
return (
|
||||
<Fragment key={entry.id}>
|
||||
<tr>
|
||||
<td /> {/** Index */}
|
||||
<td className={style.center}>
|
||||
<Tag>{entry.type}</Tag>
|
||||
</td>
|
||||
<td className={style.nowrap}>{entry.cue}</td>
|
||||
<td>{entry.title}</td>
|
||||
<td /> {/** Flag */}
|
||||
<td /> {/** Time Start */}
|
||||
<td /> {/** Time End */}
|
||||
<td /> {/** Duration */}
|
||||
<td /> {/** Warning Time */}
|
||||
<td /> {/** Danger Time */}
|
||||
<td /> {/** Count to end */}
|
||||
<td /> {/** Skip */}
|
||||
<td style={{ ...colour }}>{entry.colour}</td>
|
||||
<td /> {/** Timer Type */}
|
||||
<td /> {/** End Action */}
|
||||
{fieldKeys.map((field) => {
|
||||
let value = '';
|
||||
if (field in entry.custom) {
|
||||
value = entry.custom[field];
|
||||
}
|
||||
return <td key={field}>{value}</td>;
|
||||
})}
|
||||
<td className={style.center}>
|
||||
<Tag>{entry.id}</Tag>
|
||||
</td>
|
||||
</tr>
|
||||
{entry.note && (
|
||||
<tr>
|
||||
<td colSpan={99} className={style.secondaryRow}>
|
||||
Note: {entry.note}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
if (!isOntimeEvent(entry)) {
|
||||
return null;
|
||||
}
|
||||
@@ -107,14 +172,13 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
||||
<td className={style.center}>
|
||||
<Tag>{entry.endAction}</Tag>
|
||||
</td>
|
||||
{isOntimeEvent(entry) &&
|
||||
fieldKeys.map((field) => {
|
||||
let value = '';
|
||||
if (field in entry.custom) {
|
||||
value = entry.custom[field];
|
||||
}
|
||||
return <td key={field}>{value}</td>;
|
||||
})}
|
||||
{fieldKeys.map((field) => {
|
||||
let value = '';
|
||||
if (field in entry.custom) {
|
||||
value = entry.custom[field];
|
||||
}
|
||||
return <td key={field}>{value}</td>;
|
||||
})}
|
||||
<td className={style.center}>
|
||||
<Tag>{entry.id}</Tag>
|
||||
</td>
|
||||
|
||||
+5
-1
@@ -14,4 +14,8 @@
|
||||
.copiable {
|
||||
cursor: text ;
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
|
||||
.self {
|
||||
background-color: $blue-1100;
|
||||
}
|
||||
|
||||
+3
-2
@@ -8,6 +8,7 @@ import { RenameClientModal } from '../../../../../common/components/client-modal
|
||||
import Tag from '../../../../../common/components/tag/Tag';
|
||||
import { setClientRemote } from '../../../../../common/hooks/useSocket';
|
||||
import { useClientStore } from '../../../../../common/stores/clientStore';
|
||||
import { cx } from '../../../../../common/utils/styleUtils';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './ClientControlPanel.module.scss';
|
||||
@@ -71,13 +72,13 @@ export default function ClientList() {
|
||||
const { identify, name, path } = client;
|
||||
const isCurrent = id === key;
|
||||
return (
|
||||
<tr key={key}>
|
||||
<tr key={key} className={cx([isCurrent && style.self])}>
|
||||
<Panel.InlineElements relation='inner' as='td'>
|
||||
{isCurrent && <Tag>SELF</Tag>}
|
||||
{name}
|
||||
</Panel.InlineElements>
|
||||
<td className={style.copiable}>{path}</td>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Panel.InlineElements relation='inner' as='td'>
|
||||
<Button
|
||||
size='small'
|
||||
className={`${identify ? style.blink : ''}`}
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function ProjectCreateForm({ onClose }: ProjectCreateFromProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Section
|
||||
<Panel.Indent
|
||||
as='form'
|
||||
onSubmit={handleSubmit(handleSubmitCreate)}
|
||||
onKeyDown={(event) => preventEscape(event, onClose)}
|
||||
@@ -76,11 +76,9 @@ export default function ProjectCreateForm({ onClose }: ProjectCreateFromProps) {
|
||||
</Panel.Title>
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
<Panel.Section className={style.innerColumn}>
|
||||
<label>
|
||||
Project title
|
||||
<Input fluid placeholder='Your project name' {...register('title')} />
|
||||
</label>
|
||||
<Panel.Description>Project title</Panel.Description>
|
||||
<Input fluid placeholder='Your project name' {...register('title')} />
|
||||
</Panel.Section>
|
||||
</Panel.Section>
|
||||
</Panel.Indent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { lazy, useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { Settings } from 'ontime-types';
|
||||
|
||||
import { postSettings } from '../../../../common/api/settings';
|
||||
@@ -15,6 +16,8 @@ import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import GeneralPinInput from './composite/GeneralPinInput';
|
||||
|
||||
const TranslationModal = lazy(() => import('./composite/CustomTranslationModal'));
|
||||
|
||||
export default function GeneralSettings() {
|
||||
const { data, status, refetch } = useSettings();
|
||||
const {
|
||||
@@ -34,6 +37,8 @@ export default function GeneralSettings() {
|
||||
},
|
||||
});
|
||||
|
||||
const [isOpen, handler] = useDisclosure();
|
||||
|
||||
// update form if we get new data from server
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
@@ -63,112 +68,124 @@ export default function GeneralSettings() {
|
||||
const isLoading = status === 'pending';
|
||||
|
||||
return (
|
||||
<Panel.Section
|
||||
as='form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
onKeyDown={(event) => preventEscape(event, onReset)}
|
||||
id='app-settings'
|
||||
>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
General settings
|
||||
<Panel.InlineElements>
|
||||
<Button disabled={!isDirty || isSubmitting} variant='ghosted' onClick={onReset}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button type='submit' form='app-settings' loading={isSubmitting} disabled={disableSubmit} variant='primary'>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
{submitError && <Panel.Error>{submitError}</Panel.Error>}
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={isLoading} />
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Ontime server port'
|
||||
description={
|
||||
isOntimeCloud
|
||||
? 'Server port disabled for Ontime Cloud'
|
||||
: 'Port ontime server listens in. Defaults to 4001 (needs app restart)'
|
||||
}
|
||||
error={errors.serverPort?.message}
|
||||
/>
|
||||
<Input
|
||||
id='serverPort'
|
||||
type='number'
|
||||
maxLength={5}
|
||||
style={{ width: '75px' }}
|
||||
disabled={isOntimeCloud}
|
||||
{...register('serverPort', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
|
||||
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
|
||||
pattern: {
|
||||
value: isOnlyNumbers,
|
||||
message: 'Value should be numeric',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Editor pin code'
|
||||
description='Protect the editor view with a pin code'
|
||||
error={errors.editorKey?.message}
|
||||
/>
|
||||
<GeneralPinInput register={register} formName='editorKey' disabled={disableInputs} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Operator pin code'
|
||||
description='Protect the operator and cuesheet views with a pin code'
|
||||
error={errors.operatorKey?.message}
|
||||
/>
|
||||
<GeneralPinInput register={register} formName='operatorKey' disabled={disableInputs} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Time format'
|
||||
description='Default time format to show in views 12 /24 hours'
|
||||
error={errors.timeFormat?.message}
|
||||
/>
|
||||
<Select
|
||||
value={watch('timeFormat')}
|
||||
onValueChange={(value) => setValue('timeFormat', value as '12' | '24', { shouldDirty: true })}
|
||||
defaultValue='24'
|
||||
options={[
|
||||
{ value: '12', label: '12 hours 11:00:10 PM' },
|
||||
{ value: '24', label: '24 hours 23:00:10' },
|
||||
]}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Views language'
|
||||
description='Language to be displayed in views'
|
||||
error={errors.language?.message}
|
||||
/>
|
||||
<Select
|
||||
value={watch('language')}
|
||||
onValueChange={(value) => setValue('language', value, { shouldDirty: true })}
|
||||
disabled={disableInputs}
|
||||
defaultValue='en'
|
||||
options={[
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'French' },
|
||||
{ value: 'de', label: 'German' },
|
||||
{ value: 'it', label: 'Italian' },
|
||||
{ value: 'pt', label: 'Portuguese' },
|
||||
{ value: 'es', label: 'Spanish' },
|
||||
]}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
<>
|
||||
<TranslationModal isOpen={isOpen} onClose={handler.close} />
|
||||
<Panel.Section
|
||||
as='form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
onKeyDown={(event) => preventEscape(event, onReset)}
|
||||
id='app-settings'
|
||||
>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
General settings
|
||||
<Panel.InlineElements>
|
||||
<Button disabled={!isDirty || isSubmitting} variant='ghosted' onClick={onReset}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
form='app-settings'
|
||||
name='general-settings-submit'
|
||||
loading={isSubmitting}
|
||||
disabled={disableSubmit}
|
||||
variant='primary'
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
{submitError && <Panel.Error>{submitError}</Panel.Error>}
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={isLoading} />
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Ontime server port'
|
||||
description={
|
||||
isOntimeCloud
|
||||
? 'Server port disabled for Ontime Cloud'
|
||||
: 'Port ontime server listens in. Defaults to 4001 (needs app restart)'
|
||||
}
|
||||
error={errors.serverPort?.message}
|
||||
/>
|
||||
<Input
|
||||
id='serverPort'
|
||||
type='number'
|
||||
maxLength={5}
|
||||
style={{ width: '75px' }}
|
||||
disabled={isOntimeCloud}
|
||||
{...register('serverPort', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
|
||||
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
|
||||
pattern: {
|
||||
value: isOnlyNumbers,
|
||||
message: 'Value should be numeric',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Editor pin code'
|
||||
description='Protect the editor view with a pin code'
|
||||
error={errors.editorKey?.message}
|
||||
/>
|
||||
<GeneralPinInput register={register} formName='editorKey' disabled={disableInputs} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Operator pin code'
|
||||
description='Protect the operator and cuesheet views with a pin code'
|
||||
error={errors.operatorKey?.message}
|
||||
/>
|
||||
<GeneralPinInput register={register} formName='operatorKey' disabled={disableInputs} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Time format'
|
||||
description='Default time format to show in views 12 /24 hours'
|
||||
error={errors.timeFormat?.message}
|
||||
/>
|
||||
<Select
|
||||
value={watch('timeFormat')}
|
||||
onValueChange={(value) => setValue('timeFormat', value as '12' | '24', { shouldDirty: true })}
|
||||
defaultValue='24'
|
||||
options={[
|
||||
{ value: '12', label: '12 hours 11:00:10 PM' },
|
||||
{ value: '24', label: '24 hours 23:00:10' },
|
||||
]}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Views language'
|
||||
description='Language to be displayed in views'
|
||||
error={errors.language?.message}
|
||||
/>
|
||||
<Select
|
||||
value={watch('language')}
|
||||
onValueChange={(value) => setValue('language', value, { shouldDirty: true })}
|
||||
disabled={disableInputs}
|
||||
defaultValue='en'
|
||||
options={[
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'French' },
|
||||
{ value: 'de', label: 'German' },
|
||||
{ value: 'it', label: 'Italian' },
|
||||
{ value: 'pt', label: 'Portuguese' },
|
||||
{ value: 'es', label: 'Spanish' },
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
]}
|
||||
/>
|
||||
<Button onClick={handler.open}>Edit custom translation</Button>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { langEn, TranslationObject } from 'ontime-types';
|
||||
|
||||
import { maybeAxiosError } from '../../../../../common/api/utils';
|
||||
import Button from '../../../../../common/components/buttons/Button';
|
||||
import Info from '../../../../../common/components/info/Info';
|
||||
import Input from '../../../../../common/components/input/input/Input';
|
||||
import Modal from '../../../../../common/components/modal/Modal';
|
||||
import { useTranslation } from '../../../../../translation/TranslationProvider';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
interface CustomTranslationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function CustomTranslationModal({ isOpen, onClose }: CustomTranslationModalProps) {
|
||||
const { userTranslation, postUserTranslation } = useTranslation();
|
||||
|
||||
const defaultValues = useMemo(() => {
|
||||
const values: Record<string, string> = {};
|
||||
Object.keys(langEn).forEach((key) => {
|
||||
values[toFormKey(key)] = userTranslation[key as keyof TranslationObject] || '';
|
||||
});
|
||||
return values;
|
||||
}, [userTranslation]);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
formState: { isSubmitting, isDirty, errors, isValid },
|
||||
setError,
|
||||
} = useForm({
|
||||
defaultValues,
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
mode: 'onChange',
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: Record<string, string>) => {
|
||||
try {
|
||||
const translationData: Record<string, string> = {};
|
||||
Object.keys(formData).forEach((key) => {
|
||||
translationData[toApiKey(key)] = formData[key];
|
||||
});
|
||||
|
||||
await postUserTranslation(translationData as TranslationObject);
|
||||
reset(formData);
|
||||
} catch (error) {
|
||||
setError('root', { message: maybeAxiosError(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title='Edit custom translations'
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
showCloseButton
|
||||
showBackdrop
|
||||
bodyElements={
|
||||
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} id='custom-translations-form'>
|
||||
<Info>
|
||||
Provide custom translations for the public views of Ontime. <br />
|
||||
You will need to activate this in the settings by selecting "Custom" as the views language.
|
||||
</Info>
|
||||
<Panel.ListGroup>
|
||||
{Object.entries(langEn).map(([key, value]) => (
|
||||
<Panel.ListItem key={key}>
|
||||
<Panel.Field title={value} description='' error={errors[toFormKey(key)]?.message} />
|
||||
<Input
|
||||
maxLength={150}
|
||||
{...register(toFormKey(key), {
|
||||
required: 'This field is required',
|
||||
})}
|
||||
placeholder={value}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
))}
|
||||
</Panel.ListGroup>
|
||||
</Panel.Section>
|
||||
}
|
||||
footerElements={
|
||||
<div>
|
||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Panel.InlineElements align='apart'>
|
||||
<Panel.InlineElements>
|
||||
<Button size='large' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
size='large'
|
||||
type='submit'
|
||||
form='custom-translations-form'
|
||||
disabled={isSubmitting || !isDirty || !isValid}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function toFormKey(key: string) {
|
||||
return key.replace('.', '_');
|
||||
}
|
||||
|
||||
function toApiKey(key: string) {
|
||||
return key.replace('_', '.');
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { isStringBoolean } from '../viewers/common/viewUtils';
|
||||
|
||||
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
|
||||
const fieldOptions = makeOptionsFromCustomFields(customFields, [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'title', label: 'Title' },
|
||||
{ value: 'note', label: 'Note' },
|
||||
]);
|
||||
@@ -38,7 +39,7 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
|
||||
description: 'Field to be shown in the second line of text',
|
||||
type: 'option',
|
||||
values: fieldOptions,
|
||||
defaultValue: '',
|
||||
defaultValue: 'none',
|
||||
},
|
||||
{
|
||||
id: 'subscribe',
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
}
|
||||
|
||||
.timers {
|
||||
padding-block: 0.75rem 0.5rem;
|
||||
padding-inline: 1rem 0;
|
||||
padding-block: 1rem 0.5rem;
|
||||
padding-inline: 1rem 0.5rem;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
@@ -18,11 +18,21 @@
|
||||
.runningTimer {
|
||||
grid-area: timers;
|
||||
justify-self: center;
|
||||
|
||||
:nth-child(2) {
|
||||
font-size: 2.5rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
.timeNow {
|
||||
grid-area: clock;
|
||||
justify-self: right;
|
||||
|
||||
:nth-child(2) {
|
||||
font-size: 2.5rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
.progressOverride {
|
||||
|
||||
@@ -144,11 +144,15 @@ function FlagTimes() {
|
||||
<span className={title ? style.labelTitle : style.label}>{`${title ? title : 'Flag'} `}</span>
|
||||
<div className={style.labelledElement}>
|
||||
<Tooltip text='Time to next flag planned start' render={<TbFlagPin className={style.icon} />} />
|
||||
<span data-testid='flag-plannedStart' className={cx([style.time, !entry && style.muted])}>{plannedTimeUntilDisplay}</span>
|
||||
<span data-testid='flag-plannedStart' className={cx([style.time, !entry && style.muted])}>
|
||||
{plannedTimeUntilDisplay}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.labelledElement}>
|
||||
<Tooltip text='Time to next flag expected start' render={<TbFlagStar className={style.icon} />} />
|
||||
<span data-testid='flag-expectedStart' className={cx([style.time, expectedTimeUntil === null && style.muted])}>{expectedTimeUntilDisplay}</span>
|
||||
<span data-testid='flag-expectedStart' className={cx([style.time, expectedTimeUntil === null && style.muted])}>
|
||||
{expectedTimeUntilDisplay}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -175,8 +179,9 @@ export function OffsetOverview() {
|
||||
|
||||
export function ClockOverview({ className }: { className?: string }) {
|
||||
const { clock } = useClock();
|
||||
const formattedClock = formatTime(clock);
|
||||
|
||||
return <TimeColumn label='Time now' value={formattedTime(clock)} className={className} />;
|
||||
return <TimeColumn label='Time now' value={formattedClock} className={className} />;
|
||||
}
|
||||
|
||||
export function TimerOverview({ className }: { className?: string }) {
|
||||
|
||||
@@ -37,13 +37,14 @@ import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||
import { useRundownEditor } from '../../common/hooks/useSocket';
|
||||
import { useEntryCopy } from '../../common/stores/entryCopyStore';
|
||||
import { cloneEvent } from '../../common/utils/clone';
|
||||
import { lastMetadataKey, RundownMetadataObject } from '../../common/utils/rundownMetadata';
|
||||
import { AppMode, sessionKeys } from '../../ontimeConfig';
|
||||
|
||||
import QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons';
|
||||
import QuickAddInline from './entry-editor/quick-add-cursor/QuickAddInline';
|
||||
import RundownGroup from './rundown-group/RundownGroup';
|
||||
import RundownGroupEnd from './rundown-group/RundownGroupEnd';
|
||||
import { canDrop, makeRundownMetadata, makeSortableList } from './rundown.utils';
|
||||
import { canDrop, makeSortableList } from './rundown.utils';
|
||||
import RundownEmpty from './RundownEmpty';
|
||||
import { useEventSelection } from './useEventSelection';
|
||||
|
||||
@@ -53,13 +54,15 @@ const RundownEntry = lazy(() => import('./RundownEntry'));
|
||||
|
||||
interface RundownProps {
|
||||
data: Rundown;
|
||||
rundownMetadata: RundownMetadataObject;
|
||||
}
|
||||
|
||||
export default function Rundown({ data }: RundownProps) {
|
||||
export default function Rundown({ data, rundownMetadata }: RundownProps) {
|
||||
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 [sortableData, setSortableData] = useState<EntryId[]>(() => makeSortableList(order, entries));
|
||||
const [metadata, setMetadata] = useState(rundownMetadata);
|
||||
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
|
||||
// we ensure that this is unique to the rundown
|
||||
key: `rundown.${id}-editor-collapsed-groups`,
|
||||
@@ -306,7 +309,8 @@ export default function Rundown({ data }: RundownProps) {
|
||||
// to workaround async updates on the drag mutations
|
||||
useEffect(() => {
|
||||
setSortableData(makeSortableList(order, entries));
|
||||
}, [order, entries]);
|
||||
setMetadata(rundownMetadata);
|
||||
}, [order, entries, rundownMetadata]);
|
||||
|
||||
// in run mode, we follow selection
|
||||
useEffect(() => {
|
||||
@@ -334,19 +338,24 @@ export default function Rundown({ data }: RundownProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
// prevent dropping a group inside another
|
||||
if (
|
||||
active.data.current?.type === SupportedEntry.Group &&
|
||||
!canDrop(over.data.current?.type, over.data.current?.parent)
|
||||
) {
|
||||
if (!active.data.current || !over.data.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fromIndex = active.data.current?.sortable.index;
|
||||
const toIndex = over.data.current?.sortable.index;
|
||||
const fromIndex: number = active.data.current.sortable.index;
|
||||
const toIndex: number = over.data.current.sortable.index;
|
||||
let placement: 'before' | 'after' | 'insert' = fromIndex < toIndex ? 'after' : 'before';
|
||||
|
||||
let destinationId = over.id as EntryId;
|
||||
let order: 'before' | 'after' | 'insert' = fromIndex < toIndex ? 'after' : 'before';
|
||||
const isDraggingGroup = active.data.current?.type === SupportedEntry.Group;
|
||||
|
||||
// prevent dropping a group inside another
|
||||
if (
|
||||
isDraggingGroup &&
|
||||
!canDrop(over.data.current.type, over.data.current.parent, placement, getIsCollapsed(destinationId))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* We need to specially handle the end-group
|
||||
@@ -357,16 +366,25 @@ export default function Rundown({ data }: RundownProps) {
|
||||
if (destinationId.startsWith('end-')) {
|
||||
destinationId = destinationId.replace('end-', '');
|
||||
// if we are moving before the end, we use the insert operation
|
||||
if (order === 'before') {
|
||||
order = 'insert';
|
||||
if (placement === 'before') {
|
||||
placement = 'insert';
|
||||
}
|
||||
} else {
|
||||
const group = data.entries[destinationId];
|
||||
if (isOntimeGroup(group) && order === 'after') {
|
||||
if (group.entries.length === 0) order = 'insert';
|
||||
else {
|
||||
// if dragging into a group
|
||||
if (isOntimeGroup(group) && placement === 'after') {
|
||||
if (isDraggingGroup) {
|
||||
// ... and the dragged entry is a group, we know that the group is collapsed, because of the safe check canDrop from before
|
||||
// so we can safely push the dragged event after the group
|
||||
destinationId = group.id;
|
||||
} else if (group.entries.length === 0) {
|
||||
// ... and the group is entry, we insert
|
||||
destinationId = group.id;
|
||||
placement = 'insert';
|
||||
} else {
|
||||
// otherwise we add it to before the first group child
|
||||
destinationId = group.entries[0];
|
||||
order = 'before';
|
||||
placement = 'before';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -377,7 +395,7 @@ export default function Rundown({ data }: RundownProps) {
|
||||
setSortableData((currentEntries) => {
|
||||
return reorderArray(currentEntries, fromIndex, toIndex);
|
||||
});
|
||||
reorderEntry(active.id as EntryId, destinationId, order).catch((_) => {
|
||||
reorderEntry(active.id as EntryId, destinationId, placement).catch((_) => {
|
||||
setSortableData(currentEntries);
|
||||
});
|
||||
};
|
||||
@@ -418,11 +436,6 @@ export default function Rundown({ data }: RundownProps) {
|
||||
// 1. gather presentation options
|
||||
const isEditMode = editorMode === AppMode.Edit;
|
||||
|
||||
// 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
|
||||
@@ -440,6 +453,7 @@ export default function Rundown({ data }: RundownProps) {
|
||||
if (entryId.startsWith('end-')) {
|
||||
const parentId = entryId.split('end-')[1];
|
||||
const isGroupCollapsed = getIsCollapsed(parentId);
|
||||
const parentMetadata = metadata[parentId];
|
||||
|
||||
if (isGroupCollapsed) {
|
||||
return null;
|
||||
@@ -450,14 +464,14 @@ export default function Rundown({ data }: RundownProps) {
|
||||
// and it does not cause the reassignment of the iteration id to the previous entry
|
||||
return (
|
||||
<Fragment key={entryId}>
|
||||
{isEditMode && rundownMetadata.groupEntries === 0 && (
|
||||
{isEditMode && parentMetadata?.groupEntries === 0 && (
|
||||
<QuickAddButtons
|
||||
previousEventId={null}
|
||||
parentGroup={parentId}
|
||||
backgroundColor={rundownMetadata.groupColour}
|
||||
backgroundColor={parentMetadata?.groupColour}
|
||||
/>
|
||||
)}
|
||||
<RundownGroupEnd key={entryId} id={entryId} colour={rundownMetadata.groupColour} />
|
||||
<RundownGroupEnd key={entryId} id={entryId} colour={parentMetadata?.groupColour} />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -466,15 +480,14 @@ export default function Rundown({ data }: RundownProps) {
|
||||
// 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) return null;
|
||||
|
||||
rundownMetadata = process(entry);
|
||||
const entryMetadata = metadata[entryId];
|
||||
if (!entry || !entryMetadata) return null;
|
||||
|
||||
// if the entry has a parent, and it is collapsed, render nothing
|
||||
if (
|
||||
entry.type !== SupportedEntry.Group &&
|
||||
rundownMetadata.groupId !== null &&
|
||||
getIsCollapsed(rundownMetadata.groupId)
|
||||
entryMetadata.groupId !== null &&
|
||||
getIsCollapsed(entryMetadata.groupId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -488,7 +501,7 @@ export default function Rundown({ data }: RundownProps) {
|
||||
* ie: we are inside a group, but there is no defined colour
|
||||
* we default to $gray-500 #9d9d9d
|
||||
*/
|
||||
const groupColour = rundownMetadata.groupColour === '' ? '#9d9d9d' : rundownMetadata.groupColour;
|
||||
const groupColour = entryMetadata.groupColour === '' ? '#9d9d9d' : entryMetadata.groupColour;
|
||||
|
||||
const isFirst = index === 0;
|
||||
const isLast = entryId === order.at(-1);
|
||||
@@ -500,9 +513,8 @@ export default function Rundown({ data }: RundownProps) {
|
||||
* - when adding after, we can use the group ID directly to insert at the top of the group
|
||||
*/
|
||||
|
||||
const parentIdForBefore =
|
||||
rundownMetadata.thisId !== rundownMetadata.groupId ? rundownMetadata.groupId : null;
|
||||
const parentIdForAfter = rundownMetadata.groupId;
|
||||
const parentIdForBefore = entryMetadata.thisId !== entryMetadata.groupId ? entryMetadata.groupId : null;
|
||||
const parentIdForAfter = entryMetadata.groupId;
|
||||
|
||||
return (
|
||||
<Fragment key={entry.id}>
|
||||
@@ -513,7 +525,7 @@ export default function Rundown({ data }: RundownProps) {
|
||||
* - if it is not the first entry (the buttons would be there)
|
||||
*/}
|
||||
{isEditMode && hasCursor && !isFirst && (
|
||||
<QuickAddInline previousEventId={rundownMetadata.previousEntryId} parentGroup={parentIdForBefore} />
|
||||
<QuickAddInline placement='before' referenceEntryId={entry.id} parentGroup={parentIdForBefore} />
|
||||
)}
|
||||
{isOntimeGroup(entry) ? (
|
||||
<RundownGroup
|
||||
@@ -525,31 +537,29 @@ export default function Rundown({ data }: RundownProps) {
|
||||
) : (
|
||||
<div
|
||||
className={style.entryWrapper}
|
||||
data-testid={`entry-${rundownMetadata.eventIndex}`}
|
||||
data-testid={`entry-${entryMetadata.eventIndex}`}
|
||||
style={groupColour ? { '--user-bg': groupColour } : {}}
|
||||
>
|
||||
{isOntimeEvent(entry) && (
|
||||
<div className={style.entryIndex}>
|
||||
{entry.flag && <TbFlagFilled className={style.flag} />}
|
||||
<div className={style.index}>{rundownMetadata.eventIndex}</div>
|
||||
<div className={style.index}>{entryMetadata.eventIndex}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
|
||||
<RundownEntry
|
||||
type={entry.type}
|
||||
isPast={rundownMetadata.isPast}
|
||||
eventIndex={rundownMetadata.eventIndex}
|
||||
isPast={entryMetadata.isPast}
|
||||
eventIndex={entryMetadata.eventIndex}
|
||||
data={entry}
|
||||
loaded={rundownMetadata.isLoaded}
|
||||
loaded={entryMetadata.isLoaded}
|
||||
hasCursor={hasCursor}
|
||||
isNext={isNext}
|
||||
previousEntryId={rundownMetadata.previousEntryId}
|
||||
previousEventId={rundownMetadata.previousEvent?.id}
|
||||
playback={rundownMetadata.isLoaded ? featureData.playback : undefined}
|
||||
isNextDay={entryMetadata.isNextDay}
|
||||
playback={entryMetadata.isLoaded ? featureData.playback : undefined}
|
||||
isRolling={featureData.playback === Playback.Roll}
|
||||
isNextDay={rundownMetadata.isNextDay}
|
||||
totalGap={rundownMetadata.totalGap}
|
||||
isLinkedToLoaded={rundownMetadata.isLinkedToLoaded}
|
||||
totalGap={entryMetadata.totalGap}
|
||||
isLinkedToLoaded={entryMetadata.isLinkedToLoaded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -562,13 +572,16 @@ export default function Rundown({ data }: RundownProps) {
|
||||
* - if the entry is not the group header
|
||||
*/}
|
||||
{isEditMode && hasCursor && !isLast && (
|
||||
<QuickAddInline previousEventId={entry.id} parentGroup={parentIdForAfter} />
|
||||
<QuickAddInline placement='after' referenceEntryId={entry.id} parentGroup={parentIdForAfter} />
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{isEditMode && (
|
||||
<QuickAddButtons previousEventId={rundownMetadata.groupId ?? rundownMetadata.thisId} parentGroup={null} />
|
||||
<QuickAddButtons
|
||||
previousEventId={metadata[lastMetadataKey]?.groupId ?? metadata[lastMetadataKey].thisId}
|
||||
parentGroup={null}
|
||||
/>
|
||||
)}
|
||||
<div className={style.spacer} />
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
isOntimeDelay,
|
||||
isOntimeEvent,
|
||||
isOntimeMilestone,
|
||||
MaybeString,
|
||||
OntimeEntry,
|
||||
OntimeEvent,
|
||||
Playback,
|
||||
@@ -12,26 +10,11 @@ import {
|
||||
|
||||
import { useEntryActions } from '../../common/hooks/useEntryAction';
|
||||
import useMemoisedFn from '../../common/hooks/useMemoisedFn';
|
||||
import { useEmitLog } from '../../common/stores/logger';
|
||||
import { cloneEvent } from '../../common/utils/clone';
|
||||
|
||||
import RundownDelay from './rundown-delay/RundownDelay';
|
||||
import RundownEvent from './rundown-event/RundownEvent';
|
||||
import RundownMilestone from './rundown-milestone/RundownMilestone';
|
||||
import { useEventSelection } from './useEventSelection';
|
||||
|
||||
export type EventItemActions =
|
||||
| 'event'
|
||||
| 'event-before'
|
||||
| 'delay'
|
||||
| 'delay-before'
|
||||
| 'group'
|
||||
| 'group-before'
|
||||
| 'swap'
|
||||
| 'delete'
|
||||
| 'clone'
|
||||
| 'make-group'
|
||||
| 'update';
|
||||
|
||||
interface RundownEntryProps {
|
||||
type: SupportedEntry;
|
||||
@@ -42,8 +25,6 @@ interface RundownEntryProps {
|
||||
hasCursor: boolean;
|
||||
isNext: boolean;
|
||||
isNextDay: boolean;
|
||||
previousEntryId: MaybeString;
|
||||
previousEventId?: string;
|
||||
playback?: Playback; // we only care about this if this event is playing
|
||||
isRolling: boolean; // we need to know even if not related to this event
|
||||
totalGap: number;
|
||||
@@ -56,8 +37,6 @@ export default function RundownEntry({
|
||||
loaded,
|
||||
hasCursor,
|
||||
isNext,
|
||||
previousEntryId,
|
||||
previousEventId,
|
||||
playback,
|
||||
isRolling,
|
||||
eventIndex,
|
||||
@@ -65,105 +44,11 @@ export default function RundownEntry({
|
||||
totalGap,
|
||||
isLinkedToLoaded,
|
||||
}: RundownEntryProps) {
|
||||
const { emitError } = useEmitLog();
|
||||
const { addEntry, updateEntry, batchUpdateEvents, deleteEntry, groupEntries, swapEvents } = useEntryActions();
|
||||
const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection();
|
||||
const { addEntry } = useEntryActions();
|
||||
|
||||
const removeOpenEvent = useCallback(() => {
|
||||
unselect(data.id);
|
||||
}, [unselect, data.id]);
|
||||
|
||||
const clearMultiSelection = useCallback(() => {
|
||||
clearSelectedEvents();
|
||||
}, [clearSelectedEvents]);
|
||||
|
||||
// Create / delete new events
|
||||
type FieldValue = {
|
||||
field: keyof Omit<OntimeEvent, 'duration'> | 'durationOverride';
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
const actionHandler = useMemoisedFn((action: EventItemActions, payload?: number | FieldValue) => {
|
||||
switch (action) {
|
||||
case 'event': {
|
||||
const newEvent = { type: SupportedEntry.Event };
|
||||
const options = {
|
||||
after: data.id,
|
||||
lastEventId: previousEventId,
|
||||
};
|
||||
return addEntry(newEvent, options);
|
||||
}
|
||||
case 'event-before': {
|
||||
const newEvent = { type: SupportedEntry.Event };
|
||||
const options = {
|
||||
after: previousEntryId,
|
||||
};
|
||||
return addEntry(newEvent, options);
|
||||
}
|
||||
case 'delay': {
|
||||
return addEntry({ type: SupportedEntry.Delay }, { after: data.id });
|
||||
}
|
||||
case 'delay-before': {
|
||||
return addEntry({ type: SupportedEntry.Delay }, { after: previousEntryId });
|
||||
}
|
||||
case 'group': {
|
||||
return addEntry({ type: SupportedEntry.Group }, { after: data.id });
|
||||
}
|
||||
case 'group-before': {
|
||||
return addEntry({ type: SupportedEntry.Group }, { after: previousEntryId });
|
||||
}
|
||||
case 'swap': {
|
||||
const { value } = payload as FieldValue;
|
||||
return swapEvents({ from: value as string, to: data.id });
|
||||
}
|
||||
case 'delete': {
|
||||
if (selectedEvents.size > 1) {
|
||||
clearMultiSelection();
|
||||
return deleteEntry(Array.from(selectedEvents));
|
||||
}
|
||||
removeOpenEvent();
|
||||
return deleteEntry([data.id]);
|
||||
}
|
||||
case 'clone': {
|
||||
const newEvent = cloneEvent(data as OntimeEvent);
|
||||
addEntry(newEvent, { after: data.id });
|
||||
break;
|
||||
}
|
||||
case 'make-group': {
|
||||
if (selectedEvents.size > 1) {
|
||||
clearMultiSelection();
|
||||
return groupEntries(Array.from(selectedEvents));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'update': {
|
||||
// Handles and filters update requests
|
||||
const { field, value } = payload as FieldValue;
|
||||
if (field === undefined || value === undefined) {
|
||||
return;
|
||||
}
|
||||
const newData: Partial<OntimeEvent> = { id: data.id };
|
||||
|
||||
// if selected events are more than one
|
||||
// we need to bulk edit
|
||||
if (selectedEvents.size > 1) {
|
||||
const changes: Partial<OntimeEvent> = { [field]: value };
|
||||
batchUpdateEvents(changes, Array.from(selectedEvents));
|
||||
return;
|
||||
}
|
||||
if (field in data) {
|
||||
// @ts-expect-error -- not sure how to type this
|
||||
newData[field] = value;
|
||||
return updateEntry(newData);
|
||||
}
|
||||
|
||||
return emitError(`Unknown field: ${field}`);
|
||||
}
|
||||
default: {
|
||||
action satisfies never;
|
||||
throw new Error(`Unhandled event ${action}`);
|
||||
}
|
||||
}
|
||||
const createCloneEvent = useMemoisedFn(() => {
|
||||
const newEvent = cloneEvent(data as OntimeEvent);
|
||||
addEntry(newEvent, { after: data.id });
|
||||
});
|
||||
|
||||
if (isOntimeEvent(data)) {
|
||||
@@ -198,7 +83,7 @@ export default function RundownEntry({
|
||||
dayOffset={data.dayOffset}
|
||||
totalGap={totalGap}
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
actionHandler={actionHandler}
|
||||
createCloneEvent={createCloneEvent}
|
||||
hasTriggers={data.triggers.length > 0}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -43,6 +43,5 @@
|
||||
border-radius: 0 8px 8px 0;
|
||||
|
||||
flex: 1 1 auto; /* flex-grow: 1, flex-shrink: 1, flex-basis: auto */
|
||||
min-width: 30rem;
|
||||
max-width: 45rem;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Empty from '../../common/components/state/Empty';
|
||||
import useRundown from '../../common/hooks-query/useRundown';
|
||||
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
|
||||
|
||||
import RundownHeader from './rundown-header/RundownHeader';
|
||||
import RundownHeaderMobile from './rundown-header/RundownHeaderMobile';
|
||||
@@ -12,12 +12,16 @@ interface RundownWrapperProps {
|
||||
}
|
||||
|
||||
export default function RundownWrapper({ isSmallDevice }: RundownWrapperProps) {
|
||||
const { data, status } = useRundown();
|
||||
const { data, status, rundownMetadata } = useRundownWithMetadata();
|
||||
|
||||
return (
|
||||
<div className={styles.rundownWrapper}>
|
||||
{isSmallDevice ? <RundownHeaderMobile /> : <RundownHeader />}
|
||||
{status === 'success' && data ? <Rundown data={data} /> : <Empty text='Connecting to server' />}
|
||||
{status === 'success' && data && rundownMetadata ? (
|
||||
<Rundown data={data} rundownMetadata={rundownMetadata} />
|
||||
) : (
|
||||
<Empty text='Connecting to server' />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,293 +1,6 @@
|
||||
import { EntryId, OntimeDelay, OntimeEvent, OntimeGroup, RundownEntries, SupportedEntry } from 'ontime-types';
|
||||
import { EntryId, OntimeEvent, OntimeGroup, RundownEntries, SupportedEntry } from 'ontime-types';
|
||||
|
||||
import { makeRundownMetadata, makeSortableList, moveDown, moveUp, orderEntries } 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,
|
||||
group: {
|
||||
id: 'group',
|
||||
type: SupportedEntry.Group,
|
||||
entries: ['11', 'delay', '12', '13'],
|
||||
colour: 'red',
|
||||
} as OntimeGroup,
|
||||
'11': {
|
||||
id: '11',
|
||||
type: SupportedEntry.Event,
|
||||
parent: 'group',
|
||||
timeStart: 10,
|
||||
timeEnd: 11,
|
||||
duration: 1,
|
||||
dayOffset: 0,
|
||||
gap: 10,
|
||||
skip: false,
|
||||
linkStart: false,
|
||||
} as OntimeEvent,
|
||||
delay: {
|
||||
id: 'delay',
|
||||
type: SupportedEntry.Delay,
|
||||
parent: 'group',
|
||||
duration: 0,
|
||||
} as OntimeDelay,
|
||||
'12': {
|
||||
id: '12',
|
||||
type: SupportedEntry.Event,
|
||||
parent: 'group',
|
||||
timeStart: 11,
|
||||
timeEnd: 12,
|
||||
duration: 1,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
skip: false,
|
||||
linkStart: true,
|
||||
} as OntimeEvent,
|
||||
'13': {
|
||||
id: '13',
|
||||
type: SupportedEntry.Event,
|
||||
parent: 'group',
|
||||
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,
|
||||
groupEntries: 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,
|
||||
groupEntries: undefined,
|
||||
});
|
||||
|
||||
expect(process(demoEvents['group'])).toMatchObject({
|
||||
previousEvent: demoEvents['1'],
|
||||
latestEvent: demoEvents['1'],
|
||||
previousEntryId: demoEvents['1'].id,
|
||||
thisId: demoEvents['group'].id,
|
||||
eventIndex: 1,
|
||||
isPast: true,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: 'group',
|
||||
groupColour: 'red',
|
||||
});
|
||||
|
||||
expect(process(demoEvents['11'])).toMatchObject({
|
||||
previousEvent: demoEvents['1'],
|
||||
latestEvent: demoEvents['11'],
|
||||
previousEntryId: demoEvents['group'].id,
|
||||
thisId: demoEvents['11'].id,
|
||||
eventIndex: 2,
|
||||
isPast: true,
|
||||
isNextDay: false,
|
||||
totalGap: 10,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: 'group',
|
||||
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: 'group',
|
||||
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: 'group',
|
||||
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: 'group',
|
||||
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 groups', () => {
|
||||
const rundownStartsWithGroup = {
|
||||
group: {
|
||||
id: 'group',
|
||||
type: SupportedEntry.Group,
|
||||
colour: 'red',
|
||||
entries: ['1', '2'],
|
||||
} as OntimeGroup,
|
||||
'1': {
|
||||
id: '1',
|
||||
type: SupportedEntry.Event,
|
||||
parent: 'group',
|
||||
timeStart: 1,
|
||||
timeEnd: 2,
|
||||
duration: 1,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
skip: false,
|
||||
linkStart: false,
|
||||
} as OntimeEvent,
|
||||
'2': {
|
||||
id: '2',
|
||||
type: SupportedEntry.Event,
|
||||
parent: 'group',
|
||||
timeStart: 2,
|
||||
timeEnd: 3,
|
||||
duration: 1,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
skip: false,
|
||||
linkStart: false,
|
||||
} as OntimeEvent,
|
||||
};
|
||||
const { process } = makeRundownMetadata(null);
|
||||
|
||||
expect(process(rundownStartsWithGroup.group)).toStrictEqual({
|
||||
previousEvent: null,
|
||||
latestEvent: null,
|
||||
previousEntryId: null,
|
||||
thisId: rundownStartsWithGroup.group.id,
|
||||
eventIndex: 0,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: rundownStartsWithGroup.group.id,
|
||||
groupColour: 'red',
|
||||
groupEntries: 2,
|
||||
});
|
||||
|
||||
expect(process(rundownStartsWithGroup['1'])).toStrictEqual({
|
||||
previousEvent: null,
|
||||
latestEvent: rundownStartsWithGroup['1'],
|
||||
previousEntryId: rundownStartsWithGroup.group.id,
|
||||
thisId: rundownStartsWithGroup['1'].id,
|
||||
eventIndex: 1,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: rundownStartsWithGroup.group.id,
|
||||
groupColour: 'red',
|
||||
groupEntries: 2,
|
||||
});
|
||||
expect(process(rundownStartsWithGroup['2'])).toStrictEqual({
|
||||
previousEvent: rundownStartsWithGroup['1'],
|
||||
latestEvent: rundownStartsWithGroup['2'],
|
||||
previousEntryId: rundownStartsWithGroup['1'].id,
|
||||
thisId: rundownStartsWithGroup['2'].id,
|
||||
eventIndex: 2,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: rundownStartsWithGroup.group.id,
|
||||
groupColour: 'red',
|
||||
groupEntries: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
import { makeSortableList, moveDown, moveUp, orderEntries } from '../rundown.utils';
|
||||
|
||||
describe('makeSortableList()', () => {
|
||||
it('generates a list with group ends', () => {
|
||||
@@ -327,7 +40,7 @@ describe('makeSortableList()', () => {
|
||||
expect(sortableList).toStrictEqual(['group-1', '11', '12', 'end-group-1']);
|
||||
});
|
||||
|
||||
it('handles a list with a with just groups', () => {
|
||||
it('handles a list with just groups', () => {
|
||||
const order = ['group-1', 'group-2'];
|
||||
const entries: RundownEntries = {
|
||||
'group-1': { type: SupportedEntry.Group, id: 'group-1', entries: [] as string[] } as OntimeGroup,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { isOntimeEvent, isOntimeGroup, OntimeEntry } from 'ontime-types';
|
||||
import { isOntimeEvent, isOntimeGroup, isOntimeMilestone, OntimeEntry } from 'ontime-types';
|
||||
|
||||
import useRundown from '../../../common/hooks-query/useRundown';
|
||||
|
||||
import EventEditor from './EventEditor';
|
||||
import GroupEditor from './GroupEditor';
|
||||
import MilestoneEditor from './MilestoneEditor';
|
||||
|
||||
import style from './EntryEditor.module.scss';
|
||||
|
||||
@@ -38,6 +39,14 @@ export default function CuesheetEntryEditor({ entryId }: CuesheetEntryEditorProp
|
||||
);
|
||||
}
|
||||
|
||||
if (isOntimeMilestone(entry)) {
|
||||
return (
|
||||
<div className={style.inModal} data-testid='editor-container'>
|
||||
<MilestoneEditor milestone={entry} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isOntimeGroup(entry)) {
|
||||
return (
|
||||
<div className={style.inModal} data-testid='editor-container'>
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
.entryEditor {
|
||||
.cuesheetEditor,
|
||||
.rundownEditor {
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.rundownEditor {
|
||||
// width is locked to swatch picker elements
|
||||
min-width: calc(15 * 2rem + 13 * 0.5rem);
|
||||
|
||||
// we dont want a scrollbar when in the modal
|
||||
.content {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
padding-inline: 0.5rem 1.5rem;
|
||||
padding-bottom: 4rem;
|
||||
@@ -13,7 +24,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.timeSettings {
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function RundownEntryEditor() {
|
||||
|
||||
if (isOntimeEvent(entry)) {
|
||||
return (
|
||||
<div className={style.entryEditor} data-testid='editor-container'>
|
||||
<div className={style.rundownEditor} data-testid='editor-container'>
|
||||
<EventEditor event={entry} />
|
||||
<EventEditorFooter id={entry.id} cue={entry.cue} />
|
||||
</div>
|
||||
@@ -61,7 +61,7 @@ export default function RundownEntryEditor() {
|
||||
|
||||
if (isOntimeMilestone(entry)) {
|
||||
return (
|
||||
<div className={style.entryEditor} data-testid='editor-container'>
|
||||
<div className={style.rundownEditor} data-testid='editor-container'>
|
||||
<MilestoneEditor milestone={entry} />
|
||||
</div>
|
||||
);
|
||||
@@ -69,7 +69,7 @@ export default function RundownEntryEditor() {
|
||||
|
||||
if (isOntimeGroup(entry)) {
|
||||
return (
|
||||
<div className={style.entryEditor} data-testid='editor-container'>
|
||||
<div className={style.rundownEditor} data-testid='editor-container'>
|
||||
<GroupEditor group={entry} />
|
||||
</div>
|
||||
);
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
.triggerForm {
|
||||
padding-block: 0.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto auto;
|
||||
grid-template-columns: 8rem 1fr auto 2rem;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
.trigger {
|
||||
padding: 0.25rem 0.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
grid-template-columns: 8rem 1fr 2rem;
|
||||
align-items: center;
|
||||
|
||||
&:nth-child(even) {
|
||||
|
||||
@@ -155,7 +155,7 @@ function ExistingEventTriggers({ eventId, triggers }: ExistingEventTriggersProps
|
||||
<div key={id} className={style.trigger}>
|
||||
<Tag>{triggerLifeCycle}</Tag>
|
||||
<Tag>{automationTitle}</Tag>
|
||||
<IconButton variant='subtle-destructive' onClick={() => handleDelete(id)}>
|
||||
<IconButton variant='ghosted-destructive' onClick={() => handleDelete(id)}>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
+1
@@ -6,6 +6,7 @@
|
||||
|
||||
height: 1px;
|
||||
background: $blue-500;
|
||||
z-index: $zindex-floating;
|
||||
}
|
||||
|
||||
.addButton {
|
||||
|
||||
@@ -9,68 +9,53 @@ import { useEntryActions } from '../../../../common/hooks/useEntryAction';
|
||||
import style from './QuickAddInline.module.scss';
|
||||
|
||||
interface QuickAddInlineProps {
|
||||
previousEventId: MaybeString;
|
||||
referenceEntryId: MaybeString;
|
||||
parentGroup: MaybeString;
|
||||
placement: 'before' | 'after';
|
||||
}
|
||||
|
||||
export default memo(QuickAddInline);
|
||||
function QuickAddInline({ previousEventId, parentGroup }: QuickAddInlineProps) {
|
||||
function QuickAddInline({ referenceEntryId, parentGroup, placement }: QuickAddInlineProps) {
|
||||
const { addEntry } = useEntryActions();
|
||||
|
||||
const addEvent = () => {
|
||||
addEntry(
|
||||
{
|
||||
type: SupportedEntry.Event,
|
||||
parent: parentGroup,
|
||||
},
|
||||
{
|
||||
after: previousEventId,
|
||||
lastEventId: previousEventId,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const addDelay = () => {
|
||||
addEntry(
|
||||
{ type: SupportedEntry.Delay, parent: parentGroup },
|
||||
{
|
||||
lastEventId: previousEventId,
|
||||
after: previousEventId,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const addMilestone = () => {
|
||||
addEntry(
|
||||
{ type: SupportedEntry.Milestone, parent: parentGroup },
|
||||
{
|
||||
lastEventId: previousEventId,
|
||||
after: previousEventId,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const addGroup = () => {
|
||||
if (parentGroup !== null) {
|
||||
return;
|
||||
const handleAddEntry = (type: SupportedEntry) => {
|
||||
if (placement === 'before') {
|
||||
addEntry(
|
||||
{ type, parent: type !== SupportedEntry.Group ? parentGroup : null },
|
||||
{
|
||||
before: referenceEntryId,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
addEntry(
|
||||
{ type, parent: type !== SupportedEntry.Group ? parentGroup : null },
|
||||
{
|
||||
lastEventId: referenceEntryId,
|
||||
after: referenceEntryId,
|
||||
},
|
||||
);
|
||||
}
|
||||
addEntry(
|
||||
{ type: SupportedEntry.Group },
|
||||
{
|
||||
lastEventId: previousEventId,
|
||||
after: previousEventId,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.quickAdd} data-testid='quick-add-inline'>
|
||||
<DropdownMenu
|
||||
items={[
|
||||
{ type: 'item', icon: IoAdd, label: 'Add Event', onClick: addEvent },
|
||||
{ type: 'item', icon: IoAdd, label: 'Add Delay', onClick: addDelay },
|
||||
{ type: 'item', icon: IoAdd, label: 'Add Milestone', onClick: addMilestone },
|
||||
{ type: 'item', icon: IoAdd, label: 'Add Group', onClick: addGroup, disabled: parentGroup !== null },
|
||||
{ type: 'item', icon: IoAdd, label: 'Add Event', onClick: () => handleAddEntry(SupportedEntry.Event) },
|
||||
{ type: 'item', icon: IoAdd, label: 'Add Delay', onClick: () => handleAddEntry(SupportedEntry.Delay) },
|
||||
{
|
||||
type: 'item',
|
||||
icon: IoAdd,
|
||||
label: 'Add Milestone',
|
||||
onClick: () => handleAddEntry(SupportedEntry.Milestone),
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
icon: IoAdd,
|
||||
label: 'Add Group',
|
||||
onClick: () => handleAddEntry(SupportedEntry.Group),
|
||||
disabled: parentGroup !== null,
|
||||
},
|
||||
]}
|
||||
render={<IconButton size='small' variant='primary' className={style.addButton} />}
|
||||
>
|
||||
|
||||
@@ -12,12 +12,12 @@ import {
|
||||
import { TbFlagFilled } from 'react-icons/tb';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { EndAction, EntryId, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||
import { EndAction, EntryId, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||
import { isPlaybackActive } from 'ontime-utils';
|
||||
|
||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import type { EventItemActions } from '../RundownEntry';
|
||||
import { useEventIdSwapping } from '../useEventIdSwapping';
|
||||
import { getSelectionMode, useEventSelection } from '../useEventSelection';
|
||||
|
||||
@@ -56,15 +56,7 @@ interface RundownEventProps {
|
||||
dayOffset: number;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
actionHandler: (
|
||||
action: EventItemActions,
|
||||
payload?:
|
||||
| number
|
||||
| {
|
||||
field: keyof Omit<OntimeEvent, 'duration'> | 'durationOverride';
|
||||
value: unknown;
|
||||
},
|
||||
) => void;
|
||||
createCloneEvent: () => void;
|
||||
hasTriggers: boolean;
|
||||
}
|
||||
|
||||
@@ -98,11 +90,13 @@ export default function RundownEvent({
|
||||
dayOffset,
|
||||
totalGap,
|
||||
isLinkedToLoaded,
|
||||
actionHandler,
|
||||
hasTriggers,
|
||||
createCloneEvent,
|
||||
}: RundownEventProps) {
|
||||
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
|
||||
const { selectedEvents, setSelectedEvents } = useEventSelection();
|
||||
const { updateEntry, batchUpdateEvents, deleteEntry, groupEntries, swapEvents } = useEntryActions();
|
||||
|
||||
const { selectedEvents, unselect, setSelectedEvents, clearSelectedEvents } = useEventSelection();
|
||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
@@ -113,37 +107,48 @@ export default function RundownEvent({
|
||||
type: 'item',
|
||||
label: 'Link to previous',
|
||||
icon: IoLink,
|
||||
onClick: () =>
|
||||
actionHandler('update', {
|
||||
field: 'linkStart',
|
||||
value: 'true',
|
||||
}),
|
||||
onClick: () => {
|
||||
batchUpdateEvents({ linkStart: true }, Array.from(selectedEvents));
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Unlink from previous',
|
||||
icon: IoUnlink,
|
||||
onClick: () =>
|
||||
actionHandler('update', {
|
||||
field: 'linkStart',
|
||||
value: null,
|
||||
}),
|
||||
onClick: () => {
|
||||
batchUpdateEvents({ linkStart: false }, Array.from(selectedEvents));
|
||||
},
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{ type: 'item', label: 'Group', icon: IoFolder, onClick: () => actionHandler('make-group') },
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Group',
|
||||
icon: IoFolder,
|
||||
onClick: () => {
|
||||
groupEntries(Array.from(selectedEvents));
|
||||
clearSelectedEvents();
|
||||
},
|
||||
disabled: parent !== null,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{ type: 'item', label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Delete',
|
||||
icon: IoTrash,
|
||||
onClick: () => {
|
||||
clearSelectedEvents();
|
||||
deleteEntry(Array.from(selectedEvents));
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
type: 'item',
|
||||
label: flag ? 'Remove flag' : 'Add flag',
|
||||
icon: TbFlagFilled,
|
||||
onClick: () =>
|
||||
actionHandler('update', {
|
||||
field: 'flag',
|
||||
value: !flag,
|
||||
}),
|
||||
onClick: () => {
|
||||
updateEntry({ id: eventId, flag: !flag });
|
||||
},
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
@@ -157,7 +162,8 @@ export default function RundownEvent({
|
||||
label: `Swap this event with ${selectedEventId ?? ''}`,
|
||||
icon: IoSwapVertical,
|
||||
onClick: () => {
|
||||
actionHandler('swap', { field: 'id', value: selectedEventId });
|
||||
if (!selectedEventId) return;
|
||||
swapEvents(selectedEventId, eventId);
|
||||
clearSelectedEventId();
|
||||
},
|
||||
disabled: selectedEventId == null || selectedEventId === eventId,
|
||||
@@ -166,10 +172,18 @@ export default function RundownEvent({
|
||||
type: 'item',
|
||||
label: 'Clone',
|
||||
icon: IoDuplicateOutline,
|
||||
onClick: () => actionHandler('clone'),
|
||||
onClick: createCloneEvent,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{ type: 'item', label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Delete',
|
||||
icon: IoTrash,
|
||||
onClick: () => {
|
||||
deleteEntry([eventId]);
|
||||
unselect(eventId);
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
||||
};
|
||||
|
||||
const binderColours = data.colour && getAccessibleColour(data.colour);
|
||||
const isValidDrop = over?.id && canDrop(over.data.current?.type, over.data.current?.parent);
|
||||
const isValidDrop = isDragging && over?.id && canDrop(over.data.current?.type, over.data.current?.parent);
|
||||
|
||||
const [planOffset, planOffsetLabel] = (() => {
|
||||
if (data.targetDuration === null) {
|
||||
|
||||
@@ -3,14 +3,13 @@
|
||||
.milestone {
|
||||
@include block-styling;
|
||||
|
||||
margin-left: calc(2rem + 1px); // binder + border
|
||||
margin-block: 0.125rem;
|
||||
padding-right: 0.25rem;
|
||||
background-color: $gray-1050; // to override inline
|
||||
color: $section-white; // to override inline
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 2rem 1fr 3fr;
|
||||
grid-template-columns: 2rem 0.4fr 1fr;
|
||||
align-items: center;
|
||||
height: $secondary-block-height;
|
||||
gap: 0.5rem;
|
||||
|
||||
@@ -1,129 +1,4 @@
|
||||
import {
|
||||
EntryId,
|
||||
isOntimeEvent,
|
||||
isOntimeGroup,
|
||||
isPlayableEvent,
|
||||
MaybeString,
|
||||
OntimeDelay,
|
||||
OntimeEntry,
|
||||
OntimeEvent,
|
||||
OntimeMilestone,
|
||||
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;
|
||||
groupEntries: number | 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,
|
||||
groupEntries: 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 (isOntimeGroup(entry)) {
|
||||
processedData.groupId = entry.id;
|
||||
processedData.groupColour = entry.colour;
|
||||
processedData.groupEntries = entry.entries.length;
|
||||
} else {
|
||||
// for delays and groups, we insert the group metadata
|
||||
if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent !== processedData.groupId) {
|
||||
// if the parent is not the current group, we need to update the groupId
|
||||
processedData.groupId = (entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent;
|
||||
processedData.groupEntries = undefined;
|
||||
if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).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;
|
||||
}
|
||||
import { EntryId, isOntimeEvent, isOntimeGroup, RundownEntries, SupportedEntry } from 'ontime-types';
|
||||
|
||||
/**
|
||||
* Creates a sortable list of entries
|
||||
@@ -160,14 +35,23 @@ export function makeSortableList(order: EntryId[], entries: RundownEntries): Ent
|
||||
* Checks whether a drop operation is valid
|
||||
* Currently only used for validating dropping groups
|
||||
*/
|
||||
export function canDrop(targetType?: SupportedEntry | 'end-group', targetParent?: EntryId | null): boolean {
|
||||
export function canDrop(
|
||||
targetType: SupportedEntry | 'end-group',
|
||||
targetParent: EntryId | null,
|
||||
order?: 'after' | 'before',
|
||||
isTargetCollapsed?: boolean,
|
||||
): boolean {
|
||||
// this would mean inserting a group inside another
|
||||
if (targetType === 'end-group') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// this means swapping places with another group
|
||||
// !!! if the user is dragging down, they could be inserting into a group depending on whether the group is collapsed
|
||||
if (targetType === 'group') {
|
||||
if (order !== undefined && order === 'after' && !isTargetCollapsed) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormE
|
||||
{ value: OntimeView.Timer, label: 'Timer' },
|
||||
{ value: OntimeView.Cuesheet, label: 'Cuesheet' },
|
||||
{ value: OntimeView.Operator, label: 'Operator' },
|
||||
{ value: '', label: 'Companion' },
|
||||
{ value: '<<companion>>', label: 'Companion' },
|
||||
...urlPresetData.map((preset) => ({
|
||||
value: `preset-${preset.alias}`,
|
||||
label: `URL Preset: ${preset.alias}`,
|
||||
|
||||
@@ -40,8 +40,8 @@
|
||||
}
|
||||
|
||||
$track-color: $white-1;
|
||||
$thumb-color: $gray-1100;
|
||||
$thumb-color-hover: $gray-900;
|
||||
$thumb-color: $white-20;
|
||||
$thumb-color-hover: $white-60;
|
||||
|
||||
/* Apply a natural box layout model to all elements */
|
||||
html {
|
||||
@@ -157,7 +157,7 @@ input[type='number'] {
|
||||
|
||||
/* Track */
|
||||
::-webkit-scrollbar-track {
|
||||
background: $white-1;
|
||||
background: $track-color;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ export const initializeSentry = () => {
|
||||
/NetworkError/i,
|
||||
/The operation couldn't be completed/i,
|
||||
],
|
||||
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
|
||||
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i, /external\//i],
|
||||
beforeSend(event) {
|
||||
// Drop errors that happen during known data-unavailable states
|
||||
const error = event.exception?.values?.[0]?.value;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { createContext, PropsWithChildren, useCallback, useContext } from 'react';
|
||||
import { langEn, TranslationObject } from 'ontime-types';
|
||||
|
||||
import { postUserTranslation } from '../common/api/assets';
|
||||
import { useCustomTranslation } from '../common/hooks-query/useCustomTranslation';
|
||||
import useSettings from '../common/hooks-query/useSettings';
|
||||
|
||||
import { langDe } from './languages/de';
|
||||
import { langEn } from './languages/en';
|
||||
import { langEs } from './languages/es';
|
||||
import { langFr } from './languages/fr';
|
||||
import { langIt } from './languages/it';
|
||||
@@ -21,15 +23,20 @@ const translationsList = {
|
||||
export type TranslationKey = keyof typeof langEn;
|
||||
|
||||
interface TranslationContextValue {
|
||||
userTranslation: TranslationObject;
|
||||
getLocalizedString: (key: TranslationKey, lang?: string) => string;
|
||||
postUserTranslation: (translation: TranslationObject) => Promise<void>;
|
||||
}
|
||||
|
||||
const TranslationContext = createContext<TranslationContextValue>({
|
||||
userTranslation: langEn,
|
||||
getLocalizedString: () => '',
|
||||
postUserTranslation: async () => {},
|
||||
});
|
||||
|
||||
export const TranslationProvider = ({ children }: PropsWithChildren) => {
|
||||
const { data } = useSettings();
|
||||
const { data: translationData } = useCustomTranslation();
|
||||
|
||||
const getLocalizedString = useCallback(
|
||||
(key: TranslationKey, lang = data?.language || 'en'): string => {
|
||||
@@ -37,20 +44,24 @@ export const TranslationProvider = ({ children }: PropsWithChildren) => {
|
||||
if (key in translationsList[lang as keyof typeof translationsList]) {
|
||||
return translationsList[lang as keyof typeof translationsList][key];
|
||||
}
|
||||
} else if (lang === 'custom') {
|
||||
return translationData[key];
|
||||
}
|
||||
return langEn[key];
|
||||
},
|
||||
[data?.language],
|
||||
[data?.language, translationData],
|
||||
);
|
||||
|
||||
const contextValue = {
|
||||
userTranslation: translationData,
|
||||
getLocalizedString,
|
||||
postUserTranslation,
|
||||
};
|
||||
|
||||
return <TranslationContext.Provider value={contextValue}>{children}</TranslationContext.Provider>;
|
||||
};
|
||||
|
||||
export const useTranslation = () => {
|
||||
const { getLocalizedString } = useContext(TranslationContext);
|
||||
return { getLocalizedString };
|
||||
const { userTranslation, getLocalizedString, postUserTranslation } = useContext(TranslationContext);
|
||||
return { userTranslation, getLocalizedString, postUserTranslation };
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { TranslationObject } from './en';
|
||||
import { TranslationObject } from 'ontime-types';
|
||||
|
||||
export const langDe: TranslationObject = {
|
||||
'common.expected_finish': 'Erwartetes Ende',
|
||||
'common.minutes': 'min',
|
||||
'common.seconds': 'sek',
|
||||
'common.now': 'Jetzt',
|
||||
'common.next': 'Nächste',
|
||||
'common.scheduled_start': 'Geplanter beginn',
|
||||
@@ -15,6 +16,7 @@ export const langDe: TranslationObject = {
|
||||
'common.no_data': 'Keine Daten',
|
||||
'countdown.ended': 'Veranstaltung endete um',
|
||||
'countdown.running': 'Veranstaltung läuft',
|
||||
'countdown.loaded': 'Veranstaltung geladen',
|
||||
'countdown.select_event': 'Wählen Sie eine Veranstaltung aus, um sie zu verfolgen',
|
||||
'countdown.to_start': 'Zeit bis zum Start',
|
||||
'countdown.waiting': 'Warten auf den Veranstaltungsbeginn',
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { TranslationObject } from './en';
|
||||
import { TranslationObject } from 'ontime-types';
|
||||
|
||||
export const langEs: TranslationObject = {
|
||||
'common.expected_finish': 'Finalización esperada',
|
||||
'common.minutes': 'min',
|
||||
'common.seconds': 'sec',
|
||||
'common.now': 'Ahora',
|
||||
'common.next': 'Siguiente',
|
||||
'common.scheduled_start': 'Inicio programado',
|
||||
@@ -15,6 +16,7 @@ export const langEs: TranslationObject = {
|
||||
'common.no_data': 'Sin datos',
|
||||
'countdown.ended': 'Evento finalizado a las',
|
||||
'countdown.running': 'Evento en curso',
|
||||
'countdown.loaded': 'Evento está cargado', //TODO: check translation
|
||||
'countdown.select_event': 'Seleccionar un evento para seguir',
|
||||
'countdown.to_start': 'Tiempo para comenzar',
|
||||
'countdown.waiting': 'Esperando el inicio del evento',
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { TranslationObject } from './en';
|
||||
import { TranslationObject } from 'ontime-types';
|
||||
|
||||
export const langFr: TranslationObject = {
|
||||
'common.expected_finish': 'Fin estimée à',
|
||||
'common.minutes': 'min',
|
||||
'common.seconds': 'sec',
|
||||
'common.now': 'Maintenant',
|
||||
'common.next': 'A suivre',
|
||||
'common.scheduled_start': 'Début prévu',
|
||||
@@ -15,6 +16,7 @@ export const langFr: TranslationObject = {
|
||||
'common.no_data': 'Aucune donnée',
|
||||
'countdown.ended': 'Évènement terminé à',
|
||||
'countdown.running': 'Évènement en cours',
|
||||
'countdown.loaded': 'Évènement chargé',
|
||||
'countdown.select_event': 'Sélectionnez un évènement à suivre',
|
||||
'countdown.to_start': 'Évènement commence dans',
|
||||
'countdown.waiting': 'En attente du début de l’évènement',
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { TranslationObject } from './en';
|
||||
import { TranslationObject } from 'ontime-types';
|
||||
|
||||
export const langIt: TranslationObject = {
|
||||
'common.expected_finish': 'Fine Prevista',
|
||||
'common.minutes': 'min',
|
||||
'common.seconds': 'sec',
|
||||
'common.now': 'Adesso',
|
||||
'common.next': 'Prossimo',
|
||||
'common.scheduled_start': 'Inizio programmato',
|
||||
@@ -15,6 +16,7 @@ export const langIt: TranslationObject = {
|
||||
'common.no_data': 'Nessun dato disponibile',
|
||||
'countdown.ended': 'Evento finito alle',
|
||||
'countdown.running': 'Evento in corso',
|
||||
'countdown.loaded': 'Evento caricato', //TODO: check translation
|
||||
'countdown.select_event': 'Seleziona un evento da seguire',
|
||||
'countdown.to_start': 'Tempo alla partenza',
|
||||
'countdown.waiting': "In attesa dell'inizio dell'evento",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { TranslationObject } from './en';
|
||||
import { TranslationObject } from 'ontime-types';
|
||||
|
||||
export const langPt: TranslationObject = {
|
||||
'common.expected_finish': 'Término esperado',
|
||||
'common.minutes': 'min',
|
||||
'common.seconds': 'sec',
|
||||
'common.now': 'Agora',
|
||||
'common.next': 'Próximo',
|
||||
'common.scheduled_start': 'Início programado',
|
||||
@@ -15,6 +16,7 @@ export const langPt: TranslationObject = {
|
||||
'common.no_data': 'Sem dados',
|
||||
'countdown.ended': 'Evento encerrado às',
|
||||
'countdown.running': 'Evento em andamento',
|
||||
'countdown.loaded': 'Evento carregado', //TODO: check translation
|
||||
'countdown.select_event': 'Selecione um evento para acompanhar',
|
||||
'countdown.to_start': 'Tempo para iniciar',
|
||||
'countdown.waiting': 'Aguardando o início do evento',
|
||||
|
||||
@@ -17,8 +17,11 @@ export const getBackstageOptions = (
|
||||
customFields: CustomFields,
|
||||
projectData: ProjectData,
|
||||
): ViewOption[] => {
|
||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, [{ value: 'note', label: 'Note' }]);
|
||||
const projectDataOptions = makeProjectDataOptions(projectData);
|
||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'note', label: 'Note' },
|
||||
]);
|
||||
const projectDataOptions = makeProjectDataOptions(projectData, [{ value: 'none', label: 'None' }]);
|
||||
|
||||
return [
|
||||
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
|
||||
@@ -32,7 +35,7 @@ export const getBackstageOptions = (
|
||||
description: 'Select the data source for auxiliary text shown in now and next cards',
|
||||
type: 'option',
|
||||
values: secondaryOptions,
|
||||
defaultValue: '',
|
||||
defaultValue: 'none',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -47,7 +50,7 @@ export const getBackstageOptions = (
|
||||
description: 'Select a project data source to show in the view',
|
||||
type: 'option',
|
||||
values: projectDataOptions,
|
||||
defaultValue: '',
|
||||
defaultValue: 'none',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -118,7 +118,11 @@ $item-height: 3.5rem;
|
||||
}
|
||||
|
||||
.sub--live {
|
||||
background-color: $green-700;
|
||||
background-color: $active-green;
|
||||
}
|
||||
|
||||
.sub--armed {
|
||||
background-color: $gray-1325;
|
||||
}
|
||||
|
||||
.sub__binder {
|
||||
@@ -140,12 +144,16 @@ $item-height: 3.5rem;
|
||||
color: $delay-color;
|
||||
}
|
||||
|
||||
.sub__schedule--strike {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.sub__schedule--over {
|
||||
color: $playback-over
|
||||
color: $playback-over;
|
||||
}
|
||||
|
||||
.sub__schedule--under {
|
||||
color: $playback-under
|
||||
color: $playback-under;
|
||||
}
|
||||
|
||||
.sub__title {
|
||||
|
||||
@@ -9,6 +9,7 @@ import ViewLogo from '../../common/components/view-logo/ViewLogo';
|
||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useClock } from '../../common/hooks/useSocket';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||
import { formatTime, getDefaultFormat } from '../../common/utils/time';
|
||||
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
|
||||
import { useTranslation } from '../../translation/TranslationProvider';
|
||||
@@ -18,6 +19,7 @@ import { getCountdownOptions, useCountdownOptions } from './countdown.options';
|
||||
import { getOrderedSubscriptions } from './countdown.utils';
|
||||
import CountdownSelect from './CountdownSelect';
|
||||
import CountdownSubscriptions from './CountdownSubscriptions';
|
||||
import SingleEventCountdown from './SingleEventCountdown';
|
||||
import { CountdownData, useCountdownData } from './useCountdownData';
|
||||
|
||||
import './Countdown.scss';
|
||||
@@ -38,14 +40,14 @@ export default function CountdownLoader() {
|
||||
return <Countdown {...data} />;
|
||||
}
|
||||
|
||||
function Countdown({ customFields, events, projectData, isMirrored, settings }: CountdownData) {
|
||||
function Countdown({ customFields, rundownData, projectData, isMirrored, settings }: CountdownData) {
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const { subscriptions } = useCountdownOptions();
|
||||
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
|
||||
// gather rundown data
|
||||
const playableEvents = events.filter((event) => isOntimeEvent(event) && isPlayableEvent(event));
|
||||
const playableEvents = rundownData.filter((entry) => isOntimeEvent(entry) && isPlayableEvent(entry));
|
||||
|
||||
// gather presentation data
|
||||
const hasEvents = playableEvents.length > 0;
|
||||
@@ -84,7 +86,7 @@ function Countdown({ customFields, events, projectData, isMirrored, settings }:
|
||||
}
|
||||
|
||||
interface CountdownContentsProps {
|
||||
playableEvents: OntimeEvent[];
|
||||
playableEvents: ExtendedEntry<OntimeEvent>[];
|
||||
subscriptions: EntryId[];
|
||||
goToEditMode: () => void;
|
||||
}
|
||||
@@ -116,6 +118,12 @@ function CountdownContents({ playableEvents, subscriptions, goToEditMode }: Coun
|
||||
);
|
||||
}
|
||||
|
||||
if (subscribedEvents.length === 1) {
|
||||
const event = subscribedEvents.at(0);
|
||||
if (!event) return null;
|
||||
return <SingleEventCountdown subscribedEvent={event} goToEditMode={goToEditMode} />;
|
||||
}
|
||||
|
||||
return <CountdownSubscriptions subscribedEvents={subscribedEvents} goToEditMode={goToEditMode} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +1,37 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { IoPencil } from 'react-icons/io5';
|
||||
import { EntryId, OntimeEvent } from 'ontime-types';
|
||||
import { MaybeNumber, OntimeEvent } from 'ontime-types';
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
|
||||
import Button from '../../common/components/buttons/Button';
|
||||
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
|
||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||
import {
|
||||
useCountdownSocket,
|
||||
useCurrentDay,
|
||||
usePlayback,
|
||||
useRuntimeOffset,
|
||||
useSelectedEventId,
|
||||
} from '../../common/hooks/useSocket';
|
||||
import { useExpectedStartData, usePlayback, useSelectedEventId } from '../../common/hooks/useSocket';
|
||||
import useReport from '../../common/hooks-query/useReport';
|
||||
import { getOffsetState } from '../../common/utils/offset';
|
||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
import { throttle } from '../../common/utils/throttle';
|
||||
import FollowButton from '../../features/operator/follow-button/FollowButton';
|
||||
import ClockTime from '../../features/viewers/common/clock-time/ClockTime';
|
||||
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
|
||||
import { getPropertyValue } from '../../features/viewers/common/viewUtils';
|
||||
import { useTranslation } from '../../translation/TranslationProvider';
|
||||
|
||||
import { useCountdownOptions } from './countdown.options';
|
||||
import { getIsLive, getSubscriptionDisplayData, sanitiseTitle, timerProgress } from './countdown.utils';
|
||||
import {
|
||||
CountdownEvent,
|
||||
extendEventData,
|
||||
getIsLive,
|
||||
isOutsideRange,
|
||||
preferredFormat12,
|
||||
preferredFormat24,
|
||||
useSubscriptionDisplayData,
|
||||
} from './countdown.utils';
|
||||
|
||||
import './Countdown.scss';
|
||||
|
||||
interface CountdownSubscriptionsProps {
|
||||
subscribedEvents: OntimeEvent[];
|
||||
subscribedEvents: ExtendedEntry<OntimeEvent>[];
|
||||
goToEditMode: () => void;
|
||||
}
|
||||
|
||||
@@ -36,6 +41,9 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
||||
const { selectedEventId } = useSelectedEventId();
|
||||
const showFab = useFadeOutOnInactivity(true);
|
||||
|
||||
const { data: reportData } = useReport();
|
||||
const { offset, currentDay, actualStart, plannedStart, mode } = useExpectedStartData();
|
||||
|
||||
const timeoutId = useRef<NodeJS.Timeout | null>(null);
|
||||
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
||||
const selectedRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -92,23 +100,19 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
||||
{subscribedEvents.map((event) => {
|
||||
const secondaryData = getPropertyValue(event, secondarySource);
|
||||
const isLive = getIsLive(event.id, selectedEventId, playback);
|
||||
|
||||
const isArmed = !isLive && event.id === selectedEventId;
|
||||
const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData);
|
||||
const title = event.title.length ? event.title : ' '; // insert utf-8 empty space to avoid the line collapsing
|
||||
return (
|
||||
<div key={event.id} ref={isLive ? selectedRef : undefined} className={cx(['sub', isLive && 'sub--live'])}>
|
||||
<div
|
||||
key={event.id}
|
||||
ref={isLive ? selectedRef : undefined}
|
||||
className={cx(['sub', isLive && 'sub--live', isArmed && 'sub--armed'])}
|
||||
>
|
||||
<div className='sub__binder' style={{ '--user-color': event.colour }} />
|
||||
<div className={cx(['sub__schedule', event.delay > 0 && 'sub__schedule--delayed'])}>
|
||||
{showExpected ? (
|
||||
<ExpectedSchedule timeStart={event.timeStart} timeEnd={event.timeEnd} delay={event.delay} />
|
||||
) : (
|
||||
<>
|
||||
<ClockTime value={event.timeStart + event.delay} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
|
||||
→
|
||||
<ClockTime value={event.timeEnd + event.delay} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<SubscriptionStatus key={event.id} event={event} selectedEventId={selectedEventId} />
|
||||
<div className={cx(['sub__title', !event.title && 'subdued'])}>{sanitiseTitle(event.title)}</div>
|
||||
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
|
||||
<SubscriptionStatus event={countdownEvent} />
|
||||
<div className={cx(['sub__title', !event.title && 'subdued'])}>{title}</div>
|
||||
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
|
||||
</div>
|
||||
);
|
||||
@@ -123,63 +127,82 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
||||
);
|
||||
}
|
||||
|
||||
interface ExpectedScheduleProps {
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
delay: number;
|
||||
}
|
||||
function ExpectedSchedule(props: ExpectedScheduleProps) {
|
||||
const { timeStart, timeEnd, delay } = props;
|
||||
type ScheduleTimeProps = {
|
||||
event: CountdownEvent;
|
||||
showExpected: boolean;
|
||||
};
|
||||
//TODO: consider relative mode
|
||||
export function ScheduleTime(props: ScheduleTimeProps) {
|
||||
const { event, showExpected } = props;
|
||||
const { timeStart, duration, delay, expectedStart, countToEnd } = event;
|
||||
|
||||
const { offset } = useRuntimeOffset();
|
||||
const plannedStart = timeStart + delay + event.dayOffset * dayInMs;
|
||||
|
||||
// offset is negative if we are ahead
|
||||
const expectedOffset = offset - delay;
|
||||
const expectedState = getOffsetState(expectedOffset);
|
||||
// only show new exacted value if outside range of the planned value
|
||||
const isExpectedValueShow = showExpected && isOutsideRange(plannedStart, expectedStart);
|
||||
|
||||
const plannedStateClass = isExpectedValueShow ? 'sub__schedule--strike' : delay !== 0 ? 'sub__schedule--delayed' : '';
|
||||
|
||||
const expectedStateClass = `sub__schedule--${getOffsetState(expectedStart - plannedStart)}`;
|
||||
const plannedEnd = plannedStart + duration + delay;
|
||||
const expectedEnd = countToEnd ? Math.max(expectedStart + duration, plannedEnd) : expectedStart + duration;
|
||||
const expectedEndClass = `sub__schedule--${getOffsetState(expectedEnd - plannedEnd)}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='sub__schedule'>
|
||||
<ClockTime
|
||||
value={timeStart - expectedOffset}
|
||||
className={`sub__schedule--${expectedState}`}
|
||||
preferredFormat12='h:mm'
|
||||
preferredFormat24='HH:mm'
|
||||
value={plannedStart}
|
||||
preferredFormat12={preferredFormat12}
|
||||
preferredFormat24={preferredFormat24}
|
||||
className={plannedStateClass}
|
||||
/>
|
||||
→
|
||||
<ClockTime value={timeEnd - expectedOffset} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
|
||||
</>
|
||||
{!isExpectedValueShow && (
|
||||
<>
|
||||
→
|
||||
<ClockTime
|
||||
value={plannedEnd}
|
||||
preferredFormat12={preferredFormat12}
|
||||
preferredFormat24={preferredFormat24}
|
||||
className={plannedStateClass}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isExpectedValueShow && (
|
||||
<>
|
||||
<ClockTime
|
||||
value={expectedStart}
|
||||
className={expectedStateClass}
|
||||
preferredFormat12={preferredFormat12}
|
||||
preferredFormat24={preferredFormat24}
|
||||
/>
|
||||
→
|
||||
<ClockTime
|
||||
value={expectedEnd}
|
||||
className={expectedEndClass}
|
||||
preferredFormat12={preferredFormat12}
|
||||
preferredFormat24={preferredFormat24}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SubscriptionStatusProps {
|
||||
event: OntimeEvent;
|
||||
selectedEventId: EntryId | null;
|
||||
event: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number };
|
||||
}
|
||||
|
||||
function SubscriptionStatus({ event, selectedEventId }: SubscriptionStatusProps) {
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const { currentDay } = useCurrentDay();
|
||||
const { offset } = useRuntimeOffset();
|
||||
const { showExpected } = useCountdownOptions();
|
||||
const { playback, current, clock } = useCountdownSocket();
|
||||
|
||||
// TODO: use reporter values as in the event block chip
|
||||
const { status, timer } = getSubscriptionDisplayData(
|
||||
current,
|
||||
playback,
|
||||
clock,
|
||||
event,
|
||||
selectedEventId,
|
||||
offset,
|
||||
currentDay,
|
||||
getLocalizedString('common.minutes'),
|
||||
showExpected,
|
||||
);
|
||||
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
|
||||
const { status, statusDisplay, timeDisplay } = useSubscriptionDisplayData(event);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='sub__status'>{getLocalizedString(timerProgress[status])}</div>
|
||||
<div className='sub__timer'>{timer}</div>
|
||||
<div className='sub__status'>{statusDisplay}</div>
|
||||
{status === 'done' ? (
|
||||
<SuperscriptTime className='sub__timer' time={timeDisplay} />
|
||||
) : (
|
||||
<div className='sub__timer'>{timeDisplay}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
@use '@/theme/viewerDefs' as *;
|
||||
|
||||
.single-container {
|
||||
margin-top: 7.5vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5rem;
|
||||
}
|
||||
|
||||
.event__title {
|
||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
padding: $view-card-padding;
|
||||
border-radius: $element-border-radius;
|
||||
font-size: clamp(40px, 4.5vw, 80px);
|
||||
line-height: 1.1;
|
||||
text-align: center;
|
||||
border-left: 0.25em solid;
|
||||
border-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
|
||||
.secondary {
|
||||
text-align: left;
|
||||
font-size: $title-font-size;
|
||||
}
|
||||
|
||||
.sub__schedule {
|
||||
color: inherit;
|
||||
font-size: $base-font-size;
|
||||
}
|
||||
}
|
||||
|
||||
.event__status {
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
font-size: $header-font-size;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.event__timer {
|
||||
color: var(--timer-color-override, $timer-color);
|
||||
font-size: 15vw;
|
||||
line-height: 0.9em;
|
||||
text-align: center;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { IoPencil } from 'react-icons/io5';
|
||||
import { MaybeNumber, OntimeEvent } from 'ontime-types';
|
||||
import { getExpectedStart } from 'ontime-utils';
|
||||
|
||||
import Button from '../../common/components/buttons/Button';
|
||||
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
|
||||
import { useExpectedStartData } from '../../common/hooks/useSocket';
|
||||
import useReport from '../../common/hooks-query/useReport';
|
||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
|
||||
import { getPropertyValue } from '../../features/viewers/common/viewUtils';
|
||||
|
||||
import { useCountdownOptions } from './countdown.options';
|
||||
import { useSubscriptionDisplayData } from './countdown.utils';
|
||||
import { ScheduleTime } from './CountdownSubscriptions';
|
||||
|
||||
import './SingleEventCountdown.scss';
|
||||
|
||||
interface SingleEventCountdownProps {
|
||||
subscribedEvent: ExtendedEntry<OntimeEvent>;
|
||||
goToEditMode: () => void;
|
||||
}
|
||||
|
||||
export default function SingleEventCountdown({ subscribedEvent, goToEditMode }: SingleEventCountdownProps) {
|
||||
const { secondarySource, showExpected } = useCountdownOptions();
|
||||
const showFab = useFadeOutOnInactivity(true);
|
||||
const { data: reportData } = useReport();
|
||||
|
||||
const { offset, currentDay, actualStart, plannedStart, mode } = useExpectedStartData();
|
||||
const { totalGap, isLinkedToLoaded } = subscribedEvent;
|
||||
const expectedStart = getExpectedStart(subscribedEvent, {
|
||||
currentDay,
|
||||
totalGap,
|
||||
actualStart,
|
||||
plannedStart,
|
||||
isLinkedToLoaded,
|
||||
offset,
|
||||
mode,
|
||||
});
|
||||
|
||||
const { endedAt } = reportData[subscribedEvent.id] ?? { endedAt: null };
|
||||
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt };
|
||||
const title = subscribedEvent.title.length ? subscribedEvent.title : ' '; // insert utf-8 empty space to avoid the line collapsing
|
||||
const secondaryData = getPropertyValue(subscribedEvent, secondarySource);
|
||||
|
||||
return (
|
||||
<div className='single-container' data-testid='countdown-event'>
|
||||
<SubscriptionStatus event={countdownEvent} />
|
||||
<div className='event__title' style={{ borderColor: countdownEvent.colour }}>
|
||||
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
|
||||
{title}
|
||||
{secondaryData && <div className='secondary'>{secondaryData}</div>}
|
||||
</div>
|
||||
<div className={cx(['fab-container', !showFab && 'fab-container--hidden'])}>
|
||||
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
|
||||
<IoPencil /> Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SubscriptionStatusProps {
|
||||
event: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number };
|
||||
}
|
||||
|
||||
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
|
||||
const { status, statusDisplay, timeDisplay } = useSubscriptionDisplayData(event);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='event__status'>{statusDisplay}</div>
|
||||
{status === 'done' ? (
|
||||
<SuperscriptTime className='event__timer' time={timeDisplay} />
|
||||
) : (
|
||||
<div className='event__timer'>{timeDisplay}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,10 @@ export const getCountdownOptions = (
|
||||
customFields: CustomFields,
|
||||
persistedSubscriptions: EntryId[],
|
||||
): ViewOption[] => {
|
||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, [{ value: 'note', label: 'Note' }]);
|
||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'note', label: 'Note' },
|
||||
]);
|
||||
|
||||
return [
|
||||
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
|
||||
@@ -28,7 +31,7 @@ export const getCountdownOptions = (
|
||||
description: 'Select the data source for auxiliary text shown in the card',
|
||||
type: 'option',
|
||||
values: secondaryOptions,
|
||||
defaultValue: '',
|
||||
defaultValue: 'none',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { EntryId, MaybeNumber, OntimeEvent, Playback, TimerType } from 'ontime-types';
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
import { EntryId, MaybeNumber, OffsetMode, OntimeEntry, OntimeEvent, OntimeReport, Playback } from 'ontime-types';
|
||||
import { getExpectedStart, MILLIS_PER_MINUTE, removeSeconds } from 'ontime-utils';
|
||||
|
||||
import { getFormattedTimer } from '../../features/viewers/common/viewUtils';
|
||||
import type { TranslationKey } from '../../translation/TranslationProvider';
|
||||
import { useCountdownSocket } from '../../common/hooks/useSocket';
|
||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||
import { timerPlaceholderMin } from '../../common/utils/styleUtils';
|
||||
import { formatDuration, formatTime } from '../../common/utils/time';
|
||||
import { type TranslationKey, useTranslation } from '../../translation/TranslationProvider';
|
||||
|
||||
/**
|
||||
* Parses string as a title
|
||||
@@ -11,6 +14,9 @@ export function sanitiseTitle(title: string | null) {
|
||||
return title ?? '{no title}';
|
||||
}
|
||||
|
||||
export const preferredFormat12 = 'h:mm a';
|
||||
export const preferredFormat24 = 'HH:mm';
|
||||
|
||||
/**
|
||||
* Whether the current event is live
|
||||
*/
|
||||
@@ -18,137 +24,96 @@ export function getIsLive(currentId: EntryId, selectedId: EntryId | null, playba
|
||||
return currentId === selectedId && playback !== Playback.Armed;
|
||||
}
|
||||
|
||||
const subscriptionTimerDisplayOptions = {
|
||||
removeSeconds: true,
|
||||
removeLeadingZero: true,
|
||||
} as const;
|
||||
|
||||
const subscriptionScheduledTimeDisplayOptions = {
|
||||
removeSeconds: true,
|
||||
removeLeadingZero: false,
|
||||
} as const;
|
||||
|
||||
type TimerMessage = Record<string, TranslationKey>;
|
||||
export type ProgressStatus = 'future' | 'due' | 'live' | 'done';
|
||||
export type ProgressStatus = 'future' | 'due' | 'live' | 'done' | 'pending' | 'loaded';
|
||||
type TimerMessage = Record<ProgressStatus, TranslationKey>;
|
||||
|
||||
export const timerProgress: TimerMessage = {
|
||||
future: 'countdown.to_start',
|
||||
due: 'timeline.due',
|
||||
live: 'timeline.live',
|
||||
live: 'countdown.running',
|
||||
pending: 'countdown.waiting',
|
||||
loaded: 'countdown.loaded',
|
||||
done: 'countdown.ended',
|
||||
};
|
||||
|
||||
export function getFormattedTime(
|
||||
value: MaybeNumber,
|
||||
status: ProgressStatus,
|
||||
minText: string,
|
||||
secText: string,
|
||||
dueText: string,
|
||||
) {
|
||||
if (value === null) return timerPlaceholderMin;
|
||||
if (status === 'future' || status === 'live') {
|
||||
if (value <= 0) return dueText.toUpperCase();
|
||||
return formatDuration(value, value > MILLIS_PER_MINUTE * 2)
|
||||
.replace('m', `${minText} `)
|
||||
.replace('s', secText);
|
||||
}
|
||||
return removeSeconds(formatTime(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a parsed timer and relevant status message
|
||||
* Handles events in different days but disregards whether an event has actually played
|
||||
* TODO: get data from reporter and check if the event has played
|
||||
* TODO: get timer data granularly
|
||||
*/
|
||||
export function getSubscriptionDisplayData(
|
||||
current: MaybeNumber,
|
||||
playback: Playback,
|
||||
clock: number,
|
||||
subscribedEvent: OntimeEvent,
|
||||
selectedId: EntryId | null,
|
||||
offset: number,
|
||||
currentDay: number,
|
||||
minutesString: string,
|
||||
showExpected = false,
|
||||
): { status: ProgressStatus; timer: string } {
|
||||
const offsetAndDelay = showExpected ? offset + subscribedEvent.delay : 0;
|
||||
export function useSubscriptionDisplayData(
|
||||
subscribedEvent: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number },
|
||||
): { status: ProgressStatus; statusDisplay: string; timeDisplay: string } {
|
||||
const { playback, current, clock } = useCountdownSocket();
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
if (selectedId === subscribedEvent.id) {
|
||||
// 1. An event that is loaded but not running is {'due': <countdown | overtime>}
|
||||
const bigDuration = (value: number) => {
|
||||
if (value <= 0) return getLocalizedString('countdown.overtime').toUpperCase();
|
||||
return formatDuration(value, value > MILLIS_PER_MINUTE * 2)
|
||||
.replace('m', `${getLocalizedString('common.minutes')} `)
|
||||
.replace('s', getLocalizedString('common.seconds'));
|
||||
};
|
||||
|
||||
if (subscribedEvent.isLoaded) {
|
||||
if (playback === Playback.Armed) {
|
||||
// if we are following the event, but it is not running, we show the scheduled start
|
||||
return {
|
||||
status: 'due',
|
||||
timer: getFormattedTimer(
|
||||
subscribedEvent.timeStart + offsetAndDelay,
|
||||
TimerType.CountDown,
|
||||
minutesString,
|
||||
subscriptionScheduledTimeDisplayOptions,
|
||||
),
|
||||
status: 'loaded',
|
||||
statusDisplay: getLocalizedString(timerProgress['loaded']),
|
||||
timeDisplay: bigDuration(subscribedEvent.duration),
|
||||
};
|
||||
}
|
||||
|
||||
// 2. An event with a time-to-start lower than 0 is {'due': <countdown | scheduledStart>}, show the running timer
|
||||
return {
|
||||
status: 'live',
|
||||
timer: getFormattedTimer(current, TimerType.CountDown, minutesString, subscriptionTimerDisplayOptions),
|
||||
statusDisplay: getLocalizedString(timerProgress['live']),
|
||||
timeDisplay: bigDuration(current ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* If the running timer is not the one we are following
|
||||
* we can be in future, due or have ended
|
||||
*/
|
||||
|
||||
// 3. event is the day after, we show a countdown to start
|
||||
if (subscribedEvent.dayOffset > currentDay) {
|
||||
const dayOffset = (subscribedEvent.dayOffset - currentDay) * dayInMs;
|
||||
if (playback === Playback.Stop || playback === Playback.Armed) {
|
||||
return {
|
||||
status: 'future',
|
||||
timer: getFormattedTimer(
|
||||
subscribedEvent.timeStart + dayOffset - clock - offsetAndDelay,
|
||||
TimerType.CountDown,
|
||||
minutesString,
|
||||
subscriptionTimerDisplayOptions,
|
||||
),
|
||||
status: 'pending',
|
||||
statusDisplay: getLocalizedString(timerProgress['pending']),
|
||||
timeDisplay: ' ',
|
||||
};
|
||||
}
|
||||
|
||||
// 4. event is the before after, show the scheduled end
|
||||
// TODO: get the time from the reporter
|
||||
if (subscribedEvent.dayOffset < currentDay) {
|
||||
if (subscribedEvent.isPast) {
|
||||
return {
|
||||
status: 'done',
|
||||
timer: getFormattedTimer(
|
||||
subscribedEvent.timeEnd,
|
||||
TimerType.CountDown,
|
||||
minutesString,
|
||||
subscriptionScheduledTimeDisplayOptions,
|
||||
),
|
||||
statusDisplay: getLocalizedString(timerProgress['done']),
|
||||
timeDisplay: formatTime(subscribedEvent.endedAt, { format12: preferredFormat12, format24: preferredFormat24 }),
|
||||
};
|
||||
}
|
||||
|
||||
// 5. if event is in future, we count to the scheduled start
|
||||
// TODO: get time until
|
||||
if (clock < subscribedEvent.timeStart) {
|
||||
if (subscribedEvent.expectedStart - clock <= 0) {
|
||||
return {
|
||||
status: 'future',
|
||||
timer: getFormattedTimer(
|
||||
subscribedEvent.timeStart - clock - offsetAndDelay,
|
||||
TimerType.CountDown,
|
||||
minutesString,
|
||||
subscriptionTimerDisplayOptions,
|
||||
),
|
||||
status: 'due',
|
||||
statusDisplay: getLocalizedString(timerProgress['future']), // We use future here on purpose for the look of it
|
||||
timeDisplay: getLocalizedString(timerProgress['due']).toUpperCase(),
|
||||
};
|
||||
}
|
||||
|
||||
// 6. if event has ended, we show the scheduled end
|
||||
// TODO: get the time from the reporter
|
||||
if (clock > subscribedEvent.timeEnd) {
|
||||
return {
|
||||
status: 'done',
|
||||
timer: getFormattedTimer(
|
||||
subscribedEvent.timeEnd,
|
||||
TimerType.CountDown,
|
||||
minutesString,
|
||||
subscriptionScheduledTimeDisplayOptions,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// the event here has to be due, we show the countdown the expected start time
|
||||
return {
|
||||
status: 'due',
|
||||
timer: getFormattedTimer(
|
||||
subscribedEvent.timeStart + offsetAndDelay,
|
||||
TimerType.CountDown,
|
||||
minutesString,
|
||||
subscriptionScheduledTimeDisplayOptions,
|
||||
),
|
||||
status: 'future',
|
||||
statusDisplay: getLocalizedString(timerProgress['future']),
|
||||
timeDisplay: bigDuration(subscribedEvent.expectedStart - clock),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -181,7 +146,7 @@ export function makeSubscriptionsUrl(urlRef: string, subscriptions: EntryId[]) {
|
||||
* Since the original array is already ordered, we simply filter out the events
|
||||
* which are not in the subscriptions list.
|
||||
*/
|
||||
export function getOrderedSubscriptions(subscriptions: EntryId[], playableEvents: OntimeEvent[]): OntimeEvent[] {
|
||||
export function getOrderedSubscriptions<T extends OntimeEntry>(subscriptions: EntryId[], playableEvents: T[]): T[] {
|
||||
return playableEvents.filter((event) => subscriptions.includes(event.id));
|
||||
}
|
||||
|
||||
@@ -212,3 +177,32 @@ export function isLinkedToLoadedEvent(events: OntimeEvent[], loadedId: EntryId |
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isOutsideRange(a: number, b: number): boolean {
|
||||
return Math.abs(a - b) > MILLIS_PER_MINUTE;
|
||||
}
|
||||
|
||||
export type CountdownEvent = ExtendedEntry<OntimeEvent> & { expectedStart: number; endedAt: MaybeNumber };
|
||||
|
||||
export function extendEventData(
|
||||
event: ExtendedEntry<OntimeEvent>,
|
||||
currentDay: number,
|
||||
actualStart: MaybeNumber,
|
||||
plannedStart: MaybeNumber,
|
||||
offset: number,
|
||||
mode: OffsetMode,
|
||||
reportData: OntimeReport,
|
||||
): CountdownEvent {
|
||||
const { totalGap, isLinkedToLoaded } = event;
|
||||
const expectedStart = getExpectedStart(event, {
|
||||
currentDay,
|
||||
totalGap,
|
||||
actualStart,
|
||||
plannedStart,
|
||||
isLinkedToLoaded,
|
||||
offset,
|
||||
mode,
|
||||
});
|
||||
const { endedAt } = reportData[event.id] ?? { endedAt: null };
|
||||
return { ...event, expectedStart, endedAt };
|
||||
}
|
||||
|
||||
@@ -2,14 +2,15 @@ import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types';
|
||||
|
||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||
import useProjectData from '../../common/hooks-query/useProjectData';
|
||||
import { useFlatRundown } from '../../common/hooks-query/useRundown';
|
||||
import { useFlatRundownWithMetadata } from '../../common/hooks-query/useRundown';
|
||||
import useSettings from '../../common/hooks-query/useSettings';
|
||||
import { useViewOptionsStore } from '../../common/stores/viewOptions';
|
||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||
import { aggregateQueryStatus, ViewData } from '../utils/viewLoader.utils';
|
||||
|
||||
export interface CountdownData {
|
||||
customFields: CustomFields;
|
||||
events: OntimeEntry[];
|
||||
rundownData: ExtendedEntry<OntimeEntry>[];
|
||||
projectData: ProjectData;
|
||||
isMirrored: boolean;
|
||||
settings: Settings;
|
||||
@@ -20,7 +21,7 @@ export function useCountdownData(): ViewData<CountdownData> {
|
||||
const isMirrored = useViewOptionsStore((state) => state.mirror);
|
||||
|
||||
// HTTP API data
|
||||
const { data: rundownData, status: rundownStatus } = useFlatRundown();
|
||||
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
|
||||
const { data: projectData, status: projectDataStatus } = useProjectData();
|
||||
const { data: settings, status: settingsStatus } = useSettings();
|
||||
const { data: customFields, status: customFieldsStatus } = useCustomFields();
|
||||
@@ -28,7 +29,7 @@ export function useCountdownData(): ViewData<CountdownData> {
|
||||
return {
|
||||
data: {
|
||||
customFields,
|
||||
events: rundownData,
|
||||
rundownData,
|
||||
projectData,
|
||||
isMirrored,
|
||||
settings,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useSessionStorage } from '@mantine/hooks';
|
||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||
import { PresetContext } from '../../common/context/PresetContext';
|
||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||
import { useFlatRundown } from '../../common/hooks-query/useRundown';
|
||||
import { sessionScope } from '../../externals';
|
||||
import { AppMode, sessionKeys } from '../../ontimeConfig';
|
||||
|
||||
@@ -15,7 +14,6 @@ import { useCuesheetPermissions } from './useTablePermissions';
|
||||
|
||||
export default memo(CuesheetTableWrapper);
|
||||
function CuesheetTableWrapper() {
|
||||
const { data: flatRundown, status: rundownStatus } = useFlatRundown();
|
||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
|
||||
const preset = use(PresetContext);
|
||||
@@ -52,15 +50,11 @@ function CuesheetTableWrapper() {
|
||||
[customFields, cuesheetMode, preset],
|
||||
);
|
||||
|
||||
const isLoading = !customFields || !flatRundown || rundownStatus === 'pending' || customFieldStatus === 'pending';
|
||||
const isLoading = !customFields || customFieldStatus === 'pending';
|
||||
|
||||
return (
|
||||
<CuesheetDnd columns={columns}>
|
||||
{isLoading ? (
|
||||
<EmptyPage text='Loading...' />
|
||||
) : (
|
||||
<CuesheetTable data={flatRundown} columns={columns} cuesheetMode={cuesheetMode} />
|
||||
)}
|
||||
{isLoading ? <EmptyPage text='Loading...' /> : <CuesheetTable columns={columns} cuesheetMode={cuesheetMode} />}
|
||||
</CuesheetDnd>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@ import {
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { OntimeEntry } from 'ontime-types';
|
||||
|
||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
import useColumnManager from '../cuesheet-table/useColumnManager';
|
||||
|
||||
interface CuesheetDndProps {
|
||||
columns: ColumnDef<OntimeEntry>[];
|
||||
columns: ColumnDef<ExtendedEntry>[];
|
||||
}
|
||||
|
||||
export default function CuesheetDnd({ columns, children }: PropsWithChildren<CuesheetDndProps>) {
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
$table-font-size: 1rem;
|
||||
$table-header-font-size: calc(1rem - 2px);
|
||||
|
||||
.cuesheetContainer {
|
||||
grid-area: table;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding-bottom: 70vh; // allow focus to reach last elements
|
||||
}
|
||||
|
||||
.cuesheet {
|
||||
font-size: $table-font-size;
|
||||
font-weight: 400;
|
||||
color: $ui-white;
|
||||
padding-bottom: 70vh; // allow focus to reach last elements
|
||||
|
||||
thead {
|
||||
tr {
|
||||
&::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tr {
|
||||
display: flex;
|
||||
@@ -30,10 +32,6 @@ $table-header-font-size: calc(1rem - 2px);
|
||||
width: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&:first-of-type {
|
||||
margin-left: 4px; // compensate left border
|
||||
}
|
||||
}
|
||||
|
||||
th,
|
||||
@@ -82,7 +80,7 @@ $table-header-font-size: calc(1rem - 2px);
|
||||
|
||||
.actionColumn {
|
||||
background-color: $gray-1250;
|
||||
width: calc(2rem + 1px); // button + padding + margin
|
||||
width: 2rem; // button + padding + margin
|
||||
}
|
||||
.indexColumn {
|
||||
width: 3.5em;
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { TableVirtuoso, TableVirtuosoHandle } from 'react-virtuoso';
|
||||
import { useTableNav } from '@table-nav/react';
|
||||
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import { OntimeEntry, TimeField } from 'ontime-types';
|
||||
import { isOntimeDelay, isOntimeGroup, isOntimeMilestone, OntimeEntry, TimeField } from 'ontime-types';
|
||||
|
||||
import EmptyPage from '../../../common/components/state/EmptyPage';
|
||||
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
|
||||
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||
import { useFollowSelected } from '../../../common/hooks/useFollowComponent';
|
||||
import { useSelectedEventId } from '../../../common/hooks/useSocket';
|
||||
import { useFlatRundownWithMetadata } from '../../../common/hooks-query/useRundown';
|
||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
import { AppMode } from '../../../ontimeConfig';
|
||||
import { usePersistedCuesheetOptions } from '../cuesheet.options';
|
||||
|
||||
import CuesheetBody from './cuesheet-table-elements/CuesheetBody';
|
||||
import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
|
||||
import DelayRow from './cuesheet-table-elements/DelayRow';
|
||||
import EventRow from './cuesheet-table-elements/EventRow';
|
||||
import GroupRow from './cuesheet-table-elements/GroupRow';
|
||||
import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
|
||||
import CuesheetTableMenu from './cuesheet-table-menu/CuesheetTableMenu';
|
||||
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
|
||||
import useColumnManager from './useColumnManager';
|
||||
@@ -17,19 +25,20 @@ import useColumnManager from './useColumnManager';
|
||||
import style from './CuesheetTable.module.scss';
|
||||
|
||||
interface CuesheetTableProps {
|
||||
data: OntimeEntry[];
|
||||
columns: ColumnDef<OntimeEntry>[];
|
||||
columns: ColumnDef<ExtendedEntry>[];
|
||||
cuesheetMode: AppMode;
|
||||
}
|
||||
|
||||
export default function CuesheetTable({ data, columns, cuesheetMode }: CuesheetTableProps) {
|
||||
export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTableProps) {
|
||||
const { data, status } = useFlatRundownWithMetadata();
|
||||
const { updateEntry, updateTimer } = useEntryActions();
|
||||
const showDelayedTimes = usePersistedCuesheetOptions((state) => state.showDelayedTimes);
|
||||
const hideTableSeconds = usePersistedCuesheetOptions((state) => state.hideTableSeconds);
|
||||
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
|
||||
|
||||
const { selectedRef, scrollRef } = useFollowSelected(cuesheetMode === AppMode.Run);
|
||||
const { selectedEventId } = useSelectedEventId();
|
||||
|
||||
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
|
||||
const { listeners } = useTableNav();
|
||||
|
||||
const meta = useMemo(
|
||||
@@ -96,9 +105,15 @@ export default function CuesheetTable({ data, columns, cuesheetMode }: CuesheetT
|
||||
setColumnSizing({});
|
||||
}, [setColumnSizing]);
|
||||
|
||||
const headerGroups = table.getHeaderGroups();
|
||||
const rowModel = table.getRowModel();
|
||||
const allLeafColumns = table.getAllLeafColumns();
|
||||
// in run mode, we follow the selected row
|
||||
useEffect(() => {
|
||||
if (cuesheetMode === AppMode.Edit || virtuosoRef.current === null || !selectedEventId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventIndex = data.findIndex((event) => event.id === selectedEventId);
|
||||
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'smooth' });
|
||||
}, [cuesheetMode, data, selectedEventId]);
|
||||
|
||||
/**
|
||||
* To improve performance on resizing, we memoise the column sizes
|
||||
@@ -118,6 +133,15 @@ export default function CuesheetTable({ data, columns, cuesheetMode }: CuesheetT
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- this works well and follows documentation
|
||||
}, [table.getState().columnSizingInfo, table.getState().columnSizing]);
|
||||
|
||||
const allLeafColumns = table.getAllLeafColumns();
|
||||
const { rows } = table.getRowModel();
|
||||
|
||||
const isLoading = !data || status === 'pending';
|
||||
|
||||
if (isLoading) {
|
||||
return <EmptyPage text='Loading...' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CuesheetTableSettings
|
||||
@@ -126,25 +150,102 @@ export default function CuesheetTable({ data, columns, cuesheetMode }: CuesheetT
|
||||
handleResetReordering={resetColumnOrder}
|
||||
handleClearToggles={setAllVisible}
|
||||
/>
|
||||
<div className={style.cuesheetContainer} ref={scrollRef}>
|
||||
<table className={style.cuesheet} id='cuesheet' style={{ ...columnSizeVars }} {...listeners}>
|
||||
<CuesheetHeader headerGroups={headerGroups} cuesheetMode={cuesheetMode} />
|
||||
{table.getState().columnSizingInfo.isResizingColumn ? (
|
||||
<MemoisedBody rowModel={rowModel} selectedRef={selectedRef} table={table} />
|
||||
) : (
|
||||
<CuesheetBody rowModel={rowModel} selectedRef={selectedRef} table={table} />
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
<TableVirtuoso
|
||||
ref={virtuosoRef}
|
||||
data={data}
|
||||
increaseViewportBy={{ top: 100, bottom: 200 }}
|
||||
components={{
|
||||
EmptyPlaceholder: () => <EmptyTableBody text='No data in rundown' />,
|
||||
Table: ({ style: injectedStyles, ...virtuosoProps }) => {
|
||||
return (
|
||||
<table
|
||||
className={style.cuesheet}
|
||||
id='cuesheet'
|
||||
style={{ ...injectedStyles, ...columnSizeVars }}
|
||||
{...listeners}
|
||||
{...virtuosoProps}
|
||||
/>
|
||||
);
|
||||
},
|
||||
TableRow: ({ item: _item, style: injectedStyles, ...virtuosoProps }) => {
|
||||
// eslint-disable-next-line react/destructuring-assignment
|
||||
const rowIndex = virtuosoProps['data-index'];
|
||||
const row = rows[rowIndex];
|
||||
const key = row.original.id;
|
||||
const entry = row.original;
|
||||
|
||||
if (isOntimeGroup(entry)) {
|
||||
return (
|
||||
<GroupRow
|
||||
key={key}
|
||||
groupId={entry.id}
|
||||
colour={entry.colour}
|
||||
rowId={row.id}
|
||||
rowIndex={row.index}
|
||||
table={table}
|
||||
injectedStyles={injectedStyles}
|
||||
{...virtuosoProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isOntimeDelay(entry)) {
|
||||
return (
|
||||
<DelayRow key={key} duration={entry.duration} injectedStyles={injectedStyles} {...virtuosoProps} />
|
||||
);
|
||||
}
|
||||
|
||||
if (isOntimeMilestone(entry)) {
|
||||
return (
|
||||
<MilestoneRow
|
||||
key={key}
|
||||
entryId={entry.id}
|
||||
isPast={entry.isPast}
|
||||
parentBgColour={entry.groupColour}
|
||||
parentId={entry.parent}
|
||||
colour={entry.colour}
|
||||
rowId={row.id}
|
||||
rowIndex={rowIndex}
|
||||
table={table}
|
||||
injectedStyles={injectedStyles}
|
||||
{...virtuosoProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EventRow
|
||||
key={row.id}
|
||||
id={entry.id}
|
||||
eventIndex={entry.eventIndex}
|
||||
colour={entry.colour}
|
||||
isFirstAfterGroup={entry.isFirstAfterGroup}
|
||||
isLoaded={entry.isLoaded}
|
||||
isPast={entry.isPast}
|
||||
groupColour={entry.groupColour}
|
||||
flag={entry.flag}
|
||||
skip={entry.skip}
|
||||
parent={entry.parent}
|
||||
rowId={row.id}
|
||||
rowIndex={rowIndex}
|
||||
table={table}
|
||||
injectedStyles={injectedStyles}
|
||||
{...virtuosoProps}
|
||||
/>
|
||||
);
|
||||
},
|
||||
TableHead: (virtuosoProps) => <thead className={style.tableHeader} {...virtuosoProps} />,
|
||||
}}
|
||||
fixedHeaderContent={() => {
|
||||
return table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup) => (
|
||||
<CuesheetHeader key={headerGroup.id} cuesheetMode={cuesheetMode} headerGroup={headerGroup} />
|
||||
));
|
||||
}}
|
||||
/>
|
||||
|
||||
<CuesheetTableMenu />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* While dragging, we avoid re-rendering the body by render
|
||||
*/
|
||||
const MemoisedBody = memo(
|
||||
CuesheetBody,
|
||||
(prev, next) => prev.table.options.data === next.table.options.data,
|
||||
) as typeof CuesheetBody;
|
||||
|
||||
-189
@@ -1,189 +0,0 @@
|
||||
import { RefObject, useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { RowModel, Table } from '@tanstack/react-table';
|
||||
import {
|
||||
isOntimeDelay,
|
||||
isOntimeEvent,
|
||||
isOntimeGroup,
|
||||
isOntimeMilestone,
|
||||
OntimeEntry,
|
||||
OntimeGroup,
|
||||
Rundown,
|
||||
} from 'ontime-types';
|
||||
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
||||
|
||||
import { RUNDOWN } from '../../../../common/api/constants';
|
||||
import EmptyTableBody from '../../../../common/components/state/EmptyTableBody';
|
||||
import { useSelectedEventId } from '../../../../common/hooks/useSocket';
|
||||
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
|
||||
|
||||
import DelayRow from './DelayRow';
|
||||
import EventRow from './EventRow';
|
||||
import GroupRow from './GroupRow';
|
||||
import MilestoneRow from './MilestoneRow';
|
||||
import { cleanup } from './rowObserver';
|
||||
|
||||
interface CuesheetBodyProps {
|
||||
rowModel: RowModel<OntimeEntry>;
|
||||
selectedRef: RefObject<HTMLTableRowElement | null>;
|
||||
table: Table<OntimeEntry>;
|
||||
}
|
||||
|
||||
export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetBodyProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedEventId } = useSelectedEventId();
|
||||
const hidePast = usePersistedCuesheetOptions((state) => state.hidePast);
|
||||
const hideDelays = usePersistedCuesheetOptions((state) => state.hideDelays);
|
||||
|
||||
let eventIndex = 0;
|
||||
// for the first event, it will be past if there is something selected
|
||||
let isPast = Boolean(selectedEventId);
|
||||
let hadGroup = false;
|
||||
|
||||
// remove the observer when the table unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanup();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (rowModel.rows.length === 0) {
|
||||
return <EmptyTableBody text='No data in rundown' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<tbody>
|
||||
{rowModel.rows.map((row, index) => {
|
||||
const key = row.original.id;
|
||||
const isSelected = selectedEventId === key;
|
||||
const entry = row.original;
|
||||
if (isSelected) {
|
||||
isPast = false;
|
||||
}
|
||||
|
||||
if (isOntimeGroup(entry)) {
|
||||
return (
|
||||
<GroupRow
|
||||
key={key}
|
||||
groupId={entry.id}
|
||||
colour={entry.colour}
|
||||
hidePast={isPast && hidePast}
|
||||
rowId={row.id}
|
||||
rowIndex={row.index}
|
||||
table={table}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isOntimeDelay(entry)) {
|
||||
if (isPast && hidePast) {
|
||||
return null;
|
||||
}
|
||||
const delayVal = entry.duration;
|
||||
if (hideDelays || delayVal === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parentBgColour: string | null = null;
|
||||
if (entry.parent) {
|
||||
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||
const parentEntry = rundown?.entries[entry.parent] as OntimeGroup | undefined;
|
||||
parentBgColour = parentEntry?.colour ?? null;
|
||||
}
|
||||
return <DelayRow key={key} duration={delayVal} parentBgColour={parentBgColour} />;
|
||||
}
|
||||
if (isOntimeMilestone(entry)) {
|
||||
if (isPast && hidePast) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let rowBgColour: string | undefined;
|
||||
if (entry.colour) {
|
||||
// the colour is user defined and might be invalid
|
||||
const accessibleBackgroundColor = cssOrHexToColour(getAccessibleColour(entry.colour).backgroundColor);
|
||||
if (accessibleBackgroundColor !== null) {
|
||||
rowBgColour = colourToHex({
|
||||
...accessibleBackgroundColor,
|
||||
alpha: accessibleBackgroundColor.alpha * 0.25,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let parentBgColour: string | null = null;
|
||||
if (entry.parent) {
|
||||
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||
const parentEntry = rundown?.entries[entry.parent];
|
||||
parentBgColour = (parentEntry as OntimeGroup | undefined)?.colour ?? null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MilestoneRow
|
||||
key={key}
|
||||
entryId={entry.id}
|
||||
isPast={isPast}
|
||||
parentBgColour={parentBgColour}
|
||||
parentId={entry.parent}
|
||||
rowBgColour={rowBgColour}
|
||||
rowId={row.id}
|
||||
rowIndex={index}
|
||||
table={table}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isOntimeEvent(entry)) {
|
||||
eventIndex++;
|
||||
const isSelected = key === selectedEventId;
|
||||
|
||||
if (isPast && hidePast) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let rowBgColour: string | undefined;
|
||||
if (isSelected) {
|
||||
rowBgColour = '#087A27'; // $active-green
|
||||
} else if (entry.colour) {
|
||||
// the colour is user defined and might be invalid
|
||||
const accessibleBackgroundColor = cssOrHexToColour(getAccessibleColour(entry.colour).backgroundColor);
|
||||
if (accessibleBackgroundColor !== null) {
|
||||
rowBgColour = colourToHex({
|
||||
...accessibleBackgroundColor,
|
||||
alpha: accessibleBackgroundColor.alpha * 0.25,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let parentBgColour: string | undefined;
|
||||
let firstAfterGroup = false;
|
||||
if (entry.parent) {
|
||||
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||
const parentEntry = rundown?.entries[entry.parent] as OntimeGroup | undefined;
|
||||
parentBgColour = parentEntry?.colour;
|
||||
hadGroup = true;
|
||||
} else if (hadGroup) {
|
||||
firstAfterGroup = true;
|
||||
hadGroup = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<EventRow
|
||||
key={row.id}
|
||||
rowId={row.id}
|
||||
event={entry}
|
||||
eventIndex={eventIndex}
|
||||
rowIndex={index}
|
||||
isPast={isPast}
|
||||
selectedRef={isSelected ? selectedRef : undefined}
|
||||
rowBgColour={rowBgColour}
|
||||
parentBgColour={parentBgColour}
|
||||
table={table}
|
||||
firstAfterGroup={firstAfterGroup}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// currently there is no scenario where entryType is not handled above, either way...
|
||||
return null;
|
||||
})}
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
+34
-43
@@ -1,8 +1,8 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable';
|
||||
import { flexRender, HeaderGroup } from '@tanstack/react-table';
|
||||
import { OntimeEntry } from 'ontime-types';
|
||||
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
|
||||
@@ -12,54 +12,45 @@ import { SortableCell } from './SortableCell';
|
||||
import style from '../CuesheetTable.module.scss';
|
||||
|
||||
interface CuesheetHeaderProps {
|
||||
headerGroups: HeaderGroup<OntimeEntry>[];
|
||||
headerGroup: HeaderGroup<ExtendedEntry>;
|
||||
cuesheetMode: AppMode;
|
||||
}
|
||||
|
||||
export default function CuesheetHeader({ headerGroups, cuesheetMode }: CuesheetHeaderProps) {
|
||||
export default function CuesheetHeader({ headerGroup, cuesheetMode }: CuesheetHeaderProps) {
|
||||
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
|
||||
|
||||
return (
|
||||
<thead className={style.tableHeader}>
|
||||
{headerGroups.map((headerGroup) => {
|
||||
const key = headerGroup.id;
|
||||
<tr key={headerGroup.id}>
|
||||
{cuesheetMode === AppMode.Edit && <th className={style.actionColumn} tabIndex={-1} />}
|
||||
{!hideIndexColumn && (
|
||||
<th className={style.indexColumn} tabIndex={-1}>
|
||||
#
|
||||
</th>
|
||||
)}
|
||||
<SortableContext key={headerGroup.id} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const customBackground = header.column.columnDef.meta?.colour;
|
||||
const canWrite = header.column.columnDef.meta?.canWrite;
|
||||
|
||||
return (
|
||||
<tr key={headerGroup.id}>
|
||||
{cuesheetMode === AppMode.Edit && <th className={style.actionColumn} tabIndex={-1} />}
|
||||
{!hideIndexColumn && (
|
||||
<th className={style.indexColumn} tabIndex={-1}>
|
||||
#
|
||||
</th>
|
||||
)}
|
||||
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const customBackground = header.column.columnDef.meta?.colour;
|
||||
const canWrite = header.column.columnDef.meta?.canWrite;
|
||||
const customStyles: CSSProperties = {
|
||||
opacity: canWrite ? 1 : 0.6,
|
||||
};
|
||||
if (customBackground) {
|
||||
const customColour = getAccessibleColour(customBackground);
|
||||
customStyles.backgroundColor = customColour.backgroundColor;
|
||||
customStyles.color = customColour.color;
|
||||
}
|
||||
|
||||
const customStyles: CSSProperties = {
|
||||
opacity: canWrite ? 1 : 0.6,
|
||||
};
|
||||
if (customBackground) {
|
||||
const customColour = getAccessibleColour(customBackground);
|
||||
customStyles.backgroundColor = customColour.backgroundColor;
|
||||
customStyles.color = customColour.color;
|
||||
}
|
||||
|
||||
return (
|
||||
<SortableCell
|
||||
key={header.column.columnDef.id}
|
||||
header={header}
|
||||
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
|
||||
>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</SortableCell>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</thead>
|
||||
return (
|
||||
<SortableCell
|
||||
key={header.column.columnDef.id}
|
||||
header={header}
|
||||
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
|
||||
>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</SortableCell>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-4
@@ -1,15 +1,13 @@
|
||||
@import '../CuesheetTable.module.scss';
|
||||
|
||||
.delayRow {
|
||||
width: calc(100vw - 2rem);
|
||||
color: $ontime-delay-text;
|
||||
border-left: 4px solid var(--user-bg);
|
||||
border-left: 4px solid transparent;
|
||||
|
||||
td {
|
||||
width: 100%;
|
||||
width: calc(100% - 4px);
|
||||
padding-block: 0.5rem;
|
||||
text-align: center;
|
||||
transform: translateX(45%);
|
||||
|
||||
&:first-letter {
|
||||
text-transform: uppercase;
|
||||
|
||||
+12
-13
@@ -1,28 +1,27 @@
|
||||
import { memo } from 'react';
|
||||
import { CSSProperties, memo } from 'react';
|
||||
|
||||
import { millisToDelayString } from '../../../../common/utils/dateConfig';
|
||||
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
|
||||
|
||||
import style from './DelayRow.module.scss';
|
||||
|
||||
interface DelayRowProps {
|
||||
duration: number;
|
||||
parentBgColour: string | null;
|
||||
injectedStyles?: CSSProperties;
|
||||
}
|
||||
|
||||
function DelayRow({ duration, parentBgColour }: DelayRowProps) {
|
||||
function DelayRow({ duration, injectedStyles, ...virtuosoProps }: DelayRowProps) {
|
||||
const hideDelays = usePersistedCuesheetOptions((state) => state.hideDelays);
|
||||
|
||||
if (hideDelays || duration === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const delayTime = millisToDelayString(duration, 'expanded');
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={style.delayRow}
|
||||
style={{
|
||||
'--user-bg': parentBgColour ?? 'transparent',
|
||||
}}
|
||||
data-testid='cuesheet-delay'
|
||||
>
|
||||
<td tabIndex={0} role='cell'>
|
||||
{delayTime}
|
||||
</td>
|
||||
<tr className={style.delayRow} data-testid='cuesheet-delay' style={injectedStyles} {...virtuosoProps}>
|
||||
<td tabIndex={0}>{delayTime}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
}
|
||||
|
||||
&.firstAfterGroup {
|
||||
margin-top: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
&.skip {
|
||||
|
||||
+67
-64
@@ -1,89 +1,94 @@
|
||||
import { RefObject, useEffect, useRef } from 'react';
|
||||
import { CSSProperties, useMemo } from 'react';
|
||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||
import { flexRender, Table } from '@tanstack/react-table';
|
||||
import { OntimeEntry, OntimeEvent, RGBColour, SupportedEntry } from 'ontime-types';
|
||||
import { EntryId, OntimeEntry, RGBColour, SupportedEntry } from 'ontime-types';
|
||||
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
||||
|
||||
import { observeRow, unobserveRow } from './rowObserver';
|
||||
import { useVisibleRowsStore } from './visibleRowsStore';
|
||||
|
||||
import style from './EventRow.module.scss';
|
||||
|
||||
interface EventRowProps {
|
||||
rowId: string;
|
||||
event: OntimeEvent;
|
||||
id: EntryId;
|
||||
eventIndex: number;
|
||||
colour: string;
|
||||
isFirstAfterGroup: boolean;
|
||||
isLoaded: boolean;
|
||||
isPast: boolean;
|
||||
groupColour: string | undefined;
|
||||
flag: boolean;
|
||||
skip: boolean;
|
||||
parent: EntryId | null;
|
||||
rowIndex: number;
|
||||
isPast?: boolean;
|
||||
selectedRef?: RefObject<HTMLTableRowElement | null>;
|
||||
skip?: boolean;
|
||||
colour?: string;
|
||||
rowBgColour?: string;
|
||||
parentBgColour?: string;
|
||||
table: Table<OntimeEntry>;
|
||||
firstAfterGroup: boolean;
|
||||
table: Table<ExtendedEntry<OntimeEntry>>;
|
||||
injectedStyles?: CSSProperties;
|
||||
}
|
||||
|
||||
export default function EventRow({
|
||||
rowId,
|
||||
event,
|
||||
id,
|
||||
eventIndex,
|
||||
rowIndex,
|
||||
colour,
|
||||
isFirstAfterGroup,
|
||||
isLoaded,
|
||||
isPast,
|
||||
selectedRef,
|
||||
rowBgColour,
|
||||
parentBgColour,
|
||||
groupColour,
|
||||
flag,
|
||||
skip,
|
||||
parent,
|
||||
rowIndex,
|
||||
table,
|
||||
firstAfterGroup,
|
||||
injectedStyles,
|
||||
...virtuosoProps
|
||||
}: EventRowProps) {
|
||||
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
|
||||
cuesheetMode: AppMode.Edit,
|
||||
hideIndexColumn: false,
|
||||
};
|
||||
|
||||
const ownRef = useRef<HTMLTableRowElement>(null);
|
||||
const isVisible = useVisibleRowsStore((state) => state.visibleRows.has(rowId));
|
||||
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
||||
|
||||
// register this row with the intersection observer
|
||||
useEffect(() => {
|
||||
const element = ownRef.current;
|
||||
if (element) {
|
||||
element.id = rowId;
|
||||
observeRow(element);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (element) {
|
||||
unobserveRow(element);
|
||||
}
|
||||
};
|
||||
}, [rowId]);
|
||||
|
||||
const { color, backgroundColor } = getAccessibleColour(event.colour);
|
||||
const { color, backgroundColor } = getAccessibleColour(colour);
|
||||
const tmpColour = cssOrHexToColour(color) as RGBColour; // we know this to be a correct colour
|
||||
const mutedText = colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.8 });
|
||||
|
||||
const rowBgColour: string | undefined = useMemo(() => {
|
||||
if (isLoaded) {
|
||||
return '#087A27'; // $active-green
|
||||
} else if (colour) {
|
||||
// the colour is user defined and might be invalid
|
||||
const accessibleBackgroundColor = cssOrHexToColour(getAccessibleColour(colour).backgroundColor);
|
||||
if (accessibleBackgroundColor !== null) {
|
||||
return colourToHex({
|
||||
...accessibleBackgroundColor,
|
||||
alpha: accessibleBackgroundColor.alpha * 0.25,
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}, [colour, isLoaded]);
|
||||
|
||||
return (
|
||||
<tr
|
||||
id={rowId}
|
||||
className={cx([
|
||||
style.eventRow,
|
||||
event.skip && style.skip,
|
||||
firstAfterGroup && style.firstAfterGroup,
|
||||
Boolean(parentBgColour) && style.hasParent,
|
||||
skip && style.skip,
|
||||
isFirstAfterGroup && style.firstAfterGroup,
|
||||
parent && style.hasParent,
|
||||
])}
|
||||
style={{
|
||||
...injectedStyles,
|
||||
opacity: `${isPast ? '0.2' : '1'}`,
|
||||
'--user-bg': parentBgColour ?? 'transparent',
|
||||
'--user-bg': groupColour ?? 'transparent',
|
||||
}}
|
||||
ref={selectedRef ?? ownRef}
|
||||
data-testid='cuesheet-event'
|
||||
{...virtuosoProps}
|
||||
>
|
||||
{cuesheetMode === AppMode.Edit && (
|
||||
<td className={style.actionColumn} tabIndex={-1} role='cell'>
|
||||
@@ -94,7 +99,7 @@ export default function EventRow({
|
||||
onClick={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const yPos = 8 + rect.y + rect.height / 2;
|
||||
openMenu({ x: rect.x, y: yPos }, event.id, SupportedEntry.Event, rowIndex, event.parent, event.flag);
|
||||
openMenu({ x: rect.x, y: yPos }, id, SupportedEntry.Event, rowIndex, parent, flag);
|
||||
}}
|
||||
>
|
||||
<IoEllipsisHorizontal />
|
||||
@@ -106,26 +111,24 @@ export default function EventRow({
|
||||
{eventIndex}
|
||||
</td>
|
||||
)}
|
||||
{isVisible
|
||||
? table
|
||||
.getRow(rowId)
|
||||
.getVisibleCells()
|
||||
.map((cell) => {
|
||||
return (
|
||||
<td
|
||||
key={cell.id}
|
||||
style={{
|
||||
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
|
||||
backgroundColor: rowBgColour,
|
||||
}}
|
||||
tabIndex={-1}
|
||||
role='cell'
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
{table
|
||||
.getRow(rowId)
|
||||
.getVisibleCells()
|
||||
.map((cell) => {
|
||||
return (
|
||||
<td
|
||||
key={cell.id}
|
||||
style={{
|
||||
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
|
||||
backgroundColor: rowBgColour,
|
||||
}}
|
||||
tabIndex={-1}
|
||||
role='cell'
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
@import '../CuesheetTable.module.scss';
|
||||
|
||||
.groupRow {
|
||||
margin-top: 1rem;
|
||||
margin-top: 2em;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: start;
|
||||
|
||||
+20
-12
@@ -1,9 +1,10 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||
import { flexRender, Table } from '@tanstack/react-table';
|
||||
import { EntryId, OntimeEntry, SupportedEntry } from 'ontime-types';
|
||||
import { EntryId, SupportedEntry } from 'ontime-types';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import { useCurrentGroupId } from '../../../../common/hooks/useSocket';
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
||||
|
||||
@@ -12,15 +13,21 @@ import style from './GroupRow.module.scss';
|
||||
interface GroupRowProps {
|
||||
groupId: EntryId;
|
||||
colour: string;
|
||||
hidePast: boolean;
|
||||
rowId: string;
|
||||
rowIndex: number;
|
||||
table: Table<OntimeEntry>;
|
||||
table: Table<ExtendedEntry>;
|
||||
injectedStyles?: CSSProperties;
|
||||
}
|
||||
|
||||
export default function GroupRow({ groupId, colour, hidePast, rowId, rowIndex, table }: GroupRowProps) {
|
||||
const { currentGroupId } = useCurrentGroupId();
|
||||
|
||||
export default function GroupRow({
|
||||
groupId,
|
||||
colour,
|
||||
rowId,
|
||||
rowIndex,
|
||||
table,
|
||||
injectedStyles,
|
||||
...virtuosoProps
|
||||
}: GroupRowProps) {
|
||||
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
|
||||
cuesheetMode: AppMode.Edit,
|
||||
hideIndexColumn: false,
|
||||
@@ -28,12 +35,13 @@ export default function GroupRow({ groupId, colour, hidePast, rowId, rowIndex, t
|
||||
|
||||
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
||||
|
||||
if (hidePast && !currentGroupId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className={style.groupRow} style={{ '--user-bg': colour }} data-testid='cuesheet-group'>
|
||||
<tr
|
||||
className={style.groupRow}
|
||||
style={{ ...injectedStyles, '--user-bg': colour }}
|
||||
data-testid='cuesheet-group'
|
||||
{...virtuosoProps}
|
||||
>
|
||||
{cuesheetMode === AppMode.Edit && (
|
||||
<td className={style.actionColumn} tabIndex={-1} role='cell'>
|
||||
<IconButton
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
@import "../CuesheetTable.module.scss";
|
||||
|
||||
.milestoneRow {
|
||||
background: color-mix(in srgb, transparent 92%, var(--user-bg, $gray-500) 8%);
|
||||
background: color-mix(in srgb, transparent 98%, var(--user-bg, $gray-500) 2%);
|
||||
border-left: 4px solid var(--user-bg, $gray-500);
|
||||
|
||||
font-style: italic;
|
||||
|
||||
+27
-7
@@ -1,9 +1,12 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||
import { flexRender, Table } from '@tanstack/react-table';
|
||||
import { EntryId, OntimeEntry, SupportedEntry } from 'ontime-types';
|
||||
import { EntryId, SupportedEntry } from 'ontime-types';
|
||||
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import { cx, enDash } from '../../../../common/utils/styleUtils';
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { cx, enDash, getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
||||
|
||||
@@ -12,12 +15,13 @@ import style from './MilestoneRow.module.scss';
|
||||
interface MilestoneRowProps {
|
||||
entryId: EntryId;
|
||||
isPast: boolean;
|
||||
parentBgColour: string | null;
|
||||
parentBgColour?: string;
|
||||
parentId: EntryId | null;
|
||||
rowBgColour?: string;
|
||||
colour: string;
|
||||
rowId: string;
|
||||
rowIndex: number;
|
||||
table: Table<OntimeEntry>;
|
||||
table: Table<ExtendedEntry>;
|
||||
injectedStyles?: CSSProperties;
|
||||
}
|
||||
|
||||
export default function MilestoneRow({
|
||||
@@ -25,10 +29,12 @@ export default function MilestoneRow({
|
||||
isPast,
|
||||
parentBgColour,
|
||||
parentId,
|
||||
rowBgColour,
|
||||
colour,
|
||||
rowId,
|
||||
rowIndex,
|
||||
table,
|
||||
injectedStyles,
|
||||
...virtuosoProps
|
||||
}: MilestoneRowProps) {
|
||||
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
|
||||
cuesheetMode: AppMode.Edit,
|
||||
@@ -37,14 +43,28 @@ export default function MilestoneRow({
|
||||
|
||||
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
||||
|
||||
let rowBgColour: string | undefined;
|
||||
if (colour) {
|
||||
// the colour is user defined and might be invalid
|
||||
const accessibleBackgroundColor = cssOrHexToColour(getAccessibleColour(colour).backgroundColor);
|
||||
if (accessibleBackgroundColor !== null) {
|
||||
rowBgColour = colourToHex({
|
||||
...accessibleBackgroundColor,
|
||||
alpha: accessibleBackgroundColor.alpha * 0.25,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={cx([style.milestoneRow, Boolean(parentBgColour) && style.hasParent])}
|
||||
style={{
|
||||
...injectedStyles,
|
||||
opacity: `${isPast ? '0.2' : '1'}`,
|
||||
'--user-bg': parentBgColour ?? 'transparent',
|
||||
}}
|
||||
data-testid='cuesheet-milestone'
|
||||
{...virtuosoProps}
|
||||
>
|
||||
{cuesheetMode === AppMode.Edit && (
|
||||
<td className={style.actionColumn} tabIndex={-1} role='cell'>
|
||||
@@ -79,9 +99,9 @@ export default function MilestoneRow({
|
||||
style={{
|
||||
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
|
||||
backgroundColor: rowBgColour,
|
||||
opacity: canRender ? 1 : 0.4,
|
||||
}}
|
||||
tabIndex={-1}
|
||||
role='cell'
|
||||
>
|
||||
{canRender && flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
|
||||
+6
-7
@@ -2,12 +2,13 @@ import { CSSProperties, ReactNode } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Header } from '@tanstack/react-table';
|
||||
import { OntimeEntry } from 'ontime-types';
|
||||
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
|
||||
import style from '../CuesheetTable.module.scss';
|
||||
|
||||
interface SortableCellProps {
|
||||
header: Header<OntimeEntry, unknown>;
|
||||
header: Header<ExtendedEntry, unknown>;
|
||||
injectedStyles: CSSProperties;
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -34,11 +35,9 @@ export function SortableCell({ header, injectedStyles, children }: SortableCellP
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
{...{
|
||||
onDoubleClick: () => header.column.resetSize(),
|
||||
onMouseDown: header.getResizeHandler(),
|
||||
onTouchStart: header.getResizeHandler(),
|
||||
}}
|
||||
onDoubleClick={() => header.column.resetSize()}
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
className={style.resizer}
|
||||
/>
|
||||
</th>
|
||||
|
||||
+16
-15
@@ -1,9 +1,10 @@
|
||||
import { useCallback } from 'react';
|
||||
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
||||
import { CustomFields, isOntimeDelay, isOntimeEvent, OntimeEntry, TimeStrategy, URLPreset } from 'ontime-types';
|
||||
import { CustomFields, isOntimeDelay, isOntimeEvent, TimeStrategy, URLPreset } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { formatDuration, formatTime } from '../../../../common/utils/time';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
|
||||
@@ -16,7 +17,7 @@ import MutedText from './MutedText';
|
||||
import SingleLineCell from './SingleLineCell';
|
||||
import TimeInput from './TimeInput';
|
||||
|
||||
function MakeStart({ getValue, row, table, column }: CellContext<OntimeEntry, unknown>) {
|
||||
function MakeStart({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
|
||||
if (!table.options.meta) {
|
||||
return null;
|
||||
}
|
||||
@@ -55,7 +56,7 @@ function MakeStart({ getValue, row, table, column }: CellContext<OntimeEntry, un
|
||||
);
|
||||
}
|
||||
|
||||
function MakeEnd({ getValue, row, table, column }: CellContext<OntimeEntry, unknown>) {
|
||||
function MakeEnd({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
|
||||
if (!table.options.meta) {
|
||||
return null;
|
||||
}
|
||||
@@ -95,7 +96,7 @@ function MakeEnd({ getValue, row, table, column }: CellContext<OntimeEntry, unkn
|
||||
);
|
||||
}
|
||||
|
||||
function MakeDuration({ getValue, row, table, column }: CellContext<OntimeEntry, unknown>) {
|
||||
function MakeDuration({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
|
||||
if (!table.options.meta) {
|
||||
return null;
|
||||
}
|
||||
@@ -126,7 +127,7 @@ function MakeDuration({ getValue, row, table, column }: CellContext<OntimeEntry,
|
||||
);
|
||||
}
|
||||
|
||||
function MakeMultiLineField({ row, column, table }: CellContext<OntimeEntry, unknown>) {
|
||||
function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
|
||||
const update = useCallback(
|
||||
(newValue: string) => {
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
|
||||
@@ -135,8 +136,8 @@ function MakeMultiLineField({ row, column, table }: CellContext<OntimeEntry, unk
|
||||
);
|
||||
|
||||
// not all entries have all properties (eg groups)
|
||||
const initialValue = row.original[column.id as keyof OntimeEntry];
|
||||
if (initialValue === undefined) {
|
||||
const initialValue = row.original[column.id as keyof ExtendedEntry];
|
||||
if (typeof initialValue !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -148,7 +149,7 @@ function MakeMultiLineField({ row, column, table }: CellContext<OntimeEntry, unk
|
||||
return <MultiLineCell initialValue={initialValue as string} handleUpdate={update} />;
|
||||
}
|
||||
|
||||
function LazyImage({ row, column, table }: CellContext<OntimeEntry, unknown>) {
|
||||
function LazyImage({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
|
||||
const update = useCallback(
|
||||
(newValue: string) => {
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
|
||||
@@ -166,7 +167,7 @@ function LazyImage({ row, column, table }: CellContext<OntimeEntry, unknown>) {
|
||||
return <EditableImage initialValue={initialValue} updateValue={update} readOnly={!canWrite} />;
|
||||
}
|
||||
|
||||
function MakeSingleLineField({ row, column, table }: CellContext<OntimeEntry, unknown>) {
|
||||
function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
|
||||
const update = useCallback(
|
||||
(newValue: string) => {
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
|
||||
@@ -175,8 +176,8 @@ function MakeSingleLineField({ row, column, table }: CellContext<OntimeEntry, un
|
||||
);
|
||||
|
||||
// not all entries have all properties (eg groups)
|
||||
const initialValue = row.original[column.id as keyof OntimeEntry];
|
||||
if (initialValue === undefined) {
|
||||
const initialValue = row.original[column.id as keyof ExtendedEntry];
|
||||
if (typeof initialValue !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -188,7 +189,7 @@ function MakeSingleLineField({ row, column, table }: CellContext<OntimeEntry, un
|
||||
return <SingleLineCell initialValue={initialValue as string} handleUpdate={update} />;
|
||||
}
|
||||
|
||||
function MakeFlagField({ row }: CellContext<OntimeEntry, unknown>) {
|
||||
function MakeFlagField({ row }: CellContext<ExtendedEntry, unknown>) {
|
||||
const event = row.original;
|
||||
if (!isOntimeEvent(event) || !event.flag) {
|
||||
return null;
|
||||
@@ -196,7 +197,7 @@ function MakeFlagField({ row }: CellContext<OntimeEntry, unknown>) {
|
||||
return <FlagCell />;
|
||||
}
|
||||
|
||||
function MakeCustomField({ row, column, table }: CellContext<OntimeEntry, unknown>) {
|
||||
function MakeCustomField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
|
||||
const update = useCallback(
|
||||
(newValue: string) => {
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
|
||||
@@ -229,8 +230,8 @@ export function makeCuesheetColumns(
|
||||
customFields: CustomFields,
|
||||
cuesheetMode: AppMode,
|
||||
preset: URLPreset | undefined,
|
||||
): ColumnDef<OntimeEntry>[] {
|
||||
const columnsDef: ColumnDef<OntimeEntry>[] = [];
|
||||
): ColumnDef<ExtendedEntry>[] {
|
||||
const columnsDef: ColumnDef<ExtendedEntry>[] = [];
|
||||
const modeAllowsWrite = cuesheetMode === AppMode.Edit;
|
||||
const fullRead = preset ? preset.options?.read === 'full' : true;
|
||||
const fullWrite = preset ? preset.options?.write === 'full' : true;
|
||||
|
||||
+2
-9
@@ -6,13 +6,13 @@ import { ToggleGroup } from '@base-ui-components/react/toggle-group';
|
||||
import { Toolbar } from '@base-ui-components/react/toolbar';
|
||||
import { useSessionStorage } from '@mantine/hooks';
|
||||
import type { Column } from '@tanstack/react-table';
|
||||
import { OntimeEntry } from 'ontime-types';
|
||||
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Checkbox from '../../../../common/components/checkbox/Checkbox';
|
||||
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
||||
import PopoverContents from '../../../../common/components/popover/Popover';
|
||||
import { PresetContext } from '../../../../common/context/PresetContext';
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import { AppMode, sessionKeys } from '../../../../ontimeConfig';
|
||||
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
|
||||
@@ -23,7 +23,7 @@ import CuesheetShareModal from './CuesheetShareModal';
|
||||
import style from './CuesheetTableSettings.module.scss';
|
||||
|
||||
interface CuesheetTableSettingsProps {
|
||||
columns: Column<OntimeEntry, unknown>[];
|
||||
columns: Column<ExtendedEntry, unknown>[];
|
||||
handleResetResizing: () => void;
|
||||
handleResetReordering: () => void;
|
||||
handleClearToggles: () => void;
|
||||
@@ -106,13 +106,6 @@ function ViewSettings() {
|
||||
/>
|
||||
Hide seconds in table
|
||||
</Editor.Label>
|
||||
<Editor.Label className={style.option}>
|
||||
<Checkbox
|
||||
defaultChecked={options.hidePast}
|
||||
onCheckedChange={(checked) => options.setOption('hidePast', checked)}
|
||||
/>
|
||||
Hide past events
|
||||
</Editor.Label>
|
||||
<Editor.Label className={style.option}>
|
||||
<Checkbox
|
||||
defaultChecked={options.hideIndexColumn}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { OntimeEntry } from 'ontime-types';
|
||||
|
||||
import { debounce } from '../../../common/utils/debounce';
|
||||
import { makeStageKey } from '../../../common/utils/localStorage';
|
||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
|
||||
const tableSizesKey = makeStageKey('cuesheet-sizes');
|
||||
const tableHiddenKey = makeStageKey('cuesheet-hidden');
|
||||
@@ -14,7 +14,7 @@ const saveSizesToStorage = debounce((sizes: Record<string, number>) => {
|
||||
localStorage.setItem(tableSizesKey, JSON.stringify(sizes));
|
||||
}, 500);
|
||||
|
||||
export default function useColumnManager(columns: ColumnDef<OntimeEntry>[]) {
|
||||
export default function useColumnManager(columns: ColumnDef<ExtendedEntry>[]) {
|
||||
const [columnVisibility, setColumnVisibility] = useLocalStorage({
|
||||
key: tableHiddenKey,
|
||||
defaultValue: {},
|
||||
|
||||
@@ -4,7 +4,6 @@ import { persist } from 'zustand/middleware';
|
||||
|
||||
type OptionValues = {
|
||||
hideTableSeconds: boolean;
|
||||
hidePast: boolean;
|
||||
hideIndexColumn: boolean;
|
||||
showDelayedTimes: boolean;
|
||||
hideDelays: boolean;
|
||||
@@ -12,7 +11,6 @@ type OptionValues = {
|
||||
|
||||
const defaultOptions: OptionValues = {
|
||||
hideTableSeconds: false,
|
||||
hidePast: false,
|
||||
hideIndexColumn: false,
|
||||
showDelayedTimes: false,
|
||||
hideDelays: false,
|
||||
|
||||
@@ -42,8 +42,8 @@ const fontSizeMap: { [key: number]: number } = {
|
||||
4: 28, // 9:01
|
||||
5: 28, // -9:01, 10:01, 9 min
|
||||
6: 25, // -10:01, 10 min
|
||||
8: 20, // 23:01:01
|
||||
9: 20, // -23:01:01
|
||||
8: 18, // 23:01:01
|
||||
9: 18, // -23:01:01
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user