Compare commits

...

39 Commits

Author SHA1 Message Date
Carlos Valente 8bc3f5a56c Fix links (#1366)
* chore: remove unused code

* bump version to 3.9.3

* chore: use constant links

* refactor: build links

* chore: ignore ontime-data
2024-12-06 16:48:35 +01:00
Carlos Valente acf1afb5e9 fix: prevent overflow 2024-12-06 14:54:41 +01:00
Carlos Valente d8e2a8d092 bump version to 3.9.2 2024-12-04 21:41:49 +01:00
Carlos Valente 8e587128a1 refactor: ensure element height 2024-12-04 21:41:49 +01:00
Alex Christoffer Rasmussen 607bc40673 create addTime function in simple timer (#1363)
* create addTime function in simple timer
2024-12-04 20:25:49 +01:00
Carlos Valente e6aa7404f7 chore: correct documentation 2024-12-04 19:49:49 +01:00
Carlos Valente d622a7738f refactor: remove deprecated field 2024-12-04 19:49:49 +01:00
Carlos Valente b9ba416366 refactor: add prefix to relative assets
refactor: add prefix to server entrypoints
refactor: add prefix to express router
refactor: add prefix to router basename
2024-12-04 19:49:49 +01:00
Carlos Valente b3ce247f54 bump version to 3.9.1 2024-12-02 14:45:57 +01:00
Carlos Valente 82a2660fbc fix: router prefix 2024-12-02 14:45:57 +01:00
Carlos Valente 1e8706aa73 feat: allow add time to aux timer 2024-12-02 13:30:22 +01:00
Carlos Valente 7dec32aea7 bump version to 3.9.0 2024-12-02 13:30:11 +01:00
Carlos Valente 7e4cf6ffce refactor: improve time added display 2024-12-01 21:06:33 +01:00
Carlos Valente e6dfe9c363 fix: rolling over midnight wrongly classified as skip 2024-12-01 21:06:33 +01:00
Carlos Valente 29e682ee06 refactor: remove frozen implementation 2024-11-30 22:31:18 +01:00
Carlos Valente 55d349877b fix: prevent stale data in finder 2024-11-30 22:30:19 +01:00
Alex Christoffer Rasmussen 8f2a93d6a3 FIX: OSC TX and RX shutdown was swapped (#1352) 2024-11-30 22:27:36 +01:00
Carlos Valente 6529a05e2a fix: previous eventid is any entry 2024-11-30 21:59:53 +01:00
Alex Christoffer Rasmussen 0f5c47bc82 Small fixes (#1345)
* cleanup: remove console log

* use UI Index
2024-11-30 10:11:36 +01:00
Carlos Valente f561e52bb5 refactor: prevent caching html files 2024-11-29 19:36:39 +01:00
Carlos Valente 35f4472daa chore: define test workspaces 2024-11-29 19:36:39 +01:00
Carlos Valente 272f788c15 refactor: split vendor 2024-11-29 19:36:39 +01:00
Carlos Valente 85b71627a8 refactor: exclude html from compression 2024-11-29 19:36:39 +01:00
Carlos Valente 6e51e5083b refactor: allow route prefix 2024-11-29 19:36:39 +01:00
Ary Neto 1f998fda74 reverted unnecessary change 2024-11-28 22:10:48 +01:00
Ary Neto e7b5baa49a fix: file picker opening on delete 2024-11-28 22:10:48 +01:00
Carlos Valente f3f4cdb19f feat: add support for chinese 2024-11-25 09:55:07 +01:00
Carlos Valente b489214004 refactor: allow copy-paste in interface 2024-11-25 09:54:43 +01:00
Carlos Valente cc55414b57 refactor: show project description 2024-11-25 09:54:43 +01:00
Carlos Valente e694cb7556 refactor: style tweaks to cuesheet 2024-11-25 09:54:43 +01:00
Carlos Valente 988100c1d9 chore: remove extra character 2024-11-25 09:54:04 +01:00
Carlos Valente b4eabfae72 feat: trigger welcome modal from electron 2024-11-25 09:54:04 +01:00
Carlos Valente 22fe65c655 feat: show welcome screen on first login 2024-11-25 09:54:04 +01:00
Carlos Valente 7bf90d669a refactor: expose api for loading demo project 2024-11-25 09:54:04 +01:00
Carlos Valente a518dc4882 refactor: consolidate event editor styles 2024-11-25 09:54:04 +01:00
Carlos Valente dd5ffb2f47 refactor: improve app data 2024-11-25 09:54:04 +01:00
Carlos Valente c5a5eab225 refactor: extract utility hook 2024-11-25 09:54:04 +01:00
Carlos Valente 5d6345717f chore: update ontime assets 2024-11-25 09:54:04 +01:00
asharonbaltazar 4c88f90a15 feat(ui): disable menu buttons with locked param (#1332)
* feat: add lock icon when param `locked=true`

* chore: move `isViewLocked` logic up the component tree

* chore: remove extra container from lock icon

* chore: move fading logic into separate hook

* chore: move locked icon into separate component

* chore: rename variables
2024-11-18 09:49:49 -05:00
105 changed files with 1430 additions and 949 deletions
+1
View File
@@ -39,6 +39,7 @@ dist/
# docker utils
ontime-db
ontime-external/
ontime-data/
# versioning file
**/ONTIME_VERSION.js
+1 -1
View File
@@ -91,7 +91,7 @@ While it should allow for a generic setup, it might need to be modified to fit y
From the project root, run the following commands
- __Build docker image from__ by running `docker build -t getontime/ontime`
- __Build docker image from__ by running `docker build -t getontime/ontime .`
- __Run docker image from compose__ by running `docker-compose up -d`
Other useful commands
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "3.8.0",
"version": "3.9.3",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+25 -35
View File
@@ -1,37 +1,27 @@
<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8' />
<link rel='icon' href='/favicon.ico' />
<meta name='viewport' content='width=device-width, initial-scale=1' />
<meta name='theme-color' content='#121212' />
<meta name='ontime' content='ontime - event timer manager' />
<link rel='apple-touch-icon' href='/logo192.png' />
<link
rel='icon'
type='image/png'
sizes='32x32'
href='/favicon-32x32.png'
/>
<link
rel='icon'
type='image/png'
sizes='16x16'
href='/favicon-16x16.png'
/>
<link rel='manifest' href='/site.webmanifest' />
<link rel='manifest' href='/manifest.json' />
<title>ontime</title>
<style>
body,
html {
background-color: rgba(0, 0, 0, 0) !important;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id='root'></div>
<script type='module' src='/src/index.tsx'></script>
</body>
<html lang="en">
<head>
<meta charset="utf-8" />
<base href="/" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#101010" />
<meta name="ontime" content="ontime - time keeping for live events" />
<link rel="apple-touch-icon" href="/ontime-logo.png" />
<link rel="icon" type="image/png" href="/ontime-logo.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="manifest" href="/manifest.json" />
<title>ontime</title>
<style>
body,
html {
background-color: rgba(0, 0, 0, 0) !important;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.8.0",
"version": "3.9.3",
"private": true,
"type": "module",
"dependencies": {
@@ -39,7 +39,7 @@
"build": "vite build",
"build:local": "cross-env NODE_ENV=local vite build",
"build:electron": "cross-env NODE_ENV=local vite build",
"build:docker": "cross-env VITE_IS_CLOUD=true vite build",
"build:docker": "cross-env VITE_IS_DOCKER=true vite build",
"build:localdocker": "cross-env NODE_ENV=local vite build",
"lint": "eslint . --quiet",
"test": "vitest",
@@ -87,9 +87,9 @@
"sass": "^1.57.1",
"typescript": "catalog:",
"vite": "^5.2.11",
"vite-plugin-compression2": "^0.12.0",
"vite-plugin-compression2": "^1.3.3",
"vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^4.3.1",
"vitest": "^1.6.0"
"vitest": "catalog:"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

+2 -9
View File
@@ -4,18 +4,11 @@
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
"src": "ontime-logo.png",
"type": "image/png"
}
],
"scope": "/",
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+2 -1
View File
@@ -11,6 +11,7 @@ import { connectSocket } from './common/utils/socket';
import theme from './theme/theme';
import { TranslationProvider } from './translation/TranslationProvider';
import AppRouter from './AppRouter';
import { baseURI } from './externals';
connectSocket();
@@ -19,7 +20,7 @@ function App() {
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
<QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider>
<BrowserRouter>
<BrowserRouter basename={baseURI}>
<div className='App'>
<ErrorBoundary>
<TranslationProvider>
+8
View File
@@ -112,6 +112,14 @@ export async function loadProject(filename: string): Promise<MessageResponse> {
return res.data;
}
/**
* HTTP request to load the demo project file
*/
export async function loadDemo(): Promise<MessageResponse> {
const res = await axios.post(`${dbPath}/demo`);
return res.data;
}
/**
* HTTP request to duplicate a project file
*/
@@ -5,6 +5,7 @@ import { millisToString, parseUserTime } from 'ontime-utils';
import { useEmitLog } from '../../../stores/logger';
interface TimeInputProps<T extends string> {
id?: T;
name: T;
submitHandler: (field: T, value: string) => void;
time?: number;
@@ -15,7 +16,7 @@ interface TimeInputProps<T extends string> {
}
export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
const { name, submitHandler, time = 0, placeholder, disabled, align = 'center', className } = props;
const { id, name, submitHandler, time = 0, placeholder, disabled, align = 'center', className } = props;
const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>('');
@@ -117,6 +118,7 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
return (
<Input
id={id}
disabled={disabled}
size='sm'
ref={inputRef}
@@ -1,8 +1,7 @@
import { useEffect, useState } from 'react';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { debounce } from '../../utils/debounce';
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
import style from './NavigationMenu.module.scss';
@@ -13,32 +12,10 @@ interface FloatingNavigationProps {
export default function FloatingNavigation(props: FloatingNavigationProps) {
const { toggleMenu, toggleSettings } = props;
const [showButton, setShowButton] = useState(false);
// show on mouse move
useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null;
const setShowMenuTrue = () => {
setShowButton(true);
if (fadeOut) {
clearTimeout(fadeOut);
}
fadeOut = setTimeout(() => setShowButton(false), 3000);
};
const debouncedShowMenu = debounce(setShowMenuTrue, 1000);
document.addEventListener('mousemove', debouncedShowMenu);
return () => {
document.removeEventListener('mousemove', debouncedShowMenu);
if (fadeOut) {
clearTimeout(fadeOut);
}
};
}, []);
const isButtonShown = useFadeOutOnInactivity();
return (
<div className={`${style.buttonContainer} ${!showButton ? style.hidden : ''}`}>
<div className={`${style.buttonContainer} ${!isButtonShown ? style.hidden : ''}`}>
<button
onClick={toggleMenu}
aria-label='toggle menu'
@@ -38,6 +38,16 @@ $button-size: 3rem;
border-radius: 3px;
}
.lockIcon {
font-size: 1.5rem;
display: grid;
place-content: center;
border-radius: 3px;
background-color: transparent;
color: $active-indicator;
cursor: default;
}
.navButton {
@extend .button;
z-index: 3;
@@ -17,7 +17,7 @@ import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { isLocalhost, serverPort } from '../../../externals';
import { isLocalhost } from '../../../externals';
import { navigatorConstants } from '../../../viewerConfig';
import useClickOutside from '../../hooks/useClickOutside';
import { useElectronEvent } from '../../hooks/useElectronEvent';
@@ -25,7 +25,7 @@ import useInfo from '../../hooks-query/useInfo';
import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions';
import { isKeyEnter } from '../../utils/keyEvent';
import { handleLinks, openLink } from '../../utils/linkUtils';
import { handleLinks, linkToOtherHost, openLink } from '../../utils/linkUtils';
import { cx } from '../../utils/styleUtils';
import { RenameClientModal } from '../client-modal/RenameClientModal';
import CopyTag from '../copy-tag/CopyTag';
@@ -154,7 +154,8 @@ function OtherAddresses(props: OtherAddressesProps) {
return null;
}
const address = `http://${nif.address}:${serverPort}${currentLocation}`;
const address = linkToOtherHost(nif.address, currentLocation);
return (
<CopyTag
key={nif.name}
@@ -0,0 +1,15 @@
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
import style from './NavigationMenu.module.scss';
export default function ViewLockedIcon() {
const isLockIconShown = useFadeOutOnInactivity();
return (
<div className={`${style.buttonContainer} ${!isLockIconShown ? style.hidden : ''}`}>
<IoLockClosedOutline className={style.lockIcon} />
</div>
);
}
@@ -2,13 +2,18 @@ import { memo, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useDisclosure } from '@chakra-ui/react';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import FloatingNavigation from './FloatingNavigation';
import NavigationMenu from './NavigationMenu';
import ViewLockedIcon from './ViewLockedIcon';
function ViewNavigationMenu() {
const [searchParams, setSearchParams] = useSearchParams();
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
const isViewLocked = isStringBoolean(searchParams.get('locked'));
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
@@ -16,6 +21,10 @@ function ViewNavigationMenu() {
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
if (isViewLocked) {
return <ViewLockedIcon />;
}
return (
<>
<FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ProjectFileListResponse } from 'ontime-types';
@@ -22,3 +23,23 @@ export function useProjectList() {
});
return { data: data ?? placeholderProjectList, status, refetch };
}
export function useOrderedProjectList() {
const response = useProjectList();
const { files, lastLoadedProject } = response.data;
const reorderedProjectFiles = useMemo(() => {
if (!files.length) return [];
const currentlyLoadedIndex = files.findIndex((project) => project.filename === lastLoadedProject);
if (currentlyLoadedIndex === -1) return files;
const projectFiles = [...files];
const current = projectFiles.splice(currentlyLoadedIndex, 1)[0];
return [current, ...projectFiles];
}, [files, lastLoadedProject]);
return { ...response, data: { reorderedProjectFiles, lastLoadedProject: response.data.lastLoadedProject } };
}
@@ -1,6 +1,8 @@
import { useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { addDialog } from '../stores/dialogStore';
const isElectron = window.process?.type === 'renderer';
const ipcRenderer = isElectron ? window.require('electron').ipcRenderer : null;
@@ -24,6 +26,12 @@ export function useElectronListener() {
ipcRenderer.on('request-editor-location', (_event: unknown, location: string) => {
navigate(location, { relative: 'route' });
});
ipcRenderer.on('dialog', (_event: unknown, dialog: string) => {
if (dialog === 'welcome') {
addDialog('welcome');
}
});
}
// Clean the listener after the component is dismounted
@@ -0,0 +1,31 @@
import { useEffect, useState } from 'react';
import { debounce } from '../../common/utils/debounce';
export const useFadeOutOnInactivity = () => {
const [isMouseMoved, setIsMouseMoved] = useState(false);
// show on mouse move
useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null;
const setShowMenuTrue = () => {
setIsMouseMoved(true);
if (fadeOut) {
clearTimeout(fadeOut);
}
fadeOut = setTimeout(() => setIsMouseMoved(false), 3000);
};
const debouncedShowMenu = debounce(setShowMenuTrue, 1000);
document.addEventListener('mousemove', debouncedShowMenu);
return () => {
document.removeEventListener('mousemove', debouncedShowMenu);
if (fadeOut) {
clearTimeout(fadeOut);
}
};
}, []);
return isMouseMoved;
};
@@ -0,0 +1,24 @@
import { MaybeString } from 'ontime-types';
import { create } from 'zustand';
interface DialogStore {
showDialog: MaybeString;
setDialog: (name: string) => void;
clearDialog: () => void;
}
/**
* Used to allow the server to show a dialog in the client
*/
export const useDialogStore = create<DialogStore>((set) => ({
showDialog: null,
clearDialog: () => set({ showDialog: null }),
setDialog: (name) => set({ showDialog: name }),
}));
/**
* Allows setting a dialog to show (outside react)
*/
export function addDialog(name: string): void {
useDialogStore.getState().setDialog(name);
}
-30
View File
@@ -1,30 +0,0 @@
/**
* Returns hostname
* @type {string}
*/
export const host = window?.location?.host;
/**
* Open an external URLs: specifically for a electron / browser case
* If electron: ask main process to call a new browser window
* If browser: open in new tab
* @param url
*/
export function openLink(url) {
if (window.process?.type === 'renderer') {
window.ipcRenderer.send('send-to-link', url);
} else {
window.open(url);
}
}
/**
* Handles opening external links
* @param event
* @param location
*/
export function handleLinks(event, location) {
// we handle the link manually
event.preventDefault();
openLink(`http://${host}/${location}`);
}
+40
View File
@@ -0,0 +1,40 @@
import type { MouseEvent } from 'react';
import { baseURI, serverURL } from '../../externals';
/**
* Open an external URLs: specifically for a electron / browser case
* If electron: ask main process to call a new browser window
* If browser: open in new tab
* @param url
*/
export function openLink(url: string) {
if (window.process?.type === 'renderer') {
window.ipcRenderer.send('send-to-link', url);
} else {
window.open(url);
}
}
/**
* Handles opening external links
* @param event
* @param location
*/
export function handleLinks(event: MouseEvent, location: string) {
// we handle the link manually
event.preventDefault();
const destination = new URL(serverURL);
destination.pathname = baseURI ? `${baseURI}/${location}` : location;
openLink(destination.toString());
}
export function linkToOtherHost(host: string, path?: string) {
const destination = new URL(serverURL);
destination.hostname = host;
if (path) {
destination.pathname = baseURI ? `${baseURI}/${path}` : path;
}
return destination.toString();
}
+8
View File
@@ -12,6 +12,7 @@ import {
setClientRedirect,
setClients,
} from '../stores/clientStore';
import { addDialog } from '../stores/dialogStore';
import { addLog } from '../stores/logger';
import { patchRuntime, runtimeStore } from '../stores/runtime';
@@ -118,6 +119,13 @@ export const connectSocket = () => {
break;
}
case 'dialog': {
if (payload.dialog === 'welcome') {
addDialog('welcome');
}
break;
}
case 'ontime-log': {
addLog(payload as Log);
break;
+55 -8
View File
@@ -2,24 +2,71 @@
* This file contains a list of constants that may need to be resolved at runtime
*/
import { version } from '../../../package.json';
export const githubUrl = 'https://www.github.com/cpvalente/ontime';
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
export const websiteUrl = 'https://www.getontime.no';
export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
export const documentationUrl = 'https://docs.getontime.no';
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
// resolve environment
export const appVersion = version;
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);
export const isDockerImage = Boolean(import.meta.env.VITE_IS_DOCKER);
export const isOntimeCloud = window.location.hostname.includes('cloud.getontime.no');
// resolve protocol
const socketProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
// resolve entrypoint URLs
// 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`;
/**
* Resolve base
* @example '' for electron
* @example '/client-hash' for cloud
*/
export const baseURI = resolveBaseURI();
export const serverURL = resolveUrl('http', '');
export const websocketUrl = resolveUrl('ws', 'ws');
function resolveUrl(protocol: 'http' | 'ws', path: string) {
const url = new URL(window.location.origin);
// check if protocol URL is secure
url.protocol = protocol === 'http' ? 'http' : 'ws';
url.protocol += window.location.protocol === 'https:' ? 's' : '';
// make path name relative to the base URI
url.pathname = baseURI ? `${baseURI}/${path}` : path;
// in development mode, we use the React port for UI, but need the requests to target the server
if (isDev) {
// this is used as a fallback port for development
url.port = '4001';
}
const result = url.toString();
// prevent trailing slash
return result.endsWith('/') ? result.slice(0, -1) : result;
}
/**
* Resolves a base URI for a client that is not at the root segment
* ie: https://cloud.getontime.com/client-hash/timer
* This is necessary for ontime cloud and should otherwise not affect the client
*/
function resolveBaseURI(): string {
// in ontime cloud, the base tag is set by the server
const baseHref = document.querySelector('base')?.getAttribute('href');
const base = baseHref ?? '';
// prevent a trailing slash from either an empty base or a base with a trailing slash
if (base.endsWith('/')) {
return base.slice(0, -1);
}
return base;
}
@@ -1,5 +1,5 @@
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import { documentationUrl, githubUrl, websiteUrl } from '../../../../externals';
import { discordUrl, documentationUrl, githubUrl, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import AppVersion from './AppVersion';
@@ -19,6 +19,7 @@ export default function AboutPanel() {
<Panel.SubHeader>Links</Panel.SubHeader>
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
</Panel.Section>
<Panel.Section>
<Panel.SubHeader>Current version</Panel.SubHeader>
@@ -1,6 +1,6 @@
import { version } from '../../../../../package.json';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import useAppVersion from '../../../../common/hooks-query/useAppVersion';
import { appVersion } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
export default function AppVersion() {
@@ -9,7 +9,7 @@ export default function AppVersion() {
if (isError) {
return (
<Panel.Paragraph>
{`You are currently using Ontime version ${version}`}
{`You are currently using Ontime version ${appVersion}`}
<Panel.Error>{`Could not fetch version information: ${isError}`}</Panel.Error>
</Panel.Paragraph>
);
@@ -18,7 +18,7 @@ export default function AppVersion() {
if (data.hasUpdates) {
return (
<>
<Panel.Paragraph>{`You are currently using Ontime version ${version}.`}</Panel.Paragraph>
<Panel.Paragraph>{`You are currently using Ontime version ${appVersion}.`}</Panel.Paragraph>
<Panel.Paragraph>
{`A new version ${data.version} is available.`}
<ExternalLink href={data.url}> Please visit the release page to download</ExternalLink>
@@ -27,5 +27,5 @@ export default function AppVersion() {
);
}
return <Panel.Paragraph>{`You are currently using the latest version of Ontime: ${version}`}</Panel.Paragraph>;
return <Panel.Paragraph>{`You are currently using the latest version of Ontime: ${appVersion}`}</Panel.Paragraph>;
}
@@ -155,6 +155,7 @@ export default function GeneralPanelForm() {
<option value='es'>Spanish</option>
<option value='sv'>Swedish</option>
<option value='pl'>Polish</option>
<option value='zh'>Chinese (Simplified)</option>
</Select>
</Panel.ListItem>
</Panel.ListGroup>
@@ -2,8 +2,8 @@ import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import useInfo from '../../../../common/hooks-query/useInfo';
import { openLink } from '../../../../common/utils/linkUtils';
import { isLocalhost, serverPort } from '../../../../externals';
import { linkToOtherHost, openLink } from '../../../../common/utils/linkUtils';
import { isLocalhost } from '../../../../externals';
import style from './NetworkInterfaces.module.scss';
@@ -14,11 +14,11 @@ export default function InfoNif() {
return (
<div className={style.interfaces}>
{data.networkInterfaces?.map((nif) => {
{data.networkInterfaces.map((nif) => {
// interfaces outside localhost wont have access
if (nif.name === 'localhost' && !isLocalhost) return null;
const address = linkToOtherHost(nif.address);
const address = `http://${nif.address}:${serverPort}`;
return (
<CopyTag
key={nif.name}
@@ -3,7 +3,7 @@ import { useEffect } from 'react';
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { usePing } from '../../../../common/hooks/useSocket';
import { socketSendJson } from '../../../../common/utils/socket';
import { isOntimeCloud } from '../../../../externals';
import { isDockerImage } from '../../../../externals';
import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import ClientControlPanel from '../client-control-panel/ClientControlPanel';
@@ -19,7 +19,7 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
<>
<Panel.Header>Network</Panel.Header>
<Panel.Section>
{isOntimeCloud && <OntimeCloudStats />}
{isDockerImage && <OntimeCloudStats />}
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
</Panel.Section>
<InfoNif />
@@ -6,6 +6,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { PROJECT_LIST } from '../../../../common/api/constants';
import { createProject } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils';
import { documentationUrl, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './ProjectPanel.module.scss';
@@ -118,7 +119,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
<Input
variant='ontime-filled'
size='sm'
placeholder='www.getontime.no'
placeholder={websiteUrl}
autoComplete='off'
{...register('publicUrl')}
/>
@@ -140,7 +141,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
<Input
variant='ontime-filled'
size='sm'
placeholder='http://docs.getontime.no'
placeholder={documentationUrl}
autoComplete='off'
{...register('backstageUrl')}
/>
@@ -10,6 +10,7 @@ import { postProjectData, uploadProjectLogo } from '../../../../common/api/proje
import { maybeAxiosError } from '../../../../common/api/utils';
import useProjectData from '../../../../common/hooks-query/useProjectData';
import { validateLogo } from '../../../../common/utils/uploadUtils';
import { documentationUrl, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './ProjectPanel.module.scss';
@@ -68,7 +69,9 @@ export default function ProjectData() {
uploadInputRef.current?.click();
};
const handleDeleteLogo = () => {
const handleDeleteLogo = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setValue('projectLogo', null, {
shouldDirty: true,
});
@@ -201,7 +204,7 @@ export default function ProjectData() {
<Input
variant='ontime-filled'
size='sm'
placeholder='www.getontime.no'
placeholder={websiteUrl}
autoComplete='off'
{...register('publicUrl')}
/>
@@ -223,7 +226,7 @@ export default function ProjectData() {
<Input
variant='ontime-filled'
size='sm'
placeholder='http://docs.getontime.no'
placeholder={documentationUrl}
autoComplete='off'
{...register('backstageUrl')}
/>
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
import { useState } from 'react';
import { useProjectList } from '../../../../common/hooks-query/useProjectList';
import { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList';
import * as Panel from '../../panel-utils/PanelUtils';
import ProjectListItem, { EditMode } from './ProjectListItem';
@@ -8,8 +8,7 @@ import ProjectListItem, { EditMode } from './ProjectListItem';
import style from './ProjectPanel.module.scss';
export default function ProjectList() {
const { data, refetch } = useProjectList();
const { files, lastLoadedProject } = data;
const { data, refetch } = useOrderedProjectList();
const [editingMode, setEditingMode] = useState<EditMode | null>(null);
const [editingFilename, setEditingFilename] = useState<string | null>(null);
@@ -28,16 +27,6 @@ export default function ProjectList() {
await refetch();
};
const reorderedProjectFiles = useMemo(() => {
if (!data.files?.length) return [];
const currentlyLoadedIndex = files.findIndex((project) => project.filename === lastLoadedProject);
const projectFiles = [...files];
const current = projectFiles.splice(currentlyLoadedIndex, 1)?.[0];
return [current, ...projectFiles];
}, [data.files?.length, files, lastLoadedProject]);
return (
<Panel.Table>
<thead>
@@ -48,7 +37,7 @@ export default function ProjectList() {
</tr>
</thead>
<tbody>
{reorderedProjectFiles.map((project) => (
{data.reorderedProjectFiles.map((project) => (
<ProjectListItem
key={project.filename}
filename={project.filename}
@@ -58,7 +47,7 @@ export default function ProjectList() {
onRefetch={handleRefetch}
editingFilename={editingFilename}
editingMode={editingMode}
current={project.filename === lastLoadedProject}
current={project.filename === data.lastLoadedProject}
/>
))}
</tbody>
@@ -114,6 +114,7 @@ export default function QuickStart(props: QuickStartProps) {
<option value='es'>Spanish</option>
<option value='sv'>Swedish</option>
<option value='pl'>Polish</option>
<option value='zh'>Chinese (Simplified)</option>
</Select>
</Panel.ListItem>
</Panel.ListGroup>
@@ -1,9 +1,10 @@
import { PropsWithChildren } from 'react';
import { Tooltip } from '@chakra-ui/react';
import { Playback, TimerPhase } from 'ontime-types';
import { dayInMs, millisToMinutes, millisToSeconds, millisToString } from 'ontime-utils';
import { dayInMs, millisToString } from 'ontime-utils';
import { useTimer } from '../../../../common/hooks/useSocket';
import { formatDuration } from '../../../../common/utils/time';
import TimerDisplay from '../timer-display/TimerDisplay';
import style from './PlaybackTimer.module.scss';
@@ -13,22 +14,12 @@ interface PlaybackTimerProps {
}
function resolveAddedTimeLabel(addedTime: number) {
function resolveClosestUnit(ms: number) {
if (ms < 6000) {
return `${millisToSeconds(ms)} seconds`;
} else if (ms < 12000) {
return '1 minute';
} else {
return `${millisToMinutes(ms)} minutes`;
}
}
if (addedTime > 0) {
return `Added ${resolveClosestUnit(addedTime)}`;
return `Added ${formatDuration(addedTime, false)}`;
}
if (addedTime < 0) {
return `Removed ${resolveClosestUnit(addedTime)}`;
return `Removed ${formatDuration(Math.abs(addedTime), false)}`;
}
return '';
@@ -45,7 +45,7 @@ $table-header-font-size: calc(1rem - 3px);
.tableHeader {
position: sticky;
top: 0;
top: -1px;
z-index: 10;
font-size: $table-header-font-size;
@@ -54,6 +54,7 @@ $table-header-font-size: calc(1rem - 3px);
th {
background-color: $gray-1300;
padding-left: 0.25rem;
&:hover {
.resizer {
@@ -13,6 +13,7 @@ import useAppSettingsNavigation from '../app-settings/useAppSettingsNavigation';
import { EditorOverview } from '../overview/Overview';
import Finder from './finder/Finder';
import WelcomePlacement from './welcome/WelcomePlacement';
import styles from './Editor.module.scss';
@@ -55,6 +56,7 @@ export default function Editor() {
return (
<div className={styles.mainContainer} data-testid='event-editor'>
<WelcomePlacement />
<Finder isOpen={isFinderOpen} onClose={onFinderClose} />
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
<EditorOverview>
@@ -20,3 +20,20 @@
outline: 2px solid $gray-1000;
}
}
.header {
font-size: 1.5rem;
}
.title {
font-size: 1rem;
color: $label-gray;
}
.label {
display: block;
font-size: calc(1rem - 3px);
color: $label-gray;
margin-bottom: 0.25rem;
max-width: max-content; // prevent label from taking entire row
}
@@ -1,3 +1,4 @@
import type { LabelHTMLAttributes, ReactNode } from 'react';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { type IconBaseProps } from '@react-icons/all-files/lib';
@@ -8,3 +9,16 @@ import style from './EditorUtils.module.scss';
export function Corner({ className, ...elementProps }: IconBaseProps) {
return <IoArrowUp className={cx([style.corner, className])} {...elementProps} />;
}
export function Title({ children }: { children: ReactNode }) {
return <h3 className={style.title}>{children}</h3>;
}
export function Label({ children, className, ...elementProps }: LabelHTMLAttributes<HTMLLabelElement>) {
const classes = cx([style.label, className]);
return (
<label className={classes} {...elementProps}>
{children}
</label>
);
}
@@ -1,7 +1,7 @@
import { KeyboardEvent, useState } from 'react';
import { Input, Modal, ModalBody, ModalContent, ModalFooter, ModalOverlay } from '@chakra-ui/react';
import { useDebouncedCallback } from '@mantine/hooks';
import { isOntimeEvent, SupportedEvent } from 'ontime-types';
import { SupportedEvent } from 'ontime-types';
import { useEventSelection } from '../../rundown/useEventSelection';
@@ -65,14 +65,15 @@ export default function Finder(props: FinderProps) {
{error && <li className={style.error}>{error}</li>}
{results.length === 0 && <li className={style.empty}>No results</li>}
{results.length > 0 &&
results.map((event, index) => {
results.map((entry, index) => {
const isSelected = selected === index;
const displayIndex = event.type === SupportedEvent.Block ? '-' : event.index;
const colour = event.type === SupportedEvent.Event ? event.colour : '';
const displayIndex = entry.type === SupportedEvent.Event ? entry.eventIndex : '-';
const displayCue = entry.type === SupportedEvent.Event ? entry.cue : '';
const colour = entry.type === SupportedEvent.Event ? entry.colour : '';
return (
<li
key={event.id}
key={entry.id}
className={style.entry}
data-selected={isSelected}
data-index={index}
@@ -82,8 +83,8 @@ export default function Finder(props: FinderProps) {
<div className={style.index} style={{ '--color': colour }}>
{displayIndex}
</div>
{isOntimeEvent(event) && <div className={style.cue}>{event.cue}</div>}
<div className={style.title}>{event.title}</div>
<div className={style.cue}>{displayCue}</div>
<div className={style.title}>{entry.title}</div>
</div>
{isSelected && <span>Go </span>}
</li>
@@ -1,4 +1,4 @@
import { ChangeEvent, useState } from 'react';
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import { isOntimeBlock, isOntimeEvent, MaybeString, SupportedEvent } from 'ontime-types';
import { useFlatRundown } from '../../../common/hooks-query/useRundown';
@@ -28,6 +28,17 @@ export default function useFinder() {
const { data } = useFlatRundown();
const [results, setResults] = useState<FilterableEntry[]>([]);
const [error, setError] = useState<MaybeString>(null);
const lastSearchString = useRef('');
/** clear results when source data changes */
useEffect(() => {
setResults([]);
setError(null);
// fake a submit event to re-run the search
if (lastSearchString.current) {
find({ target: { value: lastSearchString.current } } as ChangeEvent<HTMLInputElement>);
}
}, [data]);
/** Returns a single item with a matching index */
const searchByIndex = (searchString: string) => {
@@ -155,6 +166,7 @@ export default function useFinder() {
}
const searchValue = event.target.value.toLowerCase();
lastSearchString.current = searchValue;
if (searchValue.startsWith('index ')) {
const searchString = searchValue.replace('index ', '').trim();
@@ -0,0 +1,65 @@
.sections {
display: grid;
grid-template-columns: 1fr 5fr;
gap: 2rem;
}
.column {
display: flex;
flex-direction: column;
gap: 1rem;
}
.header {
font-size: 1.5rem;
}
.logo {
max-width: 100px;
height: auto;
}
.buttonRow {
margin-top: 1rem;
display: flex;
gap: 1rem;
justify-content: end;
}
.tableContainer {
height: 350px;
max-height: 350px;
overflow-y: auto;
}
.table {
width: 100%;
tbody {
background-color: $gray-1300;
tr {
&:has(:hover) {
background-color: $gray-1350;
}
}
}
tr {
height: 2rem;
}
th,
td {
padding-inline: 0.5rem;
}
th {
text-align: left;
font-size: calc(1rem - 3px);
color: $label-gray;
}
}
.current {
background-color: $blue-700;
}
@@ -0,0 +1,99 @@
import { useNavigate } from 'react-router-dom';
import { Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalOverlay } from '@chakra-ui/react';
import { loadDemo, loadProject } from '../../../common/api/db';
import { invalidateAllCaches } from '../../../common/api/utils';
import ExternalLink from '../../../common/components/external-link/ExternalLink';
import { appVersion, discordUrl, documentationUrl, websiteUrl } from '../../../externals';
import ImportProjectButton from './composite/ImportProjectButton';
import WelcomeProjectList from './composite/WelcomeProjectList';
import style from './Welcome.module.scss';
interface WelcomeProps {
onClose: () => void;
}
export default function Welcome(props: WelcomeProps) {
const { onClose } = props;
const navigate = useNavigate();
/** handle cleanup actions before request closing the modal */
const handleClose = () => {
onClose();
};
/** handle loading a selected project */
const handleLoadProject = async (filename: string) => {
try {
await loadProject(filename);
await invalidateAllCaches();
handleClose();
} catch (_error) {
/** no error handling for now */
}
};
/** handle loading the demo project */
const handleLoadDemo = async () => {
try {
await loadDemo();
await invalidateAllCaches();
handleClose();
} catch (_error) {
/** no error handling for now */
}
};
/** handle redirect to create modal */
const handleCallCreate = () => {
navigate('/editor?settings=project__create');
handleClose();
};
return (
<Modal isOpen onClose={handleClose} variant='ontime'>
<ModalOverlay />
<ModalContent maxWidth='max(640px, 40vw)'>
<ModalCloseButton />
<ModalBody>
<div className={style.sections}>
<div className={style.column}>
<img src='ontime-logo.png' alt='ontime' className={style.logo} />
<div>Ontime v{appVersion}</div>
<ExternalLink href={websiteUrl}>Website</ExternalLink>
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
</div>
<div className={style.column}>
<div className={style.header}>Welcome to Ontime</div>
<div className={style.tableContainer}>
<table className={style.table}>
<thead>
<tr>
<th>Project Name</th>
<th>Last Used</th>
<th />
</tr>
</thead>
<WelcomeProjectList loadProject={handleLoadProject} onClose={handleClose} />
</table>
</div>
<div className={style.buttonRow}>
<ImportProjectButton onFinish={handleClose} />
<Button size='sm' variant='ontime-subtle' onClick={handleLoadDemo}>
Load demo project
</Button>
<Button size='sm' variant='ontime-filled' onClick={handleCallCreate}>
Create new...
</Button>
</div>
</div>
</div>
</ModalBody>
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,14 @@
import { useDialogStore } from '../../../common/stores/dialogStore';
import Welcome from './Welcome';
export default function WelcomePlacement() {
const showDialog = useDialogStore((state) => state.showDialog);
const clearDialog = useDialogStore((state) => state.clearDialog);
if (!showDialog) {
return null;
}
return <Welcome onClose={clearDialog} />;
}
@@ -0,0 +1,58 @@
/**
* Handles importing of a project in the welcome modal
* the logic is mostly duplicated from ManageProjects.tsx
*/
import { ChangeEvent, useRef } from 'react';
import { Button, Input } from '@chakra-ui/react';
import { uploadProjectFile } from '../../../../common/api/db';
import { invalidateAllCaches } from '../../../../common/api/utils';
import { validateProjectFile } from '../../../../common/utils/uploadUtils';
interface ImportProjectButtonProps {
onFinish: () => void;
}
export default function ImportProjectButton(props: ImportProjectButtonProps) {
const { onFinish } = props;
const fileInputRef = useRef<HTMLInputElement>(null);
const handleSelectFile = () => {
fileInputRef.current?.click();
};
const handleImport = async (event: ChangeEvent<HTMLInputElement>) => {
const selectedFile = event.target?.files?.[0];
if (!selectedFile) {
return;
}
try {
validateProjectFile(selectedFile);
await uploadProjectFile(selectedFile);
} catch (error) {
/** we do not handle errors here */
} finally {
await invalidateAllCaches();
onFinish();
}
};
return (
<>
<Input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
onChange={handleImport}
accept='.json'
data-testid='file-input'
/>
<Button size='sm' variant='ontime-subtle' onClick={handleSelectFile}>
Import project
</Button>
</>
);
}
@@ -0,0 +1,46 @@
import { Button } from '@chakra-ui/react';
import { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList';
import style from '../Welcome.module.scss';
interface WelcomeProjectListProps {
loadProject: (filename: string) => Promise<void>;
onClose: () => void;
}
export default function WelcomeProjectList(props: WelcomeProjectListProps) {
const { loadProject, onClose } = props;
const { data } = useOrderedProjectList();
return (
<tbody>
{data.reorderedProjectFiles.map((project) => {
if (project.filename === data.lastLoadedProject) {
return (
<tr className={style.current} key={project.filename}>
<td>{project.filename}</td>
<td>Loaded from last session</td>
<td>
<Button variant='ontime-subtle' size='sm' onClick={onClose}>
Continue
</Button>
</td>
</tr>
);
}
return (
<tr key={project.filename}>
<td>{project.filename}</td>
<td>{new Date(project.updatedAt).toLocaleString()}</td>
<td>
<Button variant='ontime-subtle' size='sm' onClick={() => loadProject(project.filename)}>
Load
</Button>
</td>
</tr>
);
})}
</tbody>
);
}
+32 -29
View File
@@ -214,8 +214,8 @@ export default function Rundown({ data }: RundownProps) {
['alt + D', () => insertAtId(SupportedEvent.Delay, cursor), { preventDefault: true }],
['alt + shift + D', () => insertAtId(SupportedEvent.Delay, cursor, true), { preventDefault: true }],
['mod + C', () => setEntryCopyId(cursor), { preventDefault: true }],
['mod + V', () => insertCopyAtId(cursor, entryCopyId), { preventDefault: true }],
['mod + C', () => setEntryCopyId(cursor)],
['mod + V', () => insertCopyAtId(cursor, entryCopyId)],
['mod + shift + V', () => insertCopyAtId(cursor, entryCopyId, true), { preventDefault: true }],
['alt + backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
@@ -256,10 +256,12 @@ export default function Rundown({ data }: RundownProps) {
return <RundownEmpty handleAddNew={() => insertAtId(SupportedEvent.Event, cursor)} />;
}
let lastEntry: PlayableEvent | undefined; // used by indicators
let thisEntry: PlayableEvent | undefined;
let previousEventId: string | undefined;
let thisId = previousEventId;
// last event is used to calculate relative timings
let lastEvent: PlayableEvent | undefined; // used by indicators
let thisEvent: PlayableEvent | undefined;
// previous entry is used to infer position in the rundown for new events
let previousEntryId: string | undefined;
let thisId = previousEntryId;
let eventIndex = 0;
// all events before the current selected are in the past
@@ -272,63 +274,64 @@ export default function Rundown({ data }: RundownProps) {
<DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}>
<SortableContext items={statefulEntries} strategy={verticalListSortingStrategy}>
<div className={style.list}>
{statefulEntries.map((eventId, index) => {
{statefulEntries.map((entryId, index) => {
// we iterate through a stateful copy of order to make the operations smoother
// this means that this can be out of sync with order until the useEffect runs
// instead of writing all the logic guards, we simply short circuit rendering here
const event = rundown[eventId];
if (!event) {
const entry = rundown[entryId];
if (!entry) {
return null;
}
if (index === 0) {
eventIndex = 0;
}
if (isOntimeEvent(event)) {
previousEntryId = thisId;
thisId = entryId;
if (isOntimeEvent(entry)) {
// event indexes are 1 based in frontend
eventIndex++;
previousEventId = thisId;
lastEntry = thisEntry;
lastEvent = thisEvent;
if (isPlayableEvent(event)) {
if (isPlayableEvent(entry)) {
// populate previous entry
if (isNewLatest(event.timeStart, event.timeEnd, lastEntry?.timeStart, lastEntry?.timeEnd)) {
thisEntry = event;
if (isNewLatest(entry.timeStart, entry.timeEnd, lastEvent?.timeStart, lastEvent?.timeEnd)) {
thisEvent = entry;
}
thisId = eventId;
}
}
const isFirst = index === 0;
const isLast = index === order.length - 1;
const isLoaded = featureData?.selectedEventId === event.id;
const isNext = featureData?.nextEventId === event.id;
const hasCursor = event.id === cursor;
const isLoaded = featureData?.selectedEventId === entry.id;
const isNext = featureData?.nextEventId === entry.id;
const hasCursor = entry.id === cursor;
if (isLoaded) {
isPast = false;
}
return (
<Fragment key={event.id}>
{isEditMode && (hasCursor || isFirst) && <QuickAddBlock previousEventId={previousEventId} />}
<Fragment key={entry.id}>
{isEditMode && (hasCursor || isFirst) && <QuickAddBlock previousEventId={previousEntryId} />}
<div className={style.entryWrapper} data-testid={`entry-${eventIndex}`}>
{isOntimeEvent(event) && <div className={style.entryIndex}>{eventIndex}</div>}
<div className={style.entry} key={event.id} ref={hasCursor ? cursorRef : undefined}>
{isOntimeEvent(entry) && <div className={style.entryIndex}>{eventIndex}</div>}
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
<RundownEntry
type={event.type}
type={entry.type}
isPast={isPast}
eventIndex={eventIndex}
data={event}
data={entry}
loaded={isLoaded}
hasCursor={hasCursor}
isNext={isNext}
previousStart={lastEntry?.timeStart}
previousEnd={lastEntry?.timeEnd}
previousEventId={previousEventId}
previousStart={lastEvent?.timeStart}
previousEnd={lastEvent?.timeEnd}
previousEntryId={previousEntryId}
previousEventId={lastEvent?.id}
playback={isLoaded ? featureData.playback : undefined}
isRolling={featureData.playback === Playback.Roll}
/>
</div>
</div>
{isEditMode && (hasCursor || isLast) && <QuickAddBlock previousEventId={event.id} />}
{isEditMode && (hasCursor || isLast) && <QuickAddBlock previousEventId={entry.id} />}
</Fragment>
);
})}
@@ -34,6 +34,7 @@ interface RundownEntryProps {
isNext: boolean;
previousStart?: number;
previousEnd?: number;
previousEntryId?: string;
previousEventId?: string;
playback?: Playback; // we only care about this if this event is playing
isRolling: boolean; // we need to know even if not related to this event
@@ -48,6 +49,7 @@ export default function RundownEntry(props: RundownEntryProps) {
isNext,
previousStart,
previousEnd,
previousEntryId,
previousEventId,
playback,
isRolling,
@@ -84,7 +86,7 @@ export default function RundownEntry(props: RundownEntryProps) {
case 'event-before': {
const newEvent = { type: SupportedEvent.Event };
const options = {
after: previousEventId,
after: previousEntryId,
};
return addEvent(newEvent, options);
}
@@ -92,13 +94,13 @@ export default function RundownEntry(props: RundownEntryProps) {
return addEvent({ type: SupportedEvent.Delay }, { after: data.id });
}
case 'delay-before': {
return addEvent({ type: SupportedEvent.Delay }, { after: previousEventId });
return addEvent({ type: SupportedEvent.Delay }, { after: previousEntryId });
}
case 'block': {
return addEvent({ type: SupportedEvent.Block }, { after: data.id });
}
case 'block-before': {
return addEvent({ type: SupportedEvent.Block }, { after: previousEventId });
return addEvent({ type: SupportedEvent.Block }, { after: previousEntryId });
}
case 'swap': {
const { value } = payload as FieldValue;
@@ -40,13 +40,6 @@
gap: 1rem;
}
.inputLabel {
display: block;
font-size: calc(1rem - 3px);
color: $label-gray;
margin-bottom: 0.25rem;
}
.decorated {
color: var(--decorator-color, $ui-white);
background-color: var(--decorator-bg, $gray-1100);
@@ -58,7 +51,6 @@
.delayLabel {
font-size: calc(1rem - 3px);
color: $ontime-delay-text;
margin-top: 0.25rem;
&::after {
content: '\200b';
@@ -66,8 +58,6 @@
}
.switchLabel {
font-size: calc(1rem - 3px);
color: $label-gray;
display: flex;
align-items: center;
gap: 0.5rem;
@@ -8,6 +8,7 @@ import { useEventAction } from '../../../common/hooks/useEventAction';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import useRundown from '../../../common/hooks-query/useRundown';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import * as Editor from '../../editors/editor-utils/EditorUtils';
import { useEventSelection } from '../useEventSelection';
import EventEditorTimes from './composite/EventEditorTimes';
@@ -100,7 +101,7 @@ export default function EventEditor() {
/>
<div className={style.column}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>Custom Fields</span>
<Editor.Title>Custom Fields</Editor.Title>
<Button variant='ontime-subtle' size='sm' onClick={handleOpenCustomManager}>
Manage
</Button>
@@ -6,6 +6,7 @@ import { millisToString, parseUserTime } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { useEventAction } from '../../../../common/hooks/useEventAction';
import { millisToDelayString } from '../../../../common/utils/dateConfig';
import * as Editor from '../../../editors/editor-utils/EditorUtils';
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
import style from '../EventEditor.module.scss';
@@ -72,7 +73,7 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
return (
<div className={style.column}>
<div>
<div className={style.inputLabel}>Event schedule</div>
<Editor.Label>Event schedule</Editor.Label>
<div className={style.inline}>
<TimeInputFlow
eventId={eventId}
@@ -90,15 +91,20 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
<div className={style.splitTwo}>
<div>
<label className={style.inputLabel} htmlFor='timeWarning'>
Warning Time
</label>
<TimeInput name='timeWarning' submitHandler={handleSubmit} time={timeWarning} placeholder='Duration' />
<Editor.Label htmlFor='timeWarning'>Warning Time</Editor.Label>
<TimeInput
id='timeWarning'
name='timeWarning'
submitHandler={handleSubmit}
time={timeWarning}
placeholder='Duration'
/>
</div>
<div>
<label className={style.inputLabel}>Timer Type</label>
<Editor.Label htmlFor='timerType'>Timer Type</Editor.Label>
<Select
size='sm'
id='timerType'
name='timerType'
value={timerType}
onChange={(event) => handleSubmit('timerType', event.target.value)}
@@ -112,14 +118,19 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
</Select>
</div>
<div>
<label className={style.inputLabel} htmlFor='timeDanger'>
Danger Time
</label>
<TimeInput name='timeDanger' submitHandler={handleSubmit} time={timeDanger} placeholder='Duration' />
<Editor.Label htmlFor='timeDanger'>Danger Time</Editor.Label>
<TimeInput
id='timeDanger'
name='timeDanger'
submitHandler={handleSubmit}
time={timeDanger}
placeholder='Duration'
/>
</div>
<div>
<label className={style.inputLabel}>End Action</label>
<Editor.Label htmlFor='endAction'>End Action</Editor.Label>
<Select
id='endAction'
size='sm'
name='endAction'
value={endAction}
@@ -135,11 +146,17 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
</div>
<div>
<span className={style.inputLabel}>Event Visibility</span>
<label className={style.switchLabel}>
<Switch size='md' isChecked={isPublic} onChange={() => handleSubmit('isPublic', isPublic)} variant='ontime' />
<Editor.Label htmlFor='isPublic'>Event Visibility</Editor.Label>
<Editor.Label className={style.switchLabel}>
<Switch
id='isPublic'
size='md'
isChecked={isPublic}
onChange={() => handleSubmit('isPublic', isPublic)}
variant='ontime'
/>
{isPublic ? 'Public' : 'Private'}
</label>
</Editor.Label>
</div>
</div>
);
@@ -3,6 +3,7 @@ import { Input } from '@chakra-ui/react';
import { sanitiseCue } from 'ontime-utils';
import SwatchSelect from '../../../../common/components/input/colour-input/SwatchSelect';
import * as Editor from '../../../editors/editor-utils/EditorUtils';
import { type EditorUpdateFields } from '../EventEditor';
import EventTextArea from './EventTextArea';
@@ -30,9 +31,7 @@ const EventEditorTitles = (props: EventEditorTitlesProps) => {
<div className={style.column}>
<div className={style.splitTwo}>
<div>
<label className={style.inputLabel} htmlFor='eventId'>
Event ID (read only)
</label>
<Editor.Label htmlFor='eventId'>Event ID (read only)</Editor.Label>
<Input
id='eventId'
size='sm'
@@ -45,7 +44,7 @@ const EventEditorTitles = (props: EventEditorTitlesProps) => {
<EventTextInput field='cue' label='Cue' initialValue={cue} submitHandler={cueSubmitHandler} maxLength={10} />
</div>
<div>
<label className={style.inputLabel}>Colour</label>
<Editor.Label>Colour</Editor.Label>
<SwatchSelect name='colour' value={colour} handleChange={handleSubmit} />
</div>
<EventTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} />
@@ -1,12 +1,10 @@
import { CSSProperties, useCallback, useRef } from 'react';
import { type CSSProperties, useCallback, useRef } from 'react';
import { AutoTextArea } from '../../../../common/components/input/auto-text-area/AutoTextArea';
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
import { cx } from '../../../../common/utils/styleUtils';
import * as Editor from '../../../editors/editor-utils/EditorUtils';
import { EditorUpdateFields } from '../EventEditor';
import style from '../EventEditor.module.scss';
interface CountedTextAreaProps {
className?: string;
field: EditorUpdateFields;
@@ -24,13 +22,12 @@ export default function EventTextArea(props: CountedTextAreaProps) {
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
submitOnCtrlEnter: true,
});
const classes = cx([style.inputLabel, className]);
return (
<div>
<label className={classes} htmlFor={field} style={givenStyles}>
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
{label}
</label>
</Editor.Label>
<AutoTextArea
id={field}
inputref={ref}
@@ -2,10 +2,9 @@ import { useCallback, useRef } from 'react';
import { Input, InputProps } from '@chakra-ui/react';
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
import * as Editor from '../../../editors/editor-utils/EditorUtils';
import { EditorUpdateFields } from '../EventEditor';
import style from '../EventEditor.module.scss';
interface EventTextInputProps extends InputProps {
field: EditorUpdateFields;
label: string;
@@ -24,9 +23,7 @@ export default function EventTextInput(props: EventTextInputProps) {
return (
<div>
<label className={style.inputLabel} htmlFor={field}>
{label}
</label>
<Editor.Label htmlFor={field}>{label}</Editor.Label>
<Input
id={field}
ref={ref}
@@ -149,6 +149,7 @@ $orange-active: #f60;
grid-area: schedule;
font-family: monospace;
margin-right: 2vw;
overflow: hidden;
}
.onAir {
@@ -12,6 +12,7 @@ import { langNo } from './languages/no';
import { langPl } from './languages/pl';
import { langPt } from './languages/pt';
import { langSv } from './languages/sv';
import { langZhCn } from './languages/zh';
const translationsList = {
en: langEn,
@@ -24,6 +25,7 @@ const translationsList = {
pt: langPt,
sv: langSv,
pl: langPl,
zh: langZhCn,
};
interface TranslationContextValue {
@@ -0,0 +1,26 @@
import { TranslationObject } from './en';
export const langZhCn: TranslationObject = {
'common.expected_finish': '预计完成',
'common.minutes': '分钟',
'common.now': '现在',
'common.next': '接下来',
'common.public_message': '公众消息',
'common.scheduled_start': '计划开始',
'common.scheduled_end': '计划结束',
'common.projected_start': '预计开始',
'common.projected_end': '预计结束',
'common.stage_timer': '舞台计时器',
'common.started_at': '开始于',
'common.time_now': '当前时间',
'countdown.ended': '事件结束于',
'countdown.running': '事件进行中',
'countdown.select_event': '选择要关注的事件',
'countdown.to_start': '到开始时间',
'countdown.waiting': '等待事件开始',
'countdown.overtime': '超时了',
'timeline.live': '当前进行',
'timeline.done': '已结束',
'timeline.due': '即将开始',
'timeline.followedby': '随后是',
};
@@ -36,6 +36,12 @@ export default function ProjectInfoProps(props: ProjectInfoProps) {
<div className='info__value'>{general.title}</div>
</>
)}
{general.description && (
<>
<div className='info__label'>Description</div>
<div className='info__value'>{general.description}</div>
</>
)}
<BackstageInfo general={general} />
<PublicInfo general={general} />
</div>
+15 -3
View File
@@ -2,7 +2,6 @@ import { sentryVitePlugin } from '@sentry/vite-plugin';
import react from '@vitejs/plugin-react';
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
import { splitVendorChunkPlugin } from 'vite';
import { compression } from 'vite-plugin-compression2';
import svgrPlugin from 'vite-plugin-svgr';
@@ -12,9 +11,9 @@ const sentryAuthToken = process.env.SENTRY_AUTH_TOKEN;
const isDev = process.env.NODE_ENV === 'local' || process.env.NODE_ENV === 'development';
export default defineConfig({
base: './', // Ontime cloud: we use relative paths to allow them to reference a dynamic base set at runtime
plugins: [
react(),
splitVendorChunkPlugin(),
svgrPlugin(),
!isDev &&
sentryVitePlugin({
@@ -33,7 +32,10 @@ export default defineConfig({
excludeReplayWorker: true,
},
}),
compression({ algorithm: 'brotliCompress' }),
compression({
algorithm: 'brotliCompress',
exclude: /\.(html)$/, // Ontime cloud: Exclude HTML files from compression so we can change the base property at runtime
}),
],
server: {
port: 3000,
@@ -46,6 +48,16 @@ export default defineConfig({
build: {
outDir: './build',
sourcemap: true,
rollupOptions: {
output: {
manualChunks(id) {
// Split vendor code
if (id.includes('node_modules')) {
return 'vendor';
}
},
},
},
},
resolve: {
alias: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "3.8.0",
"version": "3.9.3",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+11 -3
View File
@@ -114,13 +114,21 @@ function escalateError(error) {
}
/**
* Allows electron to ask react app redirect
* @param string location
* Allows electron to ask the react app to redirect
* @param {string} location
*/
function redirectWindow(location) {
win.webContents.send('request-editor-location', location);
}
/**
* Asks the react app to show a user dialog
* @param {string} name
*/
function showDialog(name) {
win.webContents.send('dialog', name);
}
// Ensure there isn't another instance of the app running already
const lock = app.requestSingleInstanceLock();
if (!lock) {
@@ -190,7 +198,7 @@ app.whenReady().then(() => {
.then((port) => {
const clientUrl = getClientUrl(port);
const serverUrl = getServerUrl(port);
const menu = getApplicationMenu(askToQuit, clientUrl, serverUrl, redirectWindow, (url) =>
const menu = getApplicationMenu(askToQuit, clientUrl, serverUrl, redirectWindow, showDialog, (url) =>
win.webContents.downloadURL(url),
);
Menu.setApplicationMenu(menu);
+10 -4
View File
@@ -21,13 +21,14 @@ const {
* @param {string} clientUrl - base url for the application
* @param {string} serverUrl - base url for the application
* @param {function} redirectWindow - function to redirect main window content
* @param {function} showDialog - asks the react app to show a user dialog
* @param {function} download - function to download a resource from url
* @returns {Menu} - application menu
*/
function getApplicationMenu(askToQuit, clientUrl, serverUrl, redirectWindow, download) {
function getApplicationMenu(askToQuit, clientUrl, serverUrl, redirectWindow, showDialog, download) {
const template = [
...(isMac ? [makeMacMenu(askToQuit)] : []),
makeFileMenu(serverUrl, redirectWindow, download),
makeFileMenu(serverUrl, redirectWindow, showDialog, download),
makeViewMenu(clientUrl),
makeSettingsMenu(redirectWindow),
makeHelpMenu(redirectWindow),
@@ -53,7 +54,7 @@ function makeMacMenu(askToQuit) {
{ type: 'separator' },
{
label: 'Quit',
click: () => askToQuit(),
click: askToQuit,
accelerator: isMac ? 'Cmd+Q' : 'Alt+F4',
},
],
@@ -64,10 +65,11 @@ function makeMacMenu(askToQuit) {
* Utility function generates the file menu
* @param {string} serverUrl - base url for the application
* @param {function} redirectWindow - function to redirect main window content
* @param {function} showDialog - asks the react app to show a user dialog
* @param {function} download - function to download a resource from url
* @returns {Object}
*/
function makeFileMenu(serverUrl, redirectWindow, download) {
function makeFileMenu(serverUrl, redirectWindow, showDialog, download) {
const downloadProject = () => {
try {
download(serverUrl + downloadPath);
@@ -83,6 +85,10 @@ function makeFileMenu(serverUrl, redirectWindow, download) {
label: 'New project...',
click: () => redirectWindow('/editor?settings=project__manage&new=true'),
},
{
label: 'Load...',
click: () => showDialog('welcome'),
},
{
label: 'Quick start...',
click: () => redirectWindow('/editor?settings=project__create'),
+10
View File
@@ -0,0 +1,10 @@
import * as esbuild from 'esbuild';
import { esbuildCommon } from './esbuildCommon.js';
await esbuild.build({
...esbuildCommon,
entryPoints: ['src/app.ts'],
minify: false,
dropLabels: [],
outfile: 'dist/index.cjs',
});
+8
View File
@@ -0,0 +1,8 @@
import * as esbuild from 'esbuild';
import { esbuildCommon } from './esbuildCommon.js';
await esbuild.build({
...esbuildCommon,
entryPoints: ['src/index.ts'],
outfile: 'dist/docker.cjs',
});
+8
View File
@@ -0,0 +1,8 @@
import * as esbuild from 'esbuild';
import { esbuildCommon } from './esbuildCommon.js';
await esbuild.build({
...esbuildCommon,
entryPoints: ['src/app.ts'],
outfile: 'dist/index.cjs',
});
+10
View File
@@ -0,0 +1,10 @@
export const esbuildCommon = {
logLevel: 'error',
platform: 'node',
target: ['node20'],
format: 'cjs',
bundle: true,
minify: true,
legalComments: 'external',
dropLabels: ['DEV'],
};
-3
View File
@@ -1,3 +0,0 @@
{
"ext": "js,json,ts"
}
+8 -9
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "3.8.0",
"version": "3.9.3",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -33,7 +33,7 @@
"@types/ws": "^8.5.10",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"esbuild": "^0.19.10",
"esbuild": "^0.24.0",
"eslint": "catalog:",
"eslint-plugin-prettier": "catalog:",
"ontime-types": "workspace:*",
@@ -43,7 +43,7 @@
"ts-essentials": "^9.4.1",
"tsx": "^4.16.2",
"typescript": "catalog:",
"vitest": "^1.6.0"
"vitest": "catalog:"
},
"scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
@@ -51,12 +51,11 @@
"dev": "cross-env NODE_ENV=development tsx watch ./src/index.ts",
"dev:inspect": "cross-env NODE_ENV=development tsx watch --inspect ./src/index.ts",
"dev:test": "cross-env IS_TEST=true tsx ./src/index.ts",
"build": "esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --drop-labels=DEV --outfile=dist/index.cjs",
"build:electron": "esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --drop-labels=DEV --outfile=dist/index.cjs",
"build:local": "esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --drop-labels=DEV --outfile=dist/index.cjs",
"build:docker": "esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --legal-comments=external --drop-labels=DEV --outfile=dist/docker.cjs",
"build:localdocker": "cross-env NODE_ENV=local esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --legal-comments=external --drop-labels=DEV --outfile=dist/docker.cjs",
"build:debug": "esbuild src/app.ts --platform=node --format=cjs --bundle --legal-comments=external --outfile=dist/index.cjs",
"build": "node esbuild.electron.js",
"build:electron": "node esbuild.electron.js",
"build:local": "node esbuild.dev.js",
"build:docker": "node esbuild.docker.js",
"build:localdocker": "cross-env NODE_ENV=local node esbuild.docker.js",
"lint": "eslint . --quiet",
"test": "cross-env IS_TEST=true vitest",
"test:pipeline": "cross-env IS_TEST=true vitest run",
+14 -2
View File
@@ -34,6 +34,7 @@ export class SocketServer implements IAdapter {
private wss: WebSocketServer | null;
private readonly clients: Map<string, Client>;
private lastConnection: Date | null = null;
private isFirstEditor = true;
constructor() {
if (instance) {
@@ -46,8 +47,8 @@ export class SocketServer implements IAdapter {
this.wss = null;
}
init(server: Server) {
this.wss = new WebSocketServer({ path: '/ws', server, maxPayload: this.MAX_PAYLOAD });
init(server: Server, prefix?: string) {
this.wss = new WebSocketServer({ path: `${prefix}/ws`, server, maxPayload: this.MAX_PAYLOAD });
this.wss.on('connection', (ws) => {
const clientId = generateId();
@@ -134,7 +135,18 @@ export class SocketServer implements IAdapter {
const previousData = this.clients.get(clientId);
previousData.path = payload;
this.clients.set(clientId, previousData);
if (payload.includes('editor') && this.isFirstEditor) {
this.isFirstEditor = false;
ws.send(
JSON.stringify({
type: 'dialog',
payload: { dialog: 'welcome' },
}),
);
}
}
this.sendClientList();
return;
}
@@ -192,6 +192,25 @@ export async function loadProject(req: Request, res: Response<MessageResponse |
}
}
/**
* Loads the demo project
*/
export async function loadDemo(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
const projectName = await projectService.loadDemoProject();
res.status(201).send({
message: `Loaded demo project: ${projectName}`,
});
} catch (error) {
const message = getErrorMessage(error);
if (message.startsWith('Project file')) {
return res.status(403).send({ message });
}
res.status(500).send({ message });
}
}
/**
* Duplicates a project file.
* Receives the original project filename (`filename`) from the request parameters
+2
View File
@@ -6,6 +6,7 @@ import {
deleteProjectFile,
duplicateProjectFile,
listProjects,
loadDemo,
loadProject,
patchPartialProjectFile,
postProjectFile,
@@ -36,6 +37,7 @@ router.post('/quick', validateQuickProject, quickProjectFile);
router.get('/all', listProjects);
router.post('/load', validateFilenameBody, loadProject);
router.post('/demo', loadDemo);
router.post('/:filename/duplicate', validateFilenameParam, validateNewFilenameBody, duplicateProjectFile);
router.put('/:filename/rename', validateFilenameParam, validateNewFilenameBody, renameProjectFile);
router.delete('/:filename', validateFilenameParam, deleteProjectFile);
@@ -19,7 +19,6 @@ import {
deleteEvent,
editEvent,
reorderEvent,
setFrozenState,
swapEvents,
} from '../../services/rundown-service/RundownService.js';
import {
@@ -127,17 +126,6 @@ export async function rundownBatchPut(req: Request, res: Response<MessageRespons
}
}
export async function rundownFrozenPost(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
const { frozen } = req.body;
setFrozenState(frozen);
res.status(200).send({ message: 'Rundown frozen state updated.' });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export async function rundownReorder(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
@@ -1,9 +0,0 @@
import { eventStore } from '../../stores/EventStore.js';
export const preventIfFrozen = function (req, res, next) {
if (eventStore.get('frozen')) {
res.status(403).send({ message: 'Rundown is frozen' });
} else {
next();
}
};
@@ -5,7 +5,6 @@ import {
rundownApplyDelay,
rundownBatchPut,
rundownDelete,
rundownFrozenPost,
rundownGetAll,
rundownGetById,
rundownGetNormalised,
@@ -19,14 +18,12 @@ import {
paramsMustHaveEventId,
rundownArrayOfIds,
rundownBatchPutValidator,
rundownFrozenPostValidator,
rundownGetPaginatedQueryParams,
rundownPostValidator,
rundownPutValidator,
rundownReorderValidator,
rundownSwapValidator,
} from './rundown.validation.js';
import { preventIfFrozen } from './rundown.middleware.js';
export const router = express.Router();
@@ -36,14 +33,13 @@ router.get('/normalised', rundownGetNormalised);
router.get('/:eventId', paramsMustHaveEventId, rundownGetById); // not used in Ontime frontend
router.post('/', rundownPostValidator, rundownPost);
router.post('/frozen', rundownFrozenPostValidator, rundownFrozenPost);
router.put('/', rundownPutValidator, rundownPut);
router.put('/batch', rundownBatchPutValidator, rundownBatchPut);
router.patch('/reorder/', rundownReorderValidator, preventIfFrozen, rundownReorder);
router.patch('/swap', rundownSwapValidator, preventIfFrozen, rundownSwap);
router.patch('/reorder/', rundownReorderValidator, rundownReorder);
router.patch('/swap', rundownSwapValidator, rundownSwap);
router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay);
router.delete('/', rundownArrayOfIds, preventIfFrozen, deletesEventById);
router.delete('/all', preventIfFrozen, rundownDelete);
router.delete('/', rundownArrayOfIds, deletesEventById);
router.delete('/all', rundownDelete);
@@ -21,15 +21,6 @@ export const rundownPutValidator = [
},
];
export const rundownFrozenPostValidator = [
body('frozen').isBoolean().exists(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
]
export const rundownBatchPutValidator = [
body('data').isObject().exists(),
body('ids').isArray().exists(),
@@ -241,6 +241,11 @@ const actionHandlers: Record<string, ActionHandler> = {
const timeInMs = numberOrError(command.duration) * 1000;
reply.payload = auxTimerService.setTime(timeInMs);
}
if ('addtime' in command) {
// convert addTime in seconds to ms
const timeInMs = numberOrError(command.addtime) * 1000;
reply.payload = auxTimerService.addTime(timeInMs);
}
if ('direction' in command) {
if (command.direction === SimpleDirection.CountUp || command.direction === SimpleDirection.CountDown) {
reply.payload = auxTimerService.setDirection(command.direction);
+40 -20
View File
@@ -6,11 +6,11 @@ import expressStaticGzip from 'express-static-gzip';
import http, { type Server } from 'http';
import cors from 'cors';
import serverTiming from 'server-timing';
import { extname } from 'node:path';
// import utils
import { resolve } from 'path';
import { publicDir, srcDir } from './setup/index.js';
import { environment, isProduction } from './externals.js';
import { publicDir, srcDir, srcFiles } from './setup/index.js';
import { environment, isProduction, updateRouterPrefix } from './externals.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
import { consoleSuccess, consoleHighlight, consoleError } from './utils/console.js';
@@ -53,9 +53,18 @@ if (!canLog) {
console.log(`Ontime public directory at ${publicDir.root} `);
}
/**
* When running in Ontime cloud, the client is not at the root segment
* ie: https://cloud.getontime.com/client-hash/timer
* This means:
* - changing the base path in the index.html file
* - prepending all express routes with the given prefix
*/
const prefix = updateRouterPrefix();
// Create express APP
const app = express();
if (process.env.NODE_ENV === 'development') {
if (!isProduction) {
// log server timings to requests
app.use(serverTiming());
}
@@ -72,32 +81,44 @@ app.use(express.urlencoded({ extended: true }));
app.use(express.json({ limit: '1mb' }));
// Implement route endpoints
app.use('/data', appRouter); // router for application data
app.use('/api', integrationRouter); // router for integrations
app.use(`${prefix}/data`, appRouter); // router for application data
app.use(`${prefix}/api`, integrationRouter); // router for integrations
// serve static external files
app.use('/external', express.static(publicDir.externalDir));
app.use('/user', express.static(publicDir.userDir));
// if the user reaches to the root, we show a 404
app.use('/external', (req, res) => {
app.use(`${prefix}/external`, express.static(publicDir.externalDir));
app.use(`${prefix}/external`, (req, res) => {
// if the user reaches to the root, we show a 404
res.status(404).send(`${req.originalUrl} not found`);
});
app.use(`${prefix}/user`, express.static(publicDir.userDir));
// serve static - react, in dev/test mode we fetch the React app from module
app.use(
prefix,
expressStaticGzip(srcDir.clientDir, {
enableBrotli: true,
orderPreference: ['br'],
// when we build the client all the react subfiles will get a hashed name we can the immutable tag
// when we build the client the file names contain a unique hash for the build
// this allows us to use the immutable tag
// as the contents of a build file will never change without also changing its name
// so the client dose not need to revalidate the file contetnts with the server
serveStatic: { etag: false, lastModified: false, immutable: true, maxAge: '1y' },
// meaning that the client does not need to revalidate the contents with the server
serveStatic: {
etag: false,
lastModified: false,
immutable: true,
maxAge: '1y',
setHeaders: (res, file) => {
// make sure the HTML files are always revalidated
if (extname(file) === '.html') {
res.setHeader('Cache-Control', 'public, max-age=0');
}
},
},
}),
);
app.get('*', (_req, res) => {
res.sendFile(resolve(srcDir.clientDir, 'index.html'));
app.get(`${prefix}/*`, (_req, res) => {
res.sendFile(srcFiles.clientIndexHtml);
});
// Implement catch all
@@ -161,7 +182,7 @@ export const startServer = async (
const { serverPort } = getDataProvider().getSettings();
expressServer = http.createServer(app);
socket.init(expressServer);
socket.init(expressServer, prefix);
/**
* Module initialises the services and provides initial payload for the store
@@ -187,7 +208,6 @@ export const startServer = async (
playback: SimplePlayback.Stop,
direction: SimpleDirection.CountDown,
},
frozen: false,
ping: -1,
});
@@ -207,10 +227,10 @@ export const startServer = async (
expressServer.listen(serverPort, '0.0.0.0', () => {
const nif = getNetworkInterfaces();
consoleSuccess(`Local: http://localhost:${serverPort}/editor`);
consoleSuccess(`Local: http://localhost:${serverPort}${prefix}/editor`);
for (const key in nif) {
const address = nif[key].address;
consoleSuccess(`Network: http://${address}:${serverPort}/editor`);
consoleSuccess(`Network: http://${address}:${serverPort}${prefix}/editor`);
}
});
@@ -37,6 +37,14 @@ export class SimpleTimer {
return this.state;
}
public addTime(millis: number): SimpleTimerState {
this.state.duration += millis;
// the value of current will be overridden when update is called,
// but if we are in pause or stop state it will not be changed so we do it here
this.state.current += millis;
return this.state;
}
public setDirection(direction: SimpleDirection, timeNow: number): SimpleTimerState {
// if we are playing, we need to reset the targets
if (this.state.playback === SimplePlayback.Start) {
@@ -177,5 +177,60 @@ describe('SimpleTimer count-down', () => {
playback: SimplePlayback.Start,
});
});
test('adding time affects final result', () => {
timer.reset();
timer.setTime(1000);
timer.start(0);
timer.update(100);
expect(timer.state).toMatchObject({ current: 900, duration: 1000 });
timer.addTime(1000);
timer.update(200);
expect(timer.state).toMatchObject({ current: 1800, duration: 2000 });
timer.update(300);
expect(timer.state).toMatchObject({ current: 1700, duration: 2000 });
timer.stop();
expect(timer.state).toMatchObject({ current: 1000, duration: 1000 });
});
test('adding time affects paused timer', () => {
timer.reset();
timer.setTime(1000);
timer.start(0);
timer.update(100);
expect(timer.state).toMatchObject({ current: 900, duration: 1000 });
timer.pause(200);
expect(timer.state).toMatchObject({ current: 900, duration: 1000 });
timer.addTime(1000);
timer.update(200);
expect(timer.state).toMatchObject({ current: 1900, duration: 2000 });
timer.start(300);
expect(timer.state).toMatchObject({ current: 1800, duration: 2000 });
});
test('adding time affects stopped timer, but returns to initial valuses when stopped again', () => {
timer.reset();
timer.setTime(1000);
expect(timer.state).toMatchObject({ current: 1000, duration: 1000 });
timer.addTime(1000);
expect(timer.state).toMatchObject({ current: 2000, duration: 2000 });
timer.start(0);
timer.update(100);
expect(timer.state).toMatchObject({ current: 1900, duration: 2000 });
timer.stop();
expect(timer.state).toMatchObject({ current: 1000, duration: 1000 });
});
});
});
+1 -1
View File
@@ -1,7 +1,7 @@
import { MILLIS_PER_MINUTE } from 'ontime-utils';
export const timerConfig = {
skipLimit: 1000, // threshold of skip for recalculating
skipLimit: 1000, // threshold of skip for recalculating, values lower than updateRate can cause issues with rolling over midnight
updateRate: 32, // how often do we update the timer
notificationRate: 1000, // how often do we notify clients and integrations
triggerAhead: 10, // how far ahead do we trigger the end event
+27
View File
@@ -2,6 +2,10 @@
* This file contains a list of constants that may need to be resolved at runtime
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { srcFiles } from './setup/index.js';
// =================================================
// resolve running environment
const env = process.env.NODE_ENV || 'production';
@@ -10,3 +14,26 @@ export const isTest = Boolean(process.env.IS_TEST);
export const environment = isTest ? 'test' : env;
export const isDocker = env === 'docker';
export const isProduction = isDocker || (env === 'production' && !isTest);
/**
* Updates the router prefix in the index.html file
* This is only needed in the cloud environment where the client is not at the root segment
* ie: https://cloud.getontime.com/client-hash/timer
*/
export function updateRouterPrefix(prefix: string | undefined = process.env.ROUTER_PREFIX): string {
if (!prefix) {
return '';
}
try {
const data = readFileSync(srcFiles.clientIndexHtml, { encoding: 'utf-8', flag: 'r' }).replace(
'<base href="/" />',
`<base href="/${prefix}/" />`,
);
writeFileSync(srcFiles.clientIndexHtml, data, { encoding: 'utf-8', flag: 'w' });
} catch (_error) {
/** unhandled */
}
return `/${prefix}`;
}
@@ -1,4 +1,4 @@
import { SimpleDirection, SimpleTimerState } from 'ontime-types';
import { SimpleDirection, SimplePlayback, SimpleTimerState } from 'ontime-types';
import { SimpleTimer } from '../../classes/simple-timer/SimpleTimer.js';
import { eventStore } from '../../stores/EventStore.js';
@@ -57,6 +57,15 @@ export class AuxTimerService {
return this.timer.setTime(duration);
}
@broadcastReturn
addTime(millis: number) {
if (this.timer.state.playback === SimplePlayback.Start) {
this.timer.addTime(millis);
return this.timer.update(this.getTime());
}
return this.timer.addTime(millis);
}
@broadcastReturn
private update() {
return this.timer.update(this.getTime());
@@ -128,18 +128,18 @@ export class OscIntegration implements IIntegration<OscSubscription, OSCSettings
}
private shutdownTX() {
logger.info(LogOrigin.Rx, 'Shutting down OSC integration');
if (this.oscServer) {
this.oscServer?.shutdown();
this.oscServer = null;
if (this.oscClient) {
logger.info(LogOrigin.Tx, 'Shutting down OSC integration');
this.oscClient?.close();
this.oscClient = null;
}
}
private shutdownRX() {
logger.info(LogOrigin.Tx, 'Shutting down OSC integration');
if (this.oscClient) {
this.oscClient?.close();
this.oscClient = null;
if (this.oscServer) {
logger.info(LogOrigin.Rx, 'Shutting down OSC integration');
this.oscServer?.shutdown();
this.oscServer = null;
}
}
}
@@ -59,10 +59,9 @@ export async function getCurrentProject() {
}
/**
* Private function loads a demo project
* to be composed in the loading functions
* Loads the demo project
*/
async function loadDemoProject(): Promise<string> {
export async function loadDemoProject(): Promise<string> {
const pathToNewFile = generateUniqueFileName(publicDir.projectsDir, config.demoProject);
await initPersistence(getPathToProject(pathToNewFile), demoDb);
const newName = getFileNameFromPath(pathToNewFile);
@@ -21,7 +21,6 @@ import { runtimeService } from '../runtime-service/RuntimeService.js';
import * as cache from './rundownCache.js';
import { getPlayableEvents, getTimedEvents } from './rundownUtils.js';
import { eventStore } from '../../stores/EventStore.js';
type PatchWithId = (Partial<OntimeEvent> | Partial<OntimeBlock> | Partial<OntimeDelay>) & { id: string };
@@ -275,7 +274,3 @@ export async function initRundown(rundown: Readonly<OntimeRundown>, customFields
// notify timer of change
notifyChanges({ timer: true, external: true, reload: true });
}
export async function setFrozenState(state: boolean) {
eventStore.set('frozen', state);
}
@@ -99,10 +99,15 @@ class RuntimeService {
});
this.handleLoadNext();
this.rollLoaded(keepOffset);
} else if (skippedOutOfEvent(newState, this.lastIntegrationClockUpdate, timerConfig.skipLimit)) {
} else if (
// if there is no previous clock, we could not have skipped
RuntimeService.previousState?.clock &&
skippedOutOfEvent(newState, RuntimeService.previousState.clock, timerConfig.skipLimit)
) {
// if we have skipped out of the event, we will recall roll
// to push the playback to the right place
// this comes with the caveat that we will lose our runtime data
logger.warning(LogOrigin.Playback, 'Time skip detected, reloading roll');
this.roll(true);
}
}
+2
View File
@@ -77,6 +77,8 @@ export const srcDir = {
} as const;
export const srcFiles = {
/** Path to start index.html */
clientIndexHtml: join(srcDir.clientDir, 'index.html'),
/** Path to bundled CSS */
cssOverride: join(srcDir.root, config.user, config.styles.directory, config.styles.filename),
/** Path to bundled external readme */
@@ -1,33 +0,0 @@
import { cleanURL } from '../url.js';
describe('url is correctly formatted', () => {
it('has no leading spaces', () => {
const test = ' http://testing';
const expected = 'http://testing';
expect(cleanURL(test)).toBe(expected);
});
it('has no trailing spaces', () => {
const test = 'http://testing ';
const expected = 'http://testing';
expect(cleanURL(test)).toBe(expected);
});
it('doesnt contain spaces', () => {
const test = 'http://t e s t i n g';
const expected = 'http://t%20e%20s%20t%20i%20n%20g';
expect(cleanURL(test)).toBe(expected);
});
it('only contains allowed characters', () => {
const test = 'http://<>[]{}|^';
const expected = 'http://';
expect(cleanURL(test)).toBe(expected);
});
it('begins with http://', () => {
const test = 'ontime.com';
const expected = 'http://ontime.com';
expect(cleanURL(test)).toBe(expected);
});
});
-20
View File
@@ -1,20 +0,0 @@
/**
* @description Cleans given url
* @param {string} url - URL to be checked
* @returns {string} Sanitized url
*/
export const cleanURL = (url: string): string => {
// trim whitespaces
let sanitised = url.trim();
// clear any whitespaces
sanitised = sanitised.split(' ').join('%20');
// contain only allowed characters
sanitised = sanitised.replace(/([@\s<>[\]{}|\\^])+/g, '');
// starts with http://
if (!sanitised.startsWith('http://')) sanitised = `http://${sanitised}`;
return sanitised;
};
-2
View File
@@ -1,5 +1,3 @@
version: "3"
services:
ontime:
container_name: ontime
+4
View File
@@ -4,6 +4,10 @@ const fileToUpload = 'e2e/tests/fixtures/test-db.json';
test('project file upload', async ({ page }) => {
await page.goto('http://localhost:4001/editor');
// close the welcome modal if it is open
await page.keyboard.down('Escape');
await page.getByRole('button', { name: 'Edit' }).click();
await page.getByRole('button', { name: 'Clear rundown' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "3.8.0",
"version": "3.9.3",
"description": "Time keeping for live events",
"keywords": [
"ontime",
@@ -51,6 +51,5 @@ export const runtimeStorePlaceholder: RuntimeStore = {
duration: 0,
playback: SimplePlayback.Stop,
},
frozen: false,
ping: -1,
};

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