mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-12 19:03:47 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 238eb49b5c | |||
| 7aec3e0d73 | |||
| 312b294a61 | |||
| 9bfa3e9f8e | |||
| 9a2cfb59e7 | |||
| a6e1cbbbce | |||
| 8dd448db44 | |||
| d87542dcf7 | |||
| 18575fc558 | |||
| 04e2593939 | |||
| 73d6b97afa | |||
| b55724ad0e | |||
| 3a7c2b8a55 | |||
| 420dfae652 | |||
| fb6896de76 | |||
| f574fdbc02 | |||
| 597be4e710 | |||
| 6e8734c73f | |||
| 9440ad17d2 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "3.7.0",
|
"version": "3.8.0",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "3.7.0",
|
"version": "3.8.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:local": "cross-env NODE_ENV=local vite build",
|
"build:local": "cross-env NODE_ENV=local vite build",
|
||||||
"build:electron": "cross-env NODE_ENV=local vite build",
|
"build:electron": "cross-env NODE_ENV=local vite build",
|
||||||
"build:docker": "vite build",
|
"build:docker": "cross-env VITE_IS_CLOUD=true vite build",
|
||||||
"build:localdocker": "cross-env NODE_ENV=local vite build",
|
"build:localdocker": "cross-env NODE_ENV=local vite build",
|
||||||
"lint": "eslint . --quiet",
|
"lint": "eslint . --quiet",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
|
|||||||
@@ -32,12 +32,14 @@ const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
|
|||||||
const Public = React.lazy(() => import('./features/viewers/public/Public'));
|
const Public = React.lazy(() => import('./features/viewers/public/Public'));
|
||||||
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
||||||
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
|
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
|
||||||
|
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
|
||||||
|
|
||||||
const STimer = withPreset(withData(TimerView));
|
const STimer = withPreset(withData(TimerView));
|
||||||
const SMinimalTimer = withPreset(withData(MinimalTimerView));
|
const SMinimalTimer = withPreset(withData(MinimalTimerView));
|
||||||
const SClock = withPreset(withData(ClockView));
|
const SClock = withPreset(withData(ClockView));
|
||||||
const SCountdown = withPreset(withData(Countdown));
|
const SCountdown = withPreset(withData(Countdown));
|
||||||
const SBackstage = withPreset(withData(Backstage));
|
const SBackstage = withPreset(withData(Backstage));
|
||||||
|
const SProjectInfo = withPreset(withData(ProjectInfo));
|
||||||
const SPublic = withPreset(withData(Public));
|
const SPublic = withPreset(withData(Public));
|
||||||
const SLowerThird = withPreset(withData(Lower));
|
const SLowerThird = withPreset(withData(Lower));
|
||||||
const SStudio = withPreset(withData(StudioClock));
|
const SStudio = withPreset(withData(StudioClock));
|
||||||
@@ -142,6 +144,14 @@ export default function AppRouter() {
|
|||||||
</ViewLoader>
|
</ViewLoader>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path='/info'
|
||||||
|
element={
|
||||||
|
<ViewLoader>
|
||||||
|
<SProjectInfo />
|
||||||
|
</ViewLoader>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/*/!* Protected Routes *!/*/}
|
{/*/!* Protected Routes *!/*/}
|
||||||
<Route path='/editor' element={<Editor />} />
|
<Route path='/editor' element={<Editor />} />
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { serverURL } from '../../externals';
|
||||||
|
|
||||||
// keys in tanstack store
|
// keys in tanstack store
|
||||||
export const APP_INFO = ['appinfo'];
|
export const APP_INFO = ['appinfo'];
|
||||||
export const APP_SETTINGS = ['appSettings'];
|
export const APP_SETTINGS = ['appSettings'];
|
||||||
@@ -14,19 +16,7 @@ export const URL_PRESETS = ['urlpresets'];
|
|||||||
export const VIEW_SETTINGS = ['viewSettings'];
|
export const VIEW_SETTINGS = ['viewSettings'];
|
||||||
export const CLIENT_LIST = ['clientList'];
|
export const CLIENT_LIST = ['clientList'];
|
||||||
|
|
||||||
// resolve location
|
// API URLs
|
||||||
const location = window.location;
|
|
||||||
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
||||||
export const isProduction = import.meta.env.MODE === 'production';
|
|
||||||
export const isDev = !isProduction;
|
|
||||||
export const isLocalhost = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
|
|
||||||
|
|
||||||
// resolve port
|
|
||||||
const STATIC_PORT = 4001;
|
|
||||||
export const serverPort = isProduction ? location.port : STATIC_PORT;
|
|
||||||
export const serverURL = `${location.protocol}//${location.hostname}:${serverPort}`;
|
|
||||||
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
|
|
||||||
|
|
||||||
export const apiEntryUrl = `${serverURL}/data`;
|
export const apiEntryUrl = `${serverURL}/data`;
|
||||||
|
|
||||||
export const projectDataURL = `${serverURL}/project`;
|
export const projectDataURL = `${serverURL}/project`;
|
||||||
@@ -36,3 +26,4 @@ export const ontimeURL = `${serverURL}/ontime`;
|
|||||||
export const userAssetsPath = 'user';
|
export const userAssetsPath = 'user';
|
||||||
export const cssOverridePath = 'styles/override.css';
|
export const cssOverridePath = 'styles/override.css';
|
||||||
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
|
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
|
||||||
|
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios, { AxiosResponse } from 'axios';
|
import axios, { AxiosResponse } from 'axios';
|
||||||
import { ProjectData } from 'ontime-types';
|
import { ProjectData, ProjectLogoResponse } from 'ontime-types';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
|
||||||
@@ -19,3 +19,18 @@ export async function getProjectData(): Promise<ProjectData> {
|
|||||||
export async function postProjectData(data: ProjectData): Promise<AxiosResponse<ProjectData>> {
|
export async function postProjectData(data: ProjectData): Promise<AxiosResponse<ProjectData>> {
|
||||||
return axios.post(projectPath, data);
|
return axios.post(projectPath, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to upload a project logo
|
||||||
|
*/
|
||||||
|
export async function uploadProjectLogo(file: File): Promise<AxiosResponse<ProjectLogoResponse>> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
const response = await axios.post(`${projectPath}/upload`, formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
|
|||||||
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
|
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
|
||||||
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
||||||
|
|
||||||
|
import { isLocalhost, serverPort } from '../../../externals';
|
||||||
import { navigatorConstants } from '../../../viewerConfig';
|
import { navigatorConstants } from '../../../viewerConfig';
|
||||||
import { isLocalhost, serverPort } from '../../api/constants';
|
|
||||||
import useClickOutside from '../../hooks/useClickOutside';
|
import useClickOutside from '../../hooks/useClickOutside';
|
||||||
import { useElectronEvent } from '../../hooks/useElectronEvent';
|
import { useElectronEvent } from '../../hooks/useElectronEvent';
|
||||||
import useInfo from '../../hooks-query/useInfo';
|
import useInfo from '../../hooks-query/useInfo';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { PropsWithChildren, useCallback, useContext } from 'react';
|
import { PropsWithChildren, useCallback, useContext } from 'react';
|
||||||
|
|
||||||
import { isLocalhost } from '../../api/constants';
|
import { isLocalhost } from '../../../externals';
|
||||||
import { AppContext } from '../../context/AppContext';
|
import { AppContext } from '../../context/AppContext';
|
||||||
|
|
||||||
import PinPage from './PinPage';
|
import PinPage from './PinPage';
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { projectLogoPath } from '../../api/constants';
|
||||||
|
|
||||||
|
interface ViewLogoProps {
|
||||||
|
name: string;
|
||||||
|
className: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ViewLogo(props: ViewLogoProps) {
|
||||||
|
const { name, className } = props;
|
||||||
|
return <img src={`${projectLogoPath}/${name}`} className={className} />;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Switch,
|
Switch,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
|
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
|
||||||
|
|
||||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||||
|
|
||||||
@@ -108,25 +109,21 @@ function MultiOption(props: EditFormMultiOptionProps) {
|
|||||||
const { paramField } = props;
|
const { paramField } = props;
|
||||||
const { id, defaultValue } = paramField;
|
const { id, defaultValue } = paramField;
|
||||||
|
|
||||||
const optionFromParams = (searchParams.get(id) ?? '').toLocaleLowerCase();
|
const optionFromParams = searchParams.getAll(id);
|
||||||
const defaultOptionValue = optionFromParams || defaultValue?.toLocaleLowerCase() || '';
|
const [paramState, setParamState] = useState<string[]>(optionFromParams || defaultValue || ['']);
|
||||||
|
|
||||||
const [paramState, setParamState] = useState<string>(defaultOptionValue);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<input name={id} hidden readOnly value={paramState} />
|
<input name={id} hidden readOnly value={paramState} />
|
||||||
<Menu isLazy closeOnSelect={false} variant='ontime-on-dark'>
|
<Menu isLazy closeOnSelect={false} variant='ontime-on-dark'>
|
||||||
<MenuButton as={Button} variant='ontime-subtle-white' position='relative' width='fit-content' fontWeight={400}>
|
<MenuButton as={Button} variant='ontime-subtle-white' position='relative' width='fit-content' fontWeight={400}>
|
||||||
{paramField.title}
|
{paramField.title} <IoChevronDown style={{ display: 'inline' }} />
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
<MenuList>
|
<MenuList>
|
||||||
<MenuOptionGroup
|
<MenuOptionGroup
|
||||||
type='checkbox'
|
type='checkbox'
|
||||||
value={paramState.split('_')}
|
value={paramState}
|
||||||
onChange={(value) => {
|
onChange={(value) => setParamState(Array.isArray(value) ? value : [value])}
|
||||||
setParamState(typeof value === 'object' ? value.filter((v) => v !== '').join('_') : value);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{Object.values(paramField.values).map((option) => {
|
{Object.values(paramField.values).map((option) => {
|
||||||
const { value, label } = option;
|
const { value, label } = option;
|
||||||
|
|||||||
@@ -55,10 +55,28 @@ const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOp
|
|||||||
// unfortunately this means we run all the strings through the sanitation
|
// unfortunately this means we run all the strings through the sanitation
|
||||||
const valueWithoutHash = sanitiseColour(value);
|
const valueWithoutHash = sanitiseColour(value);
|
||||||
if (defaultValues[id] !== valueWithoutHash) {
|
if (defaultValues[id] !== valueWithoutHash) {
|
||||||
newSearchParams.set(id, valueWithoutHash);
|
handleValueString(id, valueWithoutHash);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Utility function contains logic to add a value into the searchParams object */
|
||||||
|
function handleValueString(id: string, value: string) {
|
||||||
|
const maybeMultipleValues = value.split(',');
|
||||||
|
|
||||||
|
// we need to check if the value contains comma separated list, for the case of the multi-select data
|
||||||
|
if (Array.isArray(maybeMultipleValues) && maybeMultipleValues.length > 1) {
|
||||||
|
const added = new Set();
|
||||||
|
maybeMultipleValues.forEach((v) => {
|
||||||
|
if (!added.has(v)) {
|
||||||
|
added.add(v);
|
||||||
|
newSearchParams.append(id, v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
newSearchParams.set(id, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
return newSearchParams;
|
return newSearchParams;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { dayInMs } from 'ontime-utils';
|
import { dayInMs } from 'ontime-utils';
|
||||||
|
|
||||||
import { version } from '../../../../../package.json';
|
import { version } from '../../../../../package.json';
|
||||||
import { APP_VERSION, isLocalhost } from '../api/constants';
|
import { isLocalhost } from '../../externals';
|
||||||
|
import { APP_VERSION } from '../api/constants';
|
||||||
import { getLatestVersion, HasUpdate } from '../api/external';
|
import { getLatestVersion, HasUpdate } from '../api/external';
|
||||||
|
|
||||||
const placeholder: HasUpdate & { hasUpdates: boolean } = { url: '', version: '', hasUpdates: false };
|
const placeholder: HasUpdate & { hasUpdates: boolean } = { url: '', version: '', hasUpdates: false };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
|
import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
|
||||||
|
|
||||||
@@ -6,6 +6,8 @@ import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
|||||||
import { RUNDOWN } from '../api/constants';
|
import { RUNDOWN } from '../api/constants';
|
||||||
import { fetchNormalisedRundown } from '../api/rundown';
|
import { fetchNormalisedRundown } from '../api/rundown';
|
||||||
|
|
||||||
|
import useProjectData from './useProjectData';
|
||||||
|
|
||||||
// revision is -1 so that the remote revision is higher
|
// revision is -1 so that the remote revision is higher
|
||||||
const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 };
|
const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 };
|
||||||
|
|
||||||
@@ -24,7 +26,9 @@ export default function useRundown() {
|
|||||||
|
|
||||||
export function useFlatRundown() {
|
export function useFlatRundown() {
|
||||||
const { data, status } = useRundown();
|
const { data, status } = useRundown();
|
||||||
|
const { data: projectData } = useProjectData();
|
||||||
|
|
||||||
|
const loadedProject = useRef<string>('');
|
||||||
const [prevRevision, setPrevRevision] = useState<number>(-1);
|
const [prevRevision, setPrevRevision] = useState<number>(-1);
|
||||||
const [flatRunDown, setFlatRunDown] = useState<OntimeRundown>([]);
|
const [flatRunDown, setFlatRunDown] = useState<OntimeRundown>([]);
|
||||||
|
|
||||||
@@ -37,5 +41,14 @@ export function useFlatRundown() {
|
|||||||
}
|
}
|
||||||
}, [data.order, data.revision, data.rundown, prevRevision]);
|
}, [data.order, data.revision, data.rundown, prevRevision]);
|
||||||
|
|
||||||
|
// TODO: should we have a project id field?
|
||||||
|
// invalidate current version if project changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (projectData?.title !== loadedProject.current) {
|
||||||
|
setPrevRevision(-1);
|
||||||
|
loadedProject.current = projectData?.title ?? '';
|
||||||
|
}
|
||||||
|
}, [projectData]);
|
||||||
|
|
||||||
return { data: flatRunDown, status };
|
return { data: flatRunDown, status };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import { useMemo, useRef } from 'react';
|
import { useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { isDev } from '../api/constants';
|
import { isDev } from '../../externals';
|
||||||
|
|
||||||
type noop = (this: any, ...args: any[]) => any;
|
type noop = (this: any, ...args: any[]) => any;
|
||||||
|
|
||||||
|
|||||||
@@ -230,3 +230,11 @@ export const useTimelineStatus = () => {
|
|||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
return useRuntimeStore(featureSelector);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const usePing = () => {
|
||||||
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
|
ping: state.ping,
|
||||||
|
});
|
||||||
|
|
||||||
|
return useRuntimeStore(featureSelector);
|
||||||
|
};
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ export const projectDataPlaceholder: ProjectData = {
|
|||||||
publicInfo: '',
|
publicInfo: '',
|
||||||
backstageUrl: '',
|
backstageUrl: '',
|
||||||
backstageInfo: '',
|
backstageInfo: '',
|
||||||
|
projectLogo: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,4 +15,5 @@ export type OverridableOptions = {
|
|||||||
language?: string;
|
language?: string;
|
||||||
showProgressBar?: boolean;
|
showProgressBar?: boolean;
|
||||||
hideTimerSeconds?: boolean;
|
hideTimerSeconds?: boolean;
|
||||||
|
removeLeadingZeros?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Log, RundownCached, RuntimeStore } from 'ontime-types';
|
import { Log, RundownCached, RuntimeStore } from 'ontime-types';
|
||||||
|
|
||||||
import { CLIENT_LIST, CUSTOM_FIELDS, isProduction, RUNDOWN, RUNTIME, websocketUrl } from '../api/constants';
|
import { isProduction, websocketUrl } from '../../externals';
|
||||||
|
import { CLIENT_LIST, CUSTOM_FIELDS, RUNDOWN, RUNTIME } from '../api/constants';
|
||||||
|
import { invalidateAllCaches } from '../api/utils';
|
||||||
import { ontimeQueryClient } from '../queryClient';
|
import { ontimeQueryClient } from '../queryClient';
|
||||||
import {
|
import {
|
||||||
getClientId,
|
getClientId,
|
||||||
@@ -68,6 +70,12 @@ export const connectSocket = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
case 'pong': {
|
||||||
|
const offset = (new Date().getTime() - new Date(payload).getTime()) * 0.5;
|
||||||
|
patchRuntime('ping', offset);
|
||||||
|
updateDevTools({ ping: offset }, ['PING']);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'client-id': {
|
case 'client-id': {
|
||||||
if (typeof payload === 'string') {
|
if (typeof payload === 'string') {
|
||||||
setClientId(payload);
|
setClientId(payload);
|
||||||
@@ -178,10 +186,12 @@ export const connectSocket = () => {
|
|||||||
}
|
}
|
||||||
case 'ontime-refetch': {
|
case 'ontime-refetch': {
|
||||||
// the refetch message signals that the rundown has changed in the server side
|
// the refetch message signals that the rundown has changed in the server side
|
||||||
const { revision } = payload;
|
const { revision, reload } = payload;
|
||||||
const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1;
|
const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1;
|
||||||
|
|
||||||
if (revision > currentRevision) {
|
if (reload) {
|
||||||
|
invalidateAllCaches();
|
||||||
|
} else if (revision > currentRevision) {
|
||||||
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
|
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||||
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
|
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
|
||||||
}
|
}
|
||||||
@@ -214,9 +224,9 @@ export const socketSendJson = (type: string, payload?: unknown) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function updateDevTools(newData: Partial<RuntimeStore>) {
|
function updateDevTools(newData: Partial<RuntimeStore>, store = RUNTIME) {
|
||||||
if (!isProduction) {
|
if (!isProduction) {
|
||||||
ontimeQueryClient.setQueryData(RUNTIME, (oldData: RuntimeStore) => ({
|
ontimeQueryClient.setQueryData(store, (oldData: RuntimeStore) => ({
|
||||||
...oldData,
|
...oldData,
|
||||||
...newData,
|
...newData,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function validateProjectFile(file: File) {
|
|||||||
|
|
||||||
// Limit file size of a project file to around 1MB
|
// Limit file size of a project file to around 1MB
|
||||||
if (file.size > 1_000_000) {
|
if (file.size > 1_000_000) {
|
||||||
throw new Error('File size limit (10MB) exceeded');
|
throw new Error('File size limit (1MB) exceeded');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,3 +45,19 @@ export function isExcelFile(file: File | null) {
|
|||||||
export function isOntimeFile(file: File | null) {
|
export function isOntimeFile(file: File | null) {
|
||||||
return file?.name.endsWith('.json');
|
return file?.name.endsWith('.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collection of rules for pre-validating a project file
|
||||||
|
* @param file
|
||||||
|
*/
|
||||||
|
export function validateLogo(file: File) {
|
||||||
|
// Check if file is empty
|
||||||
|
if (file.size === 0) {
|
||||||
|
throw new Error('File is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit file size of a project file to around 1MB
|
||||||
|
if (file.size > 1_000_000) {
|
||||||
|
throw new Error('File size limit (1MB) exceeded');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* This file contains a list of constants that may need to be resolved at runtime
|
||||||
|
*/
|
||||||
|
|
||||||
export const githubUrl = 'https://www.github.com/cpvalente/ontime';
|
export const githubUrl = 'https://www.github.com/cpvalente/ontime';
|
||||||
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
|
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
|
||||||
export const websiteUrl = 'https://www.getontime.no';
|
export const websiteUrl = 'https://www.getontime.no';
|
||||||
|
|
||||||
export const documentationUrl = 'https://docs.getontime.no';
|
export const documentationUrl = 'https://docs.getontime.no';
|
||||||
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
|
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
|
||||||
|
|
||||||
|
// resolve environment
|
||||||
|
export const isProduction = import.meta.env.MODE === 'production';
|
||||||
|
export const isDev = !isProduction;
|
||||||
|
export const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||||
|
export const isOntimeCloud = Boolean(import.meta.env.VITE_IS_CLOUD);
|
||||||
|
|
||||||
|
// resolve protocol
|
||||||
|
const socketProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
|
||||||
|
// resolve port
|
||||||
|
const STATIC_PORT = 4001;
|
||||||
|
export const serverPort = isProduction ? window.location.port : STATIC_PORT;
|
||||||
|
export const serverURL = `${window.location.protocol}//${location.hostname}:${serverPort}`;
|
||||||
|
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
|
||||||
|
|||||||
@@ -49,6 +49,18 @@ $inner-padding: 1rem;
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.highlight {
|
||||||
|
font-size: calc(1rem - 2px);
|
||||||
|
color: $orange-500;
|
||||||
|
padding-inline: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blockquote {
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
border-left: 2px solid $gray-100;
|
||||||
|
background-color: $white-3;
|
||||||
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
font-size: $inner-section-text-size;
|
font-size: $inner-section-text-size;
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ export function Paragraph({ children }: { children: ReactNode }) {
|
|||||||
return <p className={style.paragraph}>{children}</p>;
|
return <p className={style.paragraph}>{children}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Card({ children, ...props }: { children: ReactNode } & JSX.IntrinsicElements['div']) {
|
export function Card({ children, className, ...props }: { children: ReactNode } & JSX.IntrinsicElements['div']) {
|
||||||
return (
|
return (
|
||||||
<div className={style.card} {...props}>
|
<div className={cx([style.card, className])} {...props}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -74,6 +74,14 @@ export function Description({ children }: { children: ReactNode }) {
|
|||||||
return <div className={style.fieldDescription}>{children}</div>;
|
return <div className={style.fieldDescription}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function Highlight({ children }: { children: ReactNode }) {
|
||||||
|
return <code className={style.highlight}>{children}</code>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BlockQuote({ children }: { children: ReactNode }) {
|
||||||
|
return <blockquote className={style.blockquote}>{children}</blockquote>;
|
||||||
|
}
|
||||||
|
|
||||||
export function Error({ children }: { children: ReactNode }) {
|
export function Error({ children }: { children: ReactNode }) {
|
||||||
return <div className={style.fieldError}>{children}</div>;
|
return <div className={style.fieldError}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-5
@@ -105,12 +105,25 @@ export default function UrlPresetsForm() {
|
|||||||
<Alert status='info' variant='ontime-on-dark-info'>
|
<Alert status='info' variant='ontime-on-dark-info'>
|
||||||
<AlertIcon />
|
<AlertIcon />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
URL Presets
|
URL presets are user defined aliases to Ontime URLs
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
Custom presets allow providing a short name for any ontime URL. <br />
|
<b>Preset Name</b> <br />
|
||||||
- Providing dynamic URLs for automation or unattended screens <br />- Simplifying complex URLs
|
The alias for the URL. This will be the URL you will be calling. eg: <br />
|
||||||
|
<Panel.BlockQuote>
|
||||||
|
Preset name <Panel.Highlight>cam3</Panel.Highlight> called as{' '}
|
||||||
|
<Panel.Highlight>http://localhost:4001/cam3</Panel.Highlight>
|
||||||
|
</Panel.BlockQuote>
|
||||||
<br />
|
<br />
|
||||||
|
<b>URL Segment</b> <br />
|
||||||
|
The corresponding alias path and configuration parameters. eg: <br />
|
||||||
|
<Panel.BlockQuote>
|
||||||
|
URL segment <Panel.Highlight>backstage?hidePast=true&stopCycle=true</Panel.Highlight> corresponds to
|
||||||
|
complete URL
|
||||||
|
<Panel.Highlight>http://localhost:4001/backstage?hidePast=true&stopCycle=true</Panel.Highlight>
|
||||||
|
</Panel.BlockQuote>
|
||||||
|
<br />
|
||||||
|
You will need to save the changes before the presets are functional.
|
||||||
<br />
|
<br />
|
||||||
<ExternalLink href={urlPresetsDocs}>See the docs</ExternalLink>
|
<ExternalLink href={urlPresetsDocs}>See the docs</ExternalLink>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
@@ -130,8 +143,8 @@ export default function UrlPresetsForm() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className={style.fit}>Active</th>
|
<th className={style.fit}>Active</th>
|
||||||
<th className={style.aliasConstrain}>Preset</th>
|
<th className={style.aliasConstrain}>Preset name</th>
|
||||||
<th className={style.fullWidth}>URL</th>
|
<th className={style.fullWidth}>URL segment</th>
|
||||||
<th />
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||||
|
|
||||||
import { serverPort } from '../../../../common/api/constants';
|
|
||||||
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
||||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||||
import { openLink } from '../../../../common/utils/linkUtils';
|
import { openLink } from '../../../../common/utils/linkUtils';
|
||||||
|
import { isLocalhost, serverPort } from '../../../../externals';
|
||||||
|
|
||||||
import style from './NetworkInterfaces.module.scss';
|
import style from './NetworkInterfaces.module.scss';
|
||||||
|
|
||||||
@@ -14,9 +14,11 @@ export default function InfoNif() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.interfaces}>
|
<div className={style.interfaces}>
|
||||||
{data?.networkInterfaces?.map((nif) => {
|
{data.networkInterfaces?.map((nif) => {
|
||||||
const address = `http://${nif.address}:${serverPort}`;
|
// interfaces outside localhost wont have access
|
||||||
|
if (nif.name === 'localhost' && !isLocalhost) return null;
|
||||||
|
|
||||||
|
const address = `http://${nif.address}:${serverPort}`;
|
||||||
return (
|
return (
|
||||||
<CopyTag
|
<CopyTag
|
||||||
key={nif.name}
|
key={nif.name}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||||
|
import { usePing } from '../../../../common/hooks/useSocket';
|
||||||
|
import { socketSendJson } from '../../../../common/utils/socket';
|
||||||
|
import { isOntimeCloud } from '../../../../externals';
|
||||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
import ClientControlPanel from '../client-control-panel/ClientControlPanel';
|
import ClientControlPanel from '../client-control-panel/ClientControlPanel';
|
||||||
@@ -14,6 +19,7 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
|||||||
<>
|
<>
|
||||||
<Panel.Header>Network</Panel.Header>
|
<Panel.Header>Network</Panel.Header>
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
|
{isOntimeCloud && <OntimeCloudStats />}
|
||||||
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
|
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
<InfoNif />
|
<InfoNif />
|
||||||
@@ -26,3 +32,29 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function OntimeCloudStats() {
|
||||||
|
const { ping } = usePing();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send immediate ping request, and keep sending on an interval
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
socketSendJson('ping', new Date());
|
||||||
|
|
||||||
|
const doPing = setInterval(() => {
|
||||||
|
socketSendJson('ping', new Date());
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(doPing);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel.SubHeader>
|
||||||
|
Ontime cloud
|
||||||
|
<Panel.Description>Current ping: {ping}ms</Panel.Description>
|
||||||
|
</Panel.SubHeader>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { useEffect } from 'react';
|
import { ChangeEvent, useEffect, useRef } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { Button, Input, Textarea } from '@chakra-ui/react';
|
import { Button, Input, Textarea } from '@chakra-ui/react';
|
||||||
|
import { IoDownloadOutline } from '@react-icons/all-files/io5/IoDownloadOutline';
|
||||||
|
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||||
import { type ProjectData } from 'ontime-types';
|
import { type ProjectData } from 'ontime-types';
|
||||||
|
|
||||||
import { postProjectData } from '../../../../common/api/project';
|
import { projectLogoPath } from '../../../../common/api/constants';
|
||||||
|
import { postProjectData, uploadProjectLogo } from '../../../../common/api/project';
|
||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import useProjectData from '../../../../common/hooks-query/useProjectData';
|
import useProjectData from '../../../../common/hooks-query/useProjectData';
|
||||||
|
import { validateLogo } from '../../../../common/utils/uploadUtils';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import style from './ProjectPanel.module.scss';
|
import style from './ProjectPanel.module.scss';
|
||||||
@@ -17,8 +21,10 @@ export default function ProjectData() {
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
register,
|
register,
|
||||||
reset,
|
reset,
|
||||||
formState: { isSubmitting, isValid, isDirty },
|
formState: { isSubmitting, isValid, isDirty, errors },
|
||||||
setError,
|
setError,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
} = useForm({
|
} = useForm({
|
||||||
defaultValues: data,
|
defaultValues: data,
|
||||||
values: data,
|
values: data,
|
||||||
@@ -34,6 +40,40 @@ export default function ProjectData() {
|
|||||||
}
|
}
|
||||||
}, [data, reset]);
|
}, [data, reset]);
|
||||||
|
|
||||||
|
const handleUploadProjectLogo = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateLogo(file);
|
||||||
|
const response = await uploadProjectLogo(file);
|
||||||
|
|
||||||
|
setValue('projectLogo', response.data.logoFilename, {
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = maybeAxiosError(error);
|
||||||
|
setError('projectLogo', { message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { ref, ...projectLogoRest } = register('projectLogo');
|
||||||
|
|
||||||
|
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const handleClickUpload = () => {
|
||||||
|
uploadInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteLogo = () => {
|
||||||
|
setValue('projectLogo', null, {
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const onSubmit = async (formData: ProjectData) => {
|
const onSubmit = async (formData: ProjectData) => {
|
||||||
try {
|
try {
|
||||||
await postProjectData(formData);
|
await postProjectData(formData);
|
||||||
@@ -86,6 +126,53 @@ export default function ProjectData() {
|
|||||||
{...register('title')}
|
{...register('title')}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<Panel.Section style={{ marginTop: 0 }}>
|
||||||
|
<label>
|
||||||
|
Project logo
|
||||||
|
<Input
|
||||||
|
variant='ontime-filled'
|
||||||
|
size='sm'
|
||||||
|
type='file'
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
accept='image/*'
|
||||||
|
{...projectLogoRest}
|
||||||
|
ref={(e) => {
|
||||||
|
ref(e);
|
||||||
|
uploadInputRef.current = e;
|
||||||
|
}}
|
||||||
|
onChange={handleUploadProjectLogo}
|
||||||
|
/>
|
||||||
|
<Panel.Card className={style.uploadLogoCard}>
|
||||||
|
{watch('projectLogo') ? (
|
||||||
|
<>
|
||||||
|
<img src={`${projectLogoPath}/${watch('projectLogo')}`} />
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant='ontime-filled'
|
||||||
|
isDisabled={isSubmitting || !watch('projectLogo')}
|
||||||
|
leftIcon={<IoTrash />}
|
||||||
|
onClick={handleDeleteLogo}
|
||||||
|
type='button'
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant='ontime-filled'
|
||||||
|
size='sm'
|
||||||
|
isDisabled={isSubmitting}
|
||||||
|
leftIcon={<IoDownloadOutline />}
|
||||||
|
onClick={handleClickUpload}
|
||||||
|
type='button'
|
||||||
|
>
|
||||||
|
Upload logo
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{errors?.projectLogo?.message && <Panel.Error>{errors.projectLogo.message}</Panel.Error>}
|
||||||
|
</Panel.Card>
|
||||||
|
</label>
|
||||||
|
</Panel.Section>
|
||||||
<label>
|
<label>
|
||||||
Project description
|
Project description
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -61,3 +61,26 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadLogoCard {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
background-color: $gray-1350;
|
||||||
|
border: 1px solid $white-10;
|
||||||
|
border-radius: 3px;
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 250px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import {
|
|||||||
useDisclosure,
|
useDisclosure,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
import { isLocalhost } from '../../../../common/api/constants';
|
|
||||||
import { useElectronEvent } from '../../../../common/hooks/useElectronEvent';
|
import { useElectronEvent } from '../../../../common/hooks/useElectronEvent';
|
||||||
|
import { isLocalhost } from '../../../../externals';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
export default function ShutdownPanel() {
|
export default function ShutdownPanel() {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export default function TimerPreview() {
|
|||||||
if (phase === TimerPhase.Pending) return 'Standby to start';
|
if (phase === TimerPhase.Pending) return 'Standby to start';
|
||||||
if (phase === TimerPhase.Overtime && data.endMessage) return 'Custom end message';
|
if (phase === TimerPhase.Overtime && data.endMessage) return 'Custom end message';
|
||||||
if (timerType === TimerType.TimeToEnd) return 'Time to end';
|
if (timerType === TimerType.TimeToEnd) return 'Time to end';
|
||||||
|
if (timerType === TimerType.Clock) return 'Clock';
|
||||||
if (timerType === TimerType.None) return timerPlaceholder;
|
if (timerType === TimerType.None) return timerPlaceholder;
|
||||||
return 'Timer';
|
return 'Timer';
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -114,11 +114,8 @@ export default function Operator() {
|
|||||||
// get fields which the user subscribed to
|
// get fields which the user subscribed to
|
||||||
const shouldEdit = searchParams.get('shouldEdit');
|
const shouldEdit = searchParams.get('shouldEdit');
|
||||||
|
|
||||||
const subscriptions = (searchParams.get('subscribe') ?? '')
|
// subscriptions is a MultiSelect and may have multiple values
|
||||||
.toLocaleLowerCase()
|
const subscriptions = searchParams.getAll('subscribe').filter((value) => Object.hasOwn(customFields, value));
|
||||||
.split('_')
|
|
||||||
.filter((value) => Object.hasOwn(customFields, value));
|
|
||||||
|
|
||||||
const canEdit = shouldEdit && subscriptions;
|
const canEdit = shouldEdit && subscriptions;
|
||||||
|
|
||||||
const main = searchParams.get('main') as keyof TitleFields | null;
|
const main = searchParams.get('main') as keyof TitleFields | null;
|
||||||
|
|||||||
@@ -29,6 +29,12 @@
|
|||||||
font-size: clamp(32px, 4.5vw, 64px);
|
font-size: clamp(32px, 4.5vw, 64px);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
max-width: min(200px, 20vw);
|
||||||
|
max-height: min(100px, 20vh);
|
||||||
}
|
}
|
||||||
|
|
||||||
.clock-container {
|
.clock-container {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Schedule from '../../../common/components/schedule/Schedule';
|
|||||||
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
||||||
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
||||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||||
|
import ViewLogo from '../../../common/components/view-logo/ViewLogo';
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||||
@@ -27,19 +28,19 @@ import './Backstage.scss';
|
|||||||
export const MotionTitleCard = motion(TitleCard);
|
export const MotionTitleCard = motion(TitleCard);
|
||||||
|
|
||||||
interface BackstageProps {
|
interface BackstageProps {
|
||||||
customFields: CustomFields;
|
|
||||||
isMirrored: boolean;
|
|
||||||
eventNow: OntimeEvent | null;
|
|
||||||
eventNext: OntimeEvent | null;
|
|
||||||
time: ViewExtendedTimer;
|
|
||||||
backstageEvents: OntimeEvent[];
|
backstageEvents: OntimeEvent[];
|
||||||
selectedId: string | null;
|
customFields: CustomFields;
|
||||||
|
eventNext: OntimeEvent | null;
|
||||||
|
eventNow: OntimeEvent | null;
|
||||||
general: ProjectData;
|
general: ProjectData;
|
||||||
|
isMirrored: boolean;
|
||||||
|
time: ViewExtendedTimer;
|
||||||
|
selectedId: string | null;
|
||||||
settings: Settings | undefined;
|
settings: Settings | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Backstage(props: BackstageProps) {
|
export default function Backstage(props: BackstageProps) {
|
||||||
const { customFields, isMirrored, eventNow, eventNext, time, backstageEvents, selectedId, general, settings } = props;
|
const { backstageEvents, customFields, eventNext, eventNow, general, time, isMirrored, selectedId, settings } = props;
|
||||||
|
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
const [blinkClass, setBlinkClass] = useState(false);
|
const [blinkClass, setBlinkClass] = useState(false);
|
||||||
@@ -81,6 +82,7 @@ export default function Backstage(props: BackstageProps) {
|
|||||||
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
|
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
|
||||||
<ViewParamsEditor viewOptions={backstageOptions} />
|
<ViewParamsEditor viewOptions={backstageOptions} />
|
||||||
<div className='project-header'>
|
<div className='project-header'>
|
||||||
|
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
|
||||||
{general.title}
|
{general.title}
|
||||||
<div className='clock-container'>
|
<div className='clock-container'>
|
||||||
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
||||||
|
|||||||
@@ -13,10 +13,18 @@
|
|||||||
place-content: center;
|
place-content: center;
|
||||||
|
|
||||||
.clock {
|
.clock {
|
||||||
font-family: var(--font-family-bold-override, $timer-bold-font-family) ;
|
font-family: var(--font-family-bold-override, $timer-bold-font-family);
|
||||||
font-size: 20vw;
|
font-size: 20vw;
|
||||||
position: relative;
|
position: relative;
|
||||||
color: var(--timer-color-override, $timer-color);
|
color: var(--timer-color-override, $timer-color);
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
position: absolute;
|
||||||
|
top: 2vw;
|
||||||
|
left: 2vw;
|
||||||
|
max-width: min(200px, 20vw);
|
||||||
|
max-height: min(100px, 20vh);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Settings } from 'ontime-types';
|
import { ProjectData, Settings } from 'ontime-types';
|
||||||
|
|
||||||
|
import ViewLogo from '../../../common/components/view-logo/ViewLogo';
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||||
@@ -13,13 +14,14 @@ import { getClockOptions } from './clock.options';
|
|||||||
import './Clock.scss';
|
import './Clock.scss';
|
||||||
|
|
||||||
interface ClockProps {
|
interface ClockProps {
|
||||||
|
general: ProjectData;
|
||||||
isMirrored: boolean;
|
isMirrored: boolean;
|
||||||
time: ViewExtendedTimer;
|
time: ViewExtendedTimer;
|
||||||
settings: Settings | undefined;
|
settings: Settings | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Clock(props: ClockProps) {
|
export default function Clock(props: ClockProps) {
|
||||||
const { isMirrored, time, settings } = props;
|
const { general, isMirrored, time, settings } = props;
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
useWindowTitle('Clock');
|
useWindowTitle('Clock');
|
||||||
@@ -122,6 +124,7 @@ export default function Clock(props: ClockProps) {
|
|||||||
}}
|
}}
|
||||||
data-testid='clock-view'
|
data-testid='clock-view'
|
||||||
>
|
>
|
||||||
|
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
|
||||||
<ViewParamsEditor viewOptions={clockOptions} />
|
<ViewParamsEditor viewOptions={clockOptions} />
|
||||||
<SuperscriptTime
|
<SuperscriptTime
|
||||||
time={clock}
|
time={clock}
|
||||||
|
|||||||
@@ -33,6 +33,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
position: absolute;
|
||||||
|
top: 2vw;
|
||||||
|
left: 2vw;
|
||||||
|
max-width: min(200px, 20vw);
|
||||||
|
max-height: min(100px, 20vh);
|
||||||
|
}
|
||||||
|
|
||||||
/* =================== MAIN - EVENT CONTAINER ===================*/
|
/* =================== MAIN - EVENT CONTAINER ===================*/
|
||||||
|
|
||||||
.countdown-container {
|
.countdown-container {
|
||||||
@@ -75,7 +83,7 @@
|
|||||||
.status {
|
.status {
|
||||||
grid-area: status;
|
grid-area: status;
|
||||||
color: var(--label-color-override, $viewer-label-color);
|
color: var(--label-color-override, $viewer-label-color);
|
||||||
font-size: clamp(2rem, 3.5vw, 3.5rem);
|
font-size: clamp(2rem, 3.5vw, 3.5rem);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +135,7 @@
|
|||||||
justify-items: center;
|
justify-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
row-gap: clamp(1rem, 5vh, 4rem);
|
row-gap: clamp(1rem, 5vh, 4rem);
|
||||||
|
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
height: fit-content;
|
height: fit-content;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { OntimeEvent, OntimeRundownEntry, Playback, Runtime, Settings, SupportedEvent, TimerPhase } from 'ontime-types';
|
import {
|
||||||
|
OntimeEvent,
|
||||||
|
OntimeRundownEntry,
|
||||||
|
Playback,
|
||||||
|
ProjectData,
|
||||||
|
Runtime,
|
||||||
|
Settings,
|
||||||
|
SupportedEvent,
|
||||||
|
TimerPhase,
|
||||||
|
} from 'ontime-types';
|
||||||
|
|
||||||
|
import ViewLogo from '../../../common/components/view-logo/ViewLogo';
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||||
@@ -17,8 +27,9 @@ import CountdownSelect from './CountdownSelect';
|
|||||||
import './Countdown.scss';
|
import './Countdown.scss';
|
||||||
|
|
||||||
interface CountdownProps {
|
interface CountdownProps {
|
||||||
isMirrored: boolean;
|
|
||||||
backstageEvents: OntimeEvent[];
|
backstageEvents: OntimeEvent[];
|
||||||
|
general: ProjectData;
|
||||||
|
isMirrored: boolean;
|
||||||
runtime: Runtime;
|
runtime: Runtime;
|
||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
settings: Settings | undefined;
|
settings: Settings | undefined;
|
||||||
@@ -26,7 +37,7 @@ interface CountdownProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Countdown(props: CountdownProps) {
|
export default function Countdown(props: CountdownProps) {
|
||||||
const { isMirrored, backstageEvents, runtime, selectedId, settings, time } = props;
|
const { backstageEvents, general, isMirrored, runtime, selectedId, settings, time } = props;
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
|
|
||||||
@@ -105,6 +116,7 @@ export default function Countdown(props: CountdownProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
|
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
|
||||||
|
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
|
||||||
<ViewParamsEditor viewOptions={viewOptions} />
|
<ViewParamsEditor viewOptions={viewOptions} />
|
||||||
{follow === null ? (
|
{follow === null ? (
|
||||||
<CountdownSelect events={backstageEvents} />
|
<CountdownSelect events={backstageEvents} />
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
.timer {
|
.timer {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
font-family: var(--font-family-bold-override, $timer-bold-font-family) ;
|
font-family: var(--font-family-bold-override, $timer-bold-font-family);
|
||||||
font-size: 20vw;
|
font-size: 20vw;
|
||||||
position: relative;
|
position: relative;
|
||||||
color: var(--timer-color-override, var(--phase-color));
|
color: var(--timer-color-override, var(--phase-color));
|
||||||
@@ -43,11 +43,19 @@
|
|||||||
/* =================== OVERLAY ===================*/
|
/* =================== OVERLAY ===================*/
|
||||||
|
|
||||||
.end-message {
|
.end-message {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 12vw;
|
font-size: 12vw;
|
||||||
line-height: 0.9em;
|
line-height: 0.9em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: $timer-finished-color;
|
color: $timer-finished-color;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
position: absolute;
|
||||||
|
top: 2vw;
|
||||||
|
left: 2vw;
|
||||||
|
max-width: min(200px, 20vw);
|
||||||
|
max-height: min(100px, 20vh);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Playback, TimerPhase, TimerType, ViewSettings } from 'ontime-types';
|
import { Playback, ProjectData, TimerPhase, TimerType, ViewSettings } from 'ontime-types';
|
||||||
|
|
||||||
|
import ViewLogo from '../../../common/components/view-logo/ViewLogo';
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||||
@@ -13,13 +14,14 @@ import { MINIMAL_TIMER_OPTIONS } from './minimalTimer.options';
|
|||||||
import './MinimalTimer.scss';
|
import './MinimalTimer.scss';
|
||||||
|
|
||||||
interface MinimalTimerProps {
|
interface MinimalTimerProps {
|
||||||
|
general: ProjectData;
|
||||||
isMirrored: boolean;
|
isMirrored: boolean;
|
||||||
time: ViewExtendedTimer;
|
time: ViewExtendedTimer;
|
||||||
viewSettings: ViewSettings;
|
viewSettings: ViewSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MinimalTimer(props: MinimalTimerProps) {
|
export default function MinimalTimer(props: MinimalTimerProps) {
|
||||||
const { isMirrored, time, viewSettings } = props;
|
const { general, isMirrored, time, viewSettings } = props;
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
@@ -115,14 +117,18 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
|||||||
const hideTimerSeconds = searchParams.get('hideTimerSeconds');
|
const hideTimerSeconds = searchParams.get('hideTimerSeconds');
|
||||||
userOptions.hideTimerSeconds = isStringBoolean(hideTimerSeconds);
|
userOptions.hideTimerSeconds = isStringBoolean(hideTimerSeconds);
|
||||||
|
|
||||||
|
const showLeadingZeros = searchParams.get('showLeadingZeros');
|
||||||
|
userOptions.removeLeadingZeros = !isStringBoolean(showLeadingZeros);
|
||||||
|
|
||||||
const timerIsTimeOfDay = time.timerType === TimerType.Clock;
|
const timerIsTimeOfDay = time.timerType === TimerType.Clock;
|
||||||
|
|
||||||
const isPlaying = time.playback !== Playback.Pause;
|
const isPlaying = time.playback !== Playback.Pause;
|
||||||
|
|
||||||
const shouldShowModifiers = time.timerType !== TimerType.Clock && time.timerType !== TimerType.CountUp;
|
const shouldShowModifiers = time.timerType === TimerType.CountDown || time.timerType === TimerType.TimeToEnd;
|
||||||
const finished = time.phase === TimerPhase.Overtime;
|
const finished = time.phase === TimerPhase.Overtime;
|
||||||
const showEndMessage = finished && viewSettings.endMessage && !hideEndMessage;
|
const showEndMessage = shouldShowModifiers && finished && viewSettings.endMessage && !hideEndMessage;
|
||||||
const showFinished = finished && !userOptions?.hideOvertime && (shouldShowModifiers || showEndMessage);
|
const showFinished =
|
||||||
|
shouldShowModifiers && finished && !userOptions?.hideOvertime && (shouldShowModifiers || showEndMessage);
|
||||||
|
|
||||||
const showProgress = time.playback !== Playback.Stop;
|
const showProgress = time.playback !== Playback.Stop;
|
||||||
const showWarning = shouldShowModifiers && time.phase === TimerPhase.Warning;
|
const showWarning = shouldShowModifiers && time.phase === TimerPhase.Warning;
|
||||||
@@ -135,7 +141,7 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
|||||||
const stageTimer = getTimerByType(viewSettings.freezeEnd, time);
|
const stageTimer = getTimerByType(viewSettings.freezeEnd, time);
|
||||||
const display = getFormattedTimer(stageTimer, time.timerType, getLocalizedString('common.minutes'), {
|
const display = getFormattedTimer(stageTimer, time.timerType, getLocalizedString('common.minutes'), {
|
||||||
removeSeconds: userOptions.hideTimerSeconds,
|
removeSeconds: userOptions.hideTimerSeconds,
|
||||||
removeLeadingZero: true,
|
removeLeadingZero: userOptions.removeLeadingZeros,
|
||||||
});
|
});
|
||||||
|
|
||||||
const stageTimerCharacters = display.replace('/:/g', '').length;
|
const stageTimerCharacters = display.replace('/:/g', '').length;
|
||||||
@@ -154,6 +160,7 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
|||||||
}}
|
}}
|
||||||
data-testid='minimal-timer'
|
data-testid='minimal-timer'
|
||||||
>
|
>
|
||||||
|
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
|
||||||
<ViewParamsEditor viewOptions={MINIMAL_TIMER_OPTIONS} />
|
<ViewParamsEditor viewOptions={MINIMAL_TIMER_OPTIONS} />
|
||||||
{showEndMessage ? (
|
{showEndMessage ? (
|
||||||
<div className='end-message'>{viewSettings.endMessage}</div>
|
<div className='end-message'>{viewSettings.endMessage}</div>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { hideTimerSeconds } from '../../../common/components/view-params-editor/constants';
|
import { hideTimerSeconds, showLeadingZeros } from '../../../common/components/view-params-editor/constants';
|
||||||
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
||||||
|
|
||||||
export const MINIMAL_TIMER_OPTIONS: ViewOption[] = [
|
export const MINIMAL_TIMER_OPTIONS: ViewOption[] = [
|
||||||
{ section: 'Timer Options' },
|
{ section: 'Timer Options' },
|
||||||
hideTimerSeconds,
|
hideTimerSeconds,
|
||||||
|
showLeadingZeros,
|
||||||
{ section: 'Element visibility' },
|
{ section: 'Element visibility' },
|
||||||
{
|
{
|
||||||
id: 'hideovertime',
|
id: 'hideovertime',
|
||||||
|
|||||||
@@ -29,6 +29,12 @@
|
|||||||
font-size: clamp(32px, 4.5vw, 64px);
|
font-size: clamp(32px, 4.5vw, 64px);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
max-width: min(200px, 20vw);
|
||||||
|
max-height: min(100px, 20vh);
|
||||||
}
|
}
|
||||||
|
|
||||||
.clock-container {
|
.clock-container {
|
||||||
@@ -70,7 +76,7 @@
|
|||||||
grid-area: schedule;
|
grid-area: schedule;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin-left: clamp(16px, 5vw, 64px);;
|
margin-left: clamp(16px, 5vw, 64px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-nav-container {
|
.schedule-nav-container {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Schedule from '../../../common/components/schedule/Schedule';
|
|||||||
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
||||||
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
||||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||||
|
import ViewLogo from '../../../common/components/view-logo/ViewLogo';
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||||
@@ -24,26 +25,26 @@ export const MotionTitleCard = motion(TitleCard);
|
|||||||
|
|
||||||
interface BackstageProps {
|
interface BackstageProps {
|
||||||
customFields: CustomFields;
|
customFields: CustomFields;
|
||||||
|
general: ProjectData;
|
||||||
isMirrored: boolean;
|
isMirrored: boolean;
|
||||||
publicEventNow: OntimeEvent | null;
|
publicEventNow: OntimeEvent | null;
|
||||||
publicEventNext: OntimeEvent | null;
|
publicEventNext: OntimeEvent | null;
|
||||||
time: ViewExtendedTimer;
|
time: ViewExtendedTimer;
|
||||||
events: OntimeEvent[];
|
events: OntimeEvent[];
|
||||||
publicSelectedId: string | null;
|
publicSelectedId: string | null;
|
||||||
general: ProjectData;
|
|
||||||
settings: Settings | undefined;
|
settings: Settings | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Public(props: BackstageProps) {
|
export default function Public(props: BackstageProps) {
|
||||||
const {
|
const {
|
||||||
customFields,
|
customFields,
|
||||||
|
general,
|
||||||
isMirrored,
|
isMirrored,
|
||||||
publicEventNow,
|
publicEventNow,
|
||||||
publicEventNext,
|
publicEventNext,
|
||||||
time,
|
time,
|
||||||
events,
|
events,
|
||||||
publicSelectedId,
|
publicSelectedId,
|
||||||
general,
|
|
||||||
settings,
|
settings,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
@@ -66,6 +67,7 @@ export default function Public(props: BackstageProps) {
|
|||||||
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
|
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
|
||||||
<ViewParamsEditor viewOptions={publicOptions} />
|
<ViewParamsEditor viewOptions={publicOptions} />
|
||||||
<div className='project-header'>
|
<div className='project-header'>
|
||||||
|
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
|
||||||
{general.title}
|
{general.title}
|
||||||
<div className='clock-container'>
|
<div className='clock-container'>
|
||||||
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
||||||
|
|||||||
@@ -204,6 +204,13 @@ $orange-active: #f60;
|
|||||||
background-color: var(--studio-idle, $red-idle);
|
background-color: var(--studio-idle, $red-idle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.logo {
|
||||||
|
position: absolute;
|
||||||
|
top: 2vw;
|
||||||
|
right: 2vw;
|
||||||
|
max-width: min(200px, 20vw);
|
||||||
|
max-height: min(100px, 20vh);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 1200px) {
|
@media only screen and (max-width: 1200px) {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import type { MaybeString, OntimeEvent, OntimeRundown, Settings } from 'ontime-types';
|
import type { MaybeString, OntimeEvent, OntimeRundown, ProjectData, Settings } from 'ontime-types';
|
||||||
import { Playback } from 'ontime-types';
|
import { Playback } from 'ontime-types';
|
||||||
import { millisToString, removeSeconds, secondsInMillis } from 'ontime-utils';
|
import { millisToString, removeSeconds, secondsInMillis } from 'ontime-utils';
|
||||||
|
|
||||||
import { FitText } from '../../../common/components/fit-text/FitText';
|
import { FitText } from '../../../common/components/fit-text/FitText';
|
||||||
|
import ViewLogo from '../../../common/components/view-logo/ViewLogo';
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||||
@@ -16,10 +17,11 @@ import StudioClockSchedule from './StudioClockSchedule';
|
|||||||
import './StudioClock.scss';
|
import './StudioClock.scss';
|
||||||
|
|
||||||
interface StudioClockProps {
|
interface StudioClockProps {
|
||||||
isMirrored: boolean;
|
|
||||||
eventNext: OntimeEvent | null;
|
|
||||||
time: ViewExtendedTimer;
|
|
||||||
backstageEvents: OntimeRundown;
|
backstageEvents: OntimeRundown;
|
||||||
|
eventNext: OntimeEvent | null;
|
||||||
|
general: ProjectData;
|
||||||
|
isMirrored: boolean;
|
||||||
|
time: ViewExtendedTimer;
|
||||||
selectedId: MaybeString;
|
selectedId: MaybeString;
|
||||||
nextId: MaybeString;
|
nextId: MaybeString;
|
||||||
onAir: boolean;
|
onAir: boolean;
|
||||||
@@ -27,7 +29,7 @@ interface StudioClockProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function StudioClock(props: StudioClockProps) {
|
export default function StudioClock(props: StudioClockProps) {
|
||||||
const { isMirrored, eventNext, time, backstageEvents, selectedId, nextId, onAir, settings } = props;
|
const { backstageEvents, eventNext, general, isMirrored, time, selectedId, nextId, onAir, settings } = props;
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
@@ -65,6 +67,7 @@ export default function StudioClock(props: StudioClockProps) {
|
|||||||
className={`studio-clock ${isMirrored ? 'mirror' : ''} ${hideRight ? 'hide-right' : ''}`}
|
className={`studio-clock ${isMirrored ? 'mirror' : ''} ${hideRight ? 'hide-right' : ''}`}
|
||||||
data-testid='studio-view'
|
data-testid='studio-view'
|
||||||
>
|
>
|
||||||
|
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
|
||||||
<ViewParamsEditor viewOptions={studioClockOptions} />
|
<ViewParamsEditor viewOptions={studioClockOptions} />
|
||||||
<div className='clock-container'>
|
<div className='clock-container'>
|
||||||
{hasAmPm && <div className='clock__ampm'>{hasAmPm}</div>}
|
{hasAmPm && <div className='clock__ampm'>{hasAmPm}</div>}
|
||||||
|
|||||||
@@ -194,4 +194,12 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
position: absolute;
|
||||||
|
top: 2vw;
|
||||||
|
left: 2vw;
|
||||||
|
max-width: min(200px, 20vw);
|
||||||
|
max-height: min(100px, 20vh);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
MessageState,
|
MessageState,
|
||||||
OntimeEvent,
|
OntimeEvent,
|
||||||
Playback,
|
Playback,
|
||||||
|
ProjectData,
|
||||||
Settings,
|
Settings,
|
||||||
SimpleTimerState,
|
SimpleTimerState,
|
||||||
TimerPhase,
|
TimerPhase,
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
import { FitText } from '../../../common/components/fit-text/FitText';
|
import { FitText } from '../../../common/components/fit-text/FitText';
|
||||||
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
||||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||||
|
import ViewLogo from '../../../common/components/view-logo/ViewLogo';
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||||
@@ -50,6 +52,7 @@ interface TimerProps {
|
|||||||
customFields: CustomFields;
|
customFields: CustomFields;
|
||||||
eventNext: OntimeEvent | null;
|
eventNext: OntimeEvent | null;
|
||||||
eventNow: OntimeEvent | null;
|
eventNow: OntimeEvent | null;
|
||||||
|
general: ProjectData;
|
||||||
isMirrored: boolean;
|
isMirrored: boolean;
|
||||||
message: MessageState;
|
message: MessageState;
|
||||||
settings: Settings | undefined;
|
settings: Settings | undefined;
|
||||||
@@ -58,7 +61,8 @@ interface TimerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Timer(props: TimerProps) {
|
export default function Timer(props: TimerProps) {
|
||||||
const { auxTimer, customFields, eventNow, eventNext, isMirrored, message, settings, time, viewSettings } = props;
|
const { auxTimer, customFields, eventNow, eventNext, general, isMirrored, message, settings, time, viewSettings } =
|
||||||
|
props;
|
||||||
|
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
@@ -114,10 +118,14 @@ export default function Timer(props: TimerProps) {
|
|||||||
const finished = time.phase === TimerPhase.Overtime;
|
const finished = time.phase === TimerPhase.Overtime;
|
||||||
const totalTime = (time.duration ?? 0) + (time.addedTime ?? 0);
|
const totalTime = (time.duration ?? 0) + (time.addedTime ?? 0);
|
||||||
|
|
||||||
const shouldShowModifiers = time.timerType !== TimerType.Clock && time.timerType !== TimerType.CountUp;
|
const shouldShowModifiers = time.timerType === TimerType.CountDown || time.timerType === TimerType.TimeToEnd;
|
||||||
const showEndMessage = finished && viewSettings.endMessage;
|
const showEndMessage = shouldShowModifiers && finished && viewSettings.endMessage;
|
||||||
const showProgress = time.playback !== Playback.Stop;
|
const showProgress =
|
||||||
const showFinished = finished && (shouldShowModifiers || showEndMessage);
|
eventNow !== null &&
|
||||||
|
time.timerType !== TimerType.None &&
|
||||||
|
time.timerType !== TimerType.Clock &&
|
||||||
|
time.playback !== Playback.Stop;
|
||||||
|
const showFinished = shouldShowModifiers && finished && (shouldShowModifiers || showEndMessage);
|
||||||
const showWarning = shouldShowModifiers && time.phase === TimerPhase.Warning;
|
const showWarning = shouldShowModifiers && time.phase === TimerPhase.Warning;
|
||||||
const showDanger = shouldShowModifiers && time.phase === TimerPhase.Danger;
|
const showDanger = shouldShowModifiers && time.phase === TimerPhase.Danger;
|
||||||
const showClock = time.timerType !== TimerType.Clock;
|
const showClock = time.timerType !== TimerType.Clock;
|
||||||
@@ -164,6 +172,8 @@ export default function Timer(props: TimerProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'>
|
<div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'>
|
||||||
|
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
|
||||||
|
|
||||||
<ViewParamsEditor viewOptions={timerOptions} />
|
<ViewParamsEditor viewOptions={timerOptions} />
|
||||||
<div className={message.timer.blackout ? 'blackout blackout--active' : 'blackout'} />
|
<div className={message.timer.blackout ? 'blackout blackout--active' : 'blackout'} />
|
||||||
{!userOptions.hideMessage && (
|
{!userOptions.hideMessage && (
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const navigatorConstants = [
|
|||||||
{ url: 'lower', label: 'Lower Thirds' },
|
{ url: 'lower', label: 'Lower Thirds' },
|
||||||
{ url: 'studio', label: 'Studio Clock' },
|
{ url: 'studio', label: 'Studio Clock' },
|
||||||
{ url: 'countdown', label: 'Countdown' },
|
{ url: 'countdown', label: 'Countdown' },
|
||||||
|
{ url: 'info', label: 'Project Info' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// default time format to use for users in 12 hour clocks
|
// default time format to use for users in 12 hour clocks
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
@use '../../theme/viewerDefs' as *;
|
||||||
|
|
||||||
|
.project {
|
||||||
|
margin: 0;
|
||||||
|
box-sizing: border-box; /* reset */
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%; /* restrict the page width to viewport */
|
||||||
|
height: 100vh;
|
||||||
|
|
||||||
|
font-family: var(--font-family-override, $viewer-font-family);
|
||||||
|
background: var(--background-color-override, $viewer-background-color);
|
||||||
|
color: var(--color-override, $viewer-color);
|
||||||
|
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
max-width: min(200px, 30vw);
|
||||||
|
max-height: min(100px, 20vh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
flex: 1;
|
||||||
|
max-height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
width: min(calc(100vw - 4rem), 800px);
|
||||||
|
font-size: clamp(16px, 1.5vw, 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info__label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--label-color-override, $viewer-label-color);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.info__label {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
a.info__value {
|
||||||
|
color: $action-text-color;
|
||||||
|
&:hover {
|
||||||
|
color: $ontime-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { ProjectData } from 'ontime-types';
|
||||||
|
|
||||||
|
import Empty from '../../common/components/state/Empty';
|
||||||
|
import ViewLogo from '../../common/components/view-logo/ViewLogo';
|
||||||
|
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
|
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||||
|
|
||||||
|
import BackstageInfo from './backstage-info/BackstageInfo';
|
||||||
|
import PublicInfo from './public-info/PublicInfo';
|
||||||
|
import { projectInfoOptions } from './projectInfo.options';
|
||||||
|
|
||||||
|
import './ProjectInfo.scss';
|
||||||
|
|
||||||
|
interface ProjectInfoProps {
|
||||||
|
general: ProjectData;
|
||||||
|
isMirrored: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectInfoProps(props: ProjectInfoProps) {
|
||||||
|
const { general, isMirrored } = props;
|
||||||
|
|
||||||
|
useWindowTitle('Project info');
|
||||||
|
|
||||||
|
if (!general) {
|
||||||
|
return <Empty text='No data found' />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'>
|
||||||
|
<ViewParamsEditor viewOptions={projectInfoOptions} />
|
||||||
|
{general.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
|
||||||
|
<div className='info'>
|
||||||
|
{general.title && (
|
||||||
|
<>
|
||||||
|
<div className='info__label'>Title</div>
|
||||||
|
<div className='info__value'>{general.title}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<BackstageInfo general={general} />
|
||||||
|
<PublicInfo general={general} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { ProjectData } from 'ontime-types';
|
||||||
|
|
||||||
|
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||||
|
|
||||||
|
interface BackstageInfoProps {
|
||||||
|
general: ProjectData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BackstageInfo(props: BackstageInfoProps) {
|
||||||
|
const { general } = props;
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const showBackstage = isStringBoolean(searchParams.get('showBackstage'));
|
||||||
|
|
||||||
|
if (!showBackstage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{general.backstageInfo && (
|
||||||
|
<>
|
||||||
|
<div className='info__label'>Backstage info</div>
|
||||||
|
<div className='info__value'>{general.backstageInfo}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{general.backstageUrl && (
|
||||||
|
<>
|
||||||
|
<div className='info__label'>Backstage URL</div>
|
||||||
|
<a href={general.backstageUrl} target='_blank' rel='noreferrer' className='info__value'>
|
||||||
|
{general.backstageUrl}
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { ViewOption } from '../../common/components/view-params-editor/types';
|
||||||
|
|
||||||
|
export const projectInfoOptions: ViewOption[] = [
|
||||||
|
{ section: 'Data visibility' },
|
||||||
|
{
|
||||||
|
id: 'showBackstage',
|
||||||
|
title: 'Show backstage Data',
|
||||||
|
description: 'Weather to show fields related to the backstage views',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'showPublic',
|
||||||
|
title: 'Show Public Data',
|
||||||
|
description: 'Weather to show fields related to the public views',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { ProjectData } from 'ontime-types';
|
||||||
|
|
||||||
|
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||||
|
|
||||||
|
interface PublicInfoProps {
|
||||||
|
general: ProjectData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PublicInfo(props: PublicInfoProps) {
|
||||||
|
const { general } = props;
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const showPublic = isStringBoolean(searchParams.get('showPublic'));
|
||||||
|
|
||||||
|
if (!showPublic) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{general.publicInfo && (
|
||||||
|
<>
|
||||||
|
<div className='info__label'>Public info</div>
|
||||||
|
<div className='info__value'>{general.publicInfo}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{general.publicUrl && (
|
||||||
|
<>
|
||||||
|
<div className='info__label'>Public URL</div>
|
||||||
|
<a href={general.publicUrl} target='_blank' rel='noreferrer' className='info__value'>
|
||||||
|
{general.publicUrl}
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,10 +18,17 @@
|
|||||||
font-size: clamp(32px, 4.5vw, 64px);
|
font-size: clamp(32px, 4.5vw, 64px);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
max-width: min(200px, 20vw);
|
||||||
|
max-height: min(100px, 20vh);
|
||||||
}
|
}
|
||||||
|
|
||||||
.clock-container {
|
.clock-container {
|
||||||
|
margin-left: auto;
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: clamp(16px, 1.5vw, 24px);
|
font-size: clamp(16px, 1.5vw, 24px);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { MaybeString, OntimeEvent, ProjectData, Runtime, Settings } from 'ontime-types';
|
import { MaybeString, OntimeEvent, ProjectData, Runtime, Settings } from 'ontime-types';
|
||||||
|
|
||||||
|
import ViewLogo from '../../common/components/view-logo/ViewLogo';
|
||||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||||
import { ViewExtendedTimer } from '../../common/models/TimeManager.type';
|
import { ViewExtendedTimer } from '../../common/models/TimeManager.type';
|
||||||
@@ -71,9 +72,10 @@ export default function TimelinePage(props: TimelinePageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className='timeline'>
|
<div className='timeline' data-testid='timeline-view'>
|
||||||
<ViewParamsEditor viewOptions={progressOptions} />
|
<ViewParamsEditor viewOptions={progressOptions} />
|
||||||
<div className='project-header'>
|
<div className='project-header'>
|
||||||
|
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
|
||||||
{general.title}
|
{general.title}
|
||||||
<div className='clock-container'>
|
<div className='clock-container'>
|
||||||
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime",
|
"name": "ontime",
|
||||||
"version": "3.7.0",
|
"version": "3.8.0",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ function makeViewMenu(clientUrl) {
|
|||||||
makeItemOpenInBrowser('Editor', `${clientUrl}/editor`),
|
makeItemOpenInBrowser('Editor', `${clientUrl}/editor`),
|
||||||
makeItemOpenInBrowser('Cuesheet', `${clientUrl}/cuesheet`),
|
makeItemOpenInBrowser('Cuesheet', `${clientUrl}/cuesheet`),
|
||||||
makeItemOpenInBrowser('Operator', `${clientUrl}/op`),
|
makeItemOpenInBrowser('Operator', `${clientUrl}/op`),
|
||||||
|
makeItemOpenInBrowser('Project info', `${clientUrl}/info`),
|
||||||
|
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ role: 'forceReload' },
|
{ role: 'forceReload' },
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "ontime-server",
|
"name": "ontime-server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"version": "3.7.0",
|
"version": "3.8.0",
|
||||||
"exports": "./src/index.js",
|
"exports": "./src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@googleapis/sheets": "^5.0.5",
|
"@googleapis/sheets": "^5.0.5",
|
||||||
|
|||||||
@@ -100,6 +100,16 @@ export class SocketServer implements IAdapter {
|
|||||||
const message = JSON.parse(data);
|
const message = JSON.parse(data);
|
||||||
const { type, payload } = message;
|
const { type, payload } = message;
|
||||||
|
|
||||||
|
if (type === 'ping') {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'pong',
|
||||||
|
payload,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'get-client-name') {
|
if (type === 'get-client-name') {
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import { DatabaseModel, ErrorResponse, MessageResponse, ProjectFileListResponse } from 'ontime-types';
|
import {
|
||||||
|
DatabaseModel,
|
||||||
|
ErrorResponse,
|
||||||
|
MessageResponse,
|
||||||
|
ProjectFileListResponse,
|
||||||
|
ProjectLogoResponse,
|
||||||
|
} from 'ontime-types';
|
||||||
import { getErrorMessage } from 'ontime-utils';
|
import { getErrorMessage } from 'ontime-utils';
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
|
|
||||||
import { doesProjectExist, handleUploaded } from '../../services/project-service/projectServiceUtils.js';
|
import {
|
||||||
|
doesProjectExist,
|
||||||
|
handleImageUpload,
|
||||||
|
handleUploaded,
|
||||||
|
} from '../../services/project-service/projectServiceUtils.js';
|
||||||
import * as projectService from '../../services/project-service/ProjectService.js';
|
import * as projectService from '../../services/project-service/ProjectService.js';
|
||||||
|
|
||||||
export async function patchPartialProjectFile(req: Request, res: Response<DatabaseModel | ErrorResponse>) {
|
export async function patchPartialProjectFile(req: Request, res: Response<DatabaseModel | ErrorResponse>) {
|
||||||
@@ -39,6 +49,7 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
|
|||||||
publicInfo: req.body?.publicInfo ?? '',
|
publicInfo: req.body?.publicInfo ?? '',
|
||||||
backstageUrl: req.body?.backstageUrl ?? '',
|
backstageUrl: req.body?.backstageUrl ?? '',
|
||||||
backstageInfo: req.body?.backstageInfo ?? '',
|
backstageInfo: req.body?.backstageInfo ?? '',
|
||||||
|
projectLogo: req.body?.projectLogo ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,6 +135,30 @@ export async function postProjectFile(req: Request, res: Response<MessageRespons
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads an image file to be used as a project logo.
|
||||||
|
* The image file is saved in the logo directory.
|
||||||
|
*/
|
||||||
|
export async function postProjectLogo(req: Request, res: Response<ProjectLogoResponse | ErrorResponse>) {
|
||||||
|
if (!req.file) {
|
||||||
|
res.status(400).send({ message: 'File not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { filename, path } = req.file;
|
||||||
|
|
||||||
|
const logoFilename = await handleImageUpload(path, filename);
|
||||||
|
|
||||||
|
res.status(201).send({
|
||||||
|
logoFilename,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(500).send({ message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves and lists all project files from the uploads directory.
|
* Retrieves and lists all project files from the uploads directory.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -12,8 +12,22 @@ const filterProjectFile = (_req: Request, file: Express.Multer.File, cb: FileFil
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filterImageFile = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
|
||||||
|
if (file.mimetype.includes('image')) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(null, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build multer uploader for a single file
|
// Build multer uploader for a single file
|
||||||
export const uploadProjectFile = multer({
|
export const uploadProjectFile = multer({
|
||||||
storage,
|
storage,
|
||||||
fileFilter: filterProjectFile,
|
fileFilter: filterProjectFile,
|
||||||
}).single('project');
|
}).single('project');
|
||||||
|
|
||||||
|
// Build multer uploader for a single image file
|
||||||
|
export const uploadImageFile = multer({
|
||||||
|
storage,
|
||||||
|
fileFilter: filterImageFile,
|
||||||
|
}).single('image');
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const validateNewProject = [
|
|||||||
body('publicInfo').optional().isString().trim(),
|
body('publicInfo').optional().isString().trim(),
|
||||||
body('backstageUrl').optional().isString().trim(),
|
body('backstageUrl').optional().isString().trim(),
|
||||||
body('backstageInfo').optional().isString().trim(),
|
body('backstageInfo').optional().isString().trim(),
|
||||||
|
body('projectLogo').optional().isString().trim(),
|
||||||
body('endMessage').optional().isString().trim(),
|
body('endMessage').optional().isString().trim(),
|
||||||
|
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { ErrorResponse, ProjectData } from 'ontime-types';
|
|||||||
import { getErrorMessage } from 'ontime-utils';
|
import { getErrorMessage } from 'ontime-utils';
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
import { removeUndefined } from '../../utils/parserUtils.js';
|
import { deleteFile, removeUndefined } from '../../utils/parserUtils.js';
|
||||||
import { failEmptyObjects } from '../../utils/routerUtils.js';
|
import { failEmptyObjects } from '../../utils/routerUtils.js';
|
||||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||||
|
import { publicDir } from '../../setup/index.js';
|
||||||
|
|
||||||
export async function getProjectData(_req: Request, res: Response<ProjectData>) {
|
export function getProjectData(_req: Request, res: Response<ProjectData>) {
|
||||||
res.json(getDataProvider().getProjectData());
|
res.json(getDataProvider().getProjectData());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,6 +19,8 @@ export async function postProjectData(req: Request, res: Response<ProjectData |
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const currentProjectData = getDataProvider().getProjectData();
|
||||||
|
|
||||||
const newEvent: Partial<ProjectData> = removeUndefined({
|
const newEvent: Partial<ProjectData> = removeUndefined({
|
||||||
title: req.body?.title,
|
title: req.body?.title,
|
||||||
description: req.body?.description,
|
description: req.body?.description,
|
||||||
@@ -25,8 +29,20 @@ export async function postProjectData(req: Request, res: Response<ProjectData |
|
|||||||
backstageUrl: req.body?.backstageUrl,
|
backstageUrl: req.body?.backstageUrl,
|
||||||
backstageInfo: req.body?.backstageInfo,
|
backstageInfo: req.body?.backstageInfo,
|
||||||
endMessage: req.body?.endMessage,
|
endMessage: req.body?.endMessage,
|
||||||
|
projectLogo: req.body?.projectLogo,
|
||||||
});
|
});
|
||||||
|
|
||||||
const newData = await getDataProvider().setProjectData(newEvent);
|
const newData = await getDataProvider().setProjectData(newEvent);
|
||||||
|
|
||||||
|
// Delete the old logo if the new logo is empty
|
||||||
|
if (!newData.projectLogo && currentProjectData.projectLogo) {
|
||||||
|
const filePath = join(publicDir.logoDir, currentProjectData.projectLogo);
|
||||||
|
|
||||||
|
deleteFile(filePath).catch((_error) => {
|
||||||
|
/** we do not handle this error */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.status(200).send(newData);
|
res.status(200).send(newData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import express from 'express';
|
|||||||
|
|
||||||
import { getProjectData, postProjectData } from './project.controller.js';
|
import { getProjectData, postProjectData } from './project.controller.js';
|
||||||
import { projectSanitiser } from './project.validation.js';
|
import { projectSanitiser } from './project.validation.js';
|
||||||
|
import { uploadImageFile } from '../db/db.middleware.js';
|
||||||
|
import { postProjectLogo } from '../db/db.controller.js';
|
||||||
|
|
||||||
export const router = express.Router();
|
export const router = express.Router();
|
||||||
|
|
||||||
router.get('/', getProjectData);
|
router.get('/', getProjectData);
|
||||||
router.post('/', projectSanitiser, postProjectData);
|
router.post('/', projectSanitiser, postProjectData);
|
||||||
|
router.post('/upload', uploadImageFile, postProjectLogo);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const projectSanitiser = [
|
|||||||
body('backstageUrl').optional().isString().trim(),
|
body('backstageUrl').optional().isString().trim(),
|
||||||
body('backstageInfo').optional().isString().trim(),
|
body('backstageInfo').optional().isString().trim(),
|
||||||
body('endMessage').optional().isString().trim(),
|
body('endMessage').optional().isString().trim(),
|
||||||
|
body('projectLogo').optional({ nullable: true }).isString().trim(),
|
||||||
|
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ export const startServer = async (
|
|||||||
direction: SimpleDirection.CountDown,
|
direction: SimpleDirection.CountDown,
|
||||||
},
|
},
|
||||||
frozen: false,
|
frozen: false,
|
||||||
|
ping: -1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// initialise logging service, escalateErrorFn is only exists in electron
|
// initialise logging service, escalateErrorFn is only exists in electron
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ describe('safeMerge', () => {
|
|||||||
backstageUrl: 'existing backstageUrl',
|
backstageUrl: 'existing backstageUrl',
|
||||||
publicInfo: 'existing backstageInfo',
|
publicInfo: 'existing backstageInfo',
|
||||||
backstageInfo: 'existing backstageInfo',
|
backstageInfo: 'existing backstageInfo',
|
||||||
|
projectLogo: null,
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
app: 'ontime',
|
app: 'ontime',
|
||||||
@@ -77,6 +78,7 @@ describe('safeMerge', () => {
|
|||||||
publicInfo: 'new public info',
|
publicInfo: 'new public info',
|
||||||
backstageUrl: 'existing backstageUrl',
|
backstageUrl: 'existing backstageUrl',
|
||||||
backstageInfo: 'existing backstageInfo',
|
backstageInfo: 'existing backstageInfo',
|
||||||
|
projectLogo: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const dbModel: DatabaseModel = {
|
|||||||
publicInfo: '',
|
publicInfo: '',
|
||||||
backstageUrl: '',
|
backstageUrl: '',
|
||||||
backstageInfo: '',
|
backstageInfo: '',
|
||||||
|
projectLogo: null,
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
app: 'ontime',
|
app: 'ontime',
|
||||||
|
|||||||
@@ -357,6 +357,7 @@ export const demoDb: DatabaseModel = {
|
|||||||
publicInfo: 'Rehearsal Schedule - Turin 2022',
|
publicInfo: 'Rehearsal Schedule - Turin 2022',
|
||||||
backstageUrl: 'www.github.com/cpvalente/ontime',
|
backstageUrl: 'www.github.com/cpvalente/ontime',
|
||||||
backstageInfo: 'Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal',
|
backstageInfo: 'Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal',
|
||||||
|
projectLogo: null,
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
app: 'ontime',
|
app: 'ontime',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { copyFile, readFile, rename, stat } from 'fs/promises';
|
|||||||
import { extname, join } from 'path';
|
import { extname, join } from 'path';
|
||||||
|
|
||||||
import { publicDir } from '../../setup/index.js';
|
import { publicDir } from '../../setup/index.js';
|
||||||
import { getFilesFromFolder, removeFileExtension } from '../../utils/fileManagement.js';
|
import { ensureDirectory, getFilesFromFolder, removeFileExtension } from '../../utils/fileManagement.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the upload of a new project file
|
* Handles the upload of a new project file
|
||||||
@@ -17,6 +17,14 @@ export async function handleUploaded(filePath: string, name: string) {
|
|||||||
await rename(filePath, newFilePath);
|
await rename(filePath, newFilePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function handleImageUpload(filePath: string, name: string): Promise<string> {
|
||||||
|
ensureDirectory(publicDir.logoDir);
|
||||||
|
const newFilePath = join(publicDir.logoDir, name);
|
||||||
|
await rename(filePath, newFilePath);
|
||||||
|
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asynchronously retrieves and returns an array of project files from the 'uploads' folder.
|
* Asynchronously retrieves and returns an array of project files from the 'uploads' folder.
|
||||||
* Each file in the 'uploads' folder is checked, and only those with a '.json' extension are processed.
|
* Each file in the 'uploads' folder is checked, and only those with a '.json' extension are processed.
|
||||||
|
|||||||
@@ -228,10 +228,16 @@ function updateRuntimeOnChange() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NotifyChangesOptions = {
|
||||||
|
timer?: boolean | string[]; // whether to notify the timer, could be a yes / no or an array of affected IDs
|
||||||
|
external?: boolean; // whether to notify external services
|
||||||
|
reload?: boolean; // major change, clients should consider refetching everything
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notify services of changes in the rundown
|
* Notify services of changes in the rundown
|
||||||
*/
|
*/
|
||||||
function notifyChanges(options: { timer?: boolean | string[]; external?: boolean }) {
|
function notifyChanges(options: NotifyChangesOptions) {
|
||||||
if (options.timer) {
|
if (options.timer) {
|
||||||
const playableEvents = getPlayableEvents();
|
const playableEvents = getPlayableEvents();
|
||||||
|
|
||||||
@@ -249,6 +255,7 @@ function notifyChanges(options: { timer?: boolean | string[]; external?: boolean
|
|||||||
// advice socket subscribers of change
|
// advice socket subscribers of change
|
||||||
const payload = {
|
const payload = {
|
||||||
changes: Array.isArray(options.timer) ? options.timer : undefined,
|
changes: Array.isArray(options.timer) ? options.timer : undefined,
|
||||||
|
reload: options.reload,
|
||||||
revision: cache.getMetadata().revision,
|
revision: cache.getMetadata().revision,
|
||||||
};
|
};
|
||||||
sendRefetch(payload);
|
sendRefetch(payload);
|
||||||
@@ -266,7 +273,7 @@ export async function initRundown(rundown: Readonly<OntimeRundown>, customFields
|
|||||||
updateRuntimeOnChange();
|
updateRuntimeOnChange();
|
||||||
|
|
||||||
// notify timer of change
|
// notify timer of change
|
||||||
notifyChanges({ timer: true });
|
notifyChanges({ timer: true, external: true, reload: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setFrozenState(state: boolean) {
|
export async function setFrozenState(state: boolean) {
|
||||||
|
|||||||
@@ -21,4 +21,5 @@ export const config = {
|
|||||||
filename: 'override.css',
|
filename: 'override.css',
|
||||||
},
|
},
|
||||||
uploads: 'uploads',
|
uploads: 'uploads',
|
||||||
|
logo: 'logo',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export const publicDir = {
|
|||||||
userDir: join(resolvePublicDirectory, config.user),
|
userDir: join(resolvePublicDirectory, config.user),
|
||||||
/** path to external styles override */
|
/** path to external styles override */
|
||||||
stylesDir: join(resolvePublicDirectory, config.user, config.styles.directory),
|
stylesDir: join(resolvePublicDirectory, config.user, config.styles.directory),
|
||||||
|
logoDir: join(resolvePublicDirectory, config.user, config.logo),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -60,6 +60,34 @@ describe('parseProject()', () => {
|
|||||||
expect(result).toBeTypeOf('object');
|
expect(result).toBeTypeOf('object');
|
||||||
expect(errorEmitter).toHaveBeenCalledOnce();
|
expect(errorEmitter).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('test migration with adding the logo field v3.8.0', () => {
|
||||||
|
const errorEmitter = vi.fn();
|
||||||
|
const result = parseProject(
|
||||||
|
{
|
||||||
|
//@ts-expect-error -- checking migration when the logo field is added
|
||||||
|
project: {
|
||||||
|
title: 'title',
|
||||||
|
description: 'description',
|
||||||
|
publicUrl: 'publicUrl',
|
||||||
|
publicInfo: 'publicInfo',
|
||||||
|
backstageUrl: 'backstageUrl',
|
||||||
|
backstageInfo: 'backstageInfo',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
errorEmitter,
|
||||||
|
);
|
||||||
|
expect(result).toStrictEqual({
|
||||||
|
title: 'title',
|
||||||
|
description: 'description',
|
||||||
|
publicUrl: 'publicUrl',
|
||||||
|
publicInfo: 'publicInfo',
|
||||||
|
backstageUrl: 'backstageUrl',
|
||||||
|
backstageInfo: 'backstageInfo',
|
||||||
|
projectLogo: null,
|
||||||
|
});
|
||||||
|
expect(errorEmitter).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('parseSettings()', () => {
|
describe('parseSettings()', () => {
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ export function parseProject(data: Partial<DatabaseModel>, emitError?: ErrorEmit
|
|||||||
publicInfo: data.project.publicInfo ?? dbModel.project.publicInfo,
|
publicInfo: data.project.publicInfo ?? dbModel.project.publicInfo,
|
||||||
backstageUrl: data.project.backstageUrl ?? dbModel.project.backstageUrl,
|
backstageUrl: data.project.backstageUrl ?? dbModel.project.backstageUrl,
|
||||||
backstageInfo: data.project.backstageInfo ?? dbModel.project.backstageInfo,
|
backstageInfo: data.project.backstageInfo ?? dbModel.project.backstageInfo,
|
||||||
|
projectLogo: data.project.projectLogo ?? dbModel.project.projectLogo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,55 +4,67 @@ test.describe('test view navigation feature', () => {
|
|||||||
test('user flow through links', async ({ page }) => {
|
test('user flow through links', async ({ page }) => {
|
||||||
// default view is timer view
|
// default view is timer view
|
||||||
await page.goto('http://localhost:4001/');
|
await page.goto('http://localhost:4001/');
|
||||||
await page.locator('data-test-id=timer-view');
|
page.locator('data-test-id=timer-view');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'toggle menu' }).click();
|
await page.getByRole('button', { name: 'toggle menu' }).click();
|
||||||
await page.locator('data-test-id=navigation__menu');
|
page.locator('data-test-id=navigation__menu');
|
||||||
await page.getByRole('link', { name: 'Minimal Timer' }).click();
|
await page.getByRole('link', { name: 'Minimal Timer' }).click();
|
||||||
await page.locator('data-test-id=minimal-timer');
|
page.locator('data-test-id=minimal-timer');
|
||||||
await expect(page).toHaveURL('http://localhost:4001/minimal');
|
await expect(page).toHaveURL('http://localhost:4001/minimal');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'toggle menu' }).click();
|
await page.getByRole('button', { name: 'toggle menu' }).click();
|
||||||
await page.locator('data-test-id=navigation__menu');
|
page.locator('data-test-id=navigation__menu');
|
||||||
await page.getByRole('link', { name: 'Wall Clock', exact: true }).click();
|
await page.getByRole('link', { name: 'Wall Clock', exact: true }).click();
|
||||||
await page.locator('data-test-id=clock-view');
|
page.locator('data-test-id=clock-view');
|
||||||
await expect(page).toHaveURL('http://localhost:4001/clock');
|
await expect(page).toHaveURL('http://localhost:4001/clock');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'toggle menu' }).click();
|
await page.getByRole('button', { name: 'toggle menu' }).click();
|
||||||
await page.locator('data-test-id=navigation__menu');
|
page.locator('data-test-id=navigation__menu');
|
||||||
|
await page.getByRole('link', { name: 'timeline' }).click();
|
||||||
|
page.locator('data-test-id=timeline-view');
|
||||||
|
await expect(page).toHaveURL('http://localhost:4001/timeline');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'toggle menu' }).click();
|
||||||
|
page.locator('data-test-id=navigation__menu');
|
||||||
await page.getByRole('link', { name: 'Backstage' }).click();
|
await page.getByRole('link', { name: 'Backstage' }).click();
|
||||||
await page.locator('data-test-id=backstage-view');
|
page.locator('data-test-id=backstage-view');
|
||||||
await expect(page).toHaveURL('http://localhost:4001/backstage');
|
await expect(page).toHaveURL('http://localhost:4001/backstage');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'toggle menu' }).click();
|
await page.getByRole('button', { name: 'toggle menu' }).click();
|
||||||
await page.locator('data-test-id=navigation__menu');
|
page.locator('data-test-id=navigation__menu');
|
||||||
await page.getByRole('link', { name: 'Public' }).click();
|
await page.getByRole('link', { name: 'Public' }).click();
|
||||||
await page.locator('data-test-id=public-view');
|
page.locator('data-test-id=public-view');
|
||||||
await expect(page).toHaveURL('http://localhost:4001/public');
|
await expect(page).toHaveURL('http://localhost:4001/public');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'toggle menu' }).click();
|
await page.getByRole('button', { name: 'toggle menu' }).click();
|
||||||
await page.locator('data-test-id=navigation__menu');
|
page.locator('data-test-id=navigation__menu');
|
||||||
await page.getByRole('link', { name: 'Lower Thirds' }).click();
|
await page.getByRole('link', { name: 'Lower Thirds' }).click();
|
||||||
await expect(page).toHaveURL('http://localhost:4001/lower');
|
await expect(page).toHaveURL('http://localhost:4001/lower');
|
||||||
const errorBoundary = await page.locator('data-test-id=error-container');
|
const errorBoundary = page.locator('data-test-id=error-container');
|
||||||
await expect(errorBoundary).toHaveCount(0);
|
await expect(errorBoundary).toHaveCount(0);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'toggle menu' }).click();
|
await page.getByRole('button', { name: 'toggle menu' }).click();
|
||||||
await page.locator('data-test-id=navigation__menu');
|
page.locator('data-test-id=navigation__menu');
|
||||||
await page.getByRole('link', { name: 'Studio Clock' }).click();
|
await page.getByRole('link', { name: 'Studio Clock' }).click();
|
||||||
await page.locator('data-test-id=studio-view');
|
page.locator('data-test-id=studio-view');
|
||||||
await expect(page).toHaveURL('http://localhost:4001/studio');
|
await expect(page).toHaveURL('http://localhost:4001/studio');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'toggle menu' }).click();
|
await page.getByRole('button', { name: 'toggle menu' }).click();
|
||||||
await page.locator('data-test-id=navigation__menu');
|
page.locator('data-test-id=navigation__menu');
|
||||||
await page.getByRole('link', { name: 'Countdown' }).click();
|
await page.getByRole('link', { name: 'Countdown' }).click();
|
||||||
await page.locator('data-test-id=countdown-view');
|
page.locator('data-test-id=countdown-view');
|
||||||
await expect(page).toHaveURL('http://localhost:4001/countdown');
|
await expect(page).toHaveURL('http://localhost:4001/countdown');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'toggle menu' }).click();
|
await page.getByRole('button', { name: 'toggle menu' }).click();
|
||||||
await page.locator('data-test-id=navigation__menu');
|
page.locator('data-test-id=navigation__menu');
|
||||||
|
await page.getByRole('link', { name: 'Project Info' }).click();
|
||||||
|
page.locator('data-test-id=project-view');
|
||||||
|
await expect(page).toHaveURL('http://localhost:4001/info');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'toggle menu' }).click();
|
||||||
|
page.locator('data-test-id=navigation__menu');
|
||||||
await page.getByRole('link', { name: 'Timer', exact: true }).click();
|
await page.getByRole('link', { name: 'Timer', exact: true }).click();
|
||||||
await page.locator('data-test-id=timer-view');
|
page.locator('data-test-id=timer-view');
|
||||||
await expect(page).toHaveURL('http://localhost:4001/timer');
|
await expect(page).toHaveURL('http://localhost:4001/timer');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime",
|
"name": "ontime",
|
||||||
"version": "3.7.0",
|
"version": "3.8.0",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ontime",
|
"ontime",
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ export type MessageResponse = {
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProjectLogoResponse = {
|
||||||
|
logoFilename: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ErrorResponse = MessageResponse;
|
export type ErrorResponse = MessageResponse;
|
||||||
|
|
||||||
export type AuthenticationStatus = 'authenticated' | 'not_authenticated' | 'pending';
|
export type AuthenticationStatus = 'authenticated' | 'not_authenticated' | 'pending';
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ export type ProjectData = {
|
|||||||
publicInfo: string;
|
publicInfo: string;
|
||||||
backstageUrl: string;
|
backstageUrl: string;
|
||||||
backstageInfo: string;
|
backstageInfo: string;
|
||||||
|
projectLogo: string | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -52,4 +52,5 @@ export const runtimeStorePlaceholder: RuntimeStore = {
|
|||||||
playback: SimplePlayback.Stop,
|
playback: SimplePlayback.Stop,
|
||||||
},
|
},
|
||||||
frozen: false,
|
frozen: false,
|
||||||
|
ping: -1,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,4 +27,5 @@ export type RuntimeStore = {
|
|||||||
|
|
||||||
// flags
|
// flags
|
||||||
frozen: boolean;
|
frozen: boolean;
|
||||||
|
ping: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export type {
|
|||||||
MessageResponse,
|
MessageResponse,
|
||||||
RundownPaginated,
|
RundownPaginated,
|
||||||
SessionStats,
|
SessionStats,
|
||||||
|
ProjectLogoResponse,
|
||||||
} from './api/ontime-controller/BackendResponse.type.js';
|
} from './api/ontime-controller/BackendResponse.type.js';
|
||||||
export type { QuickStartData } from './api/db/db.type.js';
|
export type { QuickStartData } from './api/db/db.type.js';
|
||||||
export type { RundownCached, NormalisedRundown } from './api/rundown-controller/BackendResponse.type.js';
|
export type { RundownCached, NormalisedRundown } from './api/rundown-controller/BackendResponse.type.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user