mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-01 05:28:00 +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
|
||||
},
|
||||
"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"],
|
||||
"overrides": [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "4.0.2",
|
||||
"version": "4.2.1",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "4.0.2",
|
||||
"version": "4.2.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -9,7 +9,7 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fontsource/open-sans": "^5.2.6",
|
||||
"@mantine/hooks": "^8.2.8",
|
||||
"@mantine/hooks": "^8.3.7",
|
||||
"@sentry/react": "^10.2.0",
|
||||
"@table-nav/react": "^0.0.7",
|
||||
"@tanstack/react-query": "^5.85.9",
|
||||
@@ -17,7 +17,6 @@
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.12.2",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
||||
"csv-stringify": "^6.6.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^19.1.1",
|
||||
@@ -66,6 +65,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||
"@typescript-eslint/parser": "catalog:",
|
||||
"@vitejs/plugin-react": "4.5.1",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"eslint": "catalog:",
|
||||
"eslint-config-prettier": "catalog:",
|
||||
"eslint-plugin-jest": "^28.6.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { CustomFields, Rundown } from 'ontime-types';
|
||||
import { CustomFields, Rundown, RundownSummary } from 'ontime-types';
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
@@ -11,28 +11,21 @@ const excelPath = `${apiEntryUrl}/excel`;
|
||||
* upload Excel file to server
|
||||
* @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();
|
||||
formData.append('excel', file);
|
||||
await axios.post(`${excelPath}/upload`, formData, {
|
||||
const response = await axios.post(`${excelPath}/upload`, formData, {
|
||||
headers: {
|
||||
'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;
|
||||
}
|
||||
|
||||
type PreviewSpreadsheetResponse = {
|
||||
rundown: Rundown;
|
||||
customFields: CustomFields;
|
||||
summary: RundownSummary;
|
||||
};
|
||||
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
|
||||
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, Transi
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
type RundownId = string;
|
||||
const rundownPath = `${apiEntryUrl}/rundowns`;
|
||||
|
||||
// #region operations on project rundowns =========================
|
||||
@@ -26,8 +27,8 @@ export async function fetchCurrentRundown(): Promise<Rundown> {
|
||||
/**
|
||||
* HTTP request to switch the currently loaded rundown
|
||||
*/
|
||||
export async function loadRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
|
||||
return axios.post(`${rundownPath}/${id}/load`);
|
||||
export async function loadRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
|
||||
return axios.post(`${rundownPath}/${rundownId}/load`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,11 +38,25 @@ export async function createRundown(title: string): Promise<AxiosResponse<Projec
|
||||
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
|
||||
*/
|
||||
export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
|
||||
return axios.delete(`${rundownPath}/${id}`);
|
||||
export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
|
||||
return axios.delete(`${rundownPath}/${rundownId}`);
|
||||
}
|
||||
|
||||
// #endregion operations on project rundowns ======================
|
||||
@@ -51,7 +66,7 @@ export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRu
|
||||
* HTTP request to post new entry
|
||||
*/
|
||||
export async function postAddEntry(
|
||||
rundownId: string,
|
||||
rundownId: RundownId,
|
||||
data: TransientEventPayload,
|
||||
): Promise<AxiosResponse<OntimeEntry>> {
|
||||
return axios.post(`${rundownPath}/${rundownId}/entry`, data);
|
||||
@@ -60,7 +75,10 @@ export async function postAddEntry(
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
@@ -72,7 +90,7 @@ export type BatchEditEntry = {
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
@@ -85,56 +103,64 @@ export type ReorderEntry = {
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request for cloning an entry
|
||||
*/
|
||||
export async function postCloneEntry(rundownId: string, entryId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`);
|
||||
export async function postCloneEntry(
|
||||
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
|
||||
*/
|
||||
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 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { apiEntryUrl } from './constants';
|
||||
@@ -56,6 +56,7 @@ export const previewRundown = async (
|
||||
): Promise<{
|
||||
rundown: Rundown;
|
||||
customFields: CustomFields;
|
||||
summary: RundownSummary;
|
||||
}> => {
|
||||
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
|
||||
return response.data;
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.item {
|
||||
outline: 0;
|
||||
cursor: default;
|
||||
@@ -61,6 +60,13 @@
|
||||
border-radius: 3px;
|
||||
background-color: $gray-1000;
|
||||
}
|
||||
|
||||
&[data-type='destructive'] {
|
||||
color: $red-500;
|
||||
svg {
|
||||
color: $red-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.separator {
|
||||
|
||||
@@ -6,7 +6,7 @@ import style from './DropdownMenu.module.scss';
|
||||
|
||||
type DropdownMenuItemDivider = { type: 'divider' };
|
||||
type DropdownMenuItem = {
|
||||
type: 'item';
|
||||
type: 'item' | 'destructive';
|
||||
label: string;
|
||||
icon?: IconType;
|
||||
disabled?: boolean;
|
||||
@@ -31,7 +31,7 @@ export function DropdownMenu({ items, children, ...triggerProps }: PropsWithChil
|
||||
return <BaseMenu.Separator key={index} className={style.separator} />;
|
||||
}
|
||||
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.label}
|
||||
</BaseMenu.Item>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
.corner {
|
||||
.arrow {
|
||||
transform: rotate(45deg);
|
||||
|
||||
}
|
||||
.corner {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
@@ -21,6 +22,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.offsetCorner {
|
||||
right: 2rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
@@ -51,6 +56,6 @@
|
||||
|
||||
&.vertical {
|
||||
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 { IoArrowUp } from 'react-icons/io5';
|
||||
import { TbPictureInPictureOff } from 'react-icons/tb';
|
||||
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import style from './EditorUtils.module.scss';
|
||||
|
||||
export function Corner({ className, ...elementProps }: IconBaseProps) {
|
||||
return <IoArrowUp className={cx([style.corner, className])} {...elementProps} />;
|
||||
export function CornerExtract({ className, ...elementProps }: IconBaseProps) {
|
||||
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>) {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
color: $gray-500;
|
||||
color: $gray-600;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@ import { useLocation } from 'react-router';
|
||||
import { Dialog } from '@base-ui-components/react/dialog';
|
||||
import { useDisclosure, useFullscreen } from '@mantine/hooks';
|
||||
|
||||
import { isLocalhost } from '../../../externals';
|
||||
import { useKeepAwakeOptions } from '../../../features/keep-awake/KeepAwake';
|
||||
import { isLocalhost, supportsFullscreen } from '../../../externals';
|
||||
import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock';
|
||||
import { navigatorConstants } from '../../../viewerConfig';
|
||||
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
|
||||
import { useClientStore } from '../../stores/clientStore';
|
||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||
import IconButton from '../buttons/IconButton';
|
||||
@@ -29,6 +30,7 @@ export default memo(NavigationMenu);
|
||||
function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
||||
const id = useClientStore((store) => store.id);
|
||||
const name = useClientStore((store) => store.name);
|
||||
const isSmallScreen = useIsSmallScreen();
|
||||
|
||||
const [isRenameOpen, handlers] = useDisclosure(false);
|
||||
const { fullscreen, toggle } = useFullscreen();
|
||||
@@ -56,16 +58,18 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className={style.body}>
|
||||
<NavigationMenuItem active={fullscreen} onClick={toggle}>
|
||||
Toggle Fullscreen
|
||||
{fullscreen ? <IoContract /> : <IoExpand />}
|
||||
</NavigationMenuItem>
|
||||
{supportsFullscreen && (
|
||||
<NavigationMenuItem active={fullscreen} onClick={toggle}>
|
||||
Toggle Fullscreen
|
||||
{fullscreen ? <IoContract /> : <IoExpand />}
|
||||
</NavigationMenuItem>
|
||||
)}
|
||||
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
|
||||
Flip Screen
|
||||
<IoSwapVertical />
|
||||
{mirror && <span className={style.note}>Active</span>}
|
||||
</NavigationMenuItem>
|
||||
{window.isSecureContext && (
|
||||
{canUseWakeLock && (
|
||||
<NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}>
|
||||
Keep Awake
|
||||
<LuCoffee />
|
||||
@@ -77,11 +81,15 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
||||
<hr className={style.separator} />
|
||||
|
||||
<EditorNavigation />
|
||||
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}>
|
||||
<ClientLink
|
||||
to='cuesheet'
|
||||
current={location.pathname === '/cuesheet'}
|
||||
postAction={isSmallScreen ? onClose : undefined}
|
||||
>
|
||||
<IoLockClosedOutline />
|
||||
Cuesheet
|
||||
</ClientLink>
|
||||
<ClientLink to='op' current={location.pathname === '/op'}>
|
||||
<ClientLink to='op' current={location.pathname === '/op'} postAction={isSmallScreen ? onClose : undefined}>
|
||||
<IoLockClosedOutline />
|
||||
Operator
|
||||
</ClientLink>
|
||||
@@ -89,7 +97,12 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
||||
<hr className={style.separator} />
|
||||
|
||||
{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}
|
||||
</ClientLink>
|
||||
))}
|
||||
|
||||
@@ -11,15 +11,22 @@ import style from './ClientLink.module.scss';
|
||||
interface ClientLinkProps {
|
||||
current: boolean;
|
||||
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 navigate = useNavigate();
|
||||
|
||||
if (isElectron) {
|
||||
return (
|
||||
<NavigationMenuItem active={current} onClick={() => handleLinks(to)}>
|
||||
<NavigationMenuItem
|
||||
active={current}
|
||||
onClick={() => {
|
||||
handleLinks(to);
|
||||
postAction?.();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</NavigationMenuItem>
|
||||
@@ -27,7 +34,13 @@ export default function ClientLink({ current, to, children }: PropsWithChildren<
|
||||
}
|
||||
|
||||
return (
|
||||
<NavigationMenuItem active={current} onClick={() => navigate(`/${to}`)}>
|
||||
<NavigationMenuItem
|
||||
active={current}
|
||||
onClick={() => {
|
||||
navigate(`/${to}`);
|
||||
postAction?.();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</NavigationMenuItem>
|
||||
);
|
||||
|
||||
@@ -32,11 +32,11 @@
|
||||
margin-left: 0.25rem;
|
||||
width: 0.75em;
|
||||
height: 0.75em;
|
||||
background: var(--user-bg);
|
||||
background: var(--user-bg, $gray-900);
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: $ui-white;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useSearchParams } from 'react-router';
|
||||
import { Dialog } from '@base-ui-components/react/dialog';
|
||||
import { OntimeView } from 'ontime-types';
|
||||
|
||||
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
|
||||
import useViewSettings from '../../hooks-query/useViewSettings';
|
||||
import Button from '../buttons/Button';
|
||||
import IconButton from '../buttons/IconButton';
|
||||
@@ -27,6 +28,7 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
||||
const [_, setSearchParams] = useSearchParams();
|
||||
const { data: viewSettings } = useViewSettings();
|
||||
const { isOpen, close } = useViewParamsEditorStore();
|
||||
const isSmallScreen = useIsSmallScreen();
|
||||
|
||||
const handleClose = () => {
|
||||
close();
|
||||
@@ -42,6 +44,10 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
||||
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
|
||||
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
|
||||
setSearchParams(newSearchParams);
|
||||
|
||||
if (isSmallScreen) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -46,7 +46,7 @@ export function makeCustomFieldSelectOptions(customFields: CustomFields, filterI
|
||||
options.push({
|
||||
value: key,
|
||||
label: value.label,
|
||||
colour: value.colour || 'transparent',
|
||||
colour: value.colour,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ProjectFileListResponse } from 'ontime-types';
|
||||
import { ProjectFile, ProjectFileList, ProjectFileListResponse } from 'ontime-types';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { PROJECT_LIST } from '../api/constants';
|
||||
@@ -24,22 +24,33 @@ function useProjectList() {
|
||||
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 { files, lastLoadedProject } = response.data;
|
||||
|
||||
const reorderedProjectFiles = useMemo(() => {
|
||||
const reorderedProjectFiles: ProjectFileList = useMemo(() => {
|
||||
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];
|
||||
const current = projectFiles.splice(currentlyLoadedIndex, 1)[0];
|
||||
|
||||
return [current, ...projectFiles];
|
||||
}, [files, lastLoadedProject]);
|
||||
return sorted;
|
||||
}, [files, lastLoadedProject, sort]);
|
||||
|
||||
return { ...response, data: { reorderedProjectFiles, lastLoadedProject: response.data.lastLoadedProject } };
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ProjectRundownsList } from 'ontime-types';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
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
|
||||
@@ -30,6 +30,26 @@ export function useMutateProjectRundowns() {
|
||||
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({
|
||||
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 {
|
||||
EntryId,
|
||||
InsertOptions,
|
||||
isOntimeEvent,
|
||||
isOntimeGroup,
|
||||
MaybeString,
|
||||
@@ -173,7 +174,8 @@ export const useEntryActions = () => {
|
||||
* @private
|
||||
*/
|
||||
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 }),
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
|
||||
});
|
||||
@@ -182,14 +184,14 @@ export const useEntryActions = () => {
|
||||
* Clone an entry
|
||||
*/
|
||||
const clone = useCallback(
|
||||
async (entryId: EntryId) => {
|
||||
async (entryId: EntryId, options?: InsertOptions) => {
|
||||
try {
|
||||
const rundownId = getCurrentRundownData()?.id;
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
await cloneEntryMutation([rundownId, entryId]);
|
||||
await cloneEntryMutation([rundownId, entryId, options]);
|
||||
} catch (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,
|
||||
actualStart: state.rundown.actualStart,
|
||||
plannedEnd: state.rundown.plannedEnd,
|
||||
}));
|
||||
|
||||
export const useRundownExpectedEnd = createSelector((state: RuntimeStore) => ({
|
||||
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);
|
||||
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,
|
||||
isPast: true,
|
||||
isNextDay: false,
|
||||
totalGap: 10,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: 'group',
|
||||
@@ -156,7 +156,7 @@ describe('initRundownMetadata()', () => {
|
||||
eventIndex: 2,
|
||||
isPast: true,
|
||||
isNextDay: false,
|
||||
totalGap: 10,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: 'group',
|
||||
@@ -172,7 +172,7 @@ describe('initRundownMetadata()', () => {
|
||||
eventIndex: 3,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
totalGap: 10,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: true,
|
||||
isLoaded: true,
|
||||
groupId: 'group',
|
||||
@@ -188,7 +188,7 @@ describe('initRundownMetadata()', () => {
|
||||
eventIndex: 4,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
totalGap: 10,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: true,
|
||||
isLoaded: false,
|
||||
groupId: 'group',
|
||||
@@ -204,7 +204,7 @@ describe('initRundownMetadata()', () => {
|
||||
eventIndex: 5,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
totalGap: 17,
|
||||
totalGap: 7,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
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()', () => {
|
||||
it('should return the current time in milliseconds', () => {
|
||||
@@ -38,3 +40,18 @@ describe('formatTime()', () => {
|
||||
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 { millisToString } from 'ontime-utils';
|
||||
import { millisToString, removeLeadingZero } from 'ontime-utils';
|
||||
|
||||
import { enDash } from './styleUtils';
|
||||
|
||||
@@ -14,7 +14,7 @@ export function getOffsetText(offset: MaybeNumber): string {
|
||||
let offsetText = '';
|
||||
if (offset < 0) offsetText += '-';
|
||||
if (offset > 0) offsetText += '+';
|
||||
offsetText += millisToString(Math.abs(offset));
|
||||
offsetText += removeLeadingZero(millisToString(Math.abs(offset)));
|
||||
return offsetText;
|
||||
}
|
||||
|
||||
|
||||
@@ -151,14 +151,14 @@ function processEntry(
|
||||
|
||||
if (isPlayableEvent(entry)) {
|
||||
processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
|
||||
processedData.totalGap += entry.gap;
|
||||
|
||||
|
||||
if (!processedData.isPast && !processedData.isLoaded) {
|
||||
/**
|
||||
* isLinkToLoaded is a chain value that we maintain until we
|
||||
* a) find an unlinked event
|
||||
* b) find a countToEnd event
|
||||
*/
|
||||
*/
|
||||
processedData.totalGap += entry.gap;
|
||||
processedData.isLinkedToLoaded =
|
||||
entry.linkStart && !processedData.previousEvent?.countToEnd && processedData.isLinkedToLoaded;
|
||||
}
|
||||
|
||||
@@ -112,11 +112,12 @@ export const formatTime = (
|
||||
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
|
||||
if (duration <= 0) {
|
||||
return '0h 0m';
|
||||
return '0m';
|
||||
}
|
||||
|
||||
const hours = Math.floor(duration / MILLIS_PER_HOUR);
|
||||
const minutes = Math.floor((duration % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE);
|
||||
|
||||
let result = '';
|
||||
if (hours > 0) {
|
||||
result += `${hours}h`;
|
||||
@@ -126,11 +127,16 @@ export function formatDuration(duration: number, hideSeconds = true): string {
|
||||
}
|
||||
|
||||
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) {
|
||||
result += `${seconds}s`;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,11 @@ declare global {
|
||||
process: {
|
||||
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 websiteUrl = 'https://www.getontime.no';
|
||||
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 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 currentHostName = window.location.hostname;
|
||||
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
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
documentationUrl,
|
||||
githubSponsorUrl,
|
||||
githubUrl,
|
||||
subredditUrl,
|
||||
websiteUrl,
|
||||
} from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
@@ -38,6 +39,7 @@ export default function AboutPanel() {
|
||||
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
|
||||
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
|
||||
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
|
||||
<ExternalLink href={subredditUrl}>Subreddit</ExternalLink>
|
||||
</Panel.Section>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function AppVersion() {
|
||||
return (
|
||||
<Panel.Paragraph>
|
||||
{`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>
|
||||
);
|
||||
}
|
||||
|
||||
+16
-2
@@ -9,6 +9,8 @@ import Textarea from '../../../../../common/components/input/textarea/Textarea';
|
||||
import Select, { SelectOption } from '../../../../../common/components/select/Select';
|
||||
import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets';
|
||||
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 * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
@@ -52,6 +54,7 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
|
||||
formState: { errors, isSubmitting, isValid, isDirty },
|
||||
} = useForm<URLPreset>({
|
||||
defaultValues: urlPreset ?? defaultValues,
|
||||
mode: 'onChange',
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
@@ -110,7 +113,15 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
|
||||
<Panel.InlineElements>
|
||||
<div>
|
||||
<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 className={style.expand}>
|
||||
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
|
||||
@@ -120,7 +131,10 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
</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>
|
||||
<Panel.Description>Target</Panel.Description>
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
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 { downloadAsExcel } from '../../../../common/api/excel';
|
||||
@@ -13,17 +21,19 @@ import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import RundownRenameForm from './composite/RundownRenameForm';
|
||||
import { ManageRundownForm } from './ManageRundownForm';
|
||||
|
||||
import style from './ManagePanel.module.scss';
|
||||
|
||||
export default function ManageRundowns() {
|
||||
const { data } = useProjectRundowns();
|
||||
const { remove, load } = useMutateProjectRundowns();
|
||||
const { duplicate, remove, load, rename } = useMutateProjectRundowns();
|
||||
const [isOpenDelete, deleteHandlers] = useDisclosure();
|
||||
const [isOpenLoad, loadHandlers] = useDisclosure();
|
||||
const [isNewLoad, newHandlers] = useDisclosure();
|
||||
const [targetRundown, setTargetRundown] = useState('');
|
||||
const [renamingRundown, setRenamingRundown] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const openLoad = (id: string) => {
|
||||
@@ -38,6 +48,11 @@ export default function ManageRundowns() {
|
||||
deleteHandlers.open();
|
||||
};
|
||||
|
||||
const openRename = (id: string) => {
|
||||
setActionError(null);
|
||||
setRenamingRundown(id);
|
||||
};
|
||||
|
||||
const submitRundownLoad = async () => {
|
||||
try {
|
||||
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 () => {
|
||||
try {
|
||||
await remove(targetRundown);
|
||||
@@ -94,6 +130,22 @@ export default function ManageRundowns() {
|
||||
<tbody>
|
||||
{data?.rundowns?.map(({ id, numEntries, title }) => {
|
||||
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 (
|
||||
<tr key={id} className={cx([isLoaded && style.current])}>
|
||||
<td>{numEntries}</td>
|
||||
@@ -104,6 +156,12 @@ export default function ManageRundowns() {
|
||||
<DropdownMenu
|
||||
render={<IconButton variant='ghosted-white' />}
|
||||
items={[
|
||||
{
|
||||
type: 'item',
|
||||
icon: IoPencilOutline,
|
||||
label: 'Rename',
|
||||
onClick: () => openRename(id),
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
icon: IoDownloadOutline,
|
||||
@@ -119,6 +177,13 @@ export default function ManageRundowns() {
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
icon: IoDuplicateOutline,
|
||||
label: 'Duplicate',
|
||||
onClick: () => submitRundownDuplicate(id),
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'destructive',
|
||||
icon: IoTrash,
|
||||
label: 'Delete',
|
||||
onClick: () => openDelete(id),
|
||||
@@ -140,7 +205,7 @@ export default function ManageRundowns() {
|
||||
<Dialog
|
||||
isOpen={isOpenDelete}
|
||||
onClose={deleteHandlers.close}
|
||||
title='Load rundown'
|
||||
title='Delete rundown'
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
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 { 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 useRundown from '../../../../../common/hooks-query/useRundown';
|
||||
import { formatDuration } from '../../../../../common/utils/time';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import PreviewSpreadsheet from './preview/PreviewRundown';
|
||||
@@ -12,12 +14,20 @@ import { useSheetStore } from './useSheetStore';
|
||||
interface ImportReviewProps {
|
||||
rundown: Rundown;
|
||||
customFields: CustomFields;
|
||||
summary: RundownSummary;
|
||||
onFinished: () => void;
|
||||
onCancel: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export default function ImportReview(props: ImportReviewProps) {
|
||||
const { rundown, customFields, onFinished, onCancel } = props;
|
||||
export default function ImportReview({
|
||||
rundown,
|
||||
customFields,
|
||||
summary,
|
||||
onFinished,
|
||||
onCancel,
|
||||
onBack,
|
||||
}: ImportReviewProps) {
|
||||
const { data: currentRundown } = useRundown();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { importRundown } = useGoogleSheet();
|
||||
@@ -51,11 +61,31 @@ export default function ImportReview(props: ImportReviewProps) {
|
||||
<Button onClick={handleCancel} variant='ghosted' disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onBack} variant='subtle' disabled={loading}>
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={applyImport} variant='primary' loading={loading}>
|
||||
Apply
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</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} />
|
||||
</Panel.Section>
|
||||
);
|
||||
|
||||
+9
-4
@@ -3,7 +3,6 @@ import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
|
||||
import { getErrorMessage, ImportMap } from 'ontime-utils';
|
||||
|
||||
import {
|
||||
getWorksheetNames as getWorksheetNamesExcel,
|
||||
importRundownPreview as importRundownPreviewExcel,
|
||||
upload as uploadExcel,
|
||||
} from '../../../../../common/api/excel';
|
||||
@@ -37,8 +36,11 @@ export default function SourcesPanel() {
|
||||
const setRundown = useSheetStore((state) => state.setRundown);
|
||||
const customFields = useSheetStore((state) => state.customFields);
|
||||
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 sheetId = useSheetStore((state) => state.sheetId);
|
||||
const resetPreview = useSheetStore((state) => state.resetPreview);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -53,8 +55,7 @@ export default function SourcesPanel() {
|
||||
try {
|
||||
setHasFile('loading');
|
||||
validateExcelImport(fileToUpload);
|
||||
await uploadExcel(fileToUpload);
|
||||
const names = await getWorksheetNamesExcel();
|
||||
const names = await uploadExcel(fileToUpload);
|
||||
setWorksheets(names);
|
||||
setImportFlow('excel');
|
||||
setHasFile('done');
|
||||
@@ -77,6 +78,7 @@ export default function SourcesPanel() {
|
||||
setHasFile('none');
|
||||
setWorksheets(null);
|
||||
setCustomFields(null);
|
||||
setSummary(null);
|
||||
setError('');
|
||||
setSheetId(null);
|
||||
};
|
||||
@@ -110,6 +112,7 @@ export default function SourcesPanel() {
|
||||
const previewData = await importRundownPreviewExcel(importMap);
|
||||
setRundown(previewData.rundown);
|
||||
setCustomFields(previewData.customFields);
|
||||
setSummary(previewData.summary);
|
||||
} catch (error) {
|
||||
setError(maybeAxiosError(error));
|
||||
}
|
||||
@@ -152,7 +155,7 @@ export default function SourcesPanel() {
|
||||
const showCompleted = importFlow === 'finished';
|
||||
const showAuth = isGSheetFlow && !isAuthenticated;
|
||||
const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile === 'done');
|
||||
const showReview = rundown !== null && customFields !== null;
|
||||
const showReview = rundown !== null && customFields !== null && summary !== null;
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
@@ -219,8 +222,10 @@ export default function SourcesPanel() {
|
||||
<ImportReview
|
||||
rundown={rundown}
|
||||
customFields={customFields}
|
||||
summary={summary}
|
||||
onFinished={handleFinished}
|
||||
onCancel={cancelImportMap}
|
||||
onBack={resetPreview}
|
||||
/>
|
||||
)}
|
||||
</Panel.Card>
|
||||
|
||||
+5
@@ -5,6 +5,7 @@ import { checkRegex, ImportMap } from 'ontime-utils';
|
||||
|
||||
import Button from '../../../../../../common/components/buttons/Button';
|
||||
import IconButton from '../../../../../../common/components/buttons/IconButton';
|
||||
import Info from '../../../../../../common/components/info/Info';
|
||||
import Input from '../../../../../../common/components/input/input/Input';
|
||||
import Select from '../../../../../../common/components/select/Select';
|
||||
import Tooltip from '../../../../../../common/components/tooltip/Tooltip';
|
||||
@@ -136,6 +137,10 @@ export default function ImportMapForm({
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</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>
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
+2
-6
@@ -18,9 +18,7 @@ function booleanToText(value?: boolean) {
|
||||
return value ? 'Yes' : undefined;
|
||||
}
|
||||
|
||||
export default function PreviewRundown(props: PreviewRundownProps) {
|
||||
const { rundown, customFields } = props;
|
||||
|
||||
export default function PreviewRundown({ rundown, customFields }: PreviewRundownProps) {
|
||||
// we only count Ontime Events which are 1 based in client
|
||||
let eventIndex = 0;
|
||||
|
||||
@@ -59,9 +57,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
||||
const colour = entry.colour ? getAccessibleColour(entry.colour) : {};
|
||||
return (
|
||||
<tr key={entry.id}>
|
||||
<td className={style.center}>
|
||||
<Tag>-</Tag>
|
||||
</td>
|
||||
<td /> {/** Index */}
|
||||
<td className={style.center}>
|
||||
<Tag>{entry.type}</Tag>
|
||||
</td>
|
||||
|
||||
+2
@@ -21,6 +21,7 @@ export default function useGoogleSheet() {
|
||||
const patchStepData = useSheetStore((state) => state.patchStepData);
|
||||
const setRundown = useSheetStore((state) => state.setRundown);
|
||||
const setCustomFields = useSheetStore((state) => state.setCustomFields);
|
||||
const setSummary = useSheetStore((state) => state.setSummary);
|
||||
|
||||
/** whether the current session has been authenticated */
|
||||
const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus; sheetId: string } | void> => {
|
||||
@@ -58,6 +59,7 @@ export default function useGoogleSheet() {
|
||||
const data = await previewRundown(sheetId, fileOptions);
|
||||
setRundown(data.rundown);
|
||||
setCustomFields(data.customFields);
|
||||
setSummary(data.summary);
|
||||
} catch (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 { create } from 'zustand';
|
||||
|
||||
@@ -17,10 +17,10 @@ type SheetStore = {
|
||||
// we get this from a preview response
|
||||
rundown: Rundown | null;
|
||||
setRundown: (rundown: Rundown | null) => void;
|
||||
|
||||
// we get this from a preview response
|
||||
customFields: CustomFields | null;
|
||||
setCustomFields: (customFields: CustomFields | null) => void;
|
||||
summary: RundownSummary | null;
|
||||
setSummary: (metadata: RundownSummary | null) => void;
|
||||
|
||||
spreadsheetImportMap: ImportMap;
|
||||
patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => void;
|
||||
@@ -43,6 +43,7 @@ const initialState = {
|
||||
authenticationStatus: 'not_authenticated' as AuthenticationStatus,
|
||||
rundown: null,
|
||||
customFields: null,
|
||||
summary: null,
|
||||
spreadsheetImportMap: defaultImportMap,
|
||||
};
|
||||
|
||||
@@ -64,6 +65,8 @@ export const useSheetStore = create<SheetStore>((set, get) => ({
|
||||
|
||||
setCustomFields: (customFields: CustomFields | null) => set({ customFields }),
|
||||
|
||||
setSummary: (summary: RundownSummary | null) => set({ summary }),
|
||||
|
||||
patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => {
|
||||
const currentImportMap = get().spreadsheetImportMap;
|
||||
if (currentImportMap[field] !== value) {
|
||||
@@ -72,5 +75,5 @@ export const useSheetStore = create<SheetStore>((set, get) => ({
|
||||
},
|
||||
|
||||
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 { IoArrowDown, IoArrowUp } from 'react-icons/io5';
|
||||
|
||||
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 ProjectListItem, { EditMode } from './ProjectListItem';
|
||||
|
||||
import style from './ProjectPanel.module.scss';
|
||||
|
||||
export default function ProjectList() {
|
||||
const { data, refetch, status } = useOrderedProjectList();
|
||||
type SortParameter = 'alphabetical' | 'modified';
|
||||
|
||||
export default function ProjectList() {
|
||||
const [editingMode, setEditingMode] = useState<EditMode | 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) => {
|
||||
setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode));
|
||||
@@ -28,6 +32,13 @@ export default function ProjectList() {
|
||||
await refetch();
|
||||
};
|
||||
|
||||
const handleSort = (sortParameter: SortParameter) => {
|
||||
setSortMode((current) => {
|
||||
const isAscending = current === `${sortParameter}-asc`;
|
||||
return `${sortParameter}-${isAscending ? 'desc' : 'asc'}` as ProjectSortMode;
|
||||
});
|
||||
};
|
||||
|
||||
if (status === 'pending') {
|
||||
return (
|
||||
<div className={style.empty}>
|
||||
@@ -48,8 +59,18 @@ export default function ProjectList() {
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={style.containCell}>File Name</th>
|
||||
<th>Last Used</th>
|
||||
<th className={style.containCell} onClick={() => handleSort('alphabetical')}>
|
||||
<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 />
|
||||
</tr>
|
||||
</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 {
|
||||
max-width: 400px;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.sortableHeader {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Settings } from 'ontime-types';
|
||||
import { postSettings } from '../../../../common/api/settings';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import Select from '../../../../common/components/select/Select';
|
||||
import useSettings from '../../../../common/hooks-query/useSettings';
|
||||
@@ -98,6 +99,7 @@ export default function GeneralSettings() {
|
||||
{submitError && <Panel.Error>{submitError}</Panel.Error>}
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Info>Changes to the time format and views language do not affect the editor view</Info>
|
||||
<Panel.Loader isLoading={isLoading} />
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
@@ -146,7 +148,7 @@ export default function GeneralSettings() {
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
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}
|
||||
/>
|
||||
<Select
|
||||
|
||||
@@ -70,7 +70,7 @@ export default function QuickStart({ isOpen, onClose }: QuickStartProps) {
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
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}
|
||||
/>
|
||||
<Select
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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 ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu';
|
||||
import ProtectRoute from '../../../common/components/protect-route/ProtectRoute';
|
||||
import { handleLinks } from '../../../common/utils/linkUtils';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { getIsNavigationLocked } from '../../../externals';
|
||||
|
||||
import MessageControl from './MessageControl';
|
||||
|
||||
@@ -19,8 +20,8 @@ function MessageControlExport() {
|
||||
return (
|
||||
<ProtectRoute permission='editor'>
|
||||
<div className={style.messages} data-testid='panel-messages-control'>
|
||||
{!isExtracted && <Corner onClick={(event) => handleLinks('messagecontrol', event)} />}
|
||||
{isExtracted && <ViewNavigationMenu suppressSettings />}
|
||||
{!isExtracted && <CornerExtract onClick={(event) => handleLinks('messagecontrol', event)} />}
|
||||
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
|
||||
|
||||
<div className={classes}>
|
||||
<ErrorBoundary>
|
||||
|
||||
@@ -2,12 +2,13 @@ import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
|
||||
import { LuArrowDownToLine } from 'react-icons/lu';
|
||||
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 { useMessagePreview } from '../../../common/hooks/useSocket';
|
||||
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
||||
import { handleLinks } from '../../../common/utils/linkUtils';
|
||||
import { cx, timerPlaceholder } from '../../../common/utils/styleUtils';
|
||||
import PipRoot from '../../../views/editor/pip-timer/PipRoot';
|
||||
|
||||
import style from './MessageControl.module.scss';
|
||||
|
||||
@@ -52,7 +53,7 @@ export default function TimerPreview() {
|
||||
|
||||
return (
|
||||
<div className={style.preview}>
|
||||
<Corner onClick={(event) => handleLinks('timer', event)} />
|
||||
<CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
|
||||
<div className={contentClasses}>
|
||||
<div
|
||||
className={style.mainContent}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
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 ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu';
|
||||
import ProtectRoute from '../../../common/components/protect-route/ProtectRoute';
|
||||
import { handleLinks } from '../../../common/utils/linkUtils';
|
||||
import { getIsNavigationLocked } from '../../../externals';
|
||||
|
||||
import PlaybackControl from './PlaybackControl';
|
||||
|
||||
@@ -17,8 +18,8 @@ function TimerControlExport() {
|
||||
return (
|
||||
<ProtectRoute permission='editor'>
|
||||
<div className={style.playback} data-testid='panel-timer-control'>
|
||||
{!isExtracted && <Corner onClick={(event) => handleLinks('timercontrol', event)} />}
|
||||
{isExtracted && <ViewNavigationMenu suppressSettings />}
|
||||
{!isExtracted && <CornerExtract onClick={(event) => handleLinks('timercontrol', event)} />}
|
||||
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
|
||||
|
||||
<div className={style.content}>
|
||||
<ErrorBoundary>
|
||||
|
||||
@@ -58,7 +58,6 @@
|
||||
height: 1.5rem;
|
||||
display: flex;
|
||||
gap: $section-spacing;
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
@@ -70,6 +69,7 @@
|
||||
.time {
|
||||
color: $section-white;
|
||||
font-size: $text-body-size;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.rolltag {
|
||||
|
||||
@@ -1,84 +1,10 @@
|
||||
import { use, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
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);
|
||||
}
|
||||
import { useWakelock } from './useWakeLock';
|
||||
|
||||
/**
|
||||
* Hook exposes the keep awake options
|
||||
* Composes the wakelock hook to contain re-renders
|
||||
*/
|
||||
export function useKeepAwakeOptions() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const maybePreset = use(PresetContext);
|
||||
export default function KeepAwake() {
|
||||
useWakelock();
|
||||
|
||||
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]);
|
||||
|
||||
return { keepAwake, toggleKeepAwake };
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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 { throttle } from '../../common/utils/throttle';
|
||||
import { getDefaultFormat } from '../../common/utils/time';
|
||||
import { isTouchDevice } from '../../externals';
|
||||
import Loader from '../../views/common/loader/Loader';
|
||||
|
||||
import EditModal from './edit-modal/EditModal';
|
||||
@@ -122,7 +123,9 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
||||
<StatusBar />
|
||||
|
||||
{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}>
|
||||
@@ -168,6 +171,14 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
||||
}
|
||||
|
||||
if (isOntimeGroup(entry)) {
|
||||
const { isPast } = rundownMetadata[entry.id];
|
||||
|
||||
const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId].groupId === entry.id : false;
|
||||
|
||||
if (hidePast && isPast && !isCurrentParent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={entry.id}>
|
||||
<OperatorGroup key={entry.id} title={entry.title} />
|
||||
|
||||
@@ -30,12 +30,12 @@
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
max-height: min(80vh, 600px);
|
||||
max-height: min(60vh, 600px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.label {
|
||||
background-color: var(--user-bg);
|
||||
background-color: var(--user-bg, $gray-900);
|
||||
width: fit-content;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
column-gap: 0.5rem;
|
||||
row-gap: 0.5rem;
|
||||
grid-template-rows: auto auto auto;
|
||||
grid-template-columns: 1.25rem 1fr auto;
|
||||
grid-template-columns: 1.5rem 1fr auto;
|
||||
grid-template-areas:
|
||||
'binder main schedule'
|
||||
'binder secondary running'
|
||||
@@ -42,6 +42,7 @@
|
||||
place-content: center;
|
||||
position: relative;
|
||||
background-color: $gray-1050; // to override inline
|
||||
font-weight: 600;
|
||||
|
||||
.cue {
|
||||
white-space: nowrap;
|
||||
@@ -71,47 +72,41 @@
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.plannedStart, .timeUntil {
|
||||
line-height: 1em;
|
||||
background-color: $gray-1000;
|
||||
.plannedStart,
|
||||
.timeUntil,
|
||||
.runningTime {
|
||||
border-radius: $component-border-radius-md;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 1px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.plannedStart {
|
||||
font-size: 1.5rem;
|
||||
margin-right: 0.5rem;
|
||||
display: inline;
|
||||
background-color: $white-20;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.timeUntil {
|
||||
font-size: calc(1rem - 2px);
|
||||
line-height: 1em;
|
||||
grid-area: schedule;
|
||||
font-size: calc(1rem - 2px);
|
||||
justify-self: end;
|
||||
background-color: $white-20;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.runningTime {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1em;
|
||||
grid-area: running;
|
||||
font-size: 1.25rem;
|
||||
justify-self: end;
|
||||
align-self: flex-start;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
background-color: $white-7;
|
||||
}
|
||||
|
||||
.fields {
|
||||
grid-area: fields;
|
||||
font-size: var(--operator-customfield-font-size-override, 1.25rem);
|
||||
font-weight: 400;
|
||||
color: $ui-black;
|
||||
margin: 0.25rem 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5em;
|
||||
row-gap: 0.25em;
|
||||
line-height: 1.25em;
|
||||
margin-top: 0;
|
||||
border-top: 0;
|
||||
|
||||
.field {
|
||||
font-weight: 600;
|
||||
@@ -133,8 +128,18 @@
|
||||
// allow multi-line text but trim before
|
||||
white-space: pre-line;
|
||||
}
|
||||
}
|
||||
|
||||
.fields::after {
|
||||
content: '\200b';
|
||||
&.fieldsWithContent {
|
||||
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 { useLongPress } from '@mantine/hooks';
|
||||
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, millisToString } from 'ontime-utils';
|
||||
import { CSSProperties, memo, RefObject, SyntheticEvent } from 'react';
|
||||
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||
|
||||
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
|
||||
import { useLongPress } from '../../../common/hooks/useLongPress';
|
||||
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 SuperscriptPeriod from '../../viewers/common/superscript-time/SuperscriptPeriod';
|
||||
import type { EditEvent, Subscribed } from '../operator.types';
|
||||
|
||||
import style from './OperatorEvent.module.scss';
|
||||
@@ -54,8 +55,10 @@ function OperatorEvent({
|
||||
* gather behaviour for long press and context menu
|
||||
*/
|
||||
const handleLongPress = (event?: SyntheticEvent) => {
|
||||
// we dont have an event out of useLongPress
|
||||
event?.preventDefault();
|
||||
// prevent default if the event is cancelable to avoid browser intervention warnings
|
||||
if (event && event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (subscribed) {
|
||||
onLongPress({ id, cue, subscriptions: subscribed });
|
||||
}
|
||||
@@ -64,22 +67,33 @@ function OperatorEvent({
|
||||
const mouseHandlers = useLongPress(handleLongPress);
|
||||
const cueColours = colour && getAccessibleColour(colour);
|
||||
|
||||
const operatorClasses = cx([
|
||||
style.event,
|
||||
isSelected && style.running,
|
||||
isPast && style.past,
|
||||
]);
|
||||
const operatorClasses = cx([style.event, isSelected && style.running, isPast && style.past]);
|
||||
|
||||
const hasFields = subscribed.some((field) => field.value);
|
||||
const columnCount = subscribed.length ? Math.min(subscribed.length, 4) : 0;
|
||||
const fieldGridStyle =
|
||||
columnCount > 0
|
||||
? ({
|
||||
gridTemplateColumns: `repeat(${columnCount}, minmax(12rem, 1fr))`,
|
||||
} satisfies CSSProperties)
|
||||
: undefined;
|
||||
|
||||
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 }}>
|
||||
<span className={style.cue}>{cue}</span>
|
||||
</div>
|
||||
|
||||
<span className={style.mainField}>
|
||||
{showStart && <span className={style.plannedStart}>{millisToString(timeStart)}</span>}
|
||||
{showStart && <SuperscriptPeriod className={style.plannedStart} time={formatTime(timeStart)} />}
|
||||
{main}
|
||||
</span>
|
||||
</span>
|
||||
<span className={style.secondaryField}>{secondary}</span>
|
||||
<OperatorEventSchedule
|
||||
timeStart={timeStart}
|
||||
@@ -95,22 +109,25 @@ function OperatorEvent({
|
||||
<RunningTime className={cx([isSelected && style.muted])} value={duration} hideLeadingZero />
|
||||
</span>
|
||||
|
||||
<div className={style.fields}>
|
||||
{subscribed
|
||||
.filter((field) => field.value)
|
||||
.map((field) => {
|
||||
const fieldClasses = cx([style.field, !field.colour ? style.noColour : null]);
|
||||
return (
|
||||
<div key={field.id}>
|
||||
<span className={fieldClasses} style={{ backgroundColor: field.colour }}>
|
||||
{field.label}
|
||||
</span>
|
||||
<span className={style.value} style={{ color: field.colour }}>
|
||||
{field.value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className={cx([style.fields, hasFields && style.fieldsWithContent])} style={fieldGridStyle}>
|
||||
{subscribed.map((field) => {
|
||||
if (!field.value) {
|
||||
return <div key={field.id} />;
|
||||
}
|
||||
return (
|
||||
<div key={field.id}>
|
||||
<span
|
||||
className={cx([style.field, !field.colour && style.noColour])}
|
||||
style={{ backgroundColor: field.colour }}
|
||||
>
|
||||
{field.label}
|
||||
</span>
|
||||
<span className={style.value} style={{ color: field.colour }}>
|
||||
{field.value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -167,5 +184,9 @@ function TimeUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
|
||||
const isDue = timeUntil < MILLIS_PER_SECOND;
|
||||
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;
|
||||
--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 (
|
||||
<div className={style.timers}>
|
||||
<TimerOverview className={style.runningTimer} />
|
||||
<ClockOverview className={style.timeNow} />
|
||||
<ClockOverview className={style.timeNow} shouldFormat />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,11 +27,11 @@ function CuesheetDesktop({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<OverviewWrapper navElements={children}>
|
||||
<TitleOverview />
|
||||
<StartTimes />
|
||||
<StartTimes shouldFormat />
|
||||
<TimerOverview />
|
||||
<OffsetOverview />
|
||||
<MetadataTimes />
|
||||
<ClockOverview />
|
||||
<ClockOverview shouldFormat />
|
||||
</OverviewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,4 +34,6 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 3rem 10rem 8rem;
|
||||
grid-template-columns: 3rem 9rem 9rem;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -29,7 +29,8 @@
|
||||
|
||||
.icon {
|
||||
font-size: 1rem;
|
||||
color: $label-gray;
|
||||
color: $secondary-text-gray;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
@@ -40,18 +41,24 @@
|
||||
|
||||
.time {
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: 0.5px;
|
||||
letter-spacing: 0.25px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.daySpan {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 0.25rem;
|
||||
|
||||
&::after {
|
||||
content: "+"attr(data-day-offset);
|
||||
vertical-align: super;
|
||||
font-size: 0.6em;
|
||||
letter-spacing: 0;
|
||||
color: $info-blue;
|
||||
content: "+" attr(data-day-offset);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
color: $ui-white;
|
||||
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;
|
||||
letter-spacing: 0;
|
||||
color: $playback-over;
|
||||
border-left: 3px solid $playback-over;
|
||||
padding-left: 0.375rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
useNextFlag,
|
||||
useOffsetOverview,
|
||||
useProgressOverview,
|
||||
useRundownExpectedEnd,
|
||||
useStartTimesOverview,
|
||||
useTimer,
|
||||
} from '../../../common/hooks/useSocket';
|
||||
@@ -27,69 +28,136 @@ import { useEntry } from '../../../common/hooks-query/useRundown';
|
||||
import { getOffsetState, getOffsetText } from '../../../common/utils/offset';
|
||||
import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
|
||||
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';
|
||||
|
||||
export function StartTimes() {
|
||||
const { plannedEnd, plannedStart, actualStart, expectedEnd } = useStartTimesOverview();
|
||||
interface OverviewTimeElementsProps {
|
||||
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 [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 (
|
||||
<div className={style.column}>
|
||||
<div className={style.row}>
|
||||
<span className={style.label}>Start</span>
|
||||
<div className={style.labelledElement}>
|
||||
<Tooltip text='Planned start time' render={<TbCalendarPin className={style.icon} />} />
|
||||
<span className={cx([style.time, plannedStart === null && style.muted])}>{plannedStartText}</span>
|
||||
</div>
|
||||
<div className={style.labelledElement} data-testid='actual-start-time'>
|
||||
<Tooltip text='Actual start time' render={<TbCalendarClock className={style.icon} />} />
|
||||
<span className={cx([style.time, actualStart === null && style.muted])}>{formattedTime(actualStart)}</span>
|
||||
</div>
|
||||
<Tooltip
|
||||
text='Planned start time'
|
||||
render={
|
||||
<div className={style.labelledElement}>
|
||||
<TbCalendarPin className={style.icon} />
|
||||
<SuperscriptPeriod
|
||||
className={cx([style.time, plannedStart === null && style.muted])}
|
||||
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 className={style.row}>
|
||||
<span className={style.label}>End</span>
|
||||
<div className={style.labelledElement}>
|
||||
<Tooltip text='Planned end time' render={<TbCalendarPin className={style.icon} />} />
|
||||
{maybePlannedDaySpan > 0 ? (
|
||||
<Tooltip
|
||||
text={`Rundown spans over ${maybePlannedDaySpan + 1} days`}
|
||||
render={<span className={cx([style.time, style.daySpan])} data-day-offset={maybePlannedDaySpan} />}
|
||||
>
|
||||
{plannedEndText}
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className={cx([style.time, plannedEnd === null && style.muted])}>{plannedEndText}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={style.labelledElement}>
|
||||
<Tooltip text='Expected end time' render={<TbCalendarStar className={style.icon} />} />
|
||||
{maybeExpectedEnd !== null && maybeExpectedDaySpan > 0 ? (
|
||||
<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>
|
||||
<Tooltip
|
||||
text={plannedEndTooltip}
|
||||
render={
|
||||
<div className={style.labelledElement}>
|
||||
<TbCalendarPin className={style.icon} />
|
||||
<SuperscriptPeriod
|
||||
className={cx([style.time, plannedEnd === null && style.muted])}
|
||||
time={plannedEndText}
|
||||
/>
|
||||
{multipleDays && (
|
||||
<span className={cx([style.time, style.daySpan])} data-day-offset={maybePlannedDaySpan} />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<RundownExpectedEnd shouldFormat={shouldFormat} />
|
||||
</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() {
|
||||
return (
|
||||
<div className={style.column}>
|
||||
@@ -229,11 +297,17 @@ export function OffsetOverview() {
|
||||
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 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 }) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.label {
|
||||
color: $label-gray;
|
||||
font-size: calc(1rem - 2px);
|
||||
font-size: calc(1rem - 3px);
|
||||
}
|
||||
|
||||
.clock {
|
||||
@@ -29,14 +29,21 @@
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.over,
|
||||
.under {
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
&[data-state='waiting'] {
|
||||
.label {
|
||||
color: $ontime-roll;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-state='over'] {
|
||||
.label .over {
|
||||
color: $playback-over;
|
||||
font-weight: 600;
|
||||
}
|
||||
.clock {
|
||||
color: $playback-over;
|
||||
@@ -46,6 +53,7 @@
|
||||
&[data-state='under'] {
|
||||
.label .under {
|
||||
color: $playback-under;
|
||||
font-weight: 600;
|
||||
}
|
||||
.clock {
|
||||
color: $playback-under;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
|
||||
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 {
|
||||
state: 'over' | 'under' | 'muted' | null;
|
||||
value: string;
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
.title {
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: $ui-white;
|
||||
line-height: 1;
|
||||
@include ellipsis-overflow;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 1rem;
|
||||
color: $label-gray;
|
||||
color: $secondary-text-gray;
|
||||
font-weight: 400;
|
||||
@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 {
|
||||
closestCenter,
|
||||
@@ -36,7 +36,6 @@ import { useEntryActions } from '../../common/hooks/useEntryAction';
|
||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||
import { useRundownEditor } from '../../common/hooks/useSocket';
|
||||
import { useEntryCopy } from '../../common/stores/entryCopyStore';
|
||||
import { cloneEvent } from '../../common/utils/clone';
|
||||
import { lastMetadataKey, RundownMetadataObject } from '../../common/utils/rundownMetadata';
|
||||
import { AppMode, sessionKeys } from '../../ontimeConfig';
|
||||
|
||||
@@ -58,6 +57,8 @@ interface RundownProps {
|
||||
}
|
||||
|
||||
export default function Rundown({ data, rundownMetadata }: RundownProps) {
|
||||
'use memo';
|
||||
|
||||
const { order, entries, id } = data;
|
||||
// we create a copy of the rundown with a data structured aligned with what dnd-kit needs
|
||||
const featureData = useRundownEditor();
|
||||
@@ -68,17 +69,20 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
|
||||
key: `rundown.${id}-editor-collapsed-groups`,
|
||||
defaultValue: [],
|
||||
});
|
||||
const collapsedGroupSet = useMemo(() => new Set(collapsedGroups), [collapsedGroups]);
|
||||
|
||||
const { addEntry, deleteEntry, move, reorderEntry } = useEntryActions();
|
||||
|
||||
const { entryCopyId, setEntryCopyId } = useEntryCopy();
|
||||
const { addEntry, clone, deleteEntry, move, reorderEntry } = useEntryActions();
|
||||
const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId);
|
||||
|
||||
// cursor
|
||||
const [editorMode] = useSessionStorage<AppMode>({
|
||||
key: sessionKeys.editorMode,
|
||||
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 scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -105,20 +109,30 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
|
||||
);
|
||||
|
||||
const insertCopyAtId = useCallback(
|
||||
(atId: string | null, copyId: string | null, above = false) => {
|
||||
const adjustedCursor = above ? getPreviousNormal(entries, order, atId ?? '').entry?.id ?? null : atId;
|
||||
if (copyId === null) {
|
||||
(atId: string | null, above = false) => {
|
||||
// lazily get the value from the store
|
||||
const { entryCopyId } = useEntryCopy.getState();
|
||||
if (entryCopyId === null || !entries[entryCopyId]) {
|
||||
// we cant clone without selection
|
||||
return;
|
||||
}
|
||||
const cloneEntry = entries[copyId];
|
||||
if (cloneEntry?.type === SupportedEntry.Event) {
|
||||
//if we don't have a cursor add the new event on top
|
||||
const newEvent = cloneEvent(cloneEntry);
|
||||
addEntry(newEvent, { after: adjustedCursor ?? undefined });
|
||||
|
||||
let normalisedAtId = atId;
|
||||
|
||||
const elementToCopy = entries[entryCopyId];
|
||||
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(
|
||||
(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 + V', () => insertCopyAtId(cursor, entryCopyId)],
|
||||
[
|
||||
'mod + shift + V',
|
||||
() => insertCopyAtId(cursor, entryCopyId, true),
|
||||
{ preventDefault: true, usePhysicalKeys: true },
|
||||
],
|
||||
['mod + V', () => insertCopyAtId(cursor)],
|
||||
['mod + shift + V', () => insertCopyAtId(cursor, true), { 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
|
||||
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
|
||||
setSortableData((currentEntries) => {
|
||||
return reorderArray(currentEntries, fromIndex, toIndex);
|
||||
@@ -438,9 +448,12 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
|
||||
return <RundownEmpty handleAddNew={(type: SupportedEntry) => addEntry({ type })} />;
|
||||
}
|
||||
|
||||
// 1. gather presentation options
|
||||
// gather presentation options
|
||||
const isEditMode = editorMode === AppMode.Edit;
|
||||
|
||||
// gather rundown wide data
|
||||
const lastEntryId = order.at(-1);
|
||||
|
||||
return (
|
||||
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
|
||||
<DndContext
|
||||
@@ -509,7 +522,7 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
|
||||
const groupColour = entryMetadata.groupColour === '' ? '#9d9d9d' : entryMetadata.groupColour;
|
||||
|
||||
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
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
import {
|
||||
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 { isOntimeDelay, isOntimeEvent, isOntimeMilestone, OntimeEntry, Playback, SupportedEntry } from 'ontime-types';
|
||||
|
||||
import RundownDelay from './rundown-delay/RundownDelay';
|
||||
import RundownEvent from './rundown-event/RundownEvent';
|
||||
@@ -44,13 +32,6 @@ export default function RundownEntry({
|
||||
totalGap,
|
||||
isLinkedToLoaded,
|
||||
}: RundownEntryProps) {
|
||||
const { addEntry } = useEntryActions();
|
||||
|
||||
const createCloneEvent = useMemoisedFn(() => {
|
||||
const newEvent = cloneEvent(data as OntimeEvent);
|
||||
addEntry(newEvent, { after: data.id });
|
||||
});
|
||||
|
||||
if (isOntimeEvent(data)) {
|
||||
return (
|
||||
<RundownEvent
|
||||
@@ -83,7 +64,6 @@ export default function RundownEntry({
|
||||
dayOffset={data.dayOffset}
|
||||
totalGap={totalGap}
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
createCloneEvent={createCloneEvent}
|
||||
hasTriggers={data.triggers.length > 0}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { memo } from 'react';
|
||||
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 ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
|
||||
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
|
||||
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
|
||||
import { handleLinks } from '../../common/utils/linkUtils';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
import { getIsNavigationLocked } from '../../externals';
|
||||
import { AppMode, sessionKeys } from '../../ontimeConfig';
|
||||
|
||||
import RundownEntryEditor from './entry-editor/RundownEntryEditor';
|
||||
@@ -55,11 +56,11 @@ function RundownExport() {
|
||||
<ProtectRoute permission='editor'>
|
||||
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
|
||||
<FinderPlacement />
|
||||
{isExtracted && <ViewNavigationMenu suppressSettings />}
|
||||
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
|
||||
<div className={style.rundown}>
|
||||
<div className={style.list}>
|
||||
<ErrorBoundary>
|
||||
{!isExtracted && <Corner onClick={(event) => handleLinks('rundown', event)} />}
|
||||
{!isExtracted && <CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
|
||||
<RundownContextMenu>
|
||||
<RundownWrapper />
|
||||
</RundownContextMenu>
|
||||
|
||||
@@ -59,9 +59,6 @@ export default function GroupEditor({ group }: GroupEditorProps) {
|
||||
<Editor.Title>Group schedule</Editor.Title>
|
||||
<div className={style.inline}>
|
||||
<div>
|
||||
{
|
||||
// TODO: format with user time settings
|
||||
}
|
||||
<Editor.Label>First event start</Editor.Label>
|
||||
<TextLikeInput className={style.textLikeInput} disabled>
|
||||
{millisToString(group.timeStart, { fallback: timerPlaceholder })}
|
||||
|
||||
@@ -134,6 +134,13 @@ $skip-opacity: 0.2;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.eventTimers.editMode:hover {
|
||||
[class*="hoverLabel"] {
|
||||
opacity: 1;
|
||||
transition: opacity 0.15s 0.5s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.chipSection {
|
||||
grid-area: chip;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,6 @@ interface RundownEventProps {
|
||||
dayOffset: number;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
createCloneEvent: () => void;
|
||||
hasTriggers: boolean;
|
||||
}
|
||||
|
||||
@@ -91,10 +90,9 @@ export default function RundownEvent({
|
||||
totalGap,
|
||||
isLinkedToLoaded,
|
||||
hasTriggers,
|
||||
createCloneEvent,
|
||||
}: RundownEventProps) {
|
||||
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 handleRef = useRef<null | HTMLSpanElement>(null);
|
||||
@@ -172,7 +170,7 @@ export default function RundownEvent({
|
||||
type: 'item',
|
||||
label: 'Clone',
|
||||
icon: IoDuplicateOutline,
|
||||
onClick: createCloneEvent,
|
||||
onClick: () => clone(eventId, { after: eventId }),
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
|
||||
@@ -10,10 +10,12 @@ import {
|
||||
IoTime,
|
||||
} from 'react-icons/io5';
|
||||
import { LuArrowDownToLine } from 'react-icons/lu';
|
||||
import { useSessionStorage } from '@mantine/hooks';
|
||||
import { EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||
|
||||
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { AppMode, sessionKeys } from '../../../ontimeConfig';
|
||||
import TitleEditor from '../common/TitleEditor';
|
||||
import TimeInputFlow from '../time-input-flow/TimeInputFlow';
|
||||
|
||||
@@ -76,6 +78,11 @@ function RundownEventInner({
|
||||
}: RundownEventInnerProps) {
|
||||
const [renderInner, setRenderInner] = useState(false);
|
||||
|
||||
const [editorMode] = useSessionStorage({
|
||||
key: sessionKeys.editorMode,
|
||||
defaultValue: AppMode.Edit,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setRenderInner(true);
|
||||
}, []);
|
||||
@@ -92,7 +99,7 @@ function RundownEventInner({
|
||||
|
||||
return !renderInner ? null : (
|
||||
<>
|
||||
<div className={style.eventTimers}>
|
||||
<div className={cx([style.eventTimers, editorMode === AppMode.Edit && style.editMode])}>
|
||||
<TimeInputFlow
|
||||
eventId={eventId}
|
||||
timeStart={timeStart}
|
||||
|
||||
@@ -10,14 +10,14 @@ import {
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
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 { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||
import { getOffsetState } from '../../../common/utils/offset';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { formatDuration, formatTime } from '../../../common/utils/time';
|
||||
import { cx, getAccessibleColour, timerPlaceholder } from '../../../common/utils/styleUtils';
|
||||
import { formatDuration } from '../../../common/utils/time';
|
||||
import TitleEditor from '../common/TitleEditor';
|
||||
import { canDrop } from '../rundown.utils';
|
||||
import { useEventSelection } from '../useEventSelection';
|
||||
@@ -149,11 +149,11 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
||||
<div className={style.metaRow}>
|
||||
<div className={style.metaEntry}>
|
||||
<div>Start</div>
|
||||
<div>{formatTime(data.timeStart)}</div>
|
||||
<div>{millisToString(data.timeStart, { fallback: timerPlaceholder })}</div>
|
||||
</div>
|
||||
<div className={style.metaEntry}>
|
||||
<div>End</div>
|
||||
<div>{formatTime(data.timeEnd)}</div>
|
||||
<div>{millisToString(data.timeEnd, { fallback: timerPlaceholder })}</div>
|
||||
</div>
|
||||
<div className={style.metaEntry}>
|
||||
<div>Duration</div>
|
||||
|
||||
@@ -15,3 +15,26 @@
|
||||
.active {
|
||||
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 (
|
||||
<>
|
||||
<div>
|
||||
<div className={style.inputWrapper}>
|
||||
{showLabels && <Editor.Label className={style.sectionTitle}>Start time</Editor.Label>}
|
||||
<Editor.Label className={style.hoverLabel}>Start</Editor.Label>
|
||||
<TimeInputGroup hasDelay={hasDelay}>
|
||||
<TimeInput
|
||||
name='timeStart'
|
||||
@@ -88,8 +89,9 @@ function TimeInputFlow({
|
||||
</TimeInputGroup>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={style.inputWrapper}>
|
||||
{showLabels && <Editor.Label>End time</Editor.Label>}
|
||||
<Editor.Label className={style.hoverLabel}>End</Editor.Label>
|
||||
<TimeInputGroup hasDelay={hasDelay}>
|
||||
<TimeInput
|
||||
name='timeEnd'
|
||||
@@ -110,8 +112,9 @@ function TimeInputFlow({
|
||||
</TimeInputGroup>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={style.inputWrapper}>
|
||||
{showLabels && <Editor.Label>Duration</Editor.Label>}
|
||||
<Editor.Label className={style.hoverLabel}>Duration</Editor.Label>
|
||||
<TimeInputGroup hasDelay={hasDelay}>
|
||||
<TimeInput
|
||||
name='duration'
|
||||
|
||||
@@ -15,7 +15,6 @@ import Switch from '../../common/components/switch/Switch';
|
||||
import { useUpdateUrlPreset } from '../../common/hooks-query/useUrlPresets';
|
||||
import copyToClipboard from '../../common/utils/copyToClipboard';
|
||||
import { preventEscape } from '../../common/utils/keyEvent';
|
||||
import { linkToOtherHost } from '../../common/utils/linkUtils';
|
||||
import { isUrlSafe } from '../../common/utils/regex';
|
||||
import { isOntimeCloud, serverURL } from '../../externals';
|
||||
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({
|
||||
baseUrl: linkToOtherHost(options.baseUrl),
|
||||
baseUrl: options.baseUrl,
|
||||
path,
|
||||
authenticate: options.authenticate,
|
||||
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(
|
||||
freezeEnd: boolean,
|
||||
timerTypeNow: TimerType,
|
||||
countToEndNow: boolean,
|
||||
clock: number,
|
||||
timerObject: Pick<TimerState, 'current' | 'elapsed'>,
|
||||
timerTypeOverride?: TimerType,
|
||||
@@ -22,13 +21,6 @@ export function getTimerByType(
|
||||
|
||||
const viewTimerType = timerTypeOverride ?? timerTypeNow;
|
||||
|
||||
if (countToEndNow) {
|
||||
if (timerObject.current === null) {
|
||||
return null;
|
||||
}
|
||||
return freezeEnd ? Math.max(timerObject.current, 0) : timerObject.current;
|
||||
}
|
||||
|
||||
switch (viewTimerType) {
|
||||
case TimerType.CountDown:
|
||||
if (timerObject.current === null) {
|
||||
@@ -36,7 +28,7 @@ export function getTimerByType(
|
||||
}
|
||||
return freezeEnd ? Math.max(timerObject.current, 0) : timerObject.current;
|
||||
case TimerType.CountUp:
|
||||
return Math.abs(timerObject.elapsed ?? 0);
|
||||
return timerObject.elapsed;
|
||||
case TimerType.Clock:
|
||||
return clock;
|
||||
case TimerType.None:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// from https://www.genomecolor.space/
|
||||
|
||||
$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-7: rgba(255, 255, 255, 0.07);
|
||||
$white-9: rgba(255, 255, 255, 0.09);
|
||||
|
||||
@@ -35,7 +35,7 @@ $ontime-paused: #c05621;
|
||||
$ontime-stop: #E4281E;
|
||||
$playback-negative: $red-500;
|
||||
$playback-ahead: $green-500;
|
||||
$active-indicator: #8bb33d;
|
||||
$active-indicator: $green-400;
|
||||
$text-black: $gray-1350;
|
||||
|
||||
$playback-over: #F57C13;
|
||||
@@ -65,7 +65,7 @@ $main-spacing: 2rem;
|
||||
// interface text
|
||||
$ontime-font-family: "Open Sans", "Segoe UI", sans-serif;
|
||||
$title-gray: $gray-200;
|
||||
$label-gray: $gray-400;
|
||||
$label-gray: $gray-600;
|
||||
$secondary-text-gray: $gray-400;
|
||||
$muted-gray: $gray-600;
|
||||
$section-white: $ui-white;
|
||||
|
||||
@@ -76,13 +76,13 @@ function Backstage({ events, customFields, projectData, isMirrored, settings }:
|
||||
const scheduledStart = (() => {
|
||||
if (showNow) 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 = (() => {
|
||||
if (showNow) 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 });
|
||||
|
||||
@@ -5,14 +5,14 @@ import { getOffsetState } from '../../../common/utils/offset';
|
||||
import { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
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 './Schedule.scss';
|
||||
|
||||
const formatOptions = {
|
||||
format12: 'hh:mm a',
|
||||
format12: 'h:mm a',
|
||||
format24: 'HH:mm',
|
||||
};
|
||||
|
||||
@@ -84,9 +84,9 @@ function PlannedScheduleItem({
|
||||
return (
|
||||
<>
|
||||
<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-colour' style={{ backgroundColor: colour }} />
|
||||
<SuperscriptTime time={start} />
|
||||
<SuperscriptPeriod time={start} />
|
||||
→
|
||||
<SuperscriptTime time={end} />
|
||||
<SuperscriptPeriod time={end} />
|
||||
</span>
|
||||
<span className='entry-times--delay'>
|
||||
<SuperscriptTime time={delayedStart} />
|
||||
<SuperscriptPeriod time={delayedStart} />
|
||||
→
|
||||
<SuperscriptTime time={delayedEnd} />
|
||||
<SuperscriptPeriod time={delayedEnd} />
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
@@ -161,5 +161,5 @@ interface ExpectedTimeProps {
|
||||
function ExpectedTime({ expectedTime, plannedTime }: ExpectedTimeProps) {
|
||||
const timeDisplay = formatTime(expectedTime);
|
||||
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 Button from '../../common/components/buttons/Button';
|
||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
import ClockTime from '../../features/viewers/common/clock-time/ClockTime';
|
||||
|
||||
@@ -12,7 +13,7 @@ import { makeSubscriptionsUrl } from './countdown.utils';
|
||||
import './Countdown.scss';
|
||||
|
||||
interface CountdownSelectProps {
|
||||
events: PlayableEvent[];
|
||||
events: ExtendedEntry<PlayableEvent>[];
|
||||
subscriptions: EntryId[];
|
||||
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__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 className='sub__label'>{isSelected ? 'Click to remove' : 'Click to add'}</div>
|
||||
<div className='sub__title'>{title}</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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 { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||
import { timerPlaceholderMin } from '../../common/utils/styleUtils';
|
||||
import { formatDuration, formatTime } from '../../common/utils/time';
|
||||
import { type TranslationKey, useTranslation } from '../../translation/TranslationProvider';
|
||||
|
||||
@@ -36,23 +35,6 @@ export const timerProgress: TimerMessage = {
|
||||
done: 'countdown.ended',
|
||||
};
|
||||
|
||||
export function getFormattedTime(
|
||||
value: MaybeNumber,
|
||||
status: ProgressStatus,
|
||||
minText: string,
|
||||
secText: string,
|
||||
dueText: string,
|
||||
) {
|
||||
if (value === null) return timerPlaceholderMin;
|
||||
if (status === 'future' || status === 'live') {
|
||||
if (value <= 0) return dueText.toUpperCase();
|
||||
return formatDuration(value, value > MILLIS_PER_MINUTE * 2)
|
||||
.replace('m', `${minText} `)
|
||||
.replace('s', secText);
|
||||
}
|
||||
return removeSeconds(formatTime(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a parsed timer and relevant status message
|
||||
* Handles events in different days but disregards whether an event has actually played
|
||||
@@ -65,7 +47,11 @@ export function useSubscriptionDisplayData(
|
||||
|
||||
const bigDuration = (value: number) => {
|
||||
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('s', getLocalizedString('common.seconds'));
|
||||
};
|
||||
@@ -90,7 +76,7 @@ export function useSubscriptionDisplayData(
|
||||
return {
|
||||
status: '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 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;
|
||||
if (!isOntimeEvent(event)) {
|
||||
@@ -62,7 +62,7 @@ function MakeEnd({ getValue, row, table, column }: CellContext<ExtendedEntry, un
|
||||
}
|
||||
|
||||
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;
|
||||
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 className='label center'>Over / under</div>
|
||||
<div className={cx(['runtime-timer', 'center', !eventNow && 'muted', offsetState && offsetState])}>
|
||||
{schedule.offset}
|
||||
</div>
|
||||
<div className={cx(['runtime-timer', 'center', !eventNow && 'muted', offsetState])}>{schedule.offset}</div>
|
||||
</div>
|
||||
<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'] {
|
||||
box-shadow: 0 0.25rem 0 0 $active-red;
|
||||
.status {
|
||||
color: $active-red;
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: $component-border-radius-sm;
|
||||
background: $active-red;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-status='future'] {
|
||||
.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 {
|
||||
|
||||
@@ -24,7 +24,7 @@ interface TimelineProps {
|
||||
export default memo(Timeline);
|
||||
function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: TimelineProps) {
|
||||
const { width: screenWidth } = useViewportSize();
|
||||
const { hidePast, autosize } = useTimelineOptions();
|
||||
const { hidePast, fixedSize } = useTimelineOptions();
|
||||
const selectedRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -38,7 +38,7 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel
|
||||
useHorizontalFollowComponent({
|
||||
followRef: selectedRef,
|
||||
scrollRef: scrollContainerRef,
|
||||
doFollow: autosize,
|
||||
doFollow: !fixedSize,
|
||||
selectedEventId: selectedEventId,
|
||||
// No offset when hiding past events to ensure content starts at 0
|
||||
leftOffset: hidePast ? 0 : screenWidth / 6,
|
||||
@@ -52,8 +52,8 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel
|
||||
duration: event.duration,
|
||||
}));
|
||||
|
||||
return calculateTimelineLayout(playableEvents, scheduleStart, scheduleEnd, screenWidth, autosize);
|
||||
}, [rundown, scheduleStart, scheduleEnd, screenWidth, autosize]);
|
||||
return calculateTimelineLayout(playableEvents, scheduleStart, scheduleEnd, screenWidth, !fixedSize);
|
||||
}, [rundown, scheduleStart, scheduleEnd, screenWidth, fixedSize]);
|
||||
|
||||
if (totalDuration === 0) {
|
||||
return null;
|
||||
@@ -75,7 +75,7 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel
|
||||
});
|
||||
|
||||
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 }}>
|
||||
<TimelineMarkers startHour={startHour} endHour={endHour} />
|
||||
{rundown.map((event, index) => {
|
||||
|
||||
@@ -30,7 +30,7 @@ interface TimelineEntryProps {
|
||||
}
|
||||
|
||||
const formatOptions = {
|
||||
format12: 'hh:mm a',
|
||||
format12: 'h:mm a',
|
||||
format24: 'HH:mm',
|
||||
};
|
||||
|
||||
@@ -150,7 +150,9 @@ function TimelineEntryStatus({
|
||||
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 */
|
||||
|
||||
@@ -74,8 +74,8 @@
|
||||
}
|
||||
|
||||
.section-title {
|
||||
line-height: 1em;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
font-size: $base-font-size;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -83,8 +83,9 @@
|
||||
}
|
||||
|
||||
.section-title__label {
|
||||
text-transform: uppercase;
|
||||
font-size: $timer-label-size;
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.section-title__status {
|
||||
@@ -94,7 +95,7 @@
|
||||
.section-content {
|
||||
min-height: 2em;
|
||||
line-height: 1em;
|
||||
font-size: 3rem;
|
||||
font-size: $timer-value-size;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -119,3 +120,12 @@
|
||||
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) {
|
||||
nextStatus = dueText;
|
||||
} 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) {
|
||||
followedByStatus = dueText;
|
||||
} 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,
|
||||
},
|
||||
{
|
||||
id: 'autosize',
|
||||
title: 'Autosize timeline',
|
||||
description: 'Timeline will adjust sizes to help with readability and automatically scroll if necessary',
|
||||
id: 'fixedSize',
|
||||
title: 'Fixed timeline size',
|
||||
description: 'Timeline will have a fixed size to prevent scrolling',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
@@ -35,7 +35,7 @@ export const getTimelineOptions = (timeFormat: string): ViewOption[] => {
|
||||
|
||||
type TimelineOptions = {
|
||||
hidePast: boolean;
|
||||
autosize: boolean;
|
||||
fixedSize: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -48,7 +48,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
|
||||
|
||||
return {
|
||||
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 formatDuration(timeToStart, timeToStart > MILLIS_PER_MINUTE * 2);
|
||||
return formatDuration(timeToStart, timeToStart > MILLIS_PER_MINUTE * 10);
|
||||
}
|
||||
|
||||
interface ScopedRundownData {
|
||||
|
||||
@@ -120,19 +120,23 @@
|
||||
color: var(--timer-overtime-color-override, $timer-finished-color);
|
||||
}
|
||||
|
||||
&[data-phase="warning"] {
|
||||
&[data-phase='warning'] {
|
||||
color: var(--timer-colour, var(--timer-warning-color-override));
|
||||
}
|
||||
&[data-phase="danger"] {
|
||||
&[data-phase='danger'] {
|
||||
color: var(--timer-colour, var(--timer-danger-color-override));
|
||||
}
|
||||
&[data-type='none'] {
|
||||
transition: 1s;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.secondary {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-height: 100%;
|
||||
height: auto;
|
||||
|
||||
margin-top: 0.125em;
|
||||
padding-block: 0.125em;
|
||||
@@ -141,7 +145,7 @@
|
||||
text-align: center;
|
||||
color: var(--external-color-override, $external-color);
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 1em;
|
||||
line-height: 1;
|
||||
transition-property: opacity, height;
|
||||
transition-duration: $viewer-transition-time;
|
||||
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
|
||||
const totalTime = getTotalTime(time.duration, time.addedTime);
|
||||
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, {
|
||||
removeSeconds: hideTimerSeconds,
|
||||
removeLeadingZero: removeLeadingZeros,
|
||||
@@ -132,7 +132,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings }
|
||||
|
||||
// gather presentation styles
|
||||
const resolvedTimerColour = getTimerColour(viewSettings, timerColour, showWarning, showDanger);
|
||||
const { timerFontSize, externalFontSize } = getEstimatedFontSize(display, secondaryContent);
|
||||
const timerFontSize = getEstimatedFontSize(display, secondaryContent);
|
||||
const userStyles = {
|
||||
...(keyColour && { '--timer-bg': keyColour }),
|
||||
...(resolvedTimerColour && { '--timer-colour': resolvedTimerColour }),
|
||||
@@ -179,16 +179,16 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings }
|
||||
<div
|
||||
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
|
||||
style={{ fontSize: `${timerFontSize}vw` }}
|
||||
data-type={viewTimerType}
|
||||
data-phase={time.phase}
|
||||
>
|
||||
{display}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}
|
||||
style={{ fontSize: `${externalFontSize}vw` }}
|
||||
>
|
||||
{secondaryContent}
|
||||
<div className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}>
|
||||
<FitText mode='multi' min={64} max={256}>
|
||||
{secondaryContent}
|
||||
</FitText>
|
||||
</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