Compare commits

...

60 Commits

Author SHA1 Message Date
Carlos Valente 9ba5ebd4bd bump version to 4.2.1 (#1896) 2025-11-28 16:26:53 +01:00
Carlos Valente 3f1f06f7c5 Improve Clone (#1897)
* refactor(rundown): optimise copy-paste performance

* chore: configure opt-in compiler

* refactor(rundown): stabilise frequently accessed data

* feat(clone): allow cloning any element

* remove paste above and cue increment from test

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2025-11-28 16:20:52 +01:00
arc-alex a78586d2fa allow count up to show negative value 2025-11-28 15:03:28 +01:00
arc-alex 8e93b0af25 fix: countToEnd should not override display options 2025-11-28 15:03:28 +01:00
Carlos Valente 9a5d50d933 fix(wakelock): check for feature support 2025-11-28 11:26:23 +01:00
Carlos Valente 1189a94bff refactor(wakelock): simplify logic into hook 2025-11-28 11:26:23 +01:00
Alex Christoffer Rasmussen f128f7acd2 fix: api change custom fields (#1888)
* fix: validate nested custom path

* chore: add test
2025-11-27 09:15:02 +01:00
Carlos Valente 80ec2d1186 refactor(pip): match timer formatting defaults 2025-11-26 20:10:01 +01:00
Carlos Valente 18e6f0d7c4 refactor(timer): allow multiline secondary text 2025-11-26 20:10:01 +01:00
Alex Christoffer Rasmussen 48f2511764 fix: custom fields not persisted (#1891) 2025-11-26 17:58:00 +01:00
Carlos Valente c20ea88021 docs: add link to subreddit 2025-11-20 18:44:23 +01:00
Carlos Valente ce2b2b9606 bump version to 4.2.0 2025-11-20 18:44:23 +01:00
Alex Christoffer Rasmussen f223766445 feat: pip for timer preview (#1862)
---------

Co-authored-by: Shobhit Nagpal <74096450+Shobhit-Nagpal@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2025-11-20 18:00:16 +01:00
Carlos Valente 3dfb49e63f refactor(overview): prevent vertical scrollbar 2025-11-20 17:58:53 +01:00
Carlos Valente a13d3dad93 refactor(op): style tweaks to tags and subscriptions 2025-11-20 17:58:53 +01:00
Carlos Valente fa9695873a refactor(timeline): style tweaks on status tags 2025-11-20 17:58:53 +01:00
Carlos Valente 70ce1d5553 feat(projects): allow sorting project table 2025-11-19 06:52:58 +01:00
Shobhit Nagpal 9974ce24cd refactor(operator): hide groups where all events have passed (#1878) 2025-11-18 06:12:02 +01:00
Carlos Valente 93e9c19e0d refactor(input): consistent placeholder colours 2025-11-16 07:44:49 +01:00
Carlos Valente 1694cca92c refactor(settings): improve error message on version fetch 2025-11-16 07:44:49 +01:00
Carlos Valente c1d9276dd5 refactor(timeline): style tweaks to small screens 2025-11-16 07:44:49 +01:00
Carlos Valente f5753a0f5a refactor(timeline): default to fixed size timeline 2025-11-16 07:44:49 +01:00
Carlos Valente 843f5a968d refactor(operator): style tweaks for small screens 2025-11-16 07:44:49 +01:00
Carlos Valente e09b6bbc16 refactor: close menu in small screen navigation 2025-11-16 07:44:49 +01:00
Carlos Valente 19d6b40bdb refactor: hide fullscreen button if not feature available 2025-11-16 07:44:49 +01:00
Carlos Valente 1f2706a063 refactor(operator): improvements to editing 2025-11-16 07:44:49 +01:00
Carlos Valente aaeadd13cf chore(operator): clarify edit prompt 2025-11-16 07:44:49 +01:00
Carlos Valente 72e3eb11d7 chore: upgrade mantine dependencies 2025-11-16 07:44:49 +01:00
Alex Christoffer Rasmussen 6f55d8a6c9 use data in <base> componenet to mark ontime cloud status (#1872) 2025-11-12 19:28:29 +08:00
Shobhit Nagpal 70846aefbe refactor: time input labels on hover (#1855) 2025-11-11 06:42:55 +01:00
Carlos Valente 47ba90f191 refactor: ensure preset names are url safe 2025-11-11 06:41:48 +01:00
Alex Christoffer Rasmussen cee3c9c070 Fix integration api (#1866)
* allow change api to set flag and countToEnd

* fix: custom fields can be uppercase

* check that prop exists and add target duration for group
2025-11-09 14:36:13 +01:00
Carlos Valente 15f3b9625c refactor: review overview styles 2025-11-09 06:44:24 +01:00
Carlos Valente d34a1f0fd8 chore: improve documentation on the 12 hour view 2025-11-09 06:44:24 +01:00
Carlos Valente 0e3b3bcf9a refactor: consistent time formatting for 12h mode 2025-11-09 06:44:24 +01:00
Carlos Valente 50c91f976b refactor: show welcome modal on connect 2025-11-02 18:58:13 +01:00
Carlos Valente 50599eccd2 refactor: show countdown if less than 10 min from start 2025-11-02 18:53:03 +01:00
Carlos Valente 9f2db10548 fix: prevent rounding error show 60s 2025-11-02 18:53:03 +01:00
Carlos Valente 5dce1a40bc refactor: prevent ids from being overriden 2025-11-02 13:27:53 +01:00
Carlos Valente b75b69c3b1 chore: cleanup directory structure 2025-11-02 13:23:05 +01:00
Carlos Valente e6070e6efd refactor: add rundown summary to import preview 2025-11-02 13:23:05 +01:00
Carlos Valente 3918664945 fix: ensure IDs on import from excel 2025-11-02 13:23:05 +01:00
Carlos Valente b65b3fa6d2 refactor: get worksheet names from excel upload 2025-11-02 13:23:05 +01:00
Carlos Valente 162693e19e refactor: allow user to review import map 2025-11-02 13:23:05 +01:00
Carlos Valente becd734ea8 refactor: simplify ux for sheet import 2025-11-02 13:23:05 +01:00
Carlos Valente 9dfc8083f0 style: run code formatting 2025-10-31 19:30:34 +01:00
Carlos Valente 7bd98c23a4 bump version to 4.1.0 2025-10-31 19:30:34 +01:00
arc-alex 6f19d78e53 addedTimer always updates the restore point 2025-10-31 19:26:30 +01:00
arc-alex 163baa752f keep restorpoint on container shutdown 2025-10-31 19:26:30 +01:00
mdavies1 6f708df6f6 feat: lock extracted rundown panels 2025-10-31 19:23:08 +01:00
Carlos Valente eb5c62cca1 chore: simplify prettier setup 2025-10-30 17:26:49 +01:00
Carlos Valente 99d2debca8 typo: incorrect title in delete confirmation 2025-10-30 17:26:49 +01:00
Carlos Valente 74e59dccdb feat: allow renaming rundown 2025-10-30 17:26:49 +01:00
Carlos Valente 812b17a221 refactor: create destructive menu type 2025-10-30 17:26:49 +01:00
Carlos Valente 32bf0f53f6 feat: allow user to duplicate a rundown 2025-10-30 17:26:49 +01:00
Carlos Valente 17f80baf48 chore: improve rundown API documentation 2025-10-30 17:26:49 +01:00
Carlos Valente e0377a7244 feat: implement simple healthcheck endpoint 2025-10-30 17:26:18 +01:00
Alex Christoffer Rasmussen b7e5723fb2 Fix multiday (#1850)
* chore: cleanup typeings

* fix: total gap should only accumulate after the curent loaded

* fix: add the current day of the event to start day offset
2025-10-30 14:22:14 +01:00
Alex Christoffer Rasmussen 897210d5e3 add timer type for css override selector (#1845) 2025-10-30 13:15:14 +01:00
Alex Christoffer Rasmussen ee3d38fce8 Fix link to host in local network (#1843)
* descripe testing for cloud

* use base url directly

* lint
2025-10-27 11:19:21 +01:00
141 changed files with 2406 additions and 815 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
"jest": true "jest": true
}, },
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier", "eslint-config-prettier"], "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
"plugins": ["@typescript-eslint", "prettier"], "plugins": ["@typescript-eslint", "prettier"],
"overrides": [ "overrides": [
{ {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "4.0.2", "version": "4.2.1",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "4.0.2", "version": "4.2.1",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
@@ -9,7 +9,7 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.6", "@fontsource/open-sans": "^5.2.6",
"@mantine/hooks": "^8.2.8", "@mantine/hooks": "^8.3.7",
"@sentry/react": "^10.2.0", "@sentry/react": "^10.2.0",
"@table-nav/react": "^0.0.7", "@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.85.9", "@tanstack/react-query": "^5.85.9",
@@ -17,7 +17,6 @@
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.12.2", "axios": "^1.12.2",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"csv-stringify": "^6.6.0", "csv-stringify": "^6.6.0",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"react": "^19.1.1", "react": "^19.1.1",
@@ -66,6 +65,7 @@
"@typescript-eslint/eslint-plugin": "catalog:", "@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:", "@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "4.5.1", "@vitejs/plugin-react": "4.5.1",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "catalog:", "eslint": "catalog:",
"eslint-config-prettier": "catalog:", "eslint-config-prettier": "catalog:",
"eslint-plugin-jest": "^28.6.0", "eslint-plugin-jest": "^28.6.0",
+4 -11
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { CustomFields, Rundown } from 'ontime-types'; import { CustomFields, Rundown, RundownSummary } from 'ontime-types';
import { ImportMap } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
@@ -11,28 +11,21 @@ const excelPath = `${apiEntryUrl}/excel`;
* upload Excel file to server * upload Excel file to server
* @return string - file ID op the uploaded file * @return string - file ID op the uploaded file
*/ */
export async function upload(file: File) { export async function upload(file: File): Promise<string[]> {
const formData = new FormData(); const formData = new FormData();
formData.append('excel', file); formData.append('excel', file);
await axios.post(`${excelPath}/upload`, formData, { const response = await axios.post(`${excelPath}/upload`, formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
}); });
}
/**
* Get Worksheet names
* @return string[] - array of available worksheets
*/
export async function getWorksheetNames(): Promise<string[]> {
const response: AxiosResponse<string[]> = await axios.get(`${excelPath}/worksheets`);
return response.data; return response.data;
} }
type PreviewSpreadsheetResponse = { type PreviewSpreadsheetResponse = {
rundown: Rundown; rundown: Rundown;
customFields: CustomFields; customFields: CustomFields;
summary: RundownSummary;
}; };
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> { export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, { const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
+42 -16
View File
@@ -3,6 +3,7 @@ import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, Transi
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
type RundownId = string;
const rundownPath = `${apiEntryUrl}/rundowns`; const rundownPath = `${apiEntryUrl}/rundowns`;
// #region operations on project rundowns ========================= // #region operations on project rundowns =========================
@@ -26,8 +27,8 @@ export async function fetchCurrentRundown(): Promise<Rundown> {
/** /**
* HTTP request to switch the currently loaded rundown * HTTP request to switch the currently loaded rundown
*/ */
export async function loadRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> { export async function loadRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/${id}/load`); return axios.post(`${rundownPath}/${rundownId}/load`);
} }
/** /**
@@ -37,11 +38,25 @@ export async function createRundown(title: string): Promise<AxiosResponse<Projec
return axios.post(rundownPath, { title }); return axios.post(rundownPath, { title });
} }
/**
* HTTP request to duplicate an existing rundown
*/
export async function duplicateRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/${rundownId}/duplicate`);
}
/**
* HTTP request to rename an existing rundown
*/
export async function renameRundown(rundownId: RundownId, title: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.patch(`${rundownPath}/${rundownId}`, { title });
}
/** /**
* HTTP request to delete a rundown * HTTP request to delete a rundown
*/ */
export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> { export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.delete(`${rundownPath}/${id}`); return axios.delete(`${rundownPath}/${rundownId}`);
} }
// #endregion operations on project rundowns ====================== // #endregion operations on project rundowns ======================
@@ -51,7 +66,7 @@ export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRu
* HTTP request to post new entry * HTTP request to post new entry
*/ */
export async function postAddEntry( export async function postAddEntry(
rundownId: string, rundownId: RundownId,
data: TransientEventPayload, data: TransientEventPayload,
): Promise<AxiosResponse<OntimeEntry>> { ): Promise<AxiosResponse<OntimeEntry>> {
return axios.post(`${rundownPath}/${rundownId}/entry`, data); return axios.post(`${rundownPath}/${rundownId}/entry`, data);
@@ -60,7 +75,10 @@ export async function postAddEntry(
/** /**
* HTTP request to edit an entry * HTTP request to edit an entry
*/ */
export async function putEditEntry(rundownId: string, data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> { export async function putEditEntry(
rundownId: RundownId,
data: Partial<OntimeEntry>,
): Promise<AxiosResponse<OntimeEntry>> {
return axios.put(`${rundownPath}/${rundownId}/entry`, data); return axios.put(`${rundownPath}/${rundownId}/entry`, data);
} }
@@ -72,7 +90,7 @@ export type BatchEditEntry = {
/** /**
* HTTP request to edit multiple events * HTTP request to edit multiple events
*/ */
export async function putBatchEditEvents(rundownId: string, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> { export async function putBatchEditEvents(rundownId: RundownId, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
return axios.put(`${rundownPath}/${rundownId}/batch`, data); return axios.put(`${rundownPath}/${rundownId}/batch`, data);
} }
@@ -85,56 +103,64 @@ export type ReorderEntry = {
/** /**
* HTTP request to reorder an entry * HTTP request to reorder an entry
*/ */
export async function patchReorderEntry(rundownId: string, data: ReorderEntry): Promise<AxiosResponse<Rundown>> { export async function patchReorderEntry(rundownId: RundownId, data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/reorder`, data); return axios.patch(`${rundownPath}/${rundownId}/reorder`, data);
} }
/** /**
* HTTP request to swap two events * HTTP request to swap two events
*/ */
export async function requestEventSwap(rundownId: string, from: EntryId, to: EntryId): Promise<AxiosResponse<Rundown>> { export async function requestEventSwap(
rundownId: RundownId,
from: EntryId,
to: EntryId,
): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to }); return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to });
} }
/** /**
* HTTP request to request application of delay * HTTP request to request application of delay
*/ */
export async function requestApplyDelay(rundownId: string, delayId: EntryId): Promise<AxiosResponse<Rundown>> { export async function requestApplyDelay(rundownId: RundownId, delayId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/applydelay/${delayId}`); return axios.patch(`${rundownPath}/${rundownId}/applydelay/${delayId}`);
} }
/** /**
* HTTP request for cloning an entry * HTTP request for cloning an entry
*/ */
export async function postCloneEntry(rundownId: string, entryId: EntryId): Promise<AxiosResponse<Rundown>> { export async function postCloneEntry(
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`); rundownId: RundownId,
entryId: EntryId,
options?: { before?: EntryId; after?: EntryId },
): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
} }
/** /**
* HTTP request for grouping a list of entries into a group * HTTP request for grouping a list of entries into a group
*/ */
export async function requestGroupEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> { export async function requestGroupEntries(rundownId: RundownId, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/group`, { ids: entryIds }); return axios.post(`${rundownPath}/${rundownId}/group`, { ids: entryIds });
} }
/** /**
* HTTP request for dissolving of a group * HTTP request for dissolving of a group
*/ */
export async function requestUngroup(rundownId: string, groupId: EntryId): Promise<AxiosResponse<Rundown>> { export async function requestUngroup(rundownId: RundownId, groupId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/ungroup/${groupId}`); return axios.post(`${rundownPath}/${rundownId}/ungroup/${groupId}`);
} }
/** /**
* HTTP request to delete entries of a given rundown * HTTP request to delete entries of a given rundown
*/ */
export async function deleteEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> { export async function deleteEntries(rundownId: RundownId, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.delete(`${rundownPath}/${rundownId}/entries`, { data: { ids: entryIds } }); return axios.delete(`${rundownPath}/${rundownId}/entries`, { data: { ids: entryIds } });
} }
/** /**
* HTTP request to delete all entries of a given rundown * HTTP request to delete all entries of a given rundown
*/ */
export async function requestDeleteAll(rundownId: string): Promise<AxiosResponse<Rundown>> { export async function requestDeleteAll(rundownId: RundownId): Promise<AxiosResponse<Rundown>> {
return axios.delete(`${rundownPath}/${rundownId}/all`); return axios.delete(`${rundownPath}/${rundownId}/all`);
} }
+2 -1
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types'; import { AuthenticationStatus, CustomFields, Rundown, RundownSummary } from 'ontime-types';
import { ImportMap } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
@@ -56,6 +56,7 @@ export const previewRundown = async (
): Promise<{ ): Promise<{
rundown: Rundown; rundown: Rundown;
customFields: CustomFields; customFields: CustomFields;
summary: RundownSummary;
}> => { }> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options }); const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
return response.data; return response.data;
@@ -27,7 +27,6 @@
} }
} }
.item { .item {
outline: 0; outline: 0;
cursor: default; cursor: default;
@@ -61,6 +60,13 @@
border-radius: 3px; border-radius: 3px;
background-color: $gray-1000; background-color: $gray-1000;
} }
&[data-type='destructive'] {
color: $red-500;
svg {
color: $red-500;
}
}
} }
.separator { .separator {
@@ -6,7 +6,7 @@ import style from './DropdownMenu.module.scss';
type DropdownMenuItemDivider = { type: 'divider' }; type DropdownMenuItemDivider = { type: 'divider' };
type DropdownMenuItem = { type DropdownMenuItem = {
type: 'item'; type: 'item' | 'destructive';
label: string; label: string;
icon?: IconType; icon?: IconType;
disabled?: boolean; disabled?: boolean;
@@ -31,7 +31,7 @@ export function DropdownMenu({ items, children, ...triggerProps }: PropsWithChil
return <BaseMenu.Separator key={index} className={style.separator} />; return <BaseMenu.Separator key={index} className={style.separator} />;
} }
return ( return (
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}> <BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled} data-type={item.type}>
{item.icon && <item.icon />} {item.icon && <item.icon />}
{item.label} {item.label}
</BaseMenu.Item> </BaseMenu.Item>
@@ -1,6 +1,7 @@
.corner { .arrow {
transform: rotate(45deg); transform: rotate(45deg);
}
.corner {
position: absolute; position: absolute;
top: 0.5rem; top: 0.5rem;
right: 0.5rem; right: 0.5rem;
@@ -21,6 +22,10 @@
} }
} }
.offsetCorner {
right: 2rem;
}
.header { .header {
font-size: 1.5rem; font-size: 1.5rem;
} }
@@ -51,6 +56,6 @@
&.vertical { &.vertical {
width: 1px; width: 1px;
height: 0.75em; height: 0.75em;
} }
} }
@@ -1,13 +1,33 @@
import type { HTMLAttributes, LabelHTMLAttributes } from 'react'; import type { HTMLAttributes, JSX, LabelHTMLAttributes, MouseEventHandler } from 'react';
import { IconBaseProps } from 'react-icons'; import { IconBaseProps } from 'react-icons';
import { IoArrowUp } from 'react-icons/io5'; import { IoArrowUp } from 'react-icons/io5';
import { TbPictureInPictureOff } from 'react-icons/tb';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import style from './EditorUtils.module.scss'; import style from './EditorUtils.module.scss';
export function Corner({ className, ...elementProps }: IconBaseProps) { export function CornerExtract({ className, ...elementProps }: IconBaseProps) {
return <IoArrowUp className={cx([style.corner, className])} {...elementProps} />; return <IoArrowUp className={cx([style.corner, style.arrow, className])} {...elementProps} />;
}
export function CornerPipButton({ className, ...elementProps }: IconBaseProps) {
return <TbPictureInPictureOff className={cx([style.corner, style.offsetCorner, className])} {...elementProps} />;
}
interface ExtractAndPip extends IconBaseProps {
onExtractClick: MouseEventHandler<SVGElement>;
pipElement: JSX.Element;
}
export function CornerWithPip({ className, pipElement, onExtractClick }: ExtractAndPip) {
return (
<>
<IoArrowUp className={cx([style.corner, style.arrow, className])} onClick={onExtractClick} />
{/* the pip element returns the icon button */}
{pipElement}
</>
);
} }
export function Title({ children, className, ...elementProps }: HTMLAttributes<HTMLHeadingElement>) { export function Title({ children, className, ...elementProps }: HTMLAttributes<HTMLHeadingElement>) {
@@ -11,7 +11,7 @@
outline: none; outline: none;
&::placeholder { &::placeholder {
color: $gray-500; color: $gray-600;
letter-spacing: 0; letter-spacing: 0;
} }
@@ -5,9 +5,10 @@ import { useLocation } from 'react-router';
import { Dialog } from '@base-ui-components/react/dialog'; import { Dialog } from '@base-ui-components/react/dialog';
import { useDisclosure, useFullscreen } from '@mantine/hooks'; import { useDisclosure, useFullscreen } from '@mantine/hooks';
import { isLocalhost } from '../../../externals'; import { isLocalhost, supportsFullscreen } from '../../../externals';
import { useKeepAwakeOptions } from '../../../features/keep-awake/KeepAwake'; import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useClientStore } from '../../stores/clientStore'; import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
@@ -29,6 +30,7 @@ export default memo(NavigationMenu);
function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) { function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
const id = useClientStore((store) => store.id); const id = useClientStore((store) => store.id);
const name = useClientStore((store) => store.name); const name = useClientStore((store) => store.name);
const isSmallScreen = useIsSmallScreen();
const [isRenameOpen, handlers] = useDisclosure(false); const [isRenameOpen, handlers] = useDisclosure(false);
const { fullscreen, toggle } = useFullscreen(); const { fullscreen, toggle } = useFullscreen();
@@ -56,16 +58,18 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
</IconButton> </IconButton>
</div> </div>
<div className={style.body}> <div className={style.body}>
<NavigationMenuItem active={fullscreen} onClick={toggle}> {supportsFullscreen && (
Toggle Fullscreen <NavigationMenuItem active={fullscreen} onClick={toggle}>
{fullscreen ? <IoContract /> : <IoExpand />} Toggle Fullscreen
</NavigationMenuItem> {fullscreen ? <IoContract /> : <IoExpand />}
</NavigationMenuItem>
)}
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}> <NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
Flip Screen Flip Screen
<IoSwapVertical /> <IoSwapVertical />
{mirror && <span className={style.note}>Active</span>} {mirror && <span className={style.note}>Active</span>}
</NavigationMenuItem> </NavigationMenuItem>
{window.isSecureContext && ( {canUseWakeLock && (
<NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}> <NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}>
Keep Awake Keep Awake
<LuCoffee /> <LuCoffee />
@@ -77,11 +81,15 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<hr className={style.separator} /> <hr className={style.separator} />
<EditorNavigation /> <EditorNavigation />
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}> <ClientLink
to='cuesheet'
current={location.pathname === '/cuesheet'}
postAction={isSmallScreen ? onClose : undefined}
>
<IoLockClosedOutline /> <IoLockClosedOutline />
Cuesheet Cuesheet
</ClientLink> </ClientLink>
<ClientLink to='op' current={location.pathname === '/op'}> <ClientLink to='op' current={location.pathname === '/op'} postAction={isSmallScreen ? onClose : undefined}>
<IoLockClosedOutline /> <IoLockClosedOutline />
Operator Operator
</ClientLink> </ClientLink>
@@ -89,7 +97,12 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<hr className={style.separator} /> <hr className={style.separator} />
{navigatorConstants.map((route) => ( {navigatorConstants.map((route) => (
<ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}> <ClientLink
key={route.url}
to={route.url}
current={location.pathname === `/${route.url}`}
postAction={isSmallScreen ? onClose : undefined}
>
{route.label} {route.label}
</ClientLink> </ClientLink>
))} ))}
@@ -11,15 +11,22 @@ import style from './ClientLink.module.scss';
interface ClientLinkProps { interface ClientLinkProps {
current: boolean; current: boolean;
to: string; to: string;
postAction?: () => void;
} }
export default function ClientLink({ current, to, children }: PropsWithChildren<ClientLinkProps>) { export default function ClientLink({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
const { isElectron } = useElectronEvent(); const { isElectron } = useElectronEvent();
const navigate = useNavigate(); const navigate = useNavigate();
if (isElectron) { if (isElectron) {
return ( return (
<NavigationMenuItem active={current} onClick={() => handleLinks(to)}> <NavigationMenuItem
active={current}
onClick={() => {
handleLinks(to);
postAction?.();
}}
>
{children} {children}
<IoArrowUp className={style.linkIcon} /> <IoArrowUp className={style.linkIcon} />
</NavigationMenuItem> </NavigationMenuItem>
@@ -27,7 +34,13 @@ export default function ClientLink({ current, to, children }: PropsWithChildren<
} }
return ( return (
<NavigationMenuItem active={current} onClick={() => navigate(`/${to}`)}> <NavigationMenuItem
active={current}
onClick={() => {
navigate(`/${to}`);
postAction?.();
}}
>
{children} {children}
</NavigationMenuItem> </NavigationMenuItem>
); );
@@ -32,11 +32,11 @@
margin-left: 0.25rem; margin-left: 0.25rem;
width: 0.75em; width: 0.75em;
height: 0.75em; height: 0.75em;
background: var(--user-bg); background: var(--user-bg, $gray-900);
border-radius: 50%; border-radius: 50%;
} }
} }
.empty { .empty {
color: $ui-white; color: $ui-white;
} }
@@ -4,6 +4,7 @@ import { useSearchParams } from 'react-router';
import { Dialog } from '@base-ui-components/react/dialog'; import { Dialog } from '@base-ui-components/react/dialog';
import { OntimeView } from 'ontime-types'; import { OntimeView } from 'ontime-types';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import useViewSettings from '../../hooks-query/useViewSettings'; import useViewSettings from '../../hooks-query/useViewSettings';
import Button from '../buttons/Button'; import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
@@ -27,6 +28,7 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const [_, setSearchParams] = useSearchParams(); const [_, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings(); const { data: viewSettings } = useViewSettings();
const { isOpen, close } = useViewParamsEditorStore(); const { isOpen, close } = useViewParamsEditorStore();
const isSmallScreen = useIsSmallScreen();
const handleClose = () => { const handleClose = () => {
close(); close();
@@ -42,6 +44,10 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget)); const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions); const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
setSearchParams(newSearchParams); setSearchParams(newSearchParams);
if (isSmallScreen) {
close();
}
}; };
return ( return (
@@ -46,7 +46,7 @@ export function makeCustomFieldSelectOptions(customFields: CustomFields, filterI
options.push({ options.push({
value: key, value: key,
label: value.label, label: value.label,
colour: value.colour || 'transparent', colour: value.colour,
}); });
} }
@@ -1,6 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { ProjectFileListResponse } from 'ontime-types'; import { ProjectFile, ProjectFileList, ProjectFileListResponse } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_LIST } from '../api/constants'; import { PROJECT_LIST } from '../api/constants';
@@ -24,22 +24,33 @@ function useProjectList() {
return { data: data ?? placeholderProjectList, status, refetch }; return { data: data ?? placeholderProjectList, status, refetch };
} }
export function useOrderedProjectList() { export type ProjectSortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'modified-asc' | 'modified-desc';
type SortComparator = (a: ProjectFile, b: ProjectFile) => number;
const sortComparators: Record<ProjectSortMode, SortComparator> = {
'alphabetical-asc': (a, b) => a.filename.localeCompare(b.filename),
'alphabetical-desc': (a, b) => b.filename.localeCompare(a.filename),
'modified-asc': (a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(),
'modified-desc': (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
};
export function useOrderedProjectList(sort: ProjectSortMode = 'modified-desc') {
const response = useProjectList(); const response = useProjectList();
const { files, lastLoadedProject } = response.data; const { files, lastLoadedProject } = response.data;
const reorderedProjectFiles = useMemo(() => { const reorderedProjectFiles: ProjectFileList = useMemo(() => {
if (!files.length) return []; if (!files.length) return [];
const currentlyLoadedIndex = files.findIndex((project) => project.filename === lastLoadedProject); const sorted = [...files].sort(sortComparators[sort]);
if (currentlyLoadedIndex === -1) return files; // keep loaded always on top
const currentlyLoadedIndex = sorted.findIndex((project) => project.filename === lastLoadedProject);
if (currentlyLoadedIndex > 0) {
const [loaded] = sorted.splice(currentlyLoadedIndex, 1);
sorted.unshift(loaded);
}
const projectFiles = [...files]; return sorted;
const current = projectFiles.splice(currentlyLoadedIndex, 1)[0]; }, [files, lastLoadedProject, sort]);
return [current, ...projectFiles];
}, [files, lastLoadedProject]);
return { ...response, data: { reorderedProjectFiles, lastLoadedProject: response.data.lastLoadedProject } }; return { ...response, data: { reorderedProjectFiles, lastLoadedProject: response.data.lastLoadedProject } };
} }
@@ -3,7 +3,7 @@ import { ProjectRundownsList } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_RUNDOWNS } from '../api/constants'; import { PROJECT_RUNDOWNS } from '../api/constants';
import { createRundown, deleteRundown, fetchProjectRundownList, loadRundown } from '../api/rundown'; import { createRundown, deleteRundown, duplicateRundown, fetchProjectRundownList, loadRundown, renameRundown } from '../api/rundown';
/** /**
* Project rundowns * Project rundowns
@@ -30,6 +30,26 @@ export function useMutateProjectRundowns() {
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data); ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
}, },
}); });
const { mutateAsync: duplicate } = useMutation({
mutationFn: duplicateRundown,
onMutate: () => {
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
},
onSuccess: (response) => {
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
},
});
const { mutateAsync: rename } = useMutation({
mutationFn: ([rundownId, title]: Parameters<typeof renameRundown>) => renameRundown(rundownId, title),
onMutate: () => {
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
},
onSuccess: (response) => {
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
},
});
const { mutateAsync: remove } = useMutation({ const { mutateAsync: remove } = useMutation({
mutationFn: deleteRundown, mutationFn: deleteRundown,
@@ -51,5 +71,5 @@ export function useMutateProjectRundowns() {
}, },
}); });
return { create, remove, load }; return { create, duplicate, remove, load, rename };
} }
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
EntryId, EntryId,
InsertOptions,
isOntimeEvent, isOntimeEvent,
isOntimeGroup, isOntimeGroup,
MaybeString, MaybeString,
@@ -173,7 +174,8 @@ export const useEntryActions = () => {
* @private * @private
*/ */
const { mutateAsync: cloneEntryMutation } = useMutation({ const { mutateAsync: cloneEntryMutation } = useMutation({
mutationFn: ([rundownId, entryId]: Parameters<typeof postCloneEntry>) => postCloneEntry(rundownId, entryId), mutationFn: ([rundownId, entryId, options]: Parameters<typeof postCloneEntry>) =>
postCloneEntry(rundownId, entryId, options),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }), onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
}); });
@@ -182,14 +184,14 @@ export const useEntryActions = () => {
* Clone an entry * Clone an entry
*/ */
const clone = useCallback( const clone = useCallback(
async (entryId: EntryId) => { async (entryId: EntryId, options?: InsertOptions) => {
try { try {
const rundownId = getCurrentRundownData()?.id; const rundownId = getCurrentRundownData()?.id;
if (!rundownId) { if (!rundownId) {
throw new Error('Rundown not initialised'); throw new Error('Rundown not initialised');
} }
await cloneEntryMutation([rundownId, entryId]); await cloneEntryMutation([rundownId, entryId, options]);
} catch (error) { } catch (error) {
logAxiosError('Error cloning entry', error); logAxiosError('Error cloning entry', error);
} }
@@ -0,0 +1,95 @@
import React, { useEffect, useMemo, useRef } from 'react';
import { throttle } from '../utils/throttle';
export interface UseLongPressOptions {
/** Time in milliseconds to trigger the long press, default is 400ms */
threshold?: number;
/** Callback triggered when the long press starts */
onStart?: (event: React.MouseEvent | React.TouchEvent) => void;
/** Callback triggered when the long press finishes */
onFinish?: (event: React.MouseEvent | React.TouchEvent) => void;
/** Callback triggered when the long press is canceled */
onCancel?: (event: React.MouseEvent | React.TouchEvent) => void;
}
export interface UseLongPressReturnValue {
onMouseDown: (event: React.MouseEvent) => void;
onMouseUp: (event: React.MouseEvent) => void;
onMouseLeave: (event: React.MouseEvent) => void;
onTouchStart: (event: React.TouchEvent) => void;
onTouchEnd: (event: React.TouchEvent) => void;
}
export function useLongPress(
onLongPress: (event: React.MouseEvent | React.TouchEvent) => void,
options: UseLongPressOptions = {},
): UseLongPressReturnValue {
const { threshold = 700, onStart, onFinish, onCancel } = options;
const isLongPressActive = useRef(false);
const isPressed = useRef(false);
const timeout = useRef<number>(-1);
useEffect(() => () => window.clearTimeout(timeout.current), []);
return useMemo(() => {
if (typeof onLongPress !== 'function') {
return {} as UseLongPressReturnValue;
}
const start = (event: React.MouseEvent | React.TouchEvent) => {
if (!isMouseEvent(event) && !isTouchEvent(event)) {
return;
}
if (onStart) {
onStart(event);
}
isPressed.current = true;
timeout.current = window.setTimeout(() => {
onLongPress(event);
isLongPressActive.current = true;
}, threshold);
};
const cancel = (event: React.MouseEvent | React.TouchEvent) => {
if (!isMouseEvent(event) && !isTouchEvent(event)) {
return;
}
if (isLongPressActive.current) {
onFinish?.(event);
} else if (isPressed.current) {
onCancel?.(event);
}
isLongPressActive.current = false;
isPressed.current = false;
if (timeout.current) {
window.clearTimeout(timeout.current);
}
};
return {
onMouseDown: start,
onMouseUp: cancel,
onMouseLeave: cancel,
onTouchStart: start,
onTouchEnd: cancel,
onTouchMove: throttle(cancel, 150),
};
}, [onLongPress, threshold, onCancel, onFinish, onStart]);
}
function isTouchEvent(event: React.MouseEvent | React.TouchEvent): event is React.TouchEvent {
return window.TouchEvent ? event.nativeEvent instanceof TouchEvent : 'touches' in event.nativeEvent;
}
function isMouseEvent(event: React.MouseEvent | React.TouchEvent): event is React.MouseEvent {
return event.nativeEvent instanceof MouseEvent;
}
@@ -202,6 +202,9 @@ export const useStartTimesOverview = createSelector((state: RuntimeStore) => ({
plannedStart: state.rundown.plannedStart, plannedStart: state.rundown.plannedStart,
actualStart: state.rundown.actualStart, actualStart: state.rundown.actualStart,
plannedEnd: state.rundown.plannedEnd, plannedEnd: state.rundown.plannedEnd,
}));
export const useRundownExpectedEnd = createSelector((state: RuntimeStore) => ({
expectedEnd: state.offset.expectedRundownEnd, expectedEnd: state.offset.expectedRundownEnd,
})); }));
@@ -1,68 +0,0 @@
import { EndAction, EntryCustomFields, OntimeEvent, SupportedEntry, TimerType, TimeStrategy } from 'ontime-types';
import { cloneEvent } from '../clone';
describe('cloneEvent()', () => {
it('creates a stem from a given event', () => {
const original: OntimeEvent = {
id: 'unique',
type: SupportedEntry.Event,
flag: false,
title: 'title',
cue: 'cue',
note: 'note',
timeStart: 0,
duration: 10,
timeEnd: 10,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
parent: 'test',
linkStart: false,
countToEnd: false,
endAction: EndAction.None,
skip: false,
colour: 'F00',
revision: 10,
timeWarning: 120000,
timeDanger: 60000,
delay: 0,
dayOffset: 0,
gap: 0,
triggers: [],
custom: {
lighting: '3',
} as EntryCustomFields,
};
const cloned = cloneEvent(original);
expect(cloned).not.toBe(original);
expect(cloned.custom).not.toBe(original.custom);
expect(cloned.triggers).not.toBe(original.triggers);
expect(cloned).toMatchObject({
type: SupportedEntry.Event,
flag: original.flag,
title: original.title,
note: original.note,
timeStart: original.timeStart,
duration: original.duration,
timeEnd: original.timeEnd,
timerType: original.timerType,
timeStrategy: original.timeStrategy,
parent: 'test',
countToEnd: original.countToEnd,
linkStart: original.linkStart,
endAction: original.endAction,
skip: original.skip,
colour: original.colour,
revision: 0,
delay: original.delay,
dayOffset: original.dayOffset,
gap: 0,
timeWarning: original.timeWarning,
timeDanger: original.timeDanger,
triggers: original.triggers,
custom: original.custom,
});
});
});
@@ -14,4 +14,11 @@ describe('linkToOTherHost', () => {
const destination = linkToOtherHost('cloud.getontime.no', 'path', serverUrl, baseUri); const destination = linkToOtherHost('cloud.getontime.no', 'path', serverUrl, baseUri);
expect(destination).toBe('https://cloud.getontime.no/user-hash/path'); expect(destination).toBe('https://cloud.getontime.no/user-hash/path');
}); });
it('should handle ontime app links', () => {
const serverUrl = 'https://app.getontime.no/user-hash';
const baseUri = 'user-hash';
const destination = linkToOtherHost('app.getontime.no', 'path', serverUrl, baseUri);
expect(destination).toBe('https://app.getontime.no/user-hash/path');
});
}); });
@@ -140,7 +140,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 2, eventIndex: 2,
isPast: true, isPast: true,
isNextDay: false, isNextDay: false,
totalGap: 10, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
groupId: 'group', groupId: 'group',
@@ -156,7 +156,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 2, eventIndex: 2,
isPast: true, isPast: true,
isNextDay: false, isNextDay: false,
totalGap: 10, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
groupId: 'group', groupId: 'group',
@@ -172,7 +172,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 3, eventIndex: 3,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
totalGap: 10, totalGap: 0,
isLinkedToLoaded: true, isLinkedToLoaded: true,
isLoaded: true, isLoaded: true,
groupId: 'group', groupId: 'group',
@@ -188,7 +188,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 4, eventIndex: 4,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
totalGap: 10, totalGap: 0,
isLinkedToLoaded: true, isLinkedToLoaded: true,
isLoaded: false, isLoaded: false,
groupId: 'group', groupId: 'group',
@@ -204,7 +204,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 5, eventIndex: 5,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
totalGap: 17, totalGap: 7,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
groupId: null, groupId: null,
@@ -1,4 +1,6 @@
import { formatTime, nowInMillis } from '../time'; import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { formatDuration, formatTime, nowInMillis } from '../time';
describe('nowInMillis()', () => { describe('nowInMillis()', () => {
it('should return the current time in milliseconds', () => { it('should return the current time in milliseconds', () => {
@@ -38,3 +40,18 @@ describe('formatTime()', () => {
expect(time).toStrictEqual('-01:00'); expect(time).toStrictEqual('-01:00');
}); });
}); });
describe('formatDuration()', () => {
it('formats durations correctly', () => {
expect(formatDuration(0)).toBe('0m');
expect(formatDuration(-5000)).toBe('0m');
expect(formatDuration(MILLIS_PER_MINUTE)).toBe('1m');
expect(formatDuration(6 * MILLIS_PER_MINUTE + 11 * MILLIS_PER_SECOND)).toBe('6m');
expect(formatDuration(MILLIS_PER_MINUTE * 10)).toBe('10m');
expect(formatDuration(MILLIS_PER_MINUTE * 10 + 100)).toBe('10m');
expect(formatDuration(MILLIS_PER_MINUTE * 10 - 100)).toBe('9m');
expect(formatDuration(2 * MILLIS_PER_HOUR + 6 * MILLIS_PER_MINUTE)).toBe('2h6m');
expect(formatDuration(2 * MILLIS_PER_HOUR + 6 * MILLIS_PER_MINUTE + 45 * MILLIS_PER_SECOND, false)).toBe('2h6m45s');
expect(formatDuration(599702, false)).toBe('9m59s');
});
});
-36
View File
@@ -1,36 +0,0 @@
import { OntimeEvent, SupportedEntry } from 'ontime-types';
/**
* @description Creates a safe duplicate of an event
* @param {OntimeEvent} event
* @param {string} [after]
* @return {OntimeEvent} clean event
*/
type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>;
export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
return {
type: SupportedEntry.Event,
flag: event.flag,
title: event.title,
note: event.note,
timeStart: event.timeStart,
duration: event.duration,
timeEnd: event.timeEnd,
timerType: event.timerType,
timeStrategy: event.timeStrategy,
countToEnd: event.countToEnd,
linkStart: event.linkStart,
endAction: event.endAction,
skip: event.skip,
colour: event.colour,
parent: event.parent,
revision: 0,
delay: event.delay, // the events will be collocated, so having the same metadata is a good start
dayOffset: event.dayOffset,
gap: 0,
timeWarning: event.timeWarning,
timeDanger: event.timeDanger,
triggers: structuredClone(event.triggers),
custom: structuredClone(event.custom),
};
};
+2 -2
View File
@@ -1,5 +1,5 @@
import { MaybeNumber } from 'ontime-types'; import { MaybeNumber } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString, removeLeadingZero } from 'ontime-utils';
import { enDash } from './styleUtils'; import { enDash } from './styleUtils';
@@ -14,7 +14,7 @@ export function getOffsetText(offset: MaybeNumber): string {
let offsetText = ''; let offsetText = '';
if (offset < 0) offsetText += '-'; if (offset < 0) offsetText += '-';
if (offset > 0) offsetText += '+'; if (offset > 0) offsetText += '+';
offsetText += millisToString(Math.abs(offset)); offsetText += removeLeadingZero(millisToString(Math.abs(offset)));
return offsetText; return offsetText;
} }
@@ -151,14 +151,14 @@ function processEntry(
if (isPlayableEvent(entry)) { if (isPlayableEvent(entry)) {
processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent); processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
processedData.totalGap += entry.gap;
if (!processedData.isPast && !processedData.isLoaded) { if (!processedData.isPast && !processedData.isLoaded) {
/** /**
* isLinkToLoaded is a chain value that we maintain until we * isLinkToLoaded is a chain value that we maintain until we
* a) find an unlinked event * a) find an unlinked event
* b) find a countToEnd event * b) find a countToEnd event
*/ */
processedData.totalGap += entry.gap;
processedData.isLinkedToLoaded = processedData.isLinkedToLoaded =
entry.linkStart && !processedData.previousEvent?.countToEnd && processedData.isLinkedToLoaded; entry.linkStart && !processedData.previousEvent?.countToEnd && processedData.isLinkedToLoaded;
} }
+8 -2
View File
@@ -112,11 +112,12 @@ export const formatTime = (
export function formatDuration(duration: number, hideSeconds = true): string { export function formatDuration(duration: number, hideSeconds = true): string {
// durations should never be negative, we handle it here to flag if there is an issue in future // durations should never be negative, we handle it here to flag if there is an issue in future
if (duration <= 0) { if (duration <= 0) {
return '0h 0m'; return '0m';
} }
const hours = Math.floor(duration / MILLIS_PER_HOUR); const hours = Math.floor(duration / MILLIS_PER_HOUR);
const minutes = Math.floor((duration % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE); const minutes = Math.floor((duration % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE);
let result = ''; let result = '';
if (hours > 0) { if (hours > 0) {
result += `${hours}h`; result += `${hours}h`;
@@ -126,11 +127,16 @@ export function formatDuration(duration: number, hideSeconds = true): string {
} }
if (!hideSeconds) { if (!hideSeconds) {
const seconds = Math.ceil((duration % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND); const remainingMs = duration % MILLIS_PER_MINUTE;
const exactSeconds = remainingMs / MILLIS_PER_SECOND;
// cap at 59 to avoid showing 60s
const seconds = Math.min(59, Math.ceil(exactSeconds));
if (seconds > 0) { if (seconds > 0) {
result += `${seconds}s`; result += `${seconds}s`;
} }
} }
return result; return result;
} }
+5
View File
@@ -19,6 +19,11 @@ declare global {
process: { process: {
type: string; type: string;
}; };
// Experimental browser feature
documentPictureInPicture: {
requestWindow: () => Promise<Window>;
window: Window;
};
} }
} }
+5 -1
View File
@@ -8,6 +8,7 @@ export const githubUrl = 'https://www.github.com/cpvalente/ontime';
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest'; export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
export const websiteUrl = 'https://www.getontime.no'; export const websiteUrl = 'https://www.getontime.no';
export const discordUrl = 'https://discord.com/invite/eje3CSUEXm'; export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
export const subredditUrl = 'https://www.reddit.com/r/ontimeapp/';
export const documentationUrl = 'https://docs.getontime.no'; export const documentationUrl = 'https://docs.getontime.no';
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/'; export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
@@ -22,7 +23,10 @@ export const isProduction = import.meta.env.PROD;
export const isDev = import.meta.env.DEV; export const isDev = import.meta.env.DEV;
export const currentHostName = window.location.hostname; export const currentHostName = window.location.hostname;
export const isLocalhost = currentHostName === 'localhost' || currentHostName === '127.0.0.1'; export const isLocalhost = currentHostName === 'localhost' || currentHostName === '127.0.0.1';
export const isOntimeCloud = currentHostName.includes('cloud.getontime.no'); export const isOntimeCloud = document.querySelector('base')?.hasAttribute('data-is-cloud')
export const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
export const supportsFullscreen = document.fullscreenEnabled;
// resolve entrypoint URLs // resolve entrypoint URLs
@@ -5,6 +5,7 @@ import {
documentationUrl, documentationUrl,
githubSponsorUrl, githubSponsorUrl,
githubUrl, githubUrl,
subredditUrl,
websiteUrl, websiteUrl,
} from '../../../../externals'; } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -38,6 +39,7 @@ export default function AboutPanel() {
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink> <ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink> <ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
<ExternalLink href={discordUrl}>Discord server</ExternalLink> <ExternalLink href={discordUrl}>Discord server</ExternalLink>
<ExternalLink href={subredditUrl}>Subreddit</ExternalLink>
</Panel.Section> </Panel.Section>
</> </>
); );
@@ -10,7 +10,7 @@ export default function AppVersion() {
return ( return (
<Panel.Paragraph> <Panel.Paragraph>
{`You are currently using Ontime version ${appVersion}`} {`You are currently using Ontime version ${appVersion}`}
<Panel.Error>{`Could not fetch version information: ${isError}`}</Panel.Error> <Panel.Error>Could not fetch version information</Panel.Error>
</Panel.Paragraph> </Panel.Paragraph>
); );
} }
@@ -9,6 +9,8 @@ import Textarea from '../../../../../common/components/input/textarea/Textarea';
import Select, { SelectOption } from '../../../../../common/components/select/Select'; import Select, { SelectOption } from '../../../../../common/components/select/Select';
import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets'; import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets';
import { preventEscape } from '../../../../../common/utils/keyEvent'; import { preventEscape } from '../../../../../common/utils/keyEvent';
import { isUrlSafe } from '../../../../../common/utils/regex';
import { enDash } from '../../../../../common/utils/styleUtils';
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets'; import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
@@ -52,6 +54,7 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
formState: { errors, isSubmitting, isValid, isDirty }, formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<URLPreset>({ } = useForm<URLPreset>({
defaultValues: urlPreset ?? defaultValues, defaultValues: urlPreset ?? defaultValues,
mode: 'onChange',
resetOptions: { resetOptions: {
keepDirtyValues: true, keepDirtyValues: true,
}, },
@@ -110,7 +113,15 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
<Panel.InlineElements> <Panel.InlineElements>
<div> <div>
<Panel.Description>Alias</Panel.Description> <Panel.Description>Alias</Panel.Description>
<Input {...register('alias', { required: 'Alias is required' })} /> <Input
{...register('alias', {
required: 'Alias is required',
pattern: {
value: isUrlSafe,
message: 'Field can only contain URL safe characters (a-z, 0-9, _ and -)',
},
})}
/>
</div> </div>
<div className={style.expand}> <div className={style.expand}>
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description> <Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
@@ -120,7 +131,10 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
</Panel.InlineElements> </Panel.InlineElements>
</div> </div>
</Panel.InlineElements> </Panel.InlineElements>
<div> - or -</div> {errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>}
<div>
{enDash} or {enDash}
</div>
<div>2. Choose a view and its parameters</div> <div>2. Choose a view and its parameters</div>
<div> <div>
<Panel.Description>Target</Panel.Description> <Panel.Description>Target</Panel.Description>
@@ -1,5 +1,13 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoAdd, IoDocumentOutline, IoDownloadOutline, IoEllipsisHorizontal, IoTrash } from 'react-icons/io5'; import {
IoAdd,
IoDocumentOutline,
IoDownloadOutline,
IoDuplicateOutline,
IoEllipsisHorizontal,
IoPencilOutline,
IoTrash,
} from 'react-icons/io5';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { downloadAsExcel } from '../../../../common/api/excel'; import { downloadAsExcel } from '../../../../common/api/excel';
@@ -13,17 +21,19 @@ import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import RundownRenameForm from './composite/RundownRenameForm';
import { ManageRundownForm } from './ManageRundownForm'; import { ManageRundownForm } from './ManageRundownForm';
import style from './ManagePanel.module.scss'; import style from './ManagePanel.module.scss';
export default function ManageRundowns() { export default function ManageRundowns() {
const { data } = useProjectRundowns(); const { data } = useProjectRundowns();
const { remove, load } = useMutateProjectRundowns(); const { duplicate, remove, load, rename } = useMutateProjectRundowns();
const [isOpenDelete, deleteHandlers] = useDisclosure(); const [isOpenDelete, deleteHandlers] = useDisclosure();
const [isOpenLoad, loadHandlers] = useDisclosure(); const [isOpenLoad, loadHandlers] = useDisclosure();
const [isNewLoad, newHandlers] = useDisclosure(); const [isNewLoad, newHandlers] = useDisclosure();
const [targetRundown, setTargetRundown] = useState(''); const [targetRundown, setTargetRundown] = useState('');
const [renamingRundown, setRenamingRundown] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const openLoad = (id: string) => { const openLoad = (id: string) => {
@@ -38,6 +48,11 @@ export default function ManageRundowns() {
deleteHandlers.open(); deleteHandlers.open();
}; };
const openRename = (id: string) => {
setActionError(null);
setRenamingRundown(id);
};
const submitRundownLoad = async () => { const submitRundownLoad = async () => {
try { try {
await load(targetRundown); await load(targetRundown);
@@ -48,6 +63,27 @@ export default function ManageRundowns() {
} }
}; };
const submitRundownDuplicate = async (id: string) => {
setActionError(null);
setRenamingRundown(null);
setTargetRundown('');
try {
await duplicate(id);
} catch (error) {
setActionError(`Failed to duplicate rundown. ${maybeAxiosError(error)}`);
}
};
const submitRundownRename = async (id: string, newTitle: string) => {
try {
await rename([id, newTitle]);
setRenamingRundown(null);
} catch (error) {
setActionError(`Failed to rename rundown. ${maybeAxiosError(error)}`);
}
};
const submitRundownDelete = async () => { const submitRundownDelete = async () => {
try { try {
await remove(targetRundown); await remove(targetRundown);
@@ -94,6 +130,22 @@ export default function ManageRundowns() {
<tbody> <tbody>
{data?.rundowns?.map(({ id, numEntries, title }) => { {data?.rundowns?.map(({ id, numEntries, title }) => {
const isLoaded = data.loaded === id; const isLoaded = data.loaded === id;
const isRenaming = renamingRundown === id;
if (isRenaming) {
return (
<tr key={id}>
<td colSpan={3}>
<RundownRenameForm
onCancel={() => setRenamingRundown(null)}
onSubmit={(newTitle: string) => submitRundownRename(id, newTitle)}
initialTitle={title}
/>
</td>
</tr>
);
}
return ( return (
<tr key={id} className={cx([isLoaded && style.current])}> <tr key={id} className={cx([isLoaded && style.current])}>
<td>{numEntries}</td> <td>{numEntries}</td>
@@ -104,6 +156,12 @@ export default function ManageRundowns() {
<DropdownMenu <DropdownMenu
render={<IconButton variant='ghosted-white' />} render={<IconButton variant='ghosted-white' />}
items={[ items={[
{
type: 'item',
icon: IoPencilOutline,
label: 'Rename',
onClick: () => openRename(id),
},
{ {
type: 'item', type: 'item',
icon: IoDownloadOutline, icon: IoDownloadOutline,
@@ -119,6 +177,13 @@ export default function ManageRundowns() {
}, },
{ {
type: 'item', type: 'item',
icon: IoDuplicateOutline,
label: 'Duplicate',
onClick: () => submitRundownDuplicate(id),
},
{ type: 'divider' },
{
type: 'destructive',
icon: IoTrash, icon: IoTrash,
label: 'Delete', label: 'Delete',
onClick: () => openDelete(id), onClick: () => openDelete(id),
@@ -140,7 +205,7 @@ export default function ManageRundowns() {
<Dialog <Dialog
isOpen={isOpenDelete} isOpen={isOpenDelete}
onClose={deleteHandlers.close} onClose={deleteHandlers.close}
title='Load rundown' title='Delete rundown'
showBackdrop showBackdrop
showCloseButton showCloseButton
bodyElements={ bodyElements={
@@ -0,0 +1,77 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { checkRegex } from 'ontime-utils';
import { maybeAxiosError } from '../../../../../common/api/utils';
import Button from '../../../../../common/components/buttons/Button';
import Input from '../../../../../common/components/input/input/Input';
import { preventEscape } from '../../../../../common/utils/keyEvent';
import * as Panel from '../../../panel-utils/PanelUtils';
interface RundownRenameFormProps {
onSubmit: (newTitle: string) => Promise<void>;
onCancel: () => void;
initialTitle: string;
}
interface FormData {
title: string;
}
export default function RundownRenameForm({ onSubmit, onCancel, initialTitle }: RundownRenameFormProps) {
const {
handleSubmit,
register,
setFocus,
setError,
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<FormData>({
defaultValues: { title: initialTitle },
mode: 'onChange',
});
const setupSubmit = async (values: FormData) => {
try {
await onSubmit(values.title);
} catch (error) {
setError('root', { type: 'custom', message: maybeAxiosError(error) });
}
};
// Give initial focus to the title input
useEffect(() => {
setFocus('title');
}, [setFocus]);
const canSubmit = isDirty && isValid;
return (
<Panel.Indent as='form' onSubmit={handleSubmit(setupSubmit)} onKeyDown={(event) => preventEscape(event, onCancel)}>
<label>
<Panel.Description>Rundown title</Panel.Description>
<Input
{...register('title', {
required: { value: true, message: 'Title is required' },
validate: (value) => {
if (value.trim().length === 0) return 'Title cannot be empty';
if (checkRegex.isAlphanumericWithSpace(value) === false)
return 'Title can only contain alphanumeric characters, spaces and underscores';
return true;
},
})}
fluid
/>
{errors.title && <Panel.Error>{errors.title.message}</Panel.Error>}
</label>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.InlineElements relation='inner' align='end'>
<Button variant='ghosted' onClick={onCancel}>
Cancel
</Button>
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
</Panel.Indent>
);
}
@@ -1,8 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { CustomFields, Rundown } from 'ontime-types'; import { CustomFields, Rundown, RundownSummary } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import Button from '../../../../../common/components/buttons/Button'; import Button from '../../../../../common/components/buttons/Button';
import useRundown from '../../../../../common/hooks-query/useRundown'; import useRundown from '../../../../../common/hooks-query/useRundown';
import { formatDuration } from '../../../../../common/utils/time';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import PreviewSpreadsheet from './preview/PreviewRundown'; import PreviewSpreadsheet from './preview/PreviewRundown';
@@ -12,12 +14,20 @@ import { useSheetStore } from './useSheetStore';
interface ImportReviewProps { interface ImportReviewProps {
rundown: Rundown; rundown: Rundown;
customFields: CustomFields; customFields: CustomFields;
summary: RundownSummary;
onFinished: () => void; onFinished: () => void;
onCancel: () => void; onCancel: () => void;
onBack: () => void;
} }
export default function ImportReview(props: ImportReviewProps) { export default function ImportReview({
const { rundown, customFields, onFinished, onCancel } = props; rundown,
customFields,
summary,
onFinished,
onCancel,
onBack,
}: ImportReviewProps) {
const { data: currentRundown } = useRundown(); const { data: currentRundown } = useRundown();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { importRundown } = useGoogleSheet(); const { importRundown } = useGoogleSheet();
@@ -51,11 +61,31 @@ export default function ImportReview(props: ImportReviewProps) {
<Button onClick={handleCancel} variant='ghosted' disabled={loading}> <Button onClick={handleCancel} variant='ghosted' disabled={loading}>
Cancel Cancel
</Button> </Button>
<Button onClick={onBack} variant='subtle' disabled={loading}>
Back
</Button>
<Button onClick={applyImport} variant='primary' loading={loading}> <Button onClick={applyImport} variant='primary' loading={loading}>
Apply Apply
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
</Panel.Title> </Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<b>Title</b> {rundown.title}
</Panel.ListItem>
<Panel.ListItem>
<b>Number of entries</b> {rundown.flatOrder.length}
</Panel.ListItem>
<Panel.ListItem>
<b>Start time</b> {millisToString(summary.start)}
</Panel.ListItem>
<Panel.ListItem>
<b>End time</b> {millisToString(summary.end)}
</Panel.ListItem>
<Panel.ListItem>
<b>Total duration</b> {formatDuration(summary.duration)}
</Panel.ListItem>
</Panel.ListGroup>
<PreviewSpreadsheet rundown={rundown} customFields={customFields} /> <PreviewSpreadsheet rundown={rundown} customFields={customFields} />
</Panel.Section> </Panel.Section>
); );
@@ -3,7 +3,6 @@ import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
import { getErrorMessage, ImportMap } from 'ontime-utils'; import { getErrorMessage, ImportMap } from 'ontime-utils';
import { import {
getWorksheetNames as getWorksheetNamesExcel,
importRundownPreview as importRundownPreviewExcel, importRundownPreview as importRundownPreviewExcel,
upload as uploadExcel, upload as uploadExcel,
} from '../../../../../common/api/excel'; } from '../../../../../common/api/excel';
@@ -37,8 +36,11 @@ export default function SourcesPanel() {
const setRundown = useSheetStore((state) => state.setRundown); const setRundown = useSheetStore((state) => state.setRundown);
const customFields = useSheetStore((state) => state.customFields); const customFields = useSheetStore((state) => state.customFields);
const setCustomFields = useSheetStore((state) => state.setCustomFields); const setCustomFields = useSheetStore((state) => state.setCustomFields);
const summary = useSheetStore((state) => state.summary);
const setSummary = useSheetStore((state) => state.setSummary);
const setSheetId = useSheetStore((state) => state.setSheetId); const setSheetId = useSheetStore((state) => state.setSheetId);
const sheetId = useSheetStore((state) => state.sheetId); const sheetId = useSheetStore((state) => state.sheetId);
const resetPreview = useSheetStore((state) => state.resetPreview);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -53,8 +55,7 @@ export default function SourcesPanel() {
try { try {
setHasFile('loading'); setHasFile('loading');
validateExcelImport(fileToUpload); validateExcelImport(fileToUpload);
await uploadExcel(fileToUpload); const names = await uploadExcel(fileToUpload);
const names = await getWorksheetNamesExcel();
setWorksheets(names); setWorksheets(names);
setImportFlow('excel'); setImportFlow('excel');
setHasFile('done'); setHasFile('done');
@@ -77,6 +78,7 @@ export default function SourcesPanel() {
setHasFile('none'); setHasFile('none');
setWorksheets(null); setWorksheets(null);
setCustomFields(null); setCustomFields(null);
setSummary(null);
setError(''); setError('');
setSheetId(null); setSheetId(null);
}; };
@@ -110,6 +112,7 @@ export default function SourcesPanel() {
const previewData = await importRundownPreviewExcel(importMap); const previewData = await importRundownPreviewExcel(importMap);
setRundown(previewData.rundown); setRundown(previewData.rundown);
setCustomFields(previewData.customFields); setCustomFields(previewData.customFields);
setSummary(previewData.summary);
} catch (error) { } catch (error) {
setError(maybeAxiosError(error)); setError(maybeAxiosError(error));
} }
@@ -152,7 +155,7 @@ export default function SourcesPanel() {
const showCompleted = importFlow === 'finished'; const showCompleted = importFlow === 'finished';
const showAuth = isGSheetFlow && !isAuthenticated; const showAuth = isGSheetFlow && !isAuthenticated;
const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile === 'done'); const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile === 'done');
const showReview = rundown !== null && customFields !== null; const showReview = rundown !== null && customFields !== null && summary !== null;
return ( return (
<Panel.Section> <Panel.Section>
@@ -219,8 +222,10 @@ export default function SourcesPanel() {
<ImportReview <ImportReview
rundown={rundown} rundown={rundown}
customFields={customFields} customFields={customFields}
summary={summary}
onFinished={handleFinished} onFinished={handleFinished}
onCancel={cancelImportMap} onCancel={cancelImportMap}
onBack={resetPreview}
/> />
)} )}
</Panel.Card> </Panel.Card>
@@ -5,6 +5,7 @@ import { checkRegex, ImportMap } from 'ontime-utils';
import Button from '../../../../../../common/components/buttons/Button'; import Button from '../../../../../../common/components/buttons/Button';
import IconButton from '../../../../../../common/components/buttons/IconButton'; import IconButton from '../../../../../../common/components/buttons/IconButton';
import Info from '../../../../../../common/components/info/Info';
import Input from '../../../../../../common/components/input/input/Input'; import Input from '../../../../../../common/components/input/input/Input';
import Select from '../../../../../../common/components/select/Select'; import Select from '../../../../../../common/components/select/Select';
import Tooltip from '../../../../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../../../../common/components/tooltip/Tooltip';
@@ -136,6 +137,10 @@ export default function ImportMapForm({
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
</Panel.Title> </Panel.Title>
<Info>
Match your spreadsheet columns to Ontime fields. <br />
You can also add Custom Fields by providing a name for Ontime and the spreadsheet column name.
</Info>
<Panel.Table> <Panel.Table>
<thead> <thead>
<tr> <tr>
@@ -18,9 +18,7 @@ function booleanToText(value?: boolean) {
return value ? 'Yes' : undefined; return value ? 'Yes' : undefined;
} }
export default function PreviewRundown(props: PreviewRundownProps) { export default function PreviewRundown({ rundown, customFields }: PreviewRundownProps) {
const { rundown, customFields } = props;
// we only count Ontime Events which are 1 based in client // we only count Ontime Events which are 1 based in client
let eventIndex = 0; let eventIndex = 0;
@@ -59,9 +57,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
const colour = entry.colour ? getAccessibleColour(entry.colour) : {}; const colour = entry.colour ? getAccessibleColour(entry.colour) : {};
return ( return (
<tr key={entry.id}> <tr key={entry.id}>
<td className={style.center}> <td /> {/** Index */}
<Tag>-</Tag>
</td>
<td className={style.center}> <td className={style.center}>
<Tag>{entry.type}</Tag> <Tag>{entry.type}</Tag>
</td> </td>
@@ -21,6 +21,7 @@ export default function useGoogleSheet() {
const patchStepData = useSheetStore((state) => state.patchStepData); const patchStepData = useSheetStore((state) => state.patchStepData);
const setRundown = useSheetStore((state) => state.setRundown); const setRundown = useSheetStore((state) => state.setRundown);
const setCustomFields = useSheetStore((state) => state.setCustomFields); const setCustomFields = useSheetStore((state) => state.setCustomFields);
const setSummary = useSheetStore((state) => state.setSummary);
/** whether the current session has been authenticated */ /** whether the current session has been authenticated */
const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus; sheetId: string } | void> => { const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus; sheetId: string } | void> => {
@@ -58,6 +59,7 @@ export default function useGoogleSheet() {
const data = await previewRundown(sheetId, fileOptions); const data = await previewRundown(sheetId, fileOptions);
setRundown(data.rundown); setRundown(data.rundown);
setCustomFields(data.customFields); setCustomFields(data.customFields);
setSummary(data.summary);
} catch (error) { } catch (error) {
patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } }); patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } });
} }
@@ -1,4 +1,4 @@
import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types'; import { AuthenticationStatus, CustomFields, Rundown, RundownSummary } from 'ontime-types';
import { defaultImportMap, ImportMap } from 'ontime-utils'; import { defaultImportMap, ImportMap } from 'ontime-utils';
import { create } from 'zustand'; import { create } from 'zustand';
@@ -17,10 +17,10 @@ type SheetStore = {
// we get this from a preview response // we get this from a preview response
rundown: Rundown | null; rundown: Rundown | null;
setRundown: (rundown: Rundown | null) => void; setRundown: (rundown: Rundown | null) => void;
// we get this from a preview response
customFields: CustomFields | null; customFields: CustomFields | null;
setCustomFields: (customFields: CustomFields | null) => void; setCustomFields: (customFields: CustomFields | null) => void;
summary: RundownSummary | null;
setSummary: (metadata: RundownSummary | null) => void;
spreadsheetImportMap: ImportMap; spreadsheetImportMap: ImportMap;
patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => void; patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => void;
@@ -43,6 +43,7 @@ const initialState = {
authenticationStatus: 'not_authenticated' as AuthenticationStatus, authenticationStatus: 'not_authenticated' as AuthenticationStatus,
rundown: null, rundown: null,
customFields: null, customFields: null,
summary: null,
spreadsheetImportMap: defaultImportMap, spreadsheetImportMap: defaultImportMap,
}; };
@@ -64,6 +65,8 @@ export const useSheetStore = create<SheetStore>((set, get) => ({
setCustomFields: (customFields: CustomFields | null) => set({ customFields }), setCustomFields: (customFields: CustomFields | null) => set({ customFields }),
setSummary: (summary: RundownSummary | null) => set({ summary }),
patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => { patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => {
const currentImportMap = get().spreadsheetImportMap; const currentImportMap = get().spreadsheetImportMap;
if (currentImportMap[field] !== value) { if (currentImportMap[field] !== value) {
@@ -72,5 +75,5 @@ export const useSheetStore = create<SheetStore>((set, get) => ({
}, },
reset: () => set(initialState), reset: () => set(initialState),
resetPreview: () => set({ rundown: null, customFields: null }), resetPreview: () => set({ rundown: null, customFields: null, summary: null }),
})); }));
@@ -1,18 +1,22 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoArrowDown, IoArrowUp } from 'react-icons/io5';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
import { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList'; import { ProjectSortMode, useOrderedProjectList } from '../../../../common/hooks-query/useProjectList';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import ProjectListItem, { EditMode } from './ProjectListItem'; import ProjectListItem, { EditMode } from './ProjectListItem';
import style from './ProjectPanel.module.scss'; import style from './ProjectPanel.module.scss';
export default function ProjectList() { type SortParameter = 'alphabetical' | 'modified';
const { data, refetch, status } = useOrderedProjectList();
export default function ProjectList() {
const [editingMode, setEditingMode] = useState<EditMode | null>(null); const [editingMode, setEditingMode] = useState<EditMode | null>(null);
const [editingFilename, setEditingFilename] = useState<string | null>(null); const [editingFilename, setEditingFilename] = useState<string | null>(null);
const [sortMode, setSortMode] = useState<ProjectSortMode>('modified-desc');
const { data, refetch, status } = useOrderedProjectList(sortMode);
const handleToggleEditMode = (editMode: EditMode, filename: string | null) => { const handleToggleEditMode = (editMode: EditMode, filename: string | null) => {
setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode)); setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode));
@@ -28,6 +32,13 @@ export default function ProjectList() {
await refetch(); await refetch();
}; };
const handleSort = (sortParameter: SortParameter) => {
setSortMode((current) => {
const isAscending = current === `${sortParameter}-asc`;
return `${sortParameter}-${isAscending ? 'desc' : 'asc'}` as ProjectSortMode;
});
};
if (status === 'pending') { if (status === 'pending') {
return ( return (
<div className={style.empty}> <div className={style.empty}>
@@ -48,8 +59,18 @@ export default function ProjectList() {
<Panel.Table> <Panel.Table>
<thead> <thead>
<tr> <tr>
<th className={style.containCell}>File Name</th> <th className={style.containCell} onClick={() => handleSort('alphabetical')}>
<th>Last Used</th> <span className={style.sortableHeader}>
File Name
<SortIcon sortMode={sortMode} type='alphabetical' />
</span>
</th>
<th onClick={() => handleSort('modified')}>
<span className={style.sortableHeader}>
Last Used
<SortIcon sortMode={sortMode} type='modified' />
</span>
</th>
<th /> <th />
</tr> </tr>
</thead> </thead>
@@ -72,3 +93,10 @@ export default function ProjectList() {
</> </>
); );
} }
function SortIcon({ sortMode, type }: { sortMode: ProjectSortMode; type: SortParameter }) {
const prefix = `${type}-`;
if (sortMode === `${prefix}asc`) return <IoArrowDown />;
if (sortMode === `${prefix}desc`) return <IoArrowUp />;
return null;
}
@@ -23,7 +23,13 @@
} }
.containCell { .containCell {
max-width: 400px; max-width: 50%;
}
.sortableHeader {
display: inline-flex;
align-items: center;
gap: 0.5em;
} }
.fullWidth { .fullWidth {
@@ -6,6 +6,7 @@ import { Settings } from 'ontime-types';
import { postSettings } from '../../../../common/api/settings'; import { postSettings } from '../../../../common/api/settings';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import useSettings from '../../../../common/hooks-query/useSettings'; import useSettings from '../../../../common/hooks-query/useSettings';
@@ -98,6 +99,7 @@ export default function GeneralSettings() {
{submitError && <Panel.Error>{submitError}</Panel.Error>} {submitError && <Panel.Error>{submitError}</Panel.Error>}
<Panel.Divider /> <Panel.Divider />
<Panel.Section> <Panel.Section>
<Info>Changes to the time format and views language do not affect the editor view</Info>
<Panel.Loader isLoading={isLoading} /> <Panel.Loader isLoading={isLoading} />
<Panel.ListGroup> <Panel.ListGroup>
<Panel.ListItem> <Panel.ListItem>
@@ -146,7 +148,7 @@ export default function GeneralSettings() {
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title='Time format' title='Time format'
description='Default time format to show in views 12 /24 hours' description='Default time format to show in views 12 / 24 hours'
error={errors.timeFormat?.message} error={errors.timeFormat?.message}
/> />
<Select <Select
@@ -70,7 +70,7 @@ export default function QuickStart({ isOpen, onClose }: QuickStartProps) {
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title='Time format' title='Time format'
description='Default time format to show in views 12 /24 hours' description='Default time format to show in views 12 / 24 hours (does not affect editor)'
error={errors.settings?.timeFormat?.message} error={errors.settings?.timeFormat?.message}
/> />
<Select <Select
@@ -1,11 +1,12 @@
import { memo } from 'react'; import { memo } from 'react';
import { Corner } from '../../../common/components/editor-utils/EditorUtils'; import { CornerExtract } from '../../../common/components/editor-utils/EditorUtils';
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu'; import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../../common/components/protect-route/ProtectRoute'; import ProtectRoute from '../../../common/components/protect-route/ProtectRoute';
import { handleLinks } from '../../../common/utils/linkUtils'; import { handleLinks } from '../../../common/utils/linkUtils';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import { getIsNavigationLocked } from '../../../externals';
import MessageControl from './MessageControl'; import MessageControl from './MessageControl';
@@ -19,8 +20,8 @@ function MessageControlExport() {
return ( return (
<ProtectRoute permission='editor'> <ProtectRoute permission='editor'>
<div className={style.messages} data-testid='panel-messages-control'> <div className={style.messages} data-testid='panel-messages-control'>
{!isExtracted && <Corner onClick={(event) => handleLinks('messagecontrol', event)} />} {!isExtracted && <CornerExtract onClick={(event) => handleLinks('messagecontrol', event)} />}
{isExtracted && <ViewNavigationMenu suppressSettings />} {isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={classes}> <div className={classes}>
<ErrorBoundary> <ErrorBoundary>
@@ -2,12 +2,13 @@ import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu'; import { LuArrowDownToLine } from 'react-icons/lu';
import { TimerPhase, TimerType } from 'ontime-types'; import { TimerPhase, TimerType } from 'ontime-types';
import { Corner } from '../../../common/components/editor-utils/EditorUtils'; import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
import Tooltip from '../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useMessagePreview } from '../../../common/hooks/useSocket'; import { useMessagePreview } from '../../../common/hooks/useSocket';
import useViewSettings from '../../../common/hooks-query/useViewSettings'; import useViewSettings from '../../../common/hooks-query/useViewSettings';
import { handleLinks } from '../../../common/utils/linkUtils'; import { handleLinks } from '../../../common/utils/linkUtils';
import { cx, timerPlaceholder } from '../../../common/utils/styleUtils'; import { cx, timerPlaceholder } from '../../../common/utils/styleUtils';
import PipRoot from '../../../views/editor/pip-timer/PipRoot';
import style from './MessageControl.module.scss'; import style from './MessageControl.module.scss';
@@ -52,7 +53,7 @@ export default function TimerPreview() {
return ( return (
<div className={style.preview}> <div className={style.preview}>
<Corner onClick={(event) => handleLinks('timer', event)} /> <CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
<div className={contentClasses}> <div className={contentClasses}>
<div <div
className={style.mainContent} className={style.mainContent}
@@ -1,10 +1,11 @@
import { memo } from 'react'; import { memo } from 'react';
import { Corner } from '../../../common/components/editor-utils/EditorUtils'; import { CornerExtract } from '../../../common/components/editor-utils/EditorUtils';
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu'; import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../../common/components/protect-route/ProtectRoute'; import ProtectRoute from '../../../common/components/protect-route/ProtectRoute';
import { handleLinks } from '../../../common/utils/linkUtils'; import { handleLinks } from '../../../common/utils/linkUtils';
import { getIsNavigationLocked } from '../../../externals';
import PlaybackControl from './PlaybackControl'; import PlaybackControl from './PlaybackControl';
@@ -17,8 +18,8 @@ function TimerControlExport() {
return ( return (
<ProtectRoute permission='editor'> <ProtectRoute permission='editor'>
<div className={style.playback} data-testid='panel-timer-control'> <div className={style.playback} data-testid='panel-timer-control'>
{!isExtracted && <Corner onClick={(event) => handleLinks('timercontrol', event)} />} {!isExtracted && <CornerExtract onClick={(event) => handleLinks('timercontrol', event)} />}
{isExtracted && <ViewNavigationMenu suppressSettings />} {isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={style.content}> <div className={style.content}>
<ErrorBoundary> <ErrorBoundary>
@@ -58,7 +58,6 @@
height: 1.5rem; height: 1.5rem;
display: flex; display: flex;
gap: $section-spacing; gap: $section-spacing;
margin-left: 1.5rem;
} }
.tag { .tag {
@@ -70,6 +69,7 @@
.time { .time {
color: $section-white; color: $section-white;
font-size: $text-body-size; font-size: $text-body-size;
display: inline-block;
} }
.rolltag { .rolltag {
@@ -1,84 +1,10 @@
import { use, useCallback, useEffect, useMemo, useState } from 'react'; import { useWakelock } from './useWakeLock';
import { useSearchParams } from 'react-router';
import { PresetContext } from '../../common/context/PresetContext';
/** @url https://developer.mozilla.org/en-US/docs/Web/API/WakeLock */
export default function KeepAwake() {
const { keepAwake } = useKeepAwakeOptions();
const [wakeLockSentinel, setWakeLockSentinel] = useState<WakeLockSentinel | null>(null);
const removeLock = () => {
if (wakeLockSentinel) wakeLockSentinel.release().finally(() => setWakeLockSentinel(null));
};
const acquireLock = () => {
if (!wakeLockSentinel || wakeLockSentinel.released) {
setWakeLockSentinel(null);
navigator.wakeLock
.request('screen')
.then((sentinel) => {
setWakeLockSentinel(sentinel);
})
.catch(console.error);
}
};
useEffect(() => {
const controller = new AbortController();
if (keepAwake) {
acquireLock();
document.addEventListener(
'visibilitychange',
() => {
if (wakeLockSentinel !== null && document.visibilityState === 'visible') {
acquireLock();
}
},
{ signal: controller.signal },
);
} else {
removeLock();
}
return () => {
controller.abort();
removeLock();
};
}, [keepAwake]);
return <></>;
}
const keepAwakeKey = 'keep-awake';
function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URLSearchParams) {
// Helper to get value from either source, prioritizing defaultValues
return defaultValues?.has(keepAwakeKey) || searchParams.has(keepAwakeKey);
}
/** /**
* Hook exposes the keep awake options * Composes the wakelock hook to contain re-renders
*/ */
export function useKeepAwakeOptions() { export default function KeepAwake() {
const [searchParams, setSearchParams] = useSearchParams(); useWakelock();
const maybePreset = use(PresetContext);
const keepAwake = useMemo(() => { return null;
const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined;
return getOptionsFromParams(searchParams, defaultValues);
}, [maybePreset, searchParams]);
const toggleKeepAwake = useCallback(() => {
setSearchParams((searchParams) => {
if (keepAwake) {
searchParams.delete(keepAwakeKey);
} else {
searchParams.set(keepAwakeKey, '1');
}
return searchParams;
});
}, [keepAwake]);
return { keepAwake, toggleKeepAwake };
} }
@@ -0,0 +1,91 @@
import { use, useCallback, useEffect, useMemo, useRef } from 'react';
import { useSearchParams } from 'react-router';
import { PresetContext } from '../../common/context/PresetContext';
// wakelock is only available in secure contexts
// however, that is covered by the navigator check
export const canUseWakeLock = typeof window !== 'undefined' && 'wakeLock' in navigator;
/** @url https://developer.mozilla.org/en-US/docs/Web/API/WakeLock */
export function useWakelock() {
const { keepAwake } = useKeepAwakeOptions();
const wakeLockSentinelRef = useRef<WakeLockSentinel | null>(null);
useEffect(() => {
const removeLock = () => {
if (wakeLockSentinelRef.current) {
wakeLockSentinelRef.current.release().finally(() => {
wakeLockSentinelRef.current = null;
});
}
};
const acquireLock = () => {
const sentinel = wakeLockSentinelRef.current;
if (!sentinel || sentinel.released) {
wakeLockSentinelRef.current = null;
navigator.wakeLock
.request('screen')
.then((sentinel) => {
wakeLockSentinelRef.current = sentinel;
})
.catch(console.error);
}
};
const controller = new AbortController();
if (keepAwake) {
acquireLock();
document.addEventListener(
'visibilitychange',
() => {
if (wakeLockSentinelRef.current !== null && document.visibilityState === 'visible') {
acquireLock();
}
},
{ signal: controller.signal },
);
} else {
removeLock();
}
return () => {
controller.abort();
removeLock();
};
}, [keepAwake]);
}
const keepAwakeKey = 'keep-awake';
function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URLSearchParams) {
// Helper to get value from either source, prioritizing defaultValues
return defaultValues?.has(keepAwakeKey) || searchParams.has(keepAwakeKey);
}
/**
* Hook exposes the keep awake options
*/
export function useKeepAwakeOptions() {
const [searchParams, setSearchParams] = useSearchParams();
const maybePreset = use(PresetContext);
const keepAwake = useMemo(() => {
const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined;
return getOptionsFromParams(searchParams, defaultValues);
}, [maybePreset, searchParams]);
const toggleKeepAwake = useCallback(() => {
setSearchParams((searchParams) => {
if (keepAwake) {
searchParams.delete(keepAwakeKey);
} else {
searchParams.set(keepAwakeKey, '1');
}
return searchParams;
});
}, [keepAwake, setSearchParams]);
return { keepAwake, toggleKeepAwake };
}
+12 -1
View File
@@ -9,6 +9,7 @@ import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import { throttle } from '../../common/utils/throttle'; import { throttle } from '../../common/utils/throttle';
import { getDefaultFormat } from '../../common/utils/time'; import { getDefaultFormat } from '../../common/utils/time';
import { isTouchDevice } from '../../externals';
import Loader from '../../views/common/loader/Loader'; import Loader from '../../views/common/loader/Loader';
import EditModal from './edit-modal/EditModal'; import EditModal from './edit-modal/EditModal';
@@ -122,7 +123,9 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
<StatusBar /> <StatusBar />
{canEdit && ( {canEdit && (
<div className={cx([style.editPrompt, showEditPrompt && style.show])}>Press and hold to edit user field</div> <div className={cx([style.editPrompt, showEditPrompt && style.show])}>
{isTouchDevice ? 'Press and hold to edit user field' : 'Right click to edit user field'}
</div>
)} )}
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}> <div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
@@ -168,6 +171,14 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
} }
if (isOntimeGroup(entry)) { if (isOntimeGroup(entry)) {
const { isPast } = rundownMetadata[entry.id];
const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId].groupId === entry.id : false;
if (hidePast && isPast && !isCurrentParent) {
return null;
}
return ( return (
<Fragment key={entry.id}> <Fragment key={entry.id}>
<OperatorGroup key={entry.id} title={entry.title} /> <OperatorGroup key={entry.id} title={entry.title} />
@@ -30,12 +30,12 @@
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
max-height: min(80vh, 600px); max-height: min(60vh, 600px);
overflow-y: auto; overflow-y: auto;
} }
.label { .label {
background-color: var(--user-bg); background-color: var(--user-bg, $gray-900);
width: fit-content; width: fit-content;
padding-inline: 1rem; padding-inline: 1rem;
} }
@@ -13,7 +13,7 @@
column-gap: 0.5rem; column-gap: 0.5rem;
row-gap: 0.5rem; row-gap: 0.5rem;
grid-template-rows: auto auto auto; grid-template-rows: auto auto auto;
grid-template-columns: 1.25rem 1fr auto; grid-template-columns: 1.5rem 1fr auto;
grid-template-areas: grid-template-areas:
'binder main schedule' 'binder main schedule'
'binder secondary running' 'binder secondary running'
@@ -42,6 +42,7 @@
place-content: center; place-content: center;
position: relative; position: relative;
background-color: $gray-1050; // to override inline background-color: $gray-1050; // to override inline
font-weight: 600;
.cue { .cue {
white-space: nowrap; white-space: nowrap;
@@ -71,47 +72,41 @@
white-space: pre-line; white-space: pre-line;
} }
.plannedStart, .timeUntil { .plannedStart,
line-height: 1em; .timeUntil,
background-color: $gray-1000; .runningTime {
border-radius: $component-border-radius-md;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
border-radius: 1px; line-height: 1;
} }
.plannedStart { .plannedStart {
font-size: 1.5rem; font-size: 1.5rem;
margin-right: 0.5rem; margin-right: 0.5rem;
display: inline;
background-color: $white-20;
letter-spacing: 1px;
} }
.timeUntil { .timeUntil {
font-size: calc(1rem - 2px);
line-height: 1em;
grid-area: schedule; grid-area: schedule;
font-size: calc(1rem - 2px);
justify-self: end; justify-self: end;
background-color: $white-20;
letter-spacing: 1px;
} }
.runningTime { .runningTime {
font-size: 1.25rem;
line-height: 1em;
grid-area: running; grid-area: running;
font-size: 1.25rem;
justify-self: end; justify-self: end;
align-self: flex-start; background-color: $white-7;
display: flex;
align-items: center;
gap: 0.5em;
} }
.fields { .fields {
grid-area: fields; grid-area: fields;
font-size: var(--operator-customfield-font-size-override, 1.25rem); margin-top: 0;
font-weight: 400; border-top: 0;
color: $ui-black;
margin: 0.25rem 0;
display: flex;
flex-wrap: wrap;
gap: 0.5em;
row-gap: 0.25em;
line-height: 1.25em;
.field { .field {
font-weight: 600; font-weight: 600;
@@ -133,8 +128,18 @@
// allow multi-line text but trim before // allow multi-line text but trim before
white-space: pre-line; white-space: pre-line;
} }
}
.fields::after { &.fieldsWithContent {
content: '\200b'; margin-top: 0.25rem;
padding-block: 0.5rem;
border-top: 1px solid $white-13;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: 0.25rem 0.75rem;
line-height: 1.25em;
font-size: var(--operator-customfield-font-size-override, 1.25rem);
font-weight: 400;
color: $ui-black;
}
} }
@@ -1,11 +1,12 @@
import { memo, RefObject, SyntheticEvent } from 'react'; import { CSSProperties, memo, RefObject, SyntheticEvent } from 'react';
import { useLongPress } from '@mantine/hooks'; import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, millisToString } from 'ontime-utils';
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator'; import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
import { useLongPress } from '../../../common/hooks/useLongPress';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration, useTimeUntilExpectedStart } from '../../../common/utils/time'; import { formatDuration, formatTime, useTimeUntilExpectedStart } from '../../../common/utils/time';
import RunningTime from '../../viewers/common/running-time/RunningTime'; import RunningTime from '../../viewers/common/running-time/RunningTime';
import SuperscriptPeriod from '../../viewers/common/superscript-time/SuperscriptPeriod';
import type { EditEvent, Subscribed } from '../operator.types'; import type { EditEvent, Subscribed } from '../operator.types';
import style from './OperatorEvent.module.scss'; import style from './OperatorEvent.module.scss';
@@ -54,8 +55,10 @@ function OperatorEvent({
* gather behaviour for long press and context menu * gather behaviour for long press and context menu
*/ */
const handleLongPress = (event?: SyntheticEvent) => { const handleLongPress = (event?: SyntheticEvent) => {
// we dont have an event out of useLongPress // prevent default if the event is cancelable to avoid browser intervention warnings
event?.preventDefault(); if (event && event.cancelable) {
event.preventDefault();
}
if (subscribed) { if (subscribed) {
onLongPress({ id, cue, subscriptions: subscribed }); onLongPress({ id, cue, subscriptions: subscribed });
} }
@@ -64,22 +67,33 @@ function OperatorEvent({
const mouseHandlers = useLongPress(handleLongPress); const mouseHandlers = useLongPress(handleLongPress);
const cueColours = colour && getAccessibleColour(colour); const cueColours = colour && getAccessibleColour(colour);
const operatorClasses = cx([ const operatorClasses = cx([style.event, isSelected && style.running, isPast && style.past]);
style.event,
isSelected && style.running, const hasFields = subscribed.some((field) => field.value);
isPast && style.past, const columnCount = subscribed.length ? Math.min(subscribed.length, 4) : 0;
]); const fieldGridStyle =
columnCount > 0
? ({
gridTemplateColumns: `repeat(${columnCount}, minmax(12rem, 1fr))`,
} satisfies CSSProperties)
: undefined;
return ( return (
<div className={operatorClasses} data-testid={cue} ref={selectedRef} onContextMenu={handleLongPress} {...mouseHandlers}> <div
className={operatorClasses}
data-testid={cue}
ref={selectedRef}
onContextMenu={handleLongPress}
{...mouseHandlers}
>
<div className={style.binder} style={{ ...cueColours }}> <div className={style.binder} style={{ ...cueColours }}>
<span className={style.cue}>{cue}</span> <span className={style.cue}>{cue}</span>
</div> </div>
<span className={style.mainField}> <span className={style.mainField}>
{showStart && <span className={style.plannedStart}>{millisToString(timeStart)}</span>} {showStart && <SuperscriptPeriod className={style.plannedStart} time={formatTime(timeStart)} />}
{main} {main}
</span> </span>
<span className={style.secondaryField}>{secondary}</span> <span className={style.secondaryField}>{secondary}</span>
<OperatorEventSchedule <OperatorEventSchedule
timeStart={timeStart} timeStart={timeStart}
@@ -95,22 +109,25 @@ function OperatorEvent({
<RunningTime className={cx([isSelected && style.muted])} value={duration} hideLeadingZero /> <RunningTime className={cx([isSelected && style.muted])} value={duration} hideLeadingZero />
</span> </span>
<div className={style.fields}> <div className={cx([style.fields, hasFields && style.fieldsWithContent])} style={fieldGridStyle}>
{subscribed {subscribed.map((field) => {
.filter((field) => field.value) if (!field.value) {
.map((field) => { return <div key={field.id} />;
const fieldClasses = cx([style.field, !field.colour ? style.noColour : null]); }
return ( return (
<div key={field.id}> <div key={field.id}>
<span className={fieldClasses} style={{ backgroundColor: field.colour }}> <span
{field.label} className={cx([style.field, !field.colour && style.noColour])}
</span> style={{ backgroundColor: field.colour }}
<span className={style.value} style={{ color: field.colour }}> >
{field.value} {field.label}
</span> </span>
</div> <span className={style.value} style={{ color: field.colour }}>
); {field.value}
})} </span>
</div>
);
})}
</div> </div>
</div> </div>
); );
@@ -167,5 +184,9 @@ function TimeUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
const isDue = timeUntil < MILLIS_PER_SECOND; const isDue = timeUntil < MILLIS_PER_SECOND;
const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`; const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`;
return <span className={style.timeUntil} data-testid='time-until'>{timeUntilString}</span>; return (
<span className={style.timeUntil} data-testid='time-until'>
{timeUntilString}
</span>
);
} }
@@ -40,3 +40,10 @@
height: 1rem; height: 1rem;
--progress-bar-br: 0; --progress-bar-br: 0;
} }
@media (max-width: $min-tablet) {
.timers {
grid-template-columns: 1fr 1fr;
grid-template-areas: 'timers clock';
}
}
@@ -6,7 +6,7 @@ export default function StatusBarTimers() {
return ( return (
<div className={style.timers}> <div className={style.timers}>
<TimerOverview className={style.runningTimer} /> <TimerOverview className={style.runningTimer} />
<ClockOverview className={style.timeNow} /> <ClockOverview className={style.timeNow} shouldFormat />
</div> </div>
); );
} }
@@ -27,11 +27,11 @@ function CuesheetDesktop({ children }: PropsWithChildren) {
return ( return (
<OverviewWrapper navElements={children}> <OverviewWrapper navElements={children}>
<TitleOverview /> <TitleOverview />
<StartTimes /> <StartTimes shouldFormat />
<TimerOverview /> <TimerOverview />
<OffsetOverview /> <OffsetOverview />
<MetadataTimes /> <MetadataTimes />
<ClockOverview /> <ClockOverview shouldFormat />
</OverviewWrapper> </OverviewWrapper>
); );
} }
@@ -34,4 +34,6 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
overflow-x: auto;
overflow-y: hidden;
} }
@@ -9,7 +9,7 @@
.row { .row {
display: grid; display: grid;
grid-template-columns: 3rem 10rem 8rem; grid-template-columns: 3rem 9rem 9rem;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
} }
@@ -29,7 +29,8 @@
.icon { .icon {
font-size: 1rem; font-size: 1rem;
color: $label-gray; color: $secondary-text-gray;
flex-shrink: 0;
} }
.label { .label {
@@ -40,18 +41,24 @@
.time { .time {
font-size: 1.25rem; font-size: 1.25rem;
letter-spacing: 0.5px; letter-spacing: 0.25px;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
line-height: 1.1; line-height: 1.1;
} }
.daySpan { .daySpan {
display: inline-flex;
align-items: center;
margin-left: 0.25rem;
&::after { &::after {
content: "+"attr(data-day-offset); content: "+" attr(data-day-offset);
vertical-align: super; font-size: 0.75rem;
font-size: 0.6em; font-weight: 400;
letter-spacing: 0; color: $ui-white;
color: $info-blue; background: $gray-900; // same as the tag component
padding: 0.125rem 0.25rem;
border-radius: $component-border-radius-sm;
} }
} }
@@ -74,4 +81,7 @@
font-size: 1rem; font-size: 1rem;
letter-spacing: 0; letter-spacing: 0;
color: $playback-over; color: $playback-over;
border-left: 3px solid $playback-over;
padding-left: 0.375rem;
display: inline-block;
} }
@@ -20,6 +20,7 @@ import {
useNextFlag, useNextFlag,
useOffsetOverview, useOffsetOverview,
useProgressOverview, useProgressOverview,
useRundownExpectedEnd,
useStartTimesOverview, useStartTimesOverview,
useTimer, useTimer,
} from '../../../common/hooks/useSocket'; } from '../../../common/hooks/useSocket';
@@ -27,69 +28,136 @@ import { useEntry } from '../../../common/hooks-query/useRundown';
import { getOffsetState, getOffsetText } from '../../../common/utils/offset'; import { getOffsetState, getOffsetText } from '../../../common/utils/offset';
import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils'; import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
import { formatTime } from '../../../common/utils/time'; import { formatTime } from '../../../common/utils/time';
import { calculateEndAndDaySpan, formatDueTime, formattedTime } from '../overview.utils'; import SuperscriptPeriod from '../../viewers/common/superscript-time/SuperscriptPeriod';
import { calculateEndAndDaySpan, formatDueTime } from '../overview.utils';
import { OverUnder, TimeColumn } from './TimeLayout'; import { OverUnder, TimeColumn, WrappedInTimeColumn } from './TimeLayout';
import style from './TimeElements.module.scss'; import style from './TimeElements.module.scss';
export function StartTimes() { interface OverviewTimeElementsProps {
const { plannedEnd, plannedStart, actualStart, expectedEnd } = useStartTimesOverview(); shouldFormat?: boolean;
}
const plannedStartText = plannedStart === null ? timerPlaceholder : formatTime(plannedStart); export function StartTimes({ shouldFormat }: OverviewTimeElementsProps) {
const { plannedEnd, plannedStart, actualStart } = useStartTimesOverview();
const formatOptions = { format12: 'hh:mm:ss a', format24: 'HH:mm:ss' };
const plannedStartText = (() => {
if (plannedStart === null) return timerPlaceholder;
if (shouldFormat) return formatTime(plannedStart, formatOptions);
return millisToString(plannedStart, { fallback: timerPlaceholder });
})();
const actualStartText = (() => {
if (actualStart === null) return timerPlaceholder;
if (shouldFormat) return formatTime(actualStart, formatOptions);
return millisToString(actualStart, { fallback: timerPlaceholder });
})();
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]); const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]);
const plannedEndText = maybePlannedEnd === null ? timerPlaceholder : formatTime(maybePlannedEnd); const plannedEndText = (() => {
if (maybePlannedEnd === null) return timerPlaceholder;
if (shouldFormat) return formatTime(maybePlannedEnd, formatOptions);
return millisToString(maybePlannedEnd, { fallback: timerPlaceholder });
})();
const multipleDays = maybePlannedDaySpan > 0;
const plannedEndTooltip = multipleDays
? `Planned end time (rundown spans over ${maybePlannedDaySpan + 1} days)`
: 'Planned end time';
return ( return (
<div className={style.column}> <div className={style.column}>
<div className={style.row}> <div className={style.row}>
<span className={style.label}>Start</span> <span className={style.label}>Start</span>
<div className={style.labelledElement}> <Tooltip
<Tooltip text='Planned start time' render={<TbCalendarPin className={style.icon} />} /> text='Planned start time'
<span className={cx([style.time, plannedStart === null && style.muted])}>{plannedStartText}</span> render={
</div> <div className={style.labelledElement}>
<div className={style.labelledElement} data-testid='actual-start-time'> <TbCalendarPin className={style.icon} />
<Tooltip text='Actual start time' render={<TbCalendarClock className={style.icon} />} /> <SuperscriptPeriod
<span className={cx([style.time, actualStart === null && style.muted])}>{formattedTime(actualStart)}</span> className={cx([style.time, plannedStart === null && style.muted])}
</div> time={plannedStartText}
/>
</div>
}
/>
<Tooltip
text='Actual start time'
render={
<div className={style.labelledElement} data-testid='actual-start-time'>
<TbCalendarClock className={style.icon} />
<SuperscriptPeriod
className={cx([style.time, actualStart === null && style.muted])}
time={actualStartText}
/>
</div>
}
/>
</div> </div>
<div className={style.row}> <div className={style.row}>
<span className={style.label}>End</span> <span className={style.label}>End</span>
<div className={style.labelledElement}> <Tooltip
<Tooltip text='Planned end time' render={<TbCalendarPin className={style.icon} />} /> text={plannedEndTooltip}
{maybePlannedDaySpan > 0 ? ( render={
<Tooltip <div className={style.labelledElement}>
text={`Rundown spans over ${maybePlannedDaySpan + 1} days`} <TbCalendarPin className={style.icon} />
render={<span className={cx([style.time, style.daySpan])} data-day-offset={maybePlannedDaySpan} />} <SuperscriptPeriod
> className={cx([style.time, plannedEnd === null && style.muted])}
{plannedEndText} time={plannedEndText}
</Tooltip> />
) : ( {multipleDays && (
<span className={cx([style.time, plannedEnd === null && style.muted])}>{plannedEndText}</span> <span className={cx([style.time, style.daySpan])} data-day-offset={maybePlannedDaySpan} />
)} )}
</div> </div>
<div className={style.labelledElement}> }
<Tooltip text='Expected end time' render={<TbCalendarStar className={style.icon} />} /> />
{maybeExpectedEnd !== null && maybeExpectedDaySpan > 0 ? ( <RundownExpectedEnd shouldFormat={shouldFormat} />
<Tooltip
text={`Rundown spans over ${maybeExpectedDaySpan + 1} days`}
render={<span className={cx([style.time, style.daySpan])} data-day-offset={maybeExpectedDaySpan} />}
>
{formattedTime(maybeExpectedEnd)}
</Tooltip>
) : (
<span className={cx([style.time, maybeExpectedEnd === null && style.muted])}>
{formattedTime(maybeExpectedEnd)}
</span>
)}
</div>
</div> </div>
</div> </div>
); );
} }
/**
* Shows the expected end for the rundown
* Extracted to improve performance as this is a ticking value
*/
function RundownExpectedEnd({ shouldFormat }: OverviewTimeElementsProps) {
const { expectedEnd } = useRundownExpectedEnd();
const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]);
const maybeExpectedEndText = (() => {
if (maybeExpectedEnd === null) return timerPlaceholder;
if (shouldFormat) return formatTime(maybeExpectedEnd, { format12: 'hh:mm:ss a', format24: 'HH:mm:ss' });
return millisToString(maybeExpectedEnd, { fallback: timerPlaceholder });
})();
const multipleDays = maybeExpectedEnd !== null && maybeExpectedDaySpan > 0;
const tooltip = multipleDays
? `Expected end time (rundown spans over ${maybeExpectedDaySpan + 1} days)`
: 'Expected end time';
return (
<Tooltip
text={tooltip}
render={
<div className={style.labelledElement}>
<TbCalendarStar className={style.icon} />
<SuperscriptPeriod
className={cx([style.time, maybeExpectedEnd === null && style.muted])}
time={maybeExpectedEndText}
/>
{multipleDays && <span className={cx([style.time, style.daySpan])} data-day-offset={maybeExpectedDaySpan} />}
</div>
}
/>
);
}
export function MetadataTimes() { export function MetadataTimes() {
return ( return (
<div className={style.column}> <div className={style.column}>
@@ -229,11 +297,17 @@ export function OffsetOverview() {
return <OverUnder state={offsetState} value={offsetText} testId='offset' />; return <OverUnder state={offsetState} value={offsetText} testId='offset' />;
} }
export function ClockOverview({ className }: { className?: string }) { export function ClockOverview({ shouldFormat, className }: OverviewTimeElementsProps & { className?: string }) {
const { clock } = useClock(); const { clock } = useClock();
const formattedClock = formatTime(clock); const formattedClock = shouldFormat ? formatTime(clock) : millisToString(clock);
return <TimeColumn label='Time now' value={formattedClock} className={className} />; return (
<WrappedInTimeColumn
label='Time now'
className={className}
render={(clockClasses) => <SuperscriptPeriod className={clockClasses} time={formattedClock} />}
/>
);
} }
export function TimerOverview({ className }: { className?: string }) { export function TimerOverview({ className }: { className?: string }) {
@@ -1,6 +1,6 @@
.label { .label {
color: $label-gray; color: $label-gray;
font-size: calc(1rem - 2px); font-size: calc(1rem - 3px);
} }
.clock { .clock {
@@ -29,14 +29,21 @@
gap: 0.25rem; gap: 0.25rem;
} }
.over,
.under {
transition: color 0.2s ease;
}
&[data-state='waiting'] { &[data-state='waiting'] {
.label { .label {
color: $ontime-roll; color: $ontime-roll;
} }
} }
&[data-state='over'] { &[data-state='over'] {
.label .over { .label .over {
color: $playback-over; color: $playback-over;
font-weight: 600;
} }
.clock { .clock {
color: $playback-over; color: $playback-over;
@@ -46,6 +53,7 @@
&[data-state='under'] { &[data-state='under'] {
.label .under { .label .under {
color: $playback-under; color: $playback-under;
font-weight: 600;
} }
.clock { .clock {
color: $playback-under; color: $playback-under;
@@ -1,3 +1,5 @@
import { ReactNode } from 'react';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import style from './TimeLayout.module.scss'; import style from './TimeLayout.module.scss';
@@ -22,6 +24,21 @@ export function TimeColumn({ label, value, state = 'active', className, testId }
); );
} }
interface WrappedInTimeColumnProps {
label: string;
state?: 'muted' | 'waiting' | 'active';
className?: string;
render: (className: string) => ReactNode;
}
export function WrappedInTimeColumn({ label, state = 'active', className, render }: WrappedInTimeColumnProps) {
return (
<div className={cx([style.column, className])} data-state={state}>
<span className={style.label}>{label}</span>
{render(style.clock)}
</div>
);
}
interface OverUnderProps { interface OverUnderProps {
state: 'over' | 'under' | 'muted' | null; state: 'over' | 'under' | 'muted' | null;
value: string; value: string;
@@ -1,10 +1,14 @@
.title { .title {
font-size: 1.5rem; font-size: 1.75rem;
font-weight: 600;
color: $ui-white;
line-height: 1;
@include ellipsis-overflow; @include ellipsis-overflow;
} }
.description { .description {
font-size: 1rem; font-size: 1rem;
color: $label-gray; color: $secondary-text-gray;
font-weight: 400;
@include ellipsis-overflow; @include ellipsis-overflow;
} }
+39 -26
View File
@@ -1,4 +1,4 @@
import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react'; import { Fragment, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { TbFlagFilled } from 'react-icons/tb'; import { TbFlagFilled } from 'react-icons/tb';
import { import {
closestCenter, closestCenter,
@@ -36,7 +36,6 @@ import { useEntryActions } from '../../common/hooks/useEntryAction';
import useFollowComponent from '../../common/hooks/useFollowComponent'; import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useRundownEditor } from '../../common/hooks/useSocket'; import { useRundownEditor } from '../../common/hooks/useSocket';
import { useEntryCopy } from '../../common/stores/entryCopyStore'; import { useEntryCopy } from '../../common/stores/entryCopyStore';
import { cloneEvent } from '../../common/utils/clone';
import { lastMetadataKey, RundownMetadataObject } from '../../common/utils/rundownMetadata'; import { lastMetadataKey, RundownMetadataObject } from '../../common/utils/rundownMetadata';
import { AppMode, sessionKeys } from '../../ontimeConfig'; import { AppMode, sessionKeys } from '../../ontimeConfig';
@@ -58,6 +57,8 @@ interface RundownProps {
} }
export default function Rundown({ data, rundownMetadata }: RundownProps) { export default function Rundown({ data, rundownMetadata }: RundownProps) {
'use memo';
const { order, entries, id } = data; const { order, entries, id } = data;
// we create a copy of the rundown with a data structured aligned with what dnd-kit needs // we create a copy of the rundown with a data structured aligned with what dnd-kit needs
const featureData = useRundownEditor(); const featureData = useRundownEditor();
@@ -68,17 +69,20 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
key: `rundown.${id}-editor-collapsed-groups`, key: `rundown.${id}-editor-collapsed-groups`,
defaultValue: [], defaultValue: [],
}); });
const collapsedGroupSet = useMemo(() => new Set(collapsedGroups), [collapsedGroups]);
const { addEntry, deleteEntry, move, reorderEntry } = useEntryActions(); const { addEntry, clone, deleteEntry, move, reorderEntry } = useEntryActions();
const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId);
const { entryCopyId, setEntryCopyId } = useEntryCopy();
// cursor // cursor
const [editorMode] = useSessionStorage<AppMode>({ const [editorMode] = useSessionStorage<AppMode>({
key: sessionKeys.editorMode, key: sessionKeys.editorMode,
defaultValue: AppMode.Edit, defaultValue: AppMode.Edit,
}); });
const { clearSelectedEvents, setSelectedEvents, cursor } = useEventSelection();
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents);
const cursor = useEventSelection((state) => state.cursor);
const cursorRef = useRef<HTMLDivElement | null>(null); const cursorRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
@@ -105,20 +109,30 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
); );
const insertCopyAtId = useCallback( const insertCopyAtId = useCallback(
(atId: string | null, copyId: string | null, above = false) => { (atId: string | null, above = false) => {
const adjustedCursor = above ? getPreviousNormal(entries, order, atId ?? '').entry?.id ?? null : atId; // lazily get the value from the store
if (copyId === null) { const { entryCopyId } = useEntryCopy.getState();
if (entryCopyId === null || !entries[entryCopyId]) {
// we cant clone without selection // we cant clone without selection
return; return;
} }
const cloneEntry = entries[copyId];
if (cloneEntry?.type === SupportedEntry.Event) { let normalisedAtId = atId;
//if we don't have a cursor add the new event on top
const newEvent = cloneEvent(cloneEntry); const elementToCopy = entries[entryCopyId];
addEntry(newEvent, { after: adjustedCursor ?? undefined }); const refElement = atId ? entries[atId] : undefined;
if (refElement && 'parent' in refElement && refElement.parent && elementToCopy.type === SupportedEntry.Group) {
normalisedAtId = refElement.parent;
} }
clone(entryCopyId, {
after: above ? undefined : normalisedAtId ?? undefined,
// if we don't have a cursor add the new event on top
before: above ? normalisedAtId ?? undefined : undefined,
});
}, },
[addEntry, order, entries], [entries, clone],
); );
/** /**
@@ -200,9 +214,9 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
*/ */
const getIsCollapsed = useCallback( const getIsCollapsed = useCallback(
(groupId: EntryId): boolean => { (groupId: EntryId): boolean => {
return Boolean(collapsedGroups.find((id) => id === groupId)); return collapsedGroupSet.has(groupId);
}, },
[collapsedGroups], [collapsedGroupSet],
); );
/** /**
@@ -300,12 +314,8 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
], ],
['mod + C', () => setEntryCopyId(cursor)], ['mod + C', () => setEntryCopyId(cursor)],
['mod + V', () => insertCopyAtId(cursor, entryCopyId)], ['mod + V', () => insertCopyAtId(cursor)],
[ ['mod + shift + V', () => insertCopyAtId(cursor, true), { preventDefault: true, usePhysicalKeys: true }],
'mod + shift + V',
() => insertCopyAtId(cursor, entryCopyId, true),
{ preventDefault: true, usePhysicalKeys: true },
],
['alt + backspace', () => deleteAtCursor(cursor), { preventDefault: true, usePhysicalKeys: true }], ['alt + backspace', () => deleteAtCursor(cursor), { preventDefault: true, usePhysicalKeys: true }],
]); ]);
@@ -395,7 +405,7 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
} }
// keep copy of the current state in case we need to revert // keep copy of the current state in case we need to revert
const currentEntries = structuredClone(sortableData); const currentEntries = [...sortableData];
// we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates // we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates
setSortableData((currentEntries) => { setSortableData((currentEntries) => {
return reorderArray(currentEntries, fromIndex, toIndex); return reorderArray(currentEntries, fromIndex, toIndex);
@@ -438,9 +448,12 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
return <RundownEmpty handleAddNew={(type: SupportedEntry) => addEntry({ type })} />; return <RundownEmpty handleAddNew={(type: SupportedEntry) => addEntry({ type })} />;
} }
// 1. gather presentation options // gather presentation options
const isEditMode = editorMode === AppMode.Edit; const isEditMode = editorMode === AppMode.Edit;
// gather rundown wide data
const lastEntryId = order.at(-1);
return ( return (
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'> <div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
<DndContext <DndContext
@@ -509,7 +522,7 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
const groupColour = entryMetadata.groupColour === '' ? '#9d9d9d' : entryMetadata.groupColour; const groupColour = entryMetadata.groupColour === '' ? '#9d9d9d' : entryMetadata.groupColour;
const isFirst = index === 0; const isFirst = index === 0;
const isLast = entryId === order.at(-1); const isLast = entryId === lastEntryId;
/** /**
* We need to provide the parent ID for the QuickAdd components * We need to provide the parent ID for the QuickAdd components
@@ -1,16 +1,4 @@
import { import { isOntimeDelay, isOntimeEvent, isOntimeMilestone, OntimeEntry, Playback, SupportedEntry } from 'ontime-types';
isOntimeDelay,
isOntimeEvent,
isOntimeMilestone,
OntimeEntry,
OntimeEvent,
Playback,
SupportedEntry,
} from 'ontime-types';
import { useEntryActions } from '../../common/hooks/useEntryAction';
import useMemoisedFn from '../../common/hooks/useMemoisedFn';
import { cloneEvent } from '../../common/utils/clone';
import RundownDelay from './rundown-delay/RundownDelay'; import RundownDelay from './rundown-delay/RundownDelay';
import RundownEvent from './rundown-event/RundownEvent'; import RundownEvent from './rundown-event/RundownEvent';
@@ -44,13 +32,6 @@ export default function RundownEntry({
totalGap, totalGap,
isLinkedToLoaded, isLinkedToLoaded,
}: RundownEntryProps) { }: RundownEntryProps) {
const { addEntry } = useEntryActions();
const createCloneEvent = useMemoisedFn(() => {
const newEvent = cloneEvent(data as OntimeEvent);
addEntry(newEvent, { after: data.id });
});
if (isOntimeEvent(data)) { if (isOntimeEvent(data)) {
return ( return (
<RundownEvent <RundownEvent
@@ -83,7 +64,6 @@ export default function RundownEntry({
dayOffset={data.dayOffset} dayOffset={data.dayOffset}
totalGap={totalGap} totalGap={totalGap}
isLinkedToLoaded={isLinkedToLoaded} isLinkedToLoaded={isLinkedToLoaded}
createCloneEvent={createCloneEvent}
hasTriggers={data.triggers.length > 0} hasTriggers={data.triggers.length > 0}
/> />
); );
@@ -1,13 +1,14 @@
import { memo } from 'react'; import { memo } from 'react';
import { useSessionStorage } from '@mantine/hooks'; import { useSessionStorage } from '@mantine/hooks';
import { Corner } from '../../common/components/editor-utils/EditorUtils'; import { CornerExtract } from '../../common/components/editor-utils/EditorUtils';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu'; import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../common/components/protect-route/ProtectRoute'; import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice'; import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
import { handleLinks } from '../../common/utils/linkUtils'; import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import { getIsNavigationLocked } from '../../externals';
import { AppMode, sessionKeys } from '../../ontimeConfig'; import { AppMode, sessionKeys } from '../../ontimeConfig';
import RundownEntryEditor from './entry-editor/RundownEntryEditor'; import RundownEntryEditor from './entry-editor/RundownEntryEditor';
@@ -55,11 +56,11 @@ function RundownExport() {
<ProtectRoute permission='editor'> <ProtectRoute permission='editor'>
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'> <div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
<FinderPlacement /> <FinderPlacement />
{isExtracted && <ViewNavigationMenu suppressSettings />} {isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={style.rundown}> <div className={style.rundown}>
<div className={style.list}> <div className={style.list}>
<ErrorBoundary> <ErrorBoundary>
{!isExtracted && <Corner onClick={(event) => handleLinks('rundown', event)} />} {!isExtracted && <CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownContextMenu> <RundownContextMenu>
<RundownWrapper /> <RundownWrapper />
</RundownContextMenu> </RundownContextMenu>
@@ -59,9 +59,6 @@ export default function GroupEditor({ group }: GroupEditorProps) {
<Editor.Title>Group schedule</Editor.Title> <Editor.Title>Group schedule</Editor.Title>
<div className={style.inline}> <div className={style.inline}>
<div> <div>
{
// TODO: format with user time settings
}
<Editor.Label>First event start</Editor.Label> <Editor.Label>First event start</Editor.Label>
<TextLikeInput className={style.textLikeInput} disabled> <TextLikeInput className={style.textLikeInput} disabled>
{millisToString(group.timeStart, { fallback: timerPlaceholder })} {millisToString(group.timeStart, { fallback: timerPlaceholder })}
@@ -134,6 +134,13 @@ $skip-opacity: 0.2;
height: 100%; height: 100%;
} }
.eventTimers.editMode:hover {
[class*="hoverLabel"] {
opacity: 1;
transition: opacity 0.15s 0.5s ease;
}
}
.chipSection { .chipSection {
grid-area: chip; grid-area: chip;
} }
@@ -56,7 +56,6 @@ interface RundownEventProps {
dayOffset: number; dayOffset: number;
totalGap: number; totalGap: number;
isLinkedToLoaded: boolean; isLinkedToLoaded: boolean;
createCloneEvent: () => void;
hasTriggers: boolean; hasTriggers: boolean;
} }
@@ -91,10 +90,9 @@ export default function RundownEvent({
totalGap, totalGap,
isLinkedToLoaded, isLinkedToLoaded,
hasTriggers, hasTriggers,
createCloneEvent,
}: RundownEventProps) { }: RundownEventProps) {
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping(); const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
const { updateEntry, batchUpdateEvents, deleteEntry, groupEntries, swapEvents } = useEntryActions(); const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents } = useEntryActions();
const { selectedEvents, unselect, setSelectedEvents, clearSelectedEvents } = useEventSelection(); const { selectedEvents, unselect, setSelectedEvents, clearSelectedEvents } = useEventSelection();
const handleRef = useRef<null | HTMLSpanElement>(null); const handleRef = useRef<null | HTMLSpanElement>(null);
@@ -172,7 +170,7 @@ export default function RundownEvent({
type: 'item', type: 'item',
label: 'Clone', label: 'Clone',
icon: IoDuplicateOutline, icon: IoDuplicateOutline,
onClick: createCloneEvent, onClick: () => clone(eventId, { after: eventId }),
}, },
{ type: 'divider' }, { type: 'divider' },
{ {
@@ -10,10 +10,12 @@ import {
IoTime, IoTime,
} from 'react-icons/io5'; } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu'; import { LuArrowDownToLine } from 'react-icons/lu';
import { useSessionStorage } from '@mantine/hooks';
import { EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types'; import { EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types';
import Tooltip from '../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../common/components/tooltip/Tooltip';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import { AppMode, sessionKeys } from '../../../ontimeConfig';
import TitleEditor from '../common/TitleEditor'; import TitleEditor from '../common/TitleEditor';
import TimeInputFlow from '../time-input-flow/TimeInputFlow'; import TimeInputFlow from '../time-input-flow/TimeInputFlow';
@@ -76,6 +78,11 @@ function RundownEventInner({
}: RundownEventInnerProps) { }: RundownEventInnerProps) {
const [renderInner, setRenderInner] = useState(false); const [renderInner, setRenderInner] = useState(false);
const [editorMode] = useSessionStorage({
key: sessionKeys.editorMode,
defaultValue: AppMode.Edit,
});
useEffect(() => { useEffect(() => {
setRenderInner(true); setRenderInner(true);
}, []); }, []);
@@ -92,7 +99,7 @@ function RundownEventInner({
return !renderInner ? null : ( return !renderInner ? null : (
<> <>
<div className={style.eventTimers}> <div className={cx([style.eventTimers, editorMode === AppMode.Edit && style.editMode])}>
<TimeInputFlow <TimeInputFlow
eventId={eventId} eventId={eventId}
timeStart={timeStart} timeStart={timeStart}
@@ -10,14 +10,14 @@ import {
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { EntryId, OntimeGroup } from 'ontime-types'; import { EntryId, OntimeGroup } from 'ontime-types';
import { MILLIS_PER_MINUTE } from 'ontime-utils'; import { MILLIS_PER_MINUTE, millisToString } from 'ontime-utils';
import IconButton from '../../../common/components/buttons/IconButton'; import IconButton from '../../../common/components/buttons/IconButton';
import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { getOffsetState } from '../../../common/utils/offset'; import { getOffsetState } from '../../../common/utils/offset';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour, timerPlaceholder } from '../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../common/utils/time'; import { formatDuration } from '../../../common/utils/time';
import TitleEditor from '../common/TitleEditor'; import TitleEditor from '../common/TitleEditor';
import { canDrop } from '../rundown.utils'; import { canDrop } from '../rundown.utils';
import { useEventSelection } from '../useEventSelection'; import { useEventSelection } from '../useEventSelection';
@@ -149,11 +149,11 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
<div className={style.metaRow}> <div className={style.metaRow}>
<div className={style.metaEntry}> <div className={style.metaEntry}>
<div>Start</div> <div>Start</div>
<div>{formatTime(data.timeStart)}</div> <div>{millisToString(data.timeStart, { fallback: timerPlaceholder })}</div>
</div> </div>
<div className={style.metaEntry}> <div className={style.metaEntry}>
<div>End</div> <div>End</div>
<div>{formatTime(data.timeEnd)}</div> <div>{millisToString(data.timeEnd, { fallback: timerPlaceholder })}</div>
</div> </div>
<div className={style.metaEntry}> <div className={style.metaEntry}>
<div>Duration</div> <div>Duration</div>
@@ -15,3 +15,26 @@
.active { .active {
color: $active-indicator; color: $active-indicator;
} }
.inputWrapper {
position: relative;
}
.hoverLabel {
position: absolute;
top: -1.5rem;
background-color: $ui-white;
padding: 0.125rem 0.5rem;
color: $ui-black;
left: 0;
opacity: 0;
border-radius: $component-border-radius-md;
transition: opacity 0.15s ease; // no delay on fade out
pointer-events: none;
z-index: $zindex-floating;
&.visible {
opacity: 1;
transition: opacity 0.15s 0.5s ease; // small delay before fade in
}
}
@@ -67,8 +67,9 @@ function TimeInputFlow({
return ( return (
<> <>
<div> <div className={style.inputWrapper}>
{showLabels && <Editor.Label className={style.sectionTitle}>Start time</Editor.Label>} {showLabels && <Editor.Label className={style.sectionTitle}>Start time</Editor.Label>}
<Editor.Label className={style.hoverLabel}>Start</Editor.Label>
<TimeInputGroup hasDelay={hasDelay}> <TimeInputGroup hasDelay={hasDelay}>
<TimeInput <TimeInput
name='timeStart' name='timeStart'
@@ -88,8 +89,9 @@ function TimeInputFlow({
</TimeInputGroup> </TimeInputGroup>
</div> </div>
<div> <div className={style.inputWrapper}>
{showLabels && <Editor.Label>End time</Editor.Label>} {showLabels && <Editor.Label>End time</Editor.Label>}
<Editor.Label className={style.hoverLabel}>End</Editor.Label>
<TimeInputGroup hasDelay={hasDelay}> <TimeInputGroup hasDelay={hasDelay}>
<TimeInput <TimeInput
name='timeEnd' name='timeEnd'
@@ -110,8 +112,9 @@ function TimeInputFlow({
</TimeInputGroup> </TimeInputGroup>
</div> </div>
<div> <div className={style.inputWrapper}>
{showLabels && <Editor.Label>Duration</Editor.Label>} {showLabels && <Editor.Label>Duration</Editor.Label>}
<Editor.Label className={style.hoverLabel}>Duration</Editor.Label>
<TimeInputGroup hasDelay={hasDelay}> <TimeInputGroup hasDelay={hasDelay}>
<TimeInput <TimeInput
name='duration' name='duration'
@@ -15,7 +15,6 @@ import Switch from '../../common/components/switch/Switch';
import { useUpdateUrlPreset } from '../../common/hooks-query/useUrlPresets'; import { useUpdateUrlPreset } from '../../common/hooks-query/useUrlPresets';
import copyToClipboard from '../../common/utils/copyToClipboard'; import copyToClipboard from '../../common/utils/copyToClipboard';
import { preventEscape } from '../../common/utils/keyEvent'; import { preventEscape } from '../../common/utils/keyEvent';
import { linkToOtherHost } from '../../common/utils/linkUtils';
import { isUrlSafe } from '../../common/utils/regex'; import { isUrlSafe } from '../../common/utils/regex';
import { isOntimeCloud, serverURL } from '../../externals'; import { isOntimeCloud, serverURL } from '../../externals';
import * as Panel from '../app-settings/panel-utils/PanelUtils'; import * as Panel from '../app-settings/panel-utils/PanelUtils';
@@ -136,7 +135,7 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
} }
const url = await generateUrl({ const url = await generateUrl({
baseUrl: linkToOtherHost(options.baseUrl), baseUrl: options.baseUrl,
path, path,
authenticate: options.authenticate, authenticate: options.authenticate,
lockConfig: options.lockConfig, lockConfig: options.lockConfig,
@@ -0,0 +1,23 @@
import './SuperscriptTime.scss';
interface SuperscriptPeriodProps {
time: string;
className?: string;
}
/**
* Receives a time string and formats periods (am/pm) as superscript
* @example 12:00 AM -> AM becomes a superscript
* @example 12:00:10 -> no formatting changes applied
*/
export default function SuperscriptPeriod({ time, className }: SuperscriptPeriodProps) {
// we assume anything after space is a period tag
const [timeString, period] = time.split(' ');
return (
<div className={className}>
{timeString}
{period && <sup className='period'>{period}</sup>}
</div>
);
}
@@ -11,7 +11,6 @@ import { formatTime } from '../../../common/utils/time';
export function getTimerByType( export function getTimerByType(
freezeEnd: boolean, freezeEnd: boolean,
timerTypeNow: TimerType, timerTypeNow: TimerType,
countToEndNow: boolean,
clock: number, clock: number,
timerObject: Pick<TimerState, 'current' | 'elapsed'>, timerObject: Pick<TimerState, 'current' | 'elapsed'>,
timerTypeOverride?: TimerType, timerTypeOverride?: TimerType,
@@ -22,13 +21,6 @@ export function getTimerByType(
const viewTimerType = timerTypeOverride ?? timerTypeNow; const viewTimerType = timerTypeOverride ?? timerTypeNow;
if (countToEndNow) {
if (timerObject.current === null) {
return null;
}
return freezeEnd ? Math.max(timerObject.current, 0) : timerObject.current;
}
switch (viewTimerType) { switch (viewTimerType) {
case TimerType.CountDown: case TimerType.CountDown:
if (timerObject.current === null) { if (timerObject.current === null) {
@@ -36,7 +28,7 @@ export function getTimerByType(
} }
return freezeEnd ? Math.max(timerObject.current, 0) : timerObject.current; return freezeEnd ? Math.max(timerObject.current, 0) : timerObject.current;
case TimerType.CountUp: case TimerType.CountUp:
return Math.abs(timerObject.elapsed ?? 0); return timerObject.elapsed;
case TimerType.Clock: case TimerType.Clock:
return clock; return clock;
case TimerType.None: case TimerType.None:
@@ -1,6 +1,7 @@
// from https://www.genomecolor.space/ // from https://www.genomecolor.space/
$white-1: rgba(255, 255, 255, 0.01); $white-1: rgba(255, 255, 255, 0.01);
$white-2: rgba(255, 255, 255, 0.02);
$white-3: rgba(255, 255, 255, 0.03); $white-3: rgba(255, 255, 255, 0.03);
$white-7: rgba(255, 255, 255, 0.07); $white-7: rgba(255, 255, 255, 0.07);
$white-9: rgba(255, 255, 255, 0.09); $white-9: rgba(255, 255, 255, 0.09);
+2 -2
View File
@@ -35,7 +35,7 @@ $ontime-paused: #c05621;
$ontime-stop: #E4281E; $ontime-stop: #E4281E;
$playback-negative: $red-500; $playback-negative: $red-500;
$playback-ahead: $green-500; $playback-ahead: $green-500;
$active-indicator: #8bb33d; $active-indicator: $green-400;
$text-black: $gray-1350; $text-black: $gray-1350;
$playback-over: #F57C13; $playback-over: #F57C13;
@@ -65,7 +65,7 @@ $main-spacing: 2rem;
// interface text // interface text
$ontime-font-family: "Open Sans", "Segoe UI", sans-serif; $ontime-font-family: "Open Sans", "Segoe UI", sans-serif;
$title-gray: $gray-200; $title-gray: $gray-200;
$label-gray: $gray-400; $label-gray: $gray-600;
$secondary-text-gray: $gray-400; $secondary-text-gray: $gray-400;
$muted-gray: $gray-600; $muted-gray: $gray-600;
$section-white: $ui-white; $section-white: $ui-white;
@@ -76,13 +76,13 @@ function Backstage({ events, customFields, projectData, isMirrored, settings }:
const scheduledStart = (() => { const scheduledStart = (() => {
if (showNow) return undefined; if (showNow) return undefined;
if (!hasEvents) return undefined; if (!hasEvents) return undefined;
return formatTime(rundown.plannedStart, { format12: 'hh:mm a', format24: 'HH:mm' }); return formatTime(rundown.plannedStart, { format12: 'h:mm a', format24: 'HH:mm' });
})(); })();
const scheduledEnd = (() => { const scheduledEnd = (() => {
if (showNow) return undefined; if (showNow) return undefined;
if (!hasEvents) return undefined; if (!hasEvents) return undefined;
return formatTime(rundown.plannedEnd, { format12: 'hh:mm a', format24: 'HH:mm' }); return formatTime(rundown.plannedEnd, { format12: 'h:mm a', format24: 'HH:mm' });
})(); })();
let displayTimer = millisToString(time.current, { fallback: timerPlaceholderMin }); let displayTimer = millisToString(time.current, { fallback: timerPlaceholderMin });
@@ -5,14 +5,14 @@ import { getOffsetState } from '../../../common/utils/offset';
import { ExtendedEntry } from '../../../common/utils/rundownMetadata'; import { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import { formatTime, getExpectedTimesFromExtendedEvent } from '../../../common/utils/time'; import { formatTime, getExpectedTimesFromExtendedEvent } from '../../../common/utils/time';
import SuperscriptTime from '../../../features/viewers/common/superscript-time/SuperscriptTime'; import SuperscriptPeriod from '../../../features/viewers/common/superscript-time/SuperscriptPeriod';
import { useScheduleOptions } from './schedule.options'; import { useScheduleOptions } from './schedule.options';
import './Schedule.scss'; import './Schedule.scss';
const formatOptions = { const formatOptions = {
format12: 'hh:mm a', format12: 'h:mm a',
format24: 'HH:mm', format24: 'HH:mm',
}; };
@@ -84,9 +84,9 @@ function PlannedScheduleItem({
return ( return (
<> <>
<span className='entry-colour' style={{ backgroundColor: colour }} /> <span className='entry-colour' style={{ backgroundColor: colour }} />
<SuperscriptTime time={start} /> <SuperscriptPeriod time={start} />
<SuperscriptTime time={end} /> <SuperscriptPeriod time={end} />
</> </>
); );
} }
@@ -106,14 +106,14 @@ function DelayedScheduleItem({
<> <>
<span className='entry-times--delayed'> <span className='entry-times--delayed'>
<span className='entry-colour' style={{ backgroundColor: colour }} /> <span className='entry-colour' style={{ backgroundColor: colour }} />
<SuperscriptTime time={start} /> <SuperscriptPeriod time={start} />
<SuperscriptTime time={end} /> <SuperscriptPeriod time={end} />
</span> </span>
<span className='entry-times--delay'> <span className='entry-times--delay'>
<SuperscriptTime time={delayedStart} /> <SuperscriptPeriod time={delayedStart} />
<SuperscriptTime time={delayedEnd} /> <SuperscriptPeriod time={delayedEnd} />
</span> </span>
</> </>
); );
@@ -161,5 +161,5 @@ interface ExpectedTimeProps {
function ExpectedTime({ expectedTime, plannedTime }: ExpectedTimeProps) { function ExpectedTime({ expectedTime, plannedTime }: ExpectedTimeProps) {
const timeDisplay = formatTime(expectedTime); const timeDisplay = formatTime(expectedTime);
const expectedState = getOffsetState(expectedTime - plannedTime); const expectedState = getOffsetState(expectedTime - plannedTime);
return <SuperscriptTime className={`entry-times--${expectedState}`} time={timeDisplay} />; return <SuperscriptPeriod className={`entry-times--${expectedState}`} time={timeDisplay} />;
} }
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router';
import { EntryId, PlayableEvent } from 'ontime-types'; import { EntryId, PlayableEvent } from 'ontime-types';
import Button from '../../common/components/buttons/Button'; import Button from '../../common/components/buttons/Button';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import ClockTime from '../../features/viewers/common/clock-time/ClockTime'; import ClockTime from '../../features/viewers/common/clock-time/ClockTime';
@@ -12,7 +13,7 @@ import { makeSubscriptionsUrl } from './countdown.utils';
import './Countdown.scss'; import './Countdown.scss';
interface CountdownSelectProps { interface CountdownSelectProps {
events: PlayableEvent[]; events: ExtendedEntry<PlayableEvent>[];
subscriptions: EntryId[]; subscriptions: EntryId[];
disableEdit: () => void; disableEdit: () => void;
} }
@@ -72,9 +73,9 @@ export default function CountdownSelect({ events, subscriptions, disableEdit }:
> >
<div className='sub__binder' style={{ '--user-color': event?.colour ?? '' }} /> <div className='sub__binder' style={{ '--user-color': event?.colour ?? '' }} />
<div className='sub__schedule'> <div className='sub__schedule'>
<ClockTime value={event.timeStart} preferredFormat12='h:mm' preferredFormat24='HH:mm' /> <ClockTime value={event.timeStart} preferredFormat12='h:mm a' preferredFormat24='HH:mm' />
<ClockTime value={event.timeEnd} preferredFormat12='h:mm' preferredFormat24='HH:mm' /> <ClockTime value={event.timeEnd} preferredFormat12='h:mm a' preferredFormat24='HH:mm' />
</div> </div>
<div className='sub__label'>{isSelected ? 'Click to remove' : 'Click to add'}</div> <div className='sub__label'>{isSelected ? 'Click to remove' : 'Click to add'}</div>
<div className='sub__title'>{title}</div> <div className='sub__title'>{title}</div>
@@ -1,9 +1,8 @@
import { EntryId, MaybeNumber, OffsetMode, OntimeEntry, OntimeEvent, OntimeReport, Playback } from 'ontime-types'; import { EntryId, MaybeNumber, OffsetMode, OntimeEntry, OntimeEvent, OntimeReport, Playback } from 'ontime-types';
import { getExpectedStart, MILLIS_PER_MINUTE, removeSeconds } from 'ontime-utils'; import { getExpectedStart, MILLIS_PER_MINUTE, millisToString, removeLeadingZero } from 'ontime-utils';
import { useCountdownSocket } from '../../common/hooks/useSocket'; import { useCountdownSocket } from '../../common/hooks/useSocket';
import { ExtendedEntry } from '../../common/utils/rundownMetadata'; import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { timerPlaceholderMin } from '../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../common/utils/time'; import { formatDuration, formatTime } from '../../common/utils/time';
import { type TranslationKey, useTranslation } from '../../translation/TranslationProvider'; import { type TranslationKey, useTranslation } from '../../translation/TranslationProvider';
@@ -36,23 +35,6 @@ export const timerProgress: TimerMessage = {
done: 'countdown.ended', 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 * Returns a parsed timer and relevant status message
* Handles events in different days but disregards whether an event has actually played * Handles events in different days but disregards whether an event has actually played
@@ -65,7 +47,11 @@ export function useSubscriptionDisplayData(
const bigDuration = (value: number) => { const bigDuration = (value: number) => {
if (value <= 0) return getLocalizedString('countdown.overtime').toUpperCase(); if (value <= 0) return getLocalizedString('countdown.overtime').toUpperCase();
return formatDuration(value, value > MILLIS_PER_MINUTE * 2) if (value < MILLIS_PER_MINUTE * 10) {
return removeLeadingZero(millisToString(value));
}
return formatDuration(value, value > MILLIS_PER_MINUTE * 10)
.replace('m', `${getLocalizedString('common.minutes')} `) .replace('m', `${getLocalizedString('common.minutes')} `)
.replace('s', getLocalizedString('common.seconds')); .replace('s', getLocalizedString('common.seconds'));
}; };
@@ -90,7 +76,7 @@ export function useSubscriptionDisplayData(
return { return {
status: 'pending', status: 'pending',
statusDisplay: getLocalizedString(timerProgress['pending']), statusDisplay: getLocalizedString(timerProgress['pending']),
timeDisplay: ' ', timeDisplay: ' ',
}; };
} }
@@ -23,7 +23,7 @@ function MakeStart({ getValue, row, table, column }: CellContext<ExtendedEntry,
} }
const { showDelayedTimes, hideTableSeconds } = table.options.meta.options; const { showDelayedTimes, hideTableSeconds } = table.options.meta.options;
const formatOpts = hideTableSeconds ? { format12: 'hh:mm a', format24: 'HH:mm' } : undefined; const formatOpts = hideTableSeconds ? { format12: 'h:mm a', format24: 'HH:mm' } : undefined;
const event = row.original; const event = row.original;
if (!isOntimeEvent(event)) { if (!isOntimeEvent(event)) {
@@ -62,7 +62,7 @@ function MakeEnd({ getValue, row, table, column }: CellContext<ExtendedEntry, un
} }
const { showDelayedTimes, hideTableSeconds } = table.options.meta.options; const { showDelayedTimes, hideTableSeconds } = table.options.meta.options;
const formatOpts = hideTableSeconds ? { format12: 'hh:mm a', format24: 'HH:mm' } : undefined; const formatOpts = hideTableSeconds ? { format12: 'h:mm a', format24: 'HH:mm' } : undefined;
const event = row.original; const event = row.original;
if (!isOntimeEvent(event)) { if (!isOntimeEvent(event)) {
@@ -0,0 +1,65 @@
import { createRoot } from 'react-dom/client';
import { ErrorBoundary } from '@sentry/react';
import { CornerPipButton } from '../../../common/components/editor-utils/EditorUtils';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
import { PipTimer } from './PipTimer';
export default function PipTimerHost() {
const { data, status } = useViewSettings();
const openPictureInPicture = async () => {
if (window.documentPictureInPicture.window) {
return;
}
let pipWindow: Window;
try {
pipWindow = await window.documentPictureInPicture.requestWindow();
} catch (err) {
console.error('Failed to open Picture-in-Picture:', err);
return;
}
[...document.styleSheets].forEach((sheet) => {
try {
if (sheet.href) {
const link = pipWindow.document.createElement('link');
link.rel = 'stylesheet';
link.href = sheet.href;
pipWindow.document.head.appendChild(link);
} else if (sheet.cssRules) {
const style = pipWindow.document.createElement('style');
style.textContent = [...sheet.cssRules].map((rule) => rule.cssText).join('');
pipWindow.document.head.appendChild(style);
}
} catch (e) {
console.warn('Stylesheet copy blocked:', e);
}
});
const pipDiv = pipWindow.document.createElement('div');
pipDiv.setAttribute('id', 'pip-root');
pipDiv.style.height = '100vh';
pipWindow.document.body.append(pipDiv);
const pipRoot = createRoot(pipWindow.document.getElementById('pip-root') as Element, {
onCaughtError: (err, _errInfo) => console.error(err),
onUncaughtError: (err, _errInfo) => console.error(err),
onRecoverableError: (err, _errInfo) => console.error(err),
});
pipWindow.addEventListener('pagehide', () => {
pipRoot.unmount();
});
pipRoot.render(
<ErrorBoundary>
<PipTimer viewSettings={data} />
</ErrorBoundary>,
);
};
return <CornerPipButton onClick={status === 'success' ? openPictureInPicture : undefined} />;
}
@@ -0,0 +1,18 @@
import { lazy, memo, Suspense } from 'react';
import { isPipSupported } from './pip.utils';
const PipTimerHost = lazy(() => import('./PipHost'));
export default memo(PipRoot);
function PipRoot() {
if (!isPipSupported) {
return null;
}
return (
<Suspense fallback={null}>
<PipTimerHost />
</Suspense>
);
}
@@ -0,0 +1,134 @@
@use '../../../theme/viewerDefs' as *;
/* The styles in this file are a stripped down version of the timer */
.pip-timer {
margin: 0;
padding: 0;
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100vh;
transition: opacity 0.5s ease-in-out;
font-family: $viewer-font-family;
background: $viewer-background-color;
color: $viewer-color;
gap: $view-element-gap;
display: flex;
flex-direction: column;
&--finished {
outline: clamp(4px, 1vw, 16px) solid $timer-finished-color;
outline-offset: calc(clamp(4px, 1vw, 16px) * -1);
transition: $viewer-transition-time;
}
/* =================== MAIN ===================*/
.timer-container {
flex: 1;
align-content: center;
justify-self: center;
align-self: center;
width: 100%;
overflow: hidden;
.timer {
opacity: 1;
font-family: $viewer-font-family;
color: var(--timer-colour, $ui-white);
line-height: 0.9em;
text-align: center;
letter-spacing: 0.05em;
font-weight: 600;
transition-property: font-size;
transition-duration: $viewer-transition-time;
&--paused {
opacity: $viewer-opacity-disabled;
transition: $viewer-transition-time;
}
// use a class instead of a phase, to allow suppressing overtime style
&--finished {
color: var(--timer-overtime-color-override, $timer-finished-color);
}
&[data-phase='warning'] {
color: var(--timer-colour, var(--timer-warning-color-override));
}
&[data-phase='danger'] {
color: var(--timer-colour, var(--timer-danger-color-override));
}
&[data-type='none'] {
transition: 1s;
opacity: 0;
}
}
}
.secondary {
white-space: nowrap;
max-height: 100%;
height: auto;
margin-top: 0.125em;
padding-block: 0.125em;
font-weight: 600;
text-align: center;
color: $external-color;
letter-spacing: 0.5px;
line-height: 1;
transition-property: opacity, height;
transition-duration: $viewer-transition-time;
border-top: 1px solid color-mix(in srgb, $external-color 10%, transparent);
&--hidden {
opacity: 0;
height: 0;
}
}
.progress-container {
width: 100%;
margin: 0 auto;
opacity: 1;
transition: $viewer-transition-time;
&--paused {
opacity: $viewer-opacity-disabled;
transition: $viewer-transition-time;
}
}
/* =================== OVERLAY ===================*/
.message-overlay {
position: fixed;
inset: 0;
padding: 2vw;
background: $viewer-background-color;
opacity: 0;
transition: opacity $viewer-transition-time;
&--active {
z-index: $zindex-floating;
opacity: 1;
}
}
.message {
display: grid;
place-content: center;
height: 100%;
width: 100%;
color: $viewer-color;
text-align: center;
font-weight: 600;
}
}
@@ -0,0 +1,109 @@
import { ViewSettings } from 'ontime-types';
import { FitText } from '../../../common/components/fit-text/FitText';
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import { useTimerSocket } from '../../../common/hooks/useSocket';
import { cx } from '../../../common/utils/styleUtils';
import { getFormattedTimer, getTimerByType } from '../../../features/viewers/common/viewUtils';
import {
getEstimatedFontSize,
getIsPlaying,
getSecondaryDisplay,
getShowMessage,
getShowModifiers,
getShowProgressBar,
getTotalTime,
} from '../../timer/timer.utils';
import { getTimerColour } from '../../utils/presentation.utils';
import './PipTimer.scss';
interface PipTimerProps {
viewSettings: ViewSettings;
}
export function PipTimer({ viewSettings }: PipTimerProps) {
const { eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket();
// gather modifiers
const showOverlay = getShowMessage(message.timer);
const { showFinished, showWarning, showDanger } = getShowModifiers(
timerTypeNow,
countToEndNow,
time.phase,
false,
'',
false,
);
const isPlaying = getIsPlaying(time.playback);
const showProgressBar = getShowProgressBar(timerTypeNow);
// gather timer data
const totalTime = getTotalTime(time.duration, time.addedTime);
const stageTimer = getTimerByType(false, timerTypeNow, clock, time, timerTypeNow);
const display = getFormattedTimer(stageTimer, timerTypeNow, 'min', {
removeSeconds: false,
removeLeadingZero: false,
});
const currentAux = (() => {
if (message.timer.secondarySource === 'aux1') {
return auxTimer.aux1;
}
if (message.timer.secondarySource === 'aux2') {
return auxTimer.aux2;
}
if (message.timer.secondarySource === 'aux3') {
return auxTimer.aux3;
}
return null;
})();
const secondaryContent = getSecondaryDisplay(message, currentAux, 'min', false, true, false);
// gather presentation styles
const resolvedTimerColour = getTimerColour(viewSettings, undefined, showWarning, showDanger);
const timerFontSize = getEstimatedFontSize(display, secondaryContent);
const userStyles = {
...(resolvedTimerColour && { '--timer-colour': resolvedTimerColour }),
};
return (
<div className={cx(['pip-timer', showFinished && 'pip-timer--finished'])} style={userStyles}>
<div className={cx(['message-overlay', showOverlay && 'message-overlay--active'])}>
<FitText mode='multi' min={12} max={256} className={cx(['message', message.timer.blink && 'blink'])}>
{message.timer.text}
</FitText>
</div>
<div className='timer-container'>
<div
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
style={{ fontSize: `${timerFontSize}vw` }}
data-phase={time.phase}
>
{display}
</div>
<div className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}>
<FitText mode='multi' min={12} max={256}>
{secondaryContent}
</FitText>
</div>
</div>
{showProgressBar && (
<MultiPartProgressBar
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
now={time.current}
complete={totalTime}
normalColor={viewSettings.normalColor}
warning={eventNow?.timeWarning}
warningColor={viewSettings.warningColor}
danger={eventNow?.timeDanger}
dangerColor={viewSettings.dangerColor}
hideOvertime={!showFinished}
/>
)}
</div>
);
}
@@ -0,0 +1 @@
export const isPipSupported = 'documentPictureInPicture' in window;
@@ -49,9 +49,7 @@ export default function StudioTimers({ viewSettings }: StudioTimersProps) {
</div> </div>
<div> <div>
<div className='label center'>Over / under</div> <div className='label center'>Over / under</div>
<div className={cx(['runtime-timer', 'center', !eventNow && 'muted', offsetState && offsetState])}> <div className={cx(['runtime-timer', 'center', !eventNow && 'muted', offsetState])}>{schedule.offset}</div>
{schedule.offset}
</div>
</div> </div>
<div> <div>
<div className='label right'>{getLocalizedString('common.expected_end')}</div> <div className='label right'>{getLocalizedString('common.expected_end')}</div>
@@ -93,15 +93,28 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
&[data-status='live'] { &[data-status='live'] {
box-shadow: 0 0.25rem 0 0 $active-red; box-shadow: 0 0.25rem 0 0 $active-red;
.status { .status {
color: $active-red; padding: 0.125rem 0.25rem;
border-radius: $component-border-radius-sm;
background: $active-red;
text-transform: uppercase;
} }
} }
&[data-status='future'] { &[data-status='future'] {
.status { .status {
color: $green-500; padding: 0.125rem 0.25rem;
border-radius: $component-border-radius-sm;
background: $green-500;
} }
} }
.status {
width: fit-content;
}
.status.due {
background: $orange-600;
}
} }
.delay { .delay {
+5 -5
View File
@@ -24,7 +24,7 @@ interface TimelineProps {
export default memo(Timeline); export default memo(Timeline);
function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: TimelineProps) { function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: TimelineProps) {
const { width: screenWidth } = useViewportSize(); const { width: screenWidth } = useViewportSize();
const { hidePast, autosize } = useTimelineOptions(); const { hidePast, fixedSize } = useTimelineOptions();
const selectedRef = useRef<HTMLDivElement>(null); const selectedRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
@@ -38,7 +38,7 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel
useHorizontalFollowComponent({ useHorizontalFollowComponent({
followRef: selectedRef, followRef: selectedRef,
scrollRef: scrollContainerRef, scrollRef: scrollContainerRef,
doFollow: autosize, doFollow: !fixedSize,
selectedEventId: selectedEventId, selectedEventId: selectedEventId,
// No offset when hiding past events to ensure content starts at 0 // No offset when hiding past events to ensure content starts at 0
leftOffset: hidePast ? 0 : screenWidth / 6, leftOffset: hidePast ? 0 : screenWidth / 6,
@@ -52,8 +52,8 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel
duration: event.duration, duration: event.duration,
})); }));
return calculateTimelineLayout(playableEvents, scheduleStart, scheduleEnd, screenWidth, autosize); return calculateTimelineLayout(playableEvents, scheduleStart, scheduleEnd, screenWidth, !fixedSize);
}, [rundown, scheduleStart, scheduleEnd, screenWidth, autosize]); }, [rundown, scheduleStart, scheduleEnd, screenWidth, fixedSize]);
if (totalDuration === 0) { if (totalDuration === 0) {
return null; return null;
@@ -75,7 +75,7 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel
}); });
return ( return (
<div ref={scrollContainerRef} className={cx([style.timelineContainer, autosize && style.scroll])}> <div ref={scrollContainerRef} className={cx([style.timelineContainer, !fixedSize && style.scroll])}>
<div className={style.timeline} style={{ width: totalWidth }}> <div className={style.timeline} style={{ width: totalWidth }}>
<TimelineMarkers startHour={startHour} endHour={endHour} /> <TimelineMarkers startHour={startHour} endHour={endHour} />
{rundown.map((event, index) => { {rundown.map((event, index) => {
@@ -30,7 +30,7 @@ interface TimelineEntryProps {
} }
const formatOptions = { const formatOptions = {
format12: 'hh:mm a', format12: 'h:mm a',
format24: 'HH:mm', format24: 'HH:mm',
}; };
@@ -150,7 +150,9 @@ function TimelineEntryStatus({
statusText = getLocalizedString('timeline.due'); statusText = getLocalizedString('timeline.due');
} }
return <div className={style.status}>{statusText}</div>; const isDue = status === 'future' && timeToStart <= 0;
return <div className={cx([style.status, isDue && style.due])}>{statusText}</div>;
} }
/** Generates a block level progress bar */ /** Generates a block level progress bar */
@@ -74,8 +74,8 @@
} }
.section-title { .section-title {
line-height: 1em; line-height: 1;
font-size: 1.5rem; font-size: $base-font-size;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -83,8 +83,9 @@
} }
.section-title__label { .section-title__label {
text-transform: uppercase; font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color); color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
} }
.section-title__status { .section-title__status {
@@ -94,7 +95,7 @@
.section-content { .section-content {
min-height: 2em; min-height: 2em;
line-height: 1em; line-height: 1em;
font-size: 3rem; font-size: $timer-value-size;
text-transform: uppercase; text-transform: uppercase;
font-weight: 600; font-weight: 600;
@@ -119,3 +120,12 @@
opacity: $opacity-disabled; opacity: $opacity-disabled;
} }
} }
@media (max-width: $min-tablet) {
.timeline {
.title-grid {
display: flex;
flex-direction: column;
}
}
}
@@ -31,7 +31,7 @@ export default function TimelineSections({ now, next, followedBy }: TimelineSect
if (timeToStart <= 0) { if (timeToStart <= 0) {
nextStatus = dueText; nextStatus = dueText;
} else { } else {
nextStatus = formatDuration(timeToStart, timeToStart > MILLIS_PER_MINUTE * 2); nextStatus = formatDuration(timeToStart, timeToStart > MILLIS_PER_MINUTE * 10);
} }
} }
@@ -40,7 +40,7 @@ export default function TimelineSections({ now, next, followedBy }: TimelineSect
if (timeToStart <= 0) { if (timeToStart <= 0) {
followedByStatus = dueText; followedByStatus = dueText;
} else { } else {
followedByStatus = formatDuration(timeToStart, timeToStart > MILLIS_PER_MINUTE * 2); followedByStatus = formatDuration(timeToStart, timeToStart > MILLIS_PER_MINUTE * 10);
} }
} }
@@ -22,9 +22,9 @@ export const getTimelineOptions = (timeFormat: string): ViewOption[] => {
defaultValue: false, defaultValue: false,
}, },
{ {
id: 'autosize', id: 'fixedSize',
title: 'Autosize timeline', title: 'Fixed timeline size',
description: 'Timeline will adjust sizes to help with readability and automatically scroll if necessary', description: 'Timeline will have a fixed size to prevent scrolling',
type: 'boolean', type: 'boolean',
defaultValue: false, defaultValue: false,
}, },
@@ -35,7 +35,7 @@ export const getTimelineOptions = (timeFormat: string): ViewOption[] => {
type TimelineOptions = { type TimelineOptions = {
hidePast: boolean; hidePast: boolean;
autosize: boolean; fixedSize: boolean;
}; };
/** /**
@@ -48,7 +48,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
return { return {
hidePast: isStringBoolean(getValue('hidePast')), hidePast: isStringBoolean(getValue('hidePast')),
autosize: isStringBoolean(getValue('autosize')), fixedSize: isStringBoolean(getValue('fixedSize')),
}; };
} }
@@ -82,7 +82,7 @@ export function getStatusLabel(timeToStart: number, status: ProgressStatus): str
return 'pending'; return 'pending';
} }
return formatDuration(timeToStart, timeToStart > MILLIS_PER_MINUTE * 2); return formatDuration(timeToStart, timeToStart > MILLIS_PER_MINUTE * 10);
} }
interface ScopedRundownData { interface ScopedRundownData {
+9 -5
View File
@@ -120,19 +120,23 @@
color: var(--timer-overtime-color-override, $timer-finished-color); color: var(--timer-overtime-color-override, $timer-finished-color);
} }
&[data-phase="warning"] { &[data-phase='warning'] {
color: var(--timer-colour, var(--timer-warning-color-override)); color: var(--timer-colour, var(--timer-warning-color-override));
} }
&[data-phase="danger"] { &[data-phase='danger'] {
color: var(--timer-colour, var(--timer-danger-color-override)); color: var(--timer-colour, var(--timer-danger-color-override));
} }
&[data-type='none'] {
transition: 1s;
opacity: 0;
}
} }
} }
.secondary { .secondary {
white-space: nowrap; white-space: nowrap;
overflow: hidden; max-height: 100%;
text-overflow: ellipsis; height: auto;
margin-top: 0.125em; margin-top: 0.125em;
padding-block: 0.125em; padding-block: 0.125em;
@@ -141,7 +145,7 @@
text-align: center; text-align: center;
color: var(--external-color-override, $external-color); color: var(--external-color-override, $external-color);
letter-spacing: 0.5px; letter-spacing: 0.5px;
line-height: 1em; line-height: 1;
transition-property: opacity, height; transition-property: opacity, height;
transition-duration: $viewer-transition-time; transition-duration: $viewer-transition-time;
border-top: 1px solid color-mix(in srgb, var(--external-color-override, $external-color) 10%, transparent); border-top: 1px solid color-mix(in srgb, var(--external-color-override, $external-color) 10%, transparent);
+7 -7
View File
@@ -102,7 +102,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings }
// gather timer data // gather timer data
const totalTime = getTotalTime(time.duration, time.addedTime); const totalTime = getTotalTime(time.duration, time.addedTime);
const formattedClock = formatTime(clock); const formattedClock = formatTime(clock);
const stageTimer = getTimerByType(freezeOvertime, timerTypeNow, countToEndNow, clock, time, timerType); const stageTimer = getTimerByType(freezeOvertime, timerTypeNow, clock, time, timerType);
const display = getFormattedTimer(stageTimer, viewTimerType, localisedMinutes, { const display = getFormattedTimer(stageTimer, viewTimerType, localisedMinutes, {
removeSeconds: hideTimerSeconds, removeSeconds: hideTimerSeconds,
removeLeadingZero: removeLeadingZeros, removeLeadingZero: removeLeadingZeros,
@@ -132,7 +132,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings }
// gather presentation styles // gather presentation styles
const resolvedTimerColour = getTimerColour(viewSettings, timerColour, showWarning, showDanger); const resolvedTimerColour = getTimerColour(viewSettings, timerColour, showWarning, showDanger);
const { timerFontSize, externalFontSize } = getEstimatedFontSize(display, secondaryContent); const timerFontSize = getEstimatedFontSize(display, secondaryContent);
const userStyles = { const userStyles = {
...(keyColour && { '--timer-bg': keyColour }), ...(keyColour && { '--timer-bg': keyColour }),
...(resolvedTimerColour && { '--timer-colour': resolvedTimerColour }), ...(resolvedTimerColour && { '--timer-colour': resolvedTimerColour }),
@@ -179,16 +179,16 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings }
<div <div
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])} className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
style={{ fontSize: `${timerFontSize}vw` }} style={{ fontSize: `${timerFontSize}vw` }}
data-type={viewTimerType}
data-phase={time.phase} data-phase={time.phase}
> >
{display} {display}
</div> </div>
)} )}
<div <div className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}>
className={cx(['secondary', !secondaryContent && 'secondary--hidden'])} <FitText mode='multi' min={64} max={256}>
style={{ fontSize: `${externalFontSize}vw` }} {secondaryContent}
> </FitText>
{secondaryContent}
</div> </div>
</div> </div>

Some files were not shown because too many files have changed in this diff Show More