Compare commits

...

19 Commits

Author SHA1 Message Date
Carlos Valente 238eb49b5c bump version to 3.8.0 2024-11-18 14:57:40 +01:00
Carlos Valente 7aec3e0d73 feat: project info view 2024-11-18 14:56:30 +01:00
Carlos Valente 312b294a61 refactor: update test 2024-11-18 14:56:30 +01:00
Carlos Valente 9bfa3e9f8e feat: implement logo in all views 2024-11-18 13:48:05 +01:00
Carlos Valente 9a2cfb59e7 fix: issue with colour string sanitation 2024-11-18 13:12:50 +01:00
Ary a6e1cbbbce Upload corporate logo (#1314)
* feat: upload logo to project data

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-11-17 22:55:20 +01:00
Carlos Valente 8dd448db44 feat: implement naive client ping 2024-11-17 21:41:10 +01:00
Carlos Valente d87542dcf7 refactor: add cloud runtime 2024-11-17 21:41:10 +01:00
Carlos Valente 18575fc558 fix: separator collides with space indicator 2024-11-13 20:23:04 +01:00
Carlos Valente 04e2593939 style: clarify select component 2024-11-13 20:23:04 +01:00
Carlos Valente 73d6b97afa fix: issue with casing in custom fields 2024-11-13 20:23:04 +01:00
Carlos Valente b55724ad0e bump version to 3.7.1 2024-11-12 21:46:31 +01:00
Carlos Valente 3a7c2b8a55 refactor: refetch on load 2024-11-12 21:46:31 +01:00
Carlos Valente 420dfae652 refactor: clarify url preset form 2024-11-09 13:35:43 +01:00
Carlos Valente fb6896de76 refactor: show timer clock in preview 2024-11-07 21:39:04 +01:00
Carlos Valente f574fdbc02 refactor: hide progress bar in timer none 2024-11-07 21:39:04 +01:00
Carlos Valente 597be4e710 refactor: omit modifier in timer type none 2024-11-07 21:39:04 +01:00
Carlos Valente 6e8734c73f fix: prevent stale data on project change 2024-11-07 21:33:20 +01:00
Carlos Valente 9440ad17d2 refactor: show leading zeroes in minimal 2024-11-07 21:33:07 +01:00
79 changed files with 855 additions and 129 deletions
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -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",
+10
View File
@@ -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 />} />
+4 -13
View File
@@ -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`;
+16 -1
View File
@@ -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;
}; };
+15 -5
View File
@@ -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,
})); }));
+17 -1
View File
@@ -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');
}
}
+19
View File
@@ -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>;
} }
@@ -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 && (
+1
View File
@@ -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 -1
View File
@@ -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' },
+1 -1
View File
@@ -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({
+37 -2
View File
@@ -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);
+1
View File
@@ -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,
}); });
}); });
+1
View File
@@ -10,6 +10,7 @@ export const dbModel: DatabaseModel = {
publicInfo: '', publicInfo: '',
backstageUrl: '', backstageUrl: '',
backstageInfo: '', backstageInfo: '',
projectLogo: null,
}, },
settings: { settings: {
app: 'ontime', app: 'ontime',
+1
View File
@@ -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) {
+1
View File
@@ -21,4 +21,5 @@ export const config = {
filename: 'override.css', filename: 'override.css',
}, },
uploads: 'uploads', uploads: 'uploads',
logo: 'logo',
}; };
+1
View File
@@ -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()', () => {
+1
View File
@@ -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,
}; };
} }
+29 -17
View File
@@ -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
View File
@@ -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;
}; };
+1
View File
@@ -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';