Compare commits

...

33 Commits

Author SHA1 Message Date
Carlos Valente 0057b4a3f1 refactor: tweaks to countdown styles 2025-09-08 06:23:39 +02:00
Carlos Valente ac8c1e333e bump version to 4.0.0-beta.2 2025-09-08 06:09:31 +02:00
Carlos Valente 321297b685 fix: redirect to preset 2025-09-08 06:09:00 +02:00
Alex Christoffer Rasmussen 67e67d6a5d feat: propper timeuntil calc in countdown view (#1765)
* fix error with is linked to loaded metadata

* pass down metadata

* create expected schedule

* expected start in sub info

* for singel subscribtions

* refactor

* add color to single events

* refactor: clean up subscribtion data

* show schedule and secondray data for single events

* improve subscribtion data display
2025-09-07 21:49:45 +02:00
Carlos Valente 4385e0b9c9 refactor: ensure clocks timer and clock are in sync (#1772)
* refactor: ensure clocks timer and clock are in sync

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2025-09-07 21:49:45 +02:00
Carlos Valente 02243242f1 fix: redirect to preset 2025-09-07 21:49:45 +02:00
arc-alex 1af42b766d fix: insert group-end where needed 2025-09-07 21:49:45 +02:00
arc-alex 9d6ec3758b chore: clarifying comments 2025-09-07 21:49:45 +02:00
Carlos Valente 9e78ff4d1c refactor: export data for all entry types 2025-09-07 21:49:45 +02:00
Carlos Valente 2037aef080 fix: prevent clipping on bold font sizes 2025-09-07 21:49:45 +02:00
Carlos Valente 48b9cf042f refactor: lax validation for settings
with this changes we allow the client and server to share the same data
shape. even if some of it is useless.
we also get an extra version property in the payload, if we want to save
the transfer cost, we would rather remove it from the sent payload
2025-09-07 21:49:45 +02:00
Carlos Valente 1866e5e78d refactor: simplify restore point 2025-09-07 21:49:45 +02:00
Carlos Valente bc1b3800c8 refactor: op clock uses time format string 2025-09-07 21:49:45 +02:00
Carlos Valente a2505c838b refactor: add none option to optional select 2025-09-07 21:49:45 +02:00
Carlos Valente bffa863fb4 fix: multi select infinite loop 2025-09-07 21:49:45 +02:00
Carlos Valente d00d7677bc fix: merge virtuoso and rundown styles 2025-09-07 21:49:45 +02:00
Carlos Valente 3ae73be562 refactor: prevent emitting errors from user pages 2025-09-07 21:49:45 +02:00
Carlos Valente 763080cd4d fix: generate link for companion 2025-09-07 21:49:45 +02:00
Carlos Valente 6ff06b21a8 fix: hide fade out elements by default 2025-09-07 21:49:45 +02:00
Carlos Valente adebde1b43 chore: upgrade client packages 2025-09-07 21:49:45 +02:00
Carlos Valente 89922fe394 revert: fixed size for editor 2025-09-03 17:48:45 +02:00
Alex Christoffer Rasmussen f219ebb8f8 refactor: settings route (#1753)
* refactor: settings route
2025-09-03 17:48:45 +02:00
Carlos Valente eace97ec7a bump version to 4.0.0-alpha.6 2025-09-03 17:48:45 +02:00
Carlos Valente ec1df8b958 fix: bundle translations 2025-09-03 17:48:45 +02:00
Carlos Valente 8d2d8ef979 feat: add user defined translations (#1756)
* feat: add user defined translations

* add refetch key for translation (#1757)

---------

Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
2025-09-03 16:06:03 +02:00
Carlos Valente ae15f3cdc5 Alpha 5 (#1741)
* refactor: align header columns

* refactor: improve scrollbar visibility

* refactor: center align table elements

* refactor: make param elements stateful

* fix: issue with collapsed elements not loosing value

* fix: prevent search params containing multiple alias references

* fix: the issue where a file disappears if it is both migrated and recovered in the same load operation (#1744)

* refactor: disable group action for elements in groups

* refactor: move context menu items into the event element (#1747)

* feat: sheet import new features for v4 (#1730)

* import milestone

* fixup! import milestone

* test: milestone import

* add entries to group

stop on new group or on group-end type

* fixup! add entries to group

* cleanup

* add event target duration

* link start if undefined

* add skip import type

* extract some to the excel paresing functions

* tweaks to presentation

* move file

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* fix: notify runtimeStore of events bieng groupd

* fix: improve authentication and stage detection in demo

* chore: ship logo with project

* fix: client is referenced by name

* fix: prevent reflow in event editor

* fix: stale render on selected event due to ref mismatch

* Create/Load/Delete multiple rundowns (#1696)

* refactor: restore last loaded rundown

* refactor: initialise rundown in ProjectService

* feat: allow switching rundowns

ensure on coordination between the db object and the working object

server provide list of rundowns

implement switch in the UI

implement delete

implement new rundown button

* fix: render order for floating button

* refactor: appropriate names to service

* refactor: rundown endpoints

* refactor: save last loaded rundown ID

* refactor: rundown management UI

* refactor: emit refetch all on project load

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* feat: recover single event subscription

* fixup! feat: sheet import new features for v4 (#1730)

* fix: prevent dropping a group inside another

* fixup! refactor: move context menu items into the event element (#1747)

* fix: prevent stale references to custom fields

* fix: propagate updates to all rundowns

* refactor: client rundown metadata (#1728)

* generate metadata in the hook

* move test

* ensure there is always a last element

* use for-loop

* update metadata in useEfect

* fully extract metadata generation

* use direct assignment

* cleanup

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* refactor: small imporvement and tests for coerce functions (#1752)

* refactor: small imporvement and tests for coerce functions

add test `coerceString`

add test `coerceBoolean`

add test `coerceColour`

* remove old todo

* fix: consistent quick add behaviour

* refactor: create flat rundown with metadata

* fix: show add buttons on top

* feat: allow editing milestones

* refactor: style tweaks to rundown elements

refactor: milestones are full width

refactor: cuesheet header alignment

fix: editor styling in cuesheet

* refactor: virtualise table

* refactor: improve overscan (#1758)

* bump version to 4.0.0-alpha.5

---------

Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
2025-09-03 15:50:20 +02:00
Carlos Valente 3c41b40c5f refactor: create fast tracks for cookie parsing 2025-08-18 22:00:16 +02:00
arc-alex 3b38756dda fix: use new token with scope 2025-08-18 22:00:16 +02:00
Carlos Valente 5a4494ba85 refactor: improve timers visibility 2025-08-18 22:00:16 +02:00
Carlos Valente 02ad852dd4 fix: external demo connects to hash 2025-08-18 22:00:16 +02:00
Carlos Valente 7ce1097826 fix: prevent reflow between entry editors 2025-08-18 22:00:16 +02:00
Carlos Valente 7ee0de5341 docs: improve development documentation 2025-08-18 22:00:16 +02:00
Carlos Valente 0db249871b bump version to 4.0.0-alpha.4 2025-08-18 22:00:16 +02:00
168 changed files with 4835 additions and 3364 deletions
+4
View File
@@ -34,6 +34,10 @@ e2e/tests/fixtures/tmp/*
build/
dist/
# bundled assets
translations.json
override.css
# working stuff
**/TODO.md
+13 -19
View File
@@ -20,19 +20,20 @@ development.
Locally, we would need to run both the React client and the node.js server in development mode
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Run dev mode__ by running `turbo dev`
- __Run dev mode__ by running `pnpm turbo dev`
### Debugging backend
To debug backend code in Node.js:
The previous command will start the development servers for both the client, server and electron applications.
Typically in dev mode we prefer to start these in separate terminals to help with error tracking and debugging.
- Open two separate terminals and navigate to the `apps/client` and `apps/server` directories.
- In each terminal, run the command `pnpm dev` to start the development servers for both the client and server
applications.
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by
running `pnpm dev:inspect`.
We do that by creating two terminals an running
- __Run the React UI__ by running `pnpm turbo dev --filter=ontime-ui`
- __Run the nodejs server__ by running `pnpm turbo dev --filter=ontime-server`
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by running `pnpm turbo dev:inspect --filter=ontime-server`.
## TESTING
@@ -45,7 +46,7 @@ Generally we have 2 types of tests.
Unit tests are contained in mostly all the apps and packages (client, server and utils)
You can run unit tests by running `turbo run test:pipeline` from the project root.
You can run unit tests by running `pnpm turbo test:pipeline` from the project root.
This will run all tests and close test runner.
Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode
@@ -76,13 +77,13 @@ You can generate a distribution for your OS by running the following steps.
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Build the UI and server__ by running `turbo run build:electron`
- __Create the package__ by running `turbo run dist-win`, `turbo run dist-mac` or `turbo run dist-linux`
- __Build the UI and server__ by running `pnpm turbo run build:electron`
- __Create the package__ by running `pnpm turbo run dist-win`, `pnpm turbo run dist-mac` or `pnpm turbo run dist-linux`
The build distribution assets will be at `.apps/electron/dist`
Note: The MacOS build will only work in CI, locally it will fail due to notarisation issues.
Use the `turbo run dist-mac:local` command to build a MacOS distribution locally.
Use the `pnpm turbo run dist-mac:local` command to build a MacOS distribution locally and skip the notary process.
## DOCKER
@@ -98,10 +99,3 @@ Other useful commands
- __List running processes__ by running `docker ps`
- __Kill running process__ by running `docker kill <process-id>`
## General Info
# APP Building
We build the app from app.js for almost all applications. The output file will still be named index.cjs. This is because of Electron.
Building the app from index.ts only applies for applications that don't use electron. index.ts will take over the initialization of the server and UI when electron isn't present.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.0.0-alpha.3",
"version": "4.0.0-beta.2",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+12 -11
View File
@@ -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",
+22 -1
View File
@@ -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 });
}
+3
View File
@@ -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}`;
+65 -47
View File
@@ -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;
@@ -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
@@ -5,6 +5,9 @@
gap: 0.25rem;
padding: 1rem 0.5rem;
margin-bottom: 1rem;
max-height: 10rem;
overflow-y: auto;
scrollbar-gutter: stable;
}
.preset {
@@ -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) {
+166 -94
View File
@@ -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);
+4 -1
View File
@@ -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;
}
+8
View File
@@ -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>
</>
@@ -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&apos;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>
);
}
@@ -12,6 +12,7 @@ tr .secondaryRow {
}
.linkStartActive {
flex-shrink: 0;
color: $active-indicator;
transform: rotate(-45deg);
}
@@ -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>
@@ -14,4 +14,8 @@
.copiable {
cursor: text ;
user-select: text;
}
}
.self {
background-color: $blue-1100;
}
@@ -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>
</>
);
}
@@ -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 &quot;Custom&quot; 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 }) {
+62 -49
View File
@@ -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>
);
@@ -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>
@@ -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;
+11 -127
View File
@@ -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}`,
+3 -3
View File
@@ -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;
}
+1 -1
View File
@@ -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 };
};
+3 -1
View File
@@ -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',
+3 -1
View File
@@ -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',
+3 -1
View File
@@ -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',
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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',
},
],
},
+11 -3
View File
@@ -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 {
+11 -3
View File
@@ -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;
@@ -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>
);
}
@@ -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>
);
}
@@ -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;
@@ -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>
);
}
@@ -12,7 +12,7 @@
}
&.firstAfterGroup {
margin-top: 1rem;
margin-top: 2rem;
}
&.skip {
@@ -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,7 +1,7 @@
@import '../CuesheetTable.module.scss';
.groupRow {
margin-top: 1rem;
margin-top: 2em;
width: 100%;
display: flex;
align-items: start;
@@ -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,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;
@@ -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>
@@ -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>
@@ -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;
@@ -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,
+2 -2
View File
@@ -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