mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-09 17:33:55 +00:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ba5ebd4bd | |||
| 3f1f06f7c5 | |||
| a78586d2fa | |||
| 8e93b0af25 | |||
| 9a5d50d933 | |||
| 1189a94bff | |||
| f128f7acd2 | |||
| 80ec2d1186 | |||
| 18e6f0d7c4 | |||
| 48f2511764 | |||
| c20ea88021 | |||
| ce2b2b9606 | |||
| f223766445 | |||
| 3dfb49e63f | |||
| a13d3dad93 | |||
| fa9695873a | |||
| 70ce1d5553 | |||
| 9974ce24cd | |||
| 93e9c19e0d | |||
| 1694cca92c | |||
| c1d9276dd5 | |||
| f5753a0f5a | |||
| 843f5a968d | |||
| e09b6bbc16 | |||
| 19d6b40bdb | |||
| 1f2706a063 | |||
| aaeadd13cf | |||
| 72e3eb11d7 | |||
| 6f55d8a6c9 | |||
| 70846aefbe | |||
| 47ba90f191 | |||
| cee3c9c070 | |||
| 15f3b9625c | |||
| d34a1f0fd8 | |||
| 0e3b3bcf9a | |||
| 50c91f976b | |||
| 50599eccd2 | |||
| 9f2db10548 | |||
| 5dce1a40bc | |||
| b75b69c3b1 | |||
| e6070e6efd | |||
| 3918664945 | |||
| b65b3fa6d2 | |||
| 162693e19e | |||
| becd734ea8 | |||
| 9dfc8083f0 | |||
| 7bd98c23a4 | |||
| 6f19d78e53 | |||
| 163baa752f | |||
| 6f708df6f6 | |||
| eb5c62cca1 | |||
| 99d2debca8 | |||
| 74e59dccdb | |||
| 812b17a221 | |||
| 32bf0f53f6 | |||
| 17f80baf48 | |||
| e0377a7244 | |||
| b7e5723fb2 | |||
| 897210d5e3 | |||
| ee3d38fce8 |
@@ -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,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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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`, {
|
||||||
|
|||||||
@@ -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`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ declare global {
|
|||||||
process: {
|
process: {
|
||||||
type: string;
|
type: string;
|
||||||
};
|
};
|
||||||
|
// Experimental browser feature
|
||||||
|
documentPictureInPicture: {
|
||||||
|
requestWindow: () => Promise<Window>;
|
||||||
|
window: Window;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-2
@@ -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={
|
||||||
|
|||||||
+77
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+33
-3
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
+9
-4
@@ -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
@@ -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>
|
||||||
|
|||||||
+2
-6
@@ -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>
|
||||||
|
|||||||
+2
@@ -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) } });
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-4
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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: ' ',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user