Compare commits

..

5 Commits

Author SHA1 Message Date
Fabian Posenau debdbd1c5a Fix danger time not shown (#775)
* wrong order of operations

* chore: version bump
2024-02-14 12:44:06 +01:00
Carlos Valente d1ae407996 chore: version bump (#764) 2024-02-06 11:46:08 +01:00
Carlos Valente 467b595375 fix: issue where a end time can be overflowing (#761) 2024-02-06 11:42:28 +01:00
Carlos Valente 965a7092d9 fix: timer type clock is not negative in public displays (#747) 2024-01-30 12:35:36 +01:00
Alex Christoffer Rasmussen 661337fef9 fix: change all ExcelImportMaps to lowercase (#716)
* fix: change all ExcelImportMaps to lowercase

* chore: bump patch version
2024-01-15 21:37:04 +01:00
434 changed files with 13174 additions and 16396 deletions
+22 -12
View File
@@ -8,12 +8,25 @@
"jest": true
},
"parser": "@typescript-eslint/parser",
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier", "eslint-config-prettier"],
"plugins": ["@typescript-eslint", "prettier"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier",
"eslint-config-prettier"
],
"plugins": [
"@typescript-eslint",
"prettier"
],
"overrides": [
{
"files": ["e2e/**/**.spec.ts", "e2e/**/**.test.ts"],
"extends": ["plugin:playwright/playwright-test"]
"files": [
"e2e/**/**.spec.ts",
"e2e/**/**.test.ts"
],
"extends": [
"plugin:playwright/playwright-test"
]
}
],
"rules": {
@@ -22,13 +35,10 @@
"no-console": [
"warn",
{
"allow": ["warn", "error"]
}
],
"no-restricted-imports": [
"error",
{
"patterns": ["ontime-types/src/*", "ontime-utils/src/*"]
"allow": [
"warn",
"error"
]
}
],
"@typescript-eslint/no-non-null-assertion": "warn",
@@ -49,4 +59,4 @@
}
]
}
}
}
+3 -3
View File
@@ -14,7 +14,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 18.18.2
node-version: 16
- name: Setup pnpm
uses: pnpm/action-setup@v2
@@ -49,7 +49,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 18.18.2
node-version: 16
- name: Setup pnpm
uses: pnpm/action-setup@v2
@@ -82,7 +82,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 18.18.2
node-version: 16
- name: Setup pnpm
uses: pnpm/action-setup@v2
+2 -8
View File
@@ -17,7 +17,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 18.18.2
node-version: 16
- name: Setup pnpm
uses: pnpm/action-setup@v2
@@ -73,7 +73,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 18.18.2
node-version: 16
- name: Setup pnpm
uses: pnpm/action-setup@v2
@@ -98,9 +98,3 @@ jobs:
name: playwright-report
path: playwright-report/
retention-days: 30
- uses: actions/upload-artifact@v3
with:
name: automated-screenshots
path: automated-screenshots/
retention-days: 14
-1
View File
@@ -27,7 +27,6 @@ ontime.code-workspace
test-results
playwright-report
/playwright/.cache/
automated-screenshots
# production
build/
+2 -2
View File
@@ -9,8 +9,8 @@ Ontime consists of 3 distinct parts
The steps below will assume you have locally installed the necessary dependencies.
Other dependencies will be installed as part of the setup
- __node__ (~18.18.2)
- __pnpm__ (>=8)
- __node__ (>=16.16)
- __pnpm__ (>=7)
- __docker__ (only necessary to run and build docker images)
## LOCAL DEVELOPMENT
+23 -23
View File
@@ -1,35 +1,36 @@
{
"name": "ontime-ui",
"version": "3.0.0-alpha",
"version": "2.28.17",
"private": true,
"type": "module",
"dependencies": {
"@chakra-ui/react": "^2.7.0",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.1",
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@react-icons/all-files": "^4.1.0",
"@sentry/react": "^7.92.0",
"@tanstack/react-query": "^5.17.9",
"@tanstack/react-query-devtools": "^5.17.9",
"@tanstack/react-table": "^8.11.3",
"@sentry/react": "^7.46.0",
"@sentry/tracing": "^7.46.0",
"@tanstack/react-query": "^5.8.4",
"@tanstack/react-query-devtools": "^5.8.4",
"@tanstack/react-table": "^8.9.2",
"autosize": "^6.0.1",
"axios": "^1.2.0",
"color": "^4.2.3",
"csv-stringify": "^6.4.5",
"csv-stringify": "^6.2.3",
"deepmerge": "^4.3.0",
"framer-motion": "^10.10.0",
"react": "^18.2.0",
"react-colorful": "^5.6.1",
"react-dom": "^18.2.0",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.49.2",
"react-qr-code": "^2.0.12",
"react-fast-compare": "^3.2.0",
"react-hook-form": "^7.43.5",
"react-qr-code": "^2.0.11",
"react-router-dom": "^6.3.0",
"typeface-open-sans": "^1.1.13",
"web-vitals": "^3.1.1",
"zustand": "^4.4.7"
"zustand": "^4.3.6"
},
"scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
@@ -44,8 +45,7 @@
"lint-staged": "eslint",
"test": "vitest",
"test:pipeline": "vitest run",
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build",
"analyse": "npx vite-bundle-visualizer"
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build"
},
"browserslist": {
"production": [
@@ -60,7 +60,7 @@
]
},
"devDependencies": {
"@sentry/vite-plugin": "^2.14.0",
"@sentry/vite-plugin": "^0.4.0",
"@tanstack/eslint-plugin-query": "^5.8.4",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.1.1",
@@ -71,7 +71,7 @@
"@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"@vitejs/plugin-react": "^4.2.1",
"@vitejs/plugin-react": "^3.0.1",
"eslint": "^8.53.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-jest": "^27.6.0",
@@ -86,10 +86,10 @@
"prettier": "^3.0.3",
"sass": "^1.57.1",
"typescript": "^5.2.2",
"vite": "^5.1.0",
"vite-plugin-compression2": "^0.12.0",
"vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^4.3.1",
"vitest": "^1.2.2"
"vite": "^4.3.1",
"vite-plugin-compression2": "^0.9.0",
"vite-plugin-svgr": "^2.4.0",
"vite-tsconfig-paths": "^4.2.0",
"vitest": "^0.30.1"
}
}
+18 -1
View File
@@ -15,7 +15,7 @@ const Countdown = lazy(() => import('./features/viewers/countdown/Countdown'));
const Backstage = lazy(() => import('./features/viewers/backstage/Backstage'));
const Public = lazy(() => import('./features/viewers/public/Public'));
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerWrapper'));
const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock'));
const STimer = withAlias(withData(TimerView));
@@ -31,20 +31,27 @@ const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'
const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport'));
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
const Info = lazy(() => import('./features/info/InfoExport'));
export default function AppRouter() {
return (
<Suspense fallback={null}>
<Routes>
<Route path='/' element={<Navigate to='/timer' />} />
<Route path='/speaker' element={<STimer />} />
<Route path='/presenter' element={<STimer />} />
<Route path='/stage' element={<STimer />} />
<Route path='/timer' element={<STimer />} />
<Route path='/minimal' element={<SMinimalTimer />} />
<Route path='/minimalTimer' element={<SMinimalTimer />} />
<Route path='/simpleTimer' element={<SMinimalTimer />} />
<Route path='/clock' element={<SClock />} />
<Route path='/countdown' element={<SCountdown />} />
<Route path='/sm' element={<SBackstage />} />
<Route path='/backstage' element={<SBackstage />} />
<Route path='/public' element={<SPublic />} />
@@ -58,6 +65,8 @@ export default function AppRouter() {
{/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<Cuesheet />} />
<Route path='/cuelist' element={<Cuesheet />} />
<Route path='/table' element={<Cuesheet />} />
{/*/!* Protected Routes - Elements *!/*/}
<Route
@@ -84,6 +93,14 @@ export default function AppRouter() {
</EditorFeatureWrapper>
}
/>
<Route
path='/info'
element={
<EditorFeatureWrapper>
<Info />
</EditorFeatureWrapper>
}
/>
{/*/!* Send to default if nothing found *!/*/}
<Route path='*' element={<STimer />} />
</Routes>
+7 -9
View File
@@ -1,21 +1,19 @@
// REST stuff
export const ALIASES = ['aliases'];
export const APP_INFO = ['appinfo'];
export const APP_SETTINGS = ['appSettings'];
export const HTTP_SETTINGS = ['httpSettings'];
export const OSC_SETTINGS = ['oscSettings'];
export const PROJECT_DATA = ['project'];
export const PROJECT_LIST = ['projectList'];
export const ALIASES = ['aliases'];
export const USERFIELDS = ['userFields'];
export const RUNDOWN = ['rundown'];
export const APP_INFO = ['appinfo'];
export const OSC_SETTINGS = ['oscSettings'];
export const HTTP_SETTINGS = ['httpSettings'];
export const APP_SETTINGS = ['appSettings'];
export const VIEW_SETTINGS = ['viewSettings'];
export const RUNTIME = ['runtimeStore'];
export const SHEET_STATE = ['sheetState'];
export const USERFIELDS = ['userFields'];
export const VIEW_SETTINGS = ['viewSettings'];
const location = window.location;
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
export const isProduction = import.meta.env.MODE === 'production';
export const isDev = !isProduction;
const STATIC_PORT = 4001;
export const serverPort = isProduction ? location.port : STATIC_PORT;
+8 -1
View File
@@ -42,5 +42,12 @@ export function logAxiosError(prepend: string, error: unknown) {
* Utility function invalidates react-query caches
*/
export async function invalidateAllCaches() {
await ontimeQueryClient.invalidateQueries();
await ontimeQueryClient.invalidateQueries({ queryKey: ['project'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['aliases'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['userFields'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['rundown'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['appinfo'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['oscSettings'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['appSettings'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['viewSettings'] });
}
+3 -16
View File
@@ -1,5 +1,5 @@
import axios from 'axios';
import { OntimeEvent, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { GetRundownCached, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import { rundownURL } from './apiConstants';
@@ -7,7 +7,7 @@ import { rundownURL } from './apiConstants';
* @description HTTP request to fetch all events
* @return {Promise}
*/
export async function fetchCachedRundown(): Promise<RundownCached> {
export async function fetchCachedRundown(): Promise<GetRundownCached> {
const res = await axios.get(`${rundownURL}/cached`);
return res.data;
}
@@ -26,7 +26,7 @@ export async function fetchRundown(): Promise<OntimeRundown> {
* @description HTTP request to post new event
* @return {Promise}
*/
export async function requestPostEvent(data: Partial<OntimeRundownEntry>) {
export async function requestPostEvent(data: OntimeRundownEntry) {
return axios.post(rundownURL, data);
}
@@ -38,19 +38,6 @@ export async function requestPutEvent(data: Partial<OntimeRundownEntry>) {
return axios.put(rundownURL, data);
}
type BatchEditEntry = {
data: Partial<OntimeEvent>;
ids: string[];
};
/**
* @description HTTP request to put multiple events
* @returns {Promise}
*/
export async function requestBatchPutEvents(data: BatchEditEntry) {
return axios.put(`${rundownURL}/batchEdit`, data);
}
export type ReorderEntry = {
eventId: string;
from: number;
+27 -103
View File
@@ -4,11 +4,10 @@ import {
DatabaseModel,
GetInfo,
HttpSettings,
MessageResponse,
OntimeRundown,
OSCSettings,
OscSubscription,
ProjectData,
ProjectFileListResponse,
Settings,
UserFields,
ViewSettings,
@@ -73,29 +72,13 @@ export async function getAliases(): Promise<Alias[]> {
}
/**
* @description HTTP request to create an alias
* @description HTTP request to mutate aliases
* @return {Promise}
*/
export async function postAlias(data: Alias) {
export async function postAliases(data: Alias[]) {
return axios.post(`${ontimeURL}/aliases`, data);
}
/**
* @description HTTP request to update aliases
* @return {Promise}
*/
export async function updateAliases(data: Partial<Alias>) {
return axios.put(`${ontimeURL}/aliases`, data);
}
/**
* @description HTTP request to delete alias
* @return {Promise}
*/
export async function deleteAlias(alias: string) {
return axios.delete(`${ontimeURL}/aliases/${alias}`);
}
/**
* @description HTTP request to retrieve user fields
* @return {Promise}
@@ -122,14 +105,6 @@ export async function getOSC(): Promise<OSCSettings> {
return res.data;
}
/**
* @description HTTP request to mutate osc settings
* @return {Promise}
*/
export async function postOSC(data: OSCSettings): Promise<AxiosResponse<OSCSettings>> {
return axios.post(`${ontimeURL}/osc`, data);
}
/**
* @description HTTP request to retrieve http settings
* @return {Promise}
@@ -143,26 +118,38 @@ export async function getHTTP(): Promise<HttpSettings> {
* @description HTTP request to mutate http settings
* @return {Promise}
*/
export async function postHTTP(data: HttpSettings): Promise<AxiosResponse<HttpSettings>> {
export async function postHTTP(data: HttpSettings) {
return axios.post(`${ontimeURL}/http`, data);
}
/**
* @description HTTP request to mutate osc settings
* @return {Promise}
*/
export async function postOSC(data: OSCSettings) {
return axios.post(`${ontimeURL}/osc`, data);
}
/**
* @description HTTP request to mutate osc subscriptions
* @return {Promise}
*/
export async function postOscSubscriptions(data: OscSubscription) {
return axios.post(`${ontimeURL}/osc-subscriptions`, data);
}
/**
* @description HTTP request to download db in CSV format
*/
export const downloadCSV = (fileName?: string) => {
return fileDownload(ontimeURL, { name: fileName ?? 'rundown', type: 'csv' }, { type: 'text/csv;charset=utf-8;' });
export const downloadCSV = () => {
return fileDownload(ontimeURL, { name: 'rundown', type: 'csv' }, { type: 'text/csv;charset=utf-8;' });
};
/**
* @description HTTP request to download db in JSON format
*/
export const downloadRundown = (fileName?: string) => {
return fileDownload(
ontimeURL,
{ name: fileName ?? 'rundown', type: 'json' },
{ type: 'application/json;charset=utf-8;' },
);
export const downloadRundown = () => {
return fileDownload(ontimeURL, { name: 'rundown', type: 'json' }, { type: 'application/json;charset=utf-8;' });
};
// TODO: should this be extracted to shared code?
@@ -208,6 +195,7 @@ export async function patchData(patchDb: Partial<DatabaseModel>) {
type PostPreviewExcelResponse = {
rundown: OntimeRundown;
project: ProjectData;
userFields: UserFields;
};
@@ -254,22 +242,8 @@ export async function getLatestVersion(): Promise<HasUpdate> {
};
}
/**
* @description HTTP request to get the list of available project files
*/
export async function getProjects(): Promise<ProjectFileListResponse> {
const res = await axios.get(`${ontimeURL}/projects`);
return res.data;
}
/**
* @description HTTP request to load a project file
*/
export async function loadProject(filename: string): Promise<MessageResponse> {
const res = await axios.post(`${ontimeURL}/load-project`, {
filename,
});
return res.data;
export async function postNew(initialData: Partial<ProjectData>) {
return axios.post(`${ontimeURL}/new`, initialData);
}
/**
@@ -344,53 +318,3 @@ export const postPushSheet = async (id: string, options: ExcelImportMap) => {
const response = await axios.post(`${ontimeURL}/sheet-push`, { id, options });
return response.data.data;
};
/**
* @description HTTP request to rename a project file
*/
export async function renameProject(filename: string, newFilename: string): Promise<MessageResponse> {
const url = `${ontimeURL}/project/${filename}/rename`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.put(decodedUrl, {
newFilename,
});
return res.data;
}
/**
* @description HTTP request to duplicate a project file
*/
export async function duplicateProject(filename: string, newFilename: string): Promise<MessageResponse> {
const url = `${ontimeURL}/project/${filename}/duplicate`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.post(decodedUrl, {
newFilename,
});
return res.data;
}
/**
* @description HTTP request to delete a project file
*/
export async function deleteProject(filename: string): Promise<MessageResponse> {
const url = `${ontimeURL}/project/${filename}`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.delete(decodedUrl);
return res.data;
}
/**
* @description HTTP request to create a project file
*/
export async function createProject(
project: Partial<
ProjectData & {
filename: string;
}
>,
): Promise<MessageResponse> {
const url = `${ontimeURL}/project`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.post(decodedUrl, project);
return res.data;
}
@@ -1,12 +0,0 @@
@use "../../../theme/mixins" as *;
.link {
@include action-link;
font-size: $inner-section-text-size;
}
.linkIcon {
margin-left: $element-inner-spacing;
display: inline-block;
transform: rotate(45deg);
}
@@ -1,26 +0,0 @@
import { MouseEvent, ReactNode } from 'react';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { openLink } from '../../utils/linkUtils';
import style from './AppLink.module.scss';
interface AppLinkProps {
href: string;
children: ReactNode;
}
export default function AppLink(props: AppLinkProps) {
const { href, children } = props;
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
openLink(href);
};
return (
<a href='#!' target='_blank' rel='noreferrer' className={style.link} onClick={handleClick}>
{children} <IoArrowUp className={style.linkIcon} />
</a>
);
}
@@ -10,12 +10,14 @@ import {
IconButton,
Tooltip,
} from '@chakra-ui/react';
import { IoPowerOutline } from '@react-icons/all-files/io5/IoPowerOutline';
import { FiPower } from '@react-icons/all-files/fi/FiPower';
import { Size } from '../../models/Util.type';
import { useEmitLog } from '../../stores/logger';
interface QuitIconBtnProps {
clickHandler: () => void;
size?: Size;
disabled?: boolean;
}
@@ -40,7 +42,7 @@ const quitBtnStyle = {
};
export default function QuitIconBtn(props: QuitIconBtnProps) {
const { clickHandler, disabled, ...rest } = props;
const { clickHandler, size = 'lg', disabled, ...rest } = props;
const [isOpen, setIsOpen] = useState(false);
const { emitInfo } = useEmitLog();
const onClose = () => setIsOpen(false);
@@ -65,8 +67,8 @@ export default function QuitIconBtn(props: QuitIconBtnProps) {
<Tooltip label='Quit Application'>
<IconButton
aria-label='Quit Application'
size='lg'
icon={<IoPowerOutline />}
size={size}
icon={<FiPower />}
onClick={() => setIsOpen(true)}
isDisabled={disabled}
{...quitBtnStyle}
@@ -79,7 +81,7 @@ export default function QuitIconBtn(props: QuitIconBtnProps) {
<AlertDialogHeader fontSize='lg' fontWeight='bold'>
Ontime Shutdown
</AlertDialogHeader>
<AlertDialogBody>This will shutdown the Ontime server. Are you sure?</AlertDialogBody>
<AlertDialogBody>This will shutdown the program and all running servers. Are you sure?</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose} variant='ghost'>
Cancel
@@ -2,7 +2,7 @@ import { MouseEvent } from 'react';
import { IconButton, IconButtonProps, Tooltip } from '@chakra-ui/react';
interface TooltipActionBtnProps extends IconButtonProps {
clickHandler: (event: MouseEvent) => void | Promise<void>;
clickHandler: (event?: MouseEvent) => void | Promise<void>;
tooltip: string;
openDelay?: number;
}
@@ -0,0 +1,24 @@
@use '../../../theme/v2Styles' as *;
.header {
font-size: $inner-section-text-size;
font-weight: 600;
display: flex;
justify-content: space-between;
color: $section-white;
margin-top: $main-spacing;
border-bottom: 1px solid $border-color-ondark;
padding-bottom: $element-inner-spacing;
margin-bottom: $element-spacing;
cursor: pointer;
}
.moreExpanded {
transform: scaleY(-1);
transition: transform $transition-time-feedback;
}
.moreCollapsed {
transform: scaleY(1);
transition: transform $transition-time-feedback;
}
@@ -0,0 +1,20 @@
import { FiChevronUp } from '@react-icons/all-files/fi/FiChevronUp';
import style from './CollapseBar.module.scss';
interface CollapseBarProps {
title: string;
isCollapsed: boolean;
onClick: () => void;
}
export default function CollapseBar(props: CollapseBarProps) {
const { title = 'Collapse bar', isCollapsed, onClick } = props;
return (
<div className={style.header} onClick={onClick}>
{title}
<FiChevronUp className={isCollapsed ? style.moreCollapsed : style.moreExpanded} />
</div>
);
}
@@ -1,5 +1,6 @@
.contextMenuButton {
position: absolute;
cursor: pointer;
}
.contextMenuBackdrop {
@@ -1,13 +1,11 @@
// logic (with some modifications) culled from:
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
import { ReactElement } from 'react';
import { Menu, MenuButton, MenuGroup, MenuList } from '@chakra-ui/react';
import { Fragment, ReactElement } from 'react';
import { Menu, MenuButton, MenuDivider, MenuItem, MenuList } from '@chakra-ui/react';
import { IconType } from '@react-icons/all-files';
import { create } from 'zustand';
import { ContextMenuOption } from './ContextMenuOption';
import style from './ContextMenu.module.scss';
type ContextMenuCoords = {
@@ -15,23 +13,14 @@ type ContextMenuCoords = {
y: number;
};
export type OptionWithoutGroup = {
export type Option = {
label: string;
isDisabled?: boolean;
icon: IconType;
onClick: () => void;
withDivider?: boolean;
isDisabled?: boolean;
};
export type OptionWithGroup = {
label: string;
group: Omit<OptionWithoutGroup, 'isGroup'>[];
};
export type Option = OptionWithoutGroup | OptionWithGroup;
const isOptionWithGroup = (option: Option): option is OptionWithGroup => 'group' in option;
type ContextMenuStore = {
coords: ContextMenuCoords;
options: Option[];
@@ -80,17 +69,14 @@ export const ContextMenu = ({ children }: ContextMenuProps) => {
}}
/>
<MenuList>
{options.map((option) =>
isOptionWithGroup(option) ? (
<MenuGroup key={option.label} title={option.label}>
{option.group.map((groupOption) => (
<ContextMenuOption key={groupOption.label} {...groupOption} />
))}
</MenuGroup>
) : (
<ContextMenuOption key={option.label} {...option} />
),
)}
{options.map(({ label, icon: Icon, onClick, withDivider, isDisabled }, i) => (
<Fragment key={label}>
{withDivider && <MenuDivider />}
<MenuItem key={i} icon={<Icon />} onClick={onClick} isDisabled={isDisabled}>
{label}
</MenuItem>
</Fragment>
))}
</MenuList>
</Menu>
</>
@@ -1,12 +0,0 @@
import { MenuDivider, MenuItem } from '@chakra-ui/react';
import { OptionWithoutGroup } from './ContextMenu';
export const ContextMenuOption = ({ label, onClick, isDisabled, icon: Icon, withDivider }: OptionWithoutGroup) => (
<>
{withDivider && <MenuDivider />}
<MenuItem icon={<Icon />} onClick={onClick} isDisabled={isDisabled}>
{label}
</MenuItem>
</>
);
@@ -1,3 +1,5 @@
@use '../../../theme/v2Styles' as *;
.delaySymbol {
svg {
font-size: 1.5rem;
@@ -17,7 +17,7 @@ export default function DelayIndicator(props: DelayIndicatorProps) {
if (typeof delayValue === 'number') {
if (delayValue < 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue, true)}>
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronDown />
</span>
@@ -27,7 +27,7 @@ export default function DelayIndicator(props: DelayIndicatorProps) {
if (delayValue > 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue, true)}>
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronUp />
</span>
@@ -3,7 +3,7 @@ import React from 'react';
// skipcq: JS-C1003 - sentry does not expose itself as an ES Module.
import * as Sentry from '@sentry/react';
import { runtimeStore } from '@/common/stores/runtime';
import { runtime } from '@/common/stores/runtime';
import { hasConnected, reconnectAttempts, shouldReconnect } from '@/common/utils/socket';
import style from './ErrorBoundary.module.scss';
@@ -23,13 +23,13 @@ class ErrorBoundary extends React.Component {
componentDidCatch(error, info) {
this.setState({
error,
error: error,
errorInfo: info,
});
Sentry.withScope((scope) => {
scope.setExtras('error', error);
scope.setExtras('store', runtimeStore.getState());
scope.setExtras('store', runtime.getState());
scope.setExtras('hasSocket', { hasConnected, shouldReconnect, reconnectAttempts });
const eventId = Sentry.captureException(error);
this.setState({ eventId, info });
@@ -1,3 +1,5 @@
@use '../../../theme/v2Styles' as *;
.errorContainer {
width: 100%;
height: 100%;
@@ -1,17 +0,0 @@
.link {
display: flex;
align-items: center;
gap: 0.25rem;
color: $blue-500;
transition-property: color;
transition-duration: $transition-time-action;
&.inline {
display: inline-flex;
}
&:hover {
color: $ontime-color;
}
}
@@ -1,29 +0,0 @@
import { MouseEvent, ReactNode } from 'react';
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
import { openLink } from '../../utils/linkUtils';
import { cx } from '../../utils/styleUtils';
import style from './ExternalLink.module.scss';
interface ExternalLinkProps {
href: string;
children: ReactNode;
inline?: boolean;
}
export default function ExternalLink(props: ExternalLinkProps) {
const { href, inline, children } = props;
const classes = cx([style.link, inline ? style.inline : null]);
const handleClick = (event: MouseEvent) => {
event.preventDefault();
openLink(href);
};
return (
<a href='#!' target='_blank' rel='noreferrer' className={classes} onClick={handleClick}>
{children} <IoOpenOutline />
</a>
);
}
@@ -4,8 +4,6 @@ import { Textarea, TextareaProps } from '@chakra-ui/react';
import autosize from 'autosize/dist/autosize';
export const AutoTextArea = (props: TextareaProps) => {
const { value } = props;
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
@@ -14,7 +12,7 @@ export const AutoTextArea = (props: TextareaProps) => {
return () => {
autosize.destroy(node);
};
}, [value]);
}, []);
return (
<Textarea
@@ -1,24 +1,24 @@
.list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem
gap: 4px;
}
.swatch {
cursor: pointer;
aspect-ratio: 1;
width: 2rem;
height: 2rem;
aspect-ratio: 1;
border-radius: 99px;
border: 2px solid $gray-1200;
border-radius: 16px;
border: 4px solid #262626;
&.selected {
border: 2px solid $blue-500;
border: 2px solid #578AF4;
}
}
.center {
display: grid;
place-content: center;
color: $ui-white;
}
color: #578AF4;
}
@@ -1,13 +1,15 @@
import { useCallback } from 'react';
import { EditorUpdateFields } from '../../../../features/event-editor/EventEditor';
import Swatch from './Swatch';
import style from './SwatchSelect.module.scss';
interface ColourInputProps {
value: string;
name: 'colour';
handleChange: (newValue: 'colour', name: string) => void;
name: EditorUpdateFields;
handleChange: (newValue: EditorUpdateFields, name: string) => void;
}
const colours = [
@@ -1,3 +1,5 @@
@use '../../../../theme/v2Styles' as *;
$input-font-size: 15px;
.delayInput {
@@ -1,3 +1,6 @@
@use "../../../../theme/_ontimeColours" as *;
@use "../../../../theme/_v2Styles" as *;
.swatch {
width: 24px;
height: 24px;
@@ -1,7 +1,7 @@
import { useCallback, useRef } from 'react';
import { Input, Textarea } from '@chakra-ui/react';
import { EventEditorSubmitActions } from '../../../../features/rundown/event-editor/EventEditor';
import { EventEditorSubmitActions } from '../../../../features/event-editor/EventEditor';
import { Size } from '../../../models/Util.type';
import useReactiveTextInput from './useReactiveTextInput';
@@ -1,4 +1,37 @@
@use "../../../../theme/v2Styles" as *;
$input-font-size: 15px;
$input-delayed-border-color: #E69056;
.timeInput {
width: fit-content !important;
.inputLeft {
max-width: fit-content;
}
.inputLeft,
.inputButton {
aspect-ratio: 1;
}
.inputField {
font-size: $input-font-size;
letter-spacing: 1px;
width: 6.5em;
width: 7.5em;
padding: 0 0 0 2.6em;
}
.warn {
&::after {
content: "*";
color: $warning-orange;
}
}
&.delayed {
.inputField {
border: 1px solid $input-delayed-border-color;
}
}
}
@@ -1,23 +1,42 @@
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { Input } from '@chakra-ui/react';
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button, Input, InputGroup, InputLeftElement, Tooltip } from '@chakra-ui/react';
import { millisToString } from 'ontime-utils';
import { tooltipDelayFast } from '../../../../ontimeConfig';
import { useEmitLog } from '../../../stores/logger';
import { forgivingStringToMillis } from '../../../utils/dateConfig';
import { cx } from '../../../utils/styleUtils';
import { TimeEntryField } from '../../../utils/timesManager';
import style from './TimeInput.module.scss';
interface TimeInputProps<T extends string> {
name: T;
submitHandler: (field: T, value: string) => void;
interface TimeInputProps {
id?: TimeEntryField;
name: TimeEntryField;
submitHandler: (field: TimeEntryField, value: number) => void;
time?: number;
delay?: number;
placeholder: string;
disabled?: boolean;
className?: string;
previousEnd?: number;
tooltip?: string;
}
export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
const { name, submitHandler, time = 0, placeholder, disabled, className } = props;
function ButtonInitial(name: TimeEntryField) {
if (name === 'timeStart') return 'S';
if (name === 'timeEnd') return 'E';
if (name === 'durationOverride') return 'D';
return '';
}
function ButtonTooltip(name: TimeEntryField, tooltip?: string) {
if (name === 'timeStart') return `Start${tooltip ? `: ${tooltip}` : ''}`;
if (name === 'timeEnd') return `End${tooltip ? `: ${tooltip}` : ''}`;
if (name === 'durationOverride') return `Duration${tooltip ? `: ${tooltip}` : ''}`;
return '';
}
export default function TimeInput(props: TimeInputProps) {
const { id, name, submitHandler, time = 0, delay = 0, placeholder, previousEnd = 0 } = props;
const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>('');
@@ -53,20 +72,32 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
return false;
}
// we dont know the values in the rundown, escalate to handler
if (newValue.startsWith('p') || newValue.startsWith('+')) {
submitHandler(name, newValue);
let newValMillis = 0;
// check for known aliases
if (newValue === 'p' || newValue === 'prev' || newValue === 'previous') {
// string to pass should be the time of the end before
if (previousEnd != null) {
newValMillis = previousEnd;
}
} else if (newValue.startsWith('+') || newValue.startsWith('p+') || newValue.startsWith('p +')) {
// string to pass should add to the end before
const val = newValue.substring(1);
newValMillis = previousEnd + forgivingStringToMillis(val);
} else {
// convert entered value to milliseconds
newValMillis = forgivingStringToMillis(newValue);
}
const valueInMillis = forgivingStringToMillis(newValue);
if (valueInMillis === time) {
return false;
}
// check if time is different from before
if (newValMillis === time) return false;
// update entry
submitHandler(name, newValMillis);
submitHandler(name, newValue);
return true;
},
[name, submitHandler, time],
[name, previousEnd, submitHandler, time],
);
/**
@@ -76,11 +107,15 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
const validateAndSubmit = useCallback(
(newValue: string) => {
const success = handleSubmit(newValue);
if (!success) {
if (success) {
const ms = forgivingStringToMillis(newValue);
const delayed = name === 'timeEnd' ? Math.max(0, ms + delay) : Math.max(0, ms + delay);
setValue(millisToString(delayed));
} else {
resetValue();
}
},
[handleSubmit, resetValue],
[delay, handleSubmit, name, resetValue],
);
/**
@@ -120,26 +155,51 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
resetValue();
}, [resetValue, time]);
const timeInputClasses = cx([style.timeInput, className]);
const isDelayed = delay !== 0;
const inputClasses = cx([style.timeInput, isDelayed ? style.delayed : null]);
const buttonClasses = cx([style.inputButton, isDelayed ? style.delayed : null]);
const TooltipLabel = useMemo(() => {
return ButtonTooltip(name, '');
}, [name]);
const ButtonText = useMemo(() => {
return ButtonInitial(name);
}, [name]);
return (
<Input
disabled={disabled}
size='sm'
ref={inputRef}
data-testid={`time-input-${name}`}
className={timeInputClasses}
fontSize='1rem'
type='text'
placeholder={placeholder}
variant='ontime-filled'
onFocus={handleFocus}
onChange={(event) => setValue(event.target.value)}
onBlur={onBlurHandler}
onKeyDown={onKeyDownHandler}
value={value}
maxLength={8}
autoComplete='off'
/>
<InputGroup size='sm' className={inputClasses}>
<InputLeftElement className={style.inputLeft}>
<Tooltip label={TooltipLabel} openDelay={tooltipDelayFast} variant='ontime-ondark'>
<Button
size='sm'
variant='ontime-subtle-white'
className={buttonClasses}
tabIndex={-1}
border={isDelayed ? '1px solid #E69056' : '1px solid transparent'}
borderRight='1px solid transparent'
borderRadius='2px 0 0 2px'
>
{ButtonText}
</Button>
</Tooltip>
</InputLeftElement>
<Input
ref={inputRef}
id={id}
data-testid={`time-input-${name}`}
className={style.inputField}
type='text'
placeholder={placeholder}
variant='ontime-filled'
onFocus={handleFocus}
onChange={(event) => setValue(event.target.value)}
onBlur={onBlurHandler}
onKeyDown={onKeyDownHandler}
value={value}
maxLength={8}
autoComplete='off'
/>
</InputGroup>
);
}
@@ -1,12 +0,0 @@
$input-delayed-border-color: $ontime-delay-text;
.timeInput {
border: 1px solid transparent;
&.delayed {
border: 1px solid $input-delayed-border-color;
}
}
.inputField {
max-width: 7.75em;
}
@@ -1,40 +0,0 @@
import { PropsWithChildren } from 'react';
import { InputGroup, Tooltip } from '@chakra-ui/react';
import { tooltipDelayFast } from '../../../../ontimeConfig';
import { cx } from '../../../utils/styleUtils';
import TimeInput from './TimeInput';
import style from './TimeInputWithButton.module.scss';
interface TimeInputWithButtonProps<T extends string> {
name: T;
submitHandler: (field: T, value: string) => void;
time?: number;
hasDelay?: boolean;
disabled?: boolean;
placeholder: string;
}
export default function TimeInputWithButton<T extends string>(props: PropsWithChildren<TimeInputWithButtonProps<T>>) {
const { name, submitHandler, time, hasDelay, placeholder, disabled, children } = props;
const inputClasses = cx([style.timeInput, hasDelay ? style.delayed : null]);
return (
<InputGroup size='sm' className={inputClasses} width='fit-content'>
<Tooltip label={placeholder} openDelay={tooltipDelayFast} variant='ontime-ondark'>
<TimeInput<T>
name={name}
submitHandler={submitHandler}
time={time}
placeholder={placeholder}
className={style.inputField}
disabled={disabled}
/>
</Tooltip>
{children}
</InputGroup>
);
}
@@ -1,16 +1,14 @@
import { MaybeNumber } from 'ontime-types';
import { clamp } from '../../utils/math';
import './MultiPartProgressBar.scss';
interface MultiPartProgressBar {
now: MaybeNumber;
now: number | null;
complete: number;
normalColor: string;
warning?: MaybeNumber;
warning: number;
warningColor: string;
danger?: MaybeNumber;
danger: number;
dangerColor: string;
hidden?: boolean;
className?: string;
@@ -21,8 +19,8 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
const percentComplete = 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
const dangerWidth = danger ? clamp((danger / complete) * 100, 0, 100) : 0;
const warningWidth = warning ? clamp((warning / complete) * 100, 0, 100) : 0;
const dangerWidth = clamp((danger / complete) * 100, 0, 100);
const warningWidth = clamp((warning / complete) * 100, 0, 100);
return (
<div className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${className}`}>
@@ -1,4 +1,6 @@
@use '../../../theme/v2Styles' as *;
@use '../../../theme/mixins' as *;
@use '../../../theme/ontimeColours' as *;
$menu-bg: $gray-1200;
$menu-hover-bg: $gray-1350;
@@ -69,6 +71,7 @@ $button-size: 48px;
@include action-link;
justify-content: space-between;
padding: 0.5rem 1rem;
cursor: pointer;
&:hover {
background-color: $menu-hover-bg;
@@ -1,4 +1,4 @@
import { memo, useEffect, useRef, useState } from 'react';
import { KeyboardEvent, memo, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Link, useLocation, useSearchParams } from 'react-router-dom';
import { useDisclosure } from '@chakra-ui/react';
@@ -13,7 +13,6 @@ import { navigatorConstants } from '../../../viewerConfig';
import useClickOutside from '../../hooks/useClickOutside';
import useFullscreen from '../../hooks/useFullscreen';
import { useViewOptionsStore } from '../../stores/viewOptions';
import { isKeyEnter } from '../../utils/keyEvent';
import RenameClientModal from './rename-client-modal/RenameClientModal';
@@ -54,6 +53,7 @@ function NavigationMenu() {
};
}, []);
const isKeyEnter = (event: KeyboardEvent<HTMLDivElement>) => event.key === 'Enter';
const handleFullscreen = () => toggleFullScreen();
const handleMirror = () => toggleMirror();
@@ -30,7 +30,7 @@ export default function RenameClientModal({ isOpen, onClose }: RenameClientModal
const handleRename = async () => {
if (newName) {
setClientName(newName);
await setClientName(newName);
persistName(newName);
onClose();
}
@@ -1,3 +1,5 @@
@use '../../../theme/v2Styles' as *;
.container {
display: grid;
place-content: center;
@@ -4,8 +4,7 @@ import { formatTime } from '../../utils/time';
import './Schedule.scss';
const formatOptions = {
format12: 'hh:mm a',
format24: 'HH:mm',
format: 'hh:mm a',
};
interface ScheduleItemProps {
@@ -1,3 +1,5 @@
@use '../../../theme/ontimeColours' as *;
.emptyContainer {
width: 100%;
text-align: center;
@@ -1,6 +1,6 @@
import { CSSProperties } from 'react';
import EmptyImage from '../../../assets/images/empty.svg?react';
import { ReactComponent as Emptyimage } from '@/assets/images/empty.svg';
import style from './Empty.module.scss';
@@ -13,7 +13,7 @@ export default function Empty(props: EmptyProps) {
const { text, ...rest } = props;
return (
<div className={style.emptyContainer} {...rest}>
<EmptyImage className={style.empty} />
<Emptyimage className={style.empty} />
<span className={style.text}>{text}</span>
</div>
);
@@ -1,11 +1,6 @@
@use '../../../../theme/viewerDefs' as *;
@use '../../../theme/viewerDefs' as *;
.timer {
grid-area: clk;
white-space: nowrap;
max-width: 18.75rem;
font-family: var(--font-family-override, $viewer-font-family);
color: var(--timer-color-override, $timer-color);
line-height: 0.9em;
@@ -14,7 +9,7 @@
font-weight: 600;
font-size: 3.5rem;
&.finished {
&--finished {
color: $timer-finished-color;
}
}
@@ -0,0 +1,33 @@
import { memo } from 'react';
import { formatDisplay } from 'ontime-utils';
import './TimerDisplay.scss';
interface TimerDisplayProps {
time?: number | null;
}
/**
* Displays time in ms in formatted timetag
* @param props
* @constructor
*/
const TimerDisplay = (props: TimerDisplayProps) => {
const { time } = props;
let display = '';
if (time === null || typeof time === 'undefined' || isNaN(time)) {
display = '-- : -- : --';
} else {
display = formatDisplay(time);
}
const isNegative = (time ?? 0) < 0;
const classes = `timer ${isNegative ? 'timer--finished' : ''}`;
return <div className={classes}>{display}</div>;
};
export default memo(TimerDisplay);
@@ -1,3 +1,6 @@
@use '../../../theme/v2Styles' as *;
@use '../../../theme/ontimeColours' as *;
.drawerContent {
background-color: $gray-1250;
}
@@ -12,7 +12,7 @@ import {
useDisclosure,
} from '@chakra-ui/react';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import { useLocalStorage } from '../../../common/hooks/useLocalStorage';
import ParamInput from './ParamInput';
import { ParamField } from './types';
@@ -23,14 +23,14 @@ type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
type SavedViewParams = Record<string, ViewParamsObj>;
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ParamField[]) => {
const defaultValues = paramFields.reduce<Record<string, string>>((acc, { id, defaultValue }) => {
return { ...acc, [id]: String(defaultValue) };
}, {});
const defaultValues = paramFields.map(({ defaultValue }) => String(defaultValue));
return Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => {
if (typeof value === 'string' && value.length) {
if (defaultValues[id] === value) {
if (defaultValues.includes(value)) {
return newSearchParams;
}
newSearchParams.set(id, value);
return newSearchParams;
@@ -1,27 +1,18 @@
import { UserFields } from 'ontime-types';
import { TimeFormat } from 'ontime-types/src/definitions/core/TimeFormat.type';
import { ParamField } from './types';
const getTimeOption = (timeFormat: string): ParamField => {
const placeholder = `${timeFormat} (default)`;
return {
id: 'timeformat',
title: 'Time format string, taken from the Application Settings',
description: 'Format for auxiliar time fields (not the running), eg. HH:mm:ss or hh:mm:ss a, see docs for help',
type: 'string',
placeholder,
};
};
export const getTimeOption = (timeFormat: TimeFormat): ParamField => ({
id: 'format',
title: '12 / 24 hour timer',
description: 'Whether to show the time in 12 or 24 hour mode. Overrides the global setting from preferences',
type: 'option',
values: { '12': '12 hour AM/PM', '24': '24 hour' },
defaultValue: timeFormat,
});
const hideTimerSeconds: ParamField = {
id: 'hideTimerSeconds',
title: 'Hide seconds in timer',
description: 'Whether to hide seconds in the running timer',
type: 'boolean',
defaultValue: false,
};
export const getClockOptions = (timeFormat: string): ParamField[] => [
export const getClockOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'key',
@@ -93,9 +84,8 @@ export const getClockOptions = (timeFormat: string): ParamField[] => [
},
];
export const getTimerOptions = (timeFormat: string): ParamField[] => [
export const getTimerOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
hideTimerSeconds,
{
id: 'hideClock',
title: 'Hide Time Now',
@@ -134,7 +124,6 @@ export const getTimerOptions = (timeFormat: string): ParamField[] => [
];
export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
hideTimerSeconds,
{
id: 'key',
title: 'Key Colour',
@@ -226,97 +215,13 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
},
];
export const LOWER_THIRD_OPTIONS: ParamField[] = [
export const LOWER_THIRDS_OPTIONS: ParamField[] = [
{
id: 'trigger',
title: 'Animation Trigger',
description: '',
type: 'option',
values: {
event: 'Event Load',
manual: 'Manual',
},
defaultValue: 'event',
},
{
id: 'top-src',
title: 'Top Text',
description: '',
type: 'option',
values: {
title: 'Title',
subtitle: 'Subtitle',
presenter: 'Presenter',
lowerMsg: 'Lower Thrid Message',
},
defaultValue: 'title',
},
{
id: 'bottom-src',
title: 'Bottom Text',
description: 'Select the text source for the bottom element',
type: 'option',
values: {
title: 'Title',
subtitle: 'Subtitle',
presenter: 'Presenter',
lowerMsg: 'Lower Thrid Message',
},
defaultValue: 'subtitle',
},
{
id: 'top-colour',
title: 'Top Text Colour',
description: 'Top text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'bottom-colour',
title: 'Bottom Text Colour',
description: 'Bottom text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'top-bg',
title: 'Top Background Colour',
description: 'Top text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'bottom-bg',
title: 'Bottom Background Colour',
description: 'Bottom text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'top-size',
title: 'Top Text Size',
description: 'Font size of the top text',
type: 'string',
placeholder: '65px',
},
{
id: 'bottom-size',
title: 'Bottom Text Size',
description: 'Font size of the bottom text',
type: 'string',
placeholder: '64px',
},
{
id: 'width',
title: 'Minimum Width',
description: 'Minimum Width of the element',
id: 'size',
title: 'Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
prefix: '%',
placeholder: '45 (default)',
placeholder: '1 (default)',
},
{
id: 'transition',
@@ -326,31 +231,39 @@ export const LOWER_THIRD_OPTIONS: ParamField[] = [
placeholder: '3 (default)',
},
{
id: 'delay',
title: 'Delay',
description: 'Delay between transition in and out in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffffa (default)',
},
{
id: 'bg',
title: 'Text Background',
description: 'Text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000033 (default)',
},
{
id: 'key',
title: 'Key Colour',
description: 'Colour of the background',
description: 'Screen background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'ffffffff (default)',
placeholder: '00000033 (default)',
},
{
id: 'line-colour',
title: 'Line Colour',
description: 'Colour of the line',
prefix: '#',
type: 'string',
placeholder: 'ff0000ff (default)',
id: 'fadeout',
title: 'Fadeout',
description: 'Time (in seconds) the lower third displays before fading out',
type: 'number',
placeholder: '3 (default)',
},
];
export const getBackstageOptions = (timeFormat: string): ParamField[] => [
export const getBackstageOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'hidePast',
@@ -375,7 +288,7 @@ export const getBackstageOptions = (timeFormat: string): ParamField[] => [
},
];
export const getPublicOptions = (timeFormat: string): ParamField[] => [
export const getPublicOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'hidePast',
@@ -399,14 +312,27 @@ export const getPublicOptions = (timeFormat: string): ParamField[] => [
placeholder: '7 (default)',
},
];
export const getStudioClockOptions = (timeFormat: string): ParamField[] => [
export const getStudioClockOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
hideTimerSeconds,
{
id: 'seconds',
title: 'Show Seconds',
description: 'Shows seconds in clock',
type: 'boolean',
defaultValue: false,
},
];
export const getOperatorOptions = (userFields: UserFields, timeFormat: string): ParamField[] => {
export const getOperatorOptions = (userFields: UserFields, timeFormat: TimeFormat): ParamField[] => {
return [
getTimeOption(timeFormat),
{
id: 'showseconds',
title: 'Show seconds',
description: 'Schedule shows hh:mm:ss',
type: 'boolean',
defaultValue: false,
},
{
id: 'hidepast',
title: 'Hide Past Events',
@@ -463,5 +389,3 @@ export const getOperatorOptions = (userFields: UserFields, timeFormat: string):
},
];
};
export const getCountdownOptions = (timeFormat: string): ParamField[] => [getTimeOption(timeFormat), hideTimerSeconds];
@@ -1,4 +1,5 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { HttpSettings } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { HTTP_SETTINGS } from '../api/apiConstants';
@@ -19,16 +20,13 @@ export function useHttpSettings() {
});
// we need to jump through some hoops because of the type op port
return { data: data ?? httpPlaceholder, status, isFetching, isError, refetch };
return { data: data! as unknown as HttpSettings, status, isFetching, isError, refetch };
}
export function usePostHttpSettings() {
const { isPending, mutateAsync } = useMutation({
mutationFn: postHTTP,
onError: (error) => logAxiosError('Error saving HTTP settings', error),
onSuccess: (res) => {
ontimeQueryClient.setQueryData(HTTP_SETTINGS, res.data);
},
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: HTTP_SETTINGS }),
});
return { isPending, mutateAsync };
@@ -3,14 +3,17 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { OSC_SETTINGS } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils';
import { getOSC, postOSC } from '../api/ontimeApi';
import { getOSC, postOSC, postOscSubscriptions } from '../api/ontimeApi';
import { oscPlaceholderSettings } from '../models/OscSettings';
import { ontimeQueryClient } from '../queryClient';
export default function useOscSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: OSC_SETTINGS,
queryFn: getOSC,
queryFn: async () => {
const oscData = await getOSC();
return { ...oscData, portIn: String(oscData.portIn), portOut: String(oscData.portOut) };
},
placeholderData: oscPlaceholderSettings,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
@@ -25,9 +28,16 @@ export function useOscSettingsMutation() {
const { isPending, mutateAsync } = useMutation({
mutationFn: postOSC,
onError: (error) => logAxiosError('Error saving OSC settings', error),
onSuccess: (res) => {
ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data);
},
onSuccess: (res) => ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data),
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
});
return { isPending, mutateAsync };
}
export function usePostOscSubscriptions() {
const { isPending, mutateAsync } = useMutation({
mutationFn: postOscSubscriptions,
onError: (error) => logAxiosError('Error saving OSC settings', error),
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
});
return { isPending, mutateAsync };
@@ -16,5 +16,5 @@ export default function useProjectData() {
networkMode: 'always',
});
return { data: data ?? projectDataPlaceholder, status, isFetching, isError, refetch };
return { data, status, isFetching, isError, refetch };
}
@@ -1,24 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { ProjectFileListResponse } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_LIST } from '../api/apiConstants';
import { getProjects } from '../api/ontimeApi';
const placeholderProjectList: ProjectFileListResponse = {
files: [],
lastLoadedProject: '',
};
export function useProjectList() {
const { data, status, refetch } = useQuery({
queryKey: PROJECT_LIST,
queryFn: getProjects,
placeholderData: placeholderProjectList,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? placeholderProjectList, status, refetch };
}
@@ -1,16 +1,15 @@
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
import { GetRundownCached } from 'ontime-types';
import { queryRefetchInterval } from '../../ontimeConfig';
import { RUNDOWN } from '../api/apiConstants';
import { fetchCachedRundown } from '../api/eventsApi';
// revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 };
const cachedRundownPlaceholder = { rundown: [], revision: -1 };
// TODO: can we leverage structural sharing to see if data has changed?
export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
const { data, status, isError, refetch, isFetching } = useQuery<GetRundownCached>({
queryKey: RUNDOWN,
queryFn: fetchCachedRundown,
placeholderData: cachedRundownPlaceholder,
@@ -18,24 +17,13 @@ export default function useRundown() {
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval,
networkMode: 'always',
// structuralSharing: (oldData: GetRundownCached | undefined, newData: GetRundownCached) => {
// if (oldData === undefined) {
// return cachedRundownPlaceholder;
// }
// const hasDataChanged = oldData?.revision === newData.revision;
// return hasDataChanged ? oldData : newData;
// },
});
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
export function useFlatRundown() {
const { data, status } = useRundown();
const [prevRevision, setPrevRevision] = useState<number>(-1);
const [flatRunDown, setFlatRunDown] = useState<OntimeRundown>([]);
// update data whenever the revision changes
useEffect(() => {
if (data.revision !== -1 && data.revision !== prevRevision) {
const flatRundown = data.order.map((id) => data.rundown[id]);
setFlatRunDown(flatRundown);
setPrevRevision(data.revision);
}
}, [data.order, data.revision, data.rundown, prevRevision]);
return { data: flatRunDown, status };
return { data: data?.rundown ?? [], status, isError, refetch, isFetching };
}
+58 -198
View File
@@ -1,14 +1,13 @@
import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { getLinkedTimes, getPreviousEventNormal, reorderArray, swapEventData } from 'ontime-utils';
import { GetRundownCached, isOntimeEvent, OntimeRundownEntry } from 'ontime-types';
import { getCueCandidate, swapOntimeEvents } from 'ontime-utils';
import { RUNDOWN } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils';
import {
ReorderEntry,
requestApplyDelay,
requestBatchPutEvents,
requestDelete,
requestDeleteAll,
requestEventSwap,
@@ -18,7 +17,6 @@ import {
SwapEntry,
} from '../api/eventsApi';
import { useEditorSettings } from '../stores/editorSettings';
import { forgivingStringToMillis } from '../utils/dateConfig';
/**
* @description Set of utilities for events
@@ -34,6 +32,8 @@ export const useEventAction = () => {
* @private
*/
const _addEventMutation = useMutation({
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
mutationFn: requestPostEvent,
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
@@ -67,13 +67,21 @@ export const useEventAction = () => {
after: options?.after,
};
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this has a value
const rundownData = queryClient.getQueryData<RundownCached>(RUNDOWN)!;
const { rundown } = rundownData;
const rundown = queryClient.getQueryData<GetRundownCached>(RUNDOWN)?.rundown ?? [];
if (newEvent?.cue === undefined) {
newEvent.cue = getCueCandidate(rundown, options?.after);
}
// hard coding duration value to be as expected for now
// this until timeOptions gets implemented
if (newEvent?.timeStart !== undefined && newEvent.timeEnd !== undefined) {
newEvent.duration = Math.max(0, newEvent?.timeEnd - newEvent?.timeStart) || 0;
}
if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) {
const previousEvent = rundown[applicationOptions.lastEventId];
if (isOntimeEvent(previousEvent)) {
const previousEvent = rundown.find((event) => event.id === applicationOptions.lastEventId);
if (previousEvent !== undefined && previousEvent.type === 'event') {
newEvent.timeStart = previousEvent.timeEnd;
newEvent.timeEnd = previousEvent.timeEnd;
}
@@ -90,6 +98,7 @@ export const useEventAction = () => {
}
try {
// @ts-expect-error -- we know that the object is well formed now
await _addEventMutation.mutateAsync(newEvent);
} catch (error) {
logAxiosError('Failed adding event', error);
@@ -110,15 +119,19 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
const eventId = newEvent.id;
if (previousData && eventId) {
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
if (previousData) {
// optimistically update object
const newRundown = { ...previousData.rundown };
// @ts-expect-error -- we expect the events to be of same type
newRundown[eventId] = { ...newRundown[eventId], ...newEvent };
queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 });
const optimisticRundown = [...previousData.rundown];
const index = optimisticRundown.findIndex((event) => event.id === newEvent.id);
if (index > -1) {
// @ts-expect-error -- we expect the event types to match
optimisticRundown[index] = { ...optimisticRundown[index], ...newEvent };
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 });
}
}
// Return a context with the previous and new events
@@ -150,154 +163,6 @@ export const useEventAction = () => {
[_updateEventMutation],
);
type TimeField = 'timeStart' | 'timeEnd' | 'duration';
/**
* Updates time of existing event
*/
const updateTimer = useCallback(
async (eventId: string, field: TimeField, value: string) => {
const getPreviousEnd = (): number => {
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (!cachedRundown?.order || !cachedRundown?.rundown) {
return 0;
}
const index = cachedRundown.order.indexOf(eventId);
if (index === 0) {
return 0;
}
let previousEnd = 0;
for (let i = index - 1; i >= 0; i--) {
const event = cachedRundown.rundown[cachedRundown.order[i]];
if (isOntimeEvent(event)) {
previousEnd = event.timeEnd;
break;
}
}
return previousEnd;
};
let newValMillis = 0;
// check for previous keyword
if (value === 'p' || value === 'prev' || value === 'previous') {
newValMillis = getPreviousEnd();
// check for adding time keyword
} else if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) {
// TODO: is this logic solid?
const remainingString = value.substring(1);
newValMillis = getPreviousEnd() + forgivingStringToMillis(remainingString);
} else {
newValMillis = forgivingStringToMillis(value);
}
const newEvent = {
id: eventId,
[field]: newValMillis,
};
try {
await _updateEventMutation.mutateAsync(newEvent);
} catch (error) {
logAxiosError('Error updating event', error);
}
},
[_updateEventMutation, queryClient],
);
/**
* Toggles link of an event to the previous
*/
const linkTimer = useCallback(
async (eventId: string, linkStart: MaybeString) => {
let newEvent: Partial<OntimeEvent> = { id: eventId };
if (!linkStart) {
newEvent.linkStart = null;
} else {
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (!cachedRundown) {
return;
}
const currentEvent = cachedRundown.rundown[eventId] as OntimeEvent;
if (!isOntimeEvent(currentEvent)) {
return;
}
const { previousEvent } = getPreviousEventNormal(cachedRundown.rundown, cachedRundown.order, eventId);
if (!previousEvent) {
newEvent.linkStart = null;
} else {
newEvent.linkStart = previousEvent.id;
const timePatch = getLinkedTimes(currentEvent, previousEvent);
newEvent = { ...newEvent, ...timePatch };
}
}
try {
await _updateEventMutation.mutateAsync(newEvent);
} catch (error) {
logAxiosError('Error updating event', error);
}
},
[_updateEventMutation, queryClient],
);
/**
* Calls mutation to edit multiple events
* @private
*/
const _batchUpdateEventsMutation = useMutation({
mutationFn: requestBatchPutEvents,
onMutate: async ({ ids, data }) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousEvents = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousEvents) {
const eventIds = new Set(ids);
const newRundown = { ...previousEvents.rundown };
eventIds.forEach((eventId) => {
if (Object.hasOwn(newRundown, eventId)) {
const event = newRundown[eventId];
if (isOntimeEvent(event)) {
newRundown[eventId] = {
...event,
...data,
};
}
}
});
queryClient.setQueryData(RUNDOWN, { order: previousEvents.order, rundown: newRundown, revision: -1 });
}
// Return a context with the previous and new events
return { previousEvents };
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
onError: (_error, _newEvent, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousEvents);
},
networkMode: 'always',
});
const batchUpdateEvents = useCallback(
async (data: Partial<OntimeEvent>, eventIds: string[]) => {
try {
await _batchUpdateEventsMutation.mutateAsync({ ids: eventIds, data });
} catch (error) {
logAxiosError('Error updating events', error);
}
},
[_batchUpdateEventsMutation],
);
/**
* Calls mutation to delete an event
* @private
@@ -310,19 +175,20 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
if (previousData) {
// optimistically update object
const newOrder = previousData.order.filter((id) => id !== eventId);
const newRundown = { ...previousData.rundown };
delete newRundown[eventId];
const optimisticRundown = [...previousData.rundown];
const index = optimisticRundown.findIndex((event) => event.id === eventId);
if (index > -1) {
optimisticRundown.splice(index, 1);
queryClient.setQueryData(RUNDOWN, {
order: newOrder,
rundown: newRundown,
revision: -1,
});
queryClient.setQueryData(RUNDOWN, {
rundown: optimisticRundown,
revision: -1,
});
}
}
// Return a context with the previous and new events
@@ -367,10 +233,10 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
// optimistically update object
queryClient.setQueryData(RUNDOWN, { rundown: {}, order: [], revision: -1 });
queryClient.setQueryData(RUNDOWN, { rundown: [], revision: -1 });
// Return a context with the previous and new events
return { previousData };
@@ -438,13 +304,15 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
if (previousData) {
// optimistically update object
const newOrder = reorderArray(previousData.order, data.from, data.to);
const optimisticRundown = [...previousData.rundown];
const [reorderedItem] = optimisticRundown.splice(data.from, 1);
optimisticRundown.splice(data.to, 0, reorderedItem);
queryClient.setQueryData(RUNDOWN, { order: newOrder, rundown: previousData.rundown, revision: -1 });
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 });
}
// Return a context with the previous and new events
@@ -470,9 +338,9 @@ export const useEventAction = () => {
async (eventId: string, from: number, to: number) => {
try {
const reorderObject: ReorderEntry = {
eventId,
from,
to,
eventId: eventId,
from: from,
to: to,
};
await _reorderEventMutation.mutateAsync(reorderObject);
} catch (error) {
@@ -494,22 +362,15 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
if (previousData) {
// optimistically update object
const newRundown = { ...previousData.rundown };
const eventA = previousData.rundown[from];
const eventB = previousData.rundown[to];
const fromEventIndex = previousData.rundown.findIndex((event) => event.id === from);
const toEventIndex = previousData.rundown.findIndex((event) => event.id === to);
if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) {
return;
}
const optimisticRundown = swapOntimeEvents(previousData.rundown, fromEventIndex, toEventIndex);
const { newA, newB } = swapEventData(eventA, eventB);
newRundown[from] = newA;
newRundown[to] = newB;
queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 });
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 });
}
// Return a context with the previous events
@@ -533,6 +394,8 @@ export const useEventAction = () => {
*/
const swapEvents = useCallback(
async ({ from, to }: SwapEntry) => {
// TODO: before calling `/swapEvents`,
// we should determine the events are of type `OntimeEvent`
try {
await _swapEvents.mutateAsync({ from, to });
} catch (error) {
@@ -544,14 +407,11 @@ export const useEventAction = () => {
return {
addEvent,
applyDelay,
batchUpdateEvents,
updateEvent,
deleteEvent,
deleteAllEvents,
linkTimer,
applyDelay,
reorderEvent,
swapEvents,
updateEvent,
updateTimer,
};
};
@@ -1,38 +0,0 @@
/**
* Shamelessly from https://ahooks.js.org/hooks/use-memoized-fn/
* Interesting technique discussed by Dan Abramov
* https://overreacted.io/making-setinterval-declarative-with-react-hooks/
*/
import { useMemo, useRef } from 'react';
import { isDev } from '../api/apiConstants';
type noop = (this: any, ...args: any[]) => any;
type PickFunction<T extends noop> = (this: ThisParameterType<T>, ...args: Parameters<T>) => ReturnType<T>;
export const isFunction = (value: unknown): value is (...args: any) => any => typeof value === 'function';
export default function useMemoisedFn<T extends noop>(fn: T) {
if (isDev) {
if (!isFunction(fn)) {
console.error(`useMemoisedFn expected function as parameter, got ${typeof fn}`);
}
}
const fnRef = useRef<T>(fn);
// why not write `fnRef.current = fn`?
// https://github.com/alibaba/hooks/issues/728
fnRef.current = useMemo(() => fn, [fn]);
const memoizedFn = useRef<PickFunction<T>>();
if (!memoizedFn.current) {
memoizedFn.current = function (this, ...args) {
return fnRef.current.apply(this, args);
};
}
return memoizedFn.current as T;
}
+44 -97
View File
@@ -1,73 +1,73 @@
import { RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
import { RuntimeStore } from 'ontime-types';
import { useRuntimeStore } from '../stores/runtime';
import { deepCompare, useRuntimeStore } from '../stores/runtime';
import { socketSendJson } from '../utils/socket';
export const useRundownEditor = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null,
nextEventId: state.eventNext?.id ?? null,
playback: state.playback,
selectedEventId: state.loaded.selectedEventId,
nextEventId: state.loaded.nextEventId,
});
return useRuntimeStore(featureSelector);
return useRuntimeStore(featureSelector, deepCompare);
};
export const useOperator = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null,
playback: state.playback,
selectedEventId: state.loaded.selectedEventId,
});
return useRuntimeStore(featureSelector);
return useRuntimeStore(featureSelector, deepCompare);
};
export const useMessageControl = () => {
const featureSelector = (state: RuntimeStore) => ({
timer: state.message.timer,
public: state.message.public,
lower: state.message.lower,
external: state.message.external,
timerMessage: state.timerMessage,
publicMessage: state.publicMessage,
lowerMessage: state.lowerMessage,
externalMessage: state.externalMessage,
onAir: state.onAir,
});
return useRuntimeStore(featureSelector);
return useRuntimeStore(featureSelector, deepCompare);
};
export const setMessage = {
timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }),
timerVisible: (payload: boolean) => socketSendJson('message', { timer: { visible: payload } }),
publicText: (payload: string) => socketSendJson('message', { public: { text: payload } }),
publicVisible: (payload: boolean) => socketSendJson('message', { public: { visible: payload } }),
lowerText: (payload: string) => socketSendJson('message', { lower: { text: payload } }),
lowerVisible: (payload: boolean) => socketSendJson('message', { lower: { visible: payload } }),
externalText: (payload: string) => socketSendJson('message', { external: { visible: payload } }),
externalVisible: (payload: boolean) => socketSendJson('message', { external: { visible: payload } }),
onAir: (payload: boolean) => socketSendJson('onAir', payload),
timerBlink: (payload: boolean) => socketSendJson('message', { timer: { blink: payload } }),
timerBlackout: (payload: boolean) => socketSendJson('message', { timer: { blackout: payload } }),
presenterText: (payload: string) => socketSendJson('set-timer-message-text', payload),
presenterVisible: (payload: boolean) => socketSendJson('set-timer-message-visible', payload),
publicText: (payload: string) => socketSendJson('set-public-message-text', payload),
publicVisible: (payload: boolean) => socketSendJson('set-public-message-visible', payload),
lowerText: (payload: string) => socketSendJson('set-lower-message-text', payload),
lowerVisible: (payload: boolean) => socketSendJson('set-lower-message-visible', payload),
externalText: (payload: string) => socketSendJson('set-external-message-text', payload),
externalVisible: (payload: boolean) => socketSendJson('set-external-message-visible', payload),
onAir: (payload: boolean) => socketSendJson('set-onAir', payload),
timerBlink: (payload: boolean) => socketSendJson('set-timer-blink', payload),
timerBlackout: (payload: boolean) => socketSendJson('set-timer-blackout', payload),
};
export const usePlaybackControl = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
playback: state.playback,
selectedEventIndex: state.loaded.selectedEventIndex,
numEvents: state.loaded.numEvents,
});
return useRuntimeStore(featureSelector);
return useRuntimeStore(featureSelector, deepCompare);
};
export const setPlayback = {
start: () => socketSendJson('start'),
pause: () => socketSendJson('pause'),
roll: () => socketSendJson('roll'),
startNext: () => socketSendJson('start', { next: true }),
startNext: () => socketSendJson('start-next'),
previous: () => {
socketSendJson('load', { previous: true });
socketSendJson('previous');
},
next: () => {
socketSendJson('load', { next: true });
socketSendJson('next');
},
stop: () => {
socketSendJson('stop');
@@ -84,52 +84,29 @@ export const useInfoPanel = () => {
const featureSelector = (state: RuntimeStore) => ({
eventNow: state.eventNow,
eventNext: state.eventNext,
playback: state.timer.playback,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
playback: state.playback,
selectedEventIndex: state.loaded.selectedEventIndex,
numEvents: state.loaded.numEvents,
});
return useRuntimeStore(featureSelector);
};
export const useExtraTimerTime = () => {
const featureSelector = (state: RuntimeStore) => state.timer1.current;
return useRuntimeStore(featureSelector);
};
export const useExtraTimerControl = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer1.playback,
direction: state.timer1.direction,
});
return useRuntimeStore(featureSelector);
};
export const setExtraTimer = {
start: () => socketSendJson('extratimer', SimplePlayback.Start),
pause: () => socketSendJson('extratimer', SimplePlayback.Pause),
stop: () => socketSendJson('extratimer', SimplePlayback.Stop),
setDirection: (direction: SimpleDirection) => socketSendJson('extratimer', { direction }),
setTime: (time: number) => socketSendJson('extratimer', { settime: time }),
return useRuntimeStore(featureSelector, deepCompare);
};
export const useCuesheet = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
playback: state.playback,
selectedEventId: state.loaded.selectedEventId,
selectedEventIndex: state.loaded.selectedEventIndex,
numEvents: state.loaded.numEvents,
titleNow: state.eventNow?.title || '',
});
return useRuntimeStore(featureSelector);
return useRuntimeStore(featureSelector, deepCompare);
};
export const setEventPlayback = {
loadEvent: (id: string) => socketSendJson('load', { id }),
startEvent: (id: string) => socketSendJson('start', { id }),
loadEvent: (eventId: string) => socketSendJson('loadid', eventId),
startEvent: (eventId: string) => socketSendJson('startid', eventId),
start: () => socketSendJson('start'),
pause: () => socketSendJson('pause'),
};
@@ -139,37 +116,7 @@ export const useTimer = () => {
...state.timer,
});
return useRuntimeStore(featureSelector);
};
export const useClock = () => {
const featureSelector = (state: RuntimeStore) => ({
clock: state.clock,
});
return useRuntimeStore(featureSelector);
};
/** Used by the progress bar components */
export const useProgressData = () => {
const featureSelector = (state: RuntimeStore) => ({
addedTime: state.timer.addedTime,
current: state.timer.current,
duration: state.timer.duration,
timeWarning: state.eventNow?.timeWarning ?? null,
timeDanger: state.eventNow?.timeDanger ?? null,
});
return useRuntimeStore(featureSelector);
return useRuntimeStore(featureSelector, deepCompare);
};
export const setClientName = (newName: string) => socketSendJson('set-client-name', newName);
export const useRuntimeOverview = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
clock: state.clock,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
});
return useRuntimeStore(featureSelector);
};
+8 -1
View File
@@ -2,5 +2,12 @@ import { HttpSettings } from 'ontime-types';
export const httpPlaceholder: HttpSettings = {
enabledOut: false,
subscriptions: [],
subscriptions: {
onLoad: [],
onStart: [],
onUpdate: [],
onPause: [],
onStop: [],
onFinish: [],
},
};
+16 -2
View File
@@ -1,6 +1,20 @@
import { GetInfo } from 'ontime-types';
import { GetInfo, OSCSettings } from 'ontime-types';
import { oscPlaceholderSettings } from './OscSettings';
export const oscPlaceholderSettings: OSCSettings = {
portIn: 0,
portOut: 0,
targetIP: '',
enabledIn: false,
enabledOut: false,
subscriptions: {
onLoad: [],
onStart: [],
onPause: [],
onStop: [],
onUpdate: [],
onFinish: [],
},
};
export const ontimePlaceholderInfo: GetInfo = {
networkInterfaces: [],
+18 -5
View File
@@ -1,10 +1,23 @@
import { OSCSettings } from 'ontime-types';
export const oscPlaceholderSettings: OSCSettings = {
portIn: 8888,
portOut: 9999,
targetIP: '127.0.0.1',
// in the placeholder, we pass strings to satisfy input type
export interface PlaceholderSettings extends Omit<OSCSettings, 'portIn' | 'portOut'> {
portIn: string;
portOut: string;
}
export const oscPlaceholderSettings: PlaceholderSettings = {
portIn: '',
portOut: '',
targetIP: '',
enabledIn: false,
enabledOut: false,
subscriptions: [],
subscriptions: {
onLoad: [],
onStart: [],
onPause: [],
onStop: [],
onUpdate: [],
onFinish: [],
},
};
@@ -1,19 +1,18 @@
import { MaybeNumber, Playback, TimerType } from 'ontime-types';
// first set extends TimerState
export type ViewExtendedTimer = {
addedTime: number;
current: MaybeNumber;
duration: MaybeNumber;
elapsed: MaybeNumber;
expectedFinish: MaybeNumber;
finishedAt: MaybeNumber;
playback: Playback;
secondaryTimer: MaybeNumber;
startedAt: MaybeNumber;
import { Playback, TimerType } from 'ontime-types';
export type TimeManagerType = {
clock: number;
timeDanger: MaybeNumber;
timeWarning: MaybeNumber;
current: null | number;
elapsed: null | number;
duration: null | number;
timerBehaviour?: string;
timerType: TimerType;
expectedFinish: null | number;
addedTime: number;
startedAt: null | number;
finishedAt: null | number;
secondaryTimer: null | number;
finished: boolean;
playback: Playback;
};
@@ -14,5 +14,4 @@ export type OverridableOptions = {
hideEndMessage?: boolean;
language?: string;
showProgressBar?: boolean;
hideTimerSeconds?: boolean;
};
@@ -4,6 +4,8 @@ export const viewsSettingsPlaceholder: ViewSettings = {
overrideStyles: false,
normalColor: '#ffffffcc',
warningColor: '#FFAB33',
warningThreshold: 120000,
dangerColor: '#ED3333',
dangerThreshold: 60000,
endMessage: '',
};
+47 -13
View File
@@ -8,29 +8,63 @@ export enum AppMode {
const appModeKey = 'ontime-app-mode';
function getModeFromSession() {
return sessionStorage.getItem(appModeKey) === AppMode.Run ? AppMode.Run : AppMode.Edit;
return localStorage.getItem(appModeKey) === AppMode.Run ? AppMode.Run : AppMode.Edit;
}
function persistModeToSession(mode: AppMode) {
sessionStorage.setItem(appModeKey, mode);
async function persistModeToSession(mode: AppMode) {
localStorage.setItem(appModeKey, mode);
}
type AppModeStore = {
mode: AppMode;
setMode: (mode: AppMode) => void;
cursor: string | null;
setCursor: (cursor: string | null) => void;
editId: string | null;
setMode: (mode: AppMode) => void;
setCursor: (id: string | null, isEditable?: boolean) => void;
setEditId: (id: string | null) => void;
};
export const useAppMode = create<AppModeStore>()((set) => ({
mode: getModeFromSession(),
cursor: null,
setMode: (mode: AppMode) => {
persistModeToSession(mode);
return set(() => {
return { mode };
});
},
setCursor: (cursor: string | null) => set(() => ({ cursor })),
editId: null,
setMode: (mode: AppMode) =>
set((state) => {
persistModeToSession(mode);
return mode === AppMode.Edit
? {
editId: state.cursor,
mode: mode,
}
: {
editId: null,
mode: mode,
};
}),
setCursor: (id: string | null, isEditable?: boolean) =>
set((state) => {
if (isEditable) {
return state.mode === AppMode.Edit
? {
cursor: id,
editId: id,
}
: {
cursor: id,
};
} else {
return { cursor: id, editId: null };
}
}),
setEditId: (id: string | null) =>
set((state) => {
return state.mode === AppMode.Edit
? {
cursor: id,
editId: id,
}
: {
editId: id,
};
}),
}));
@@ -6,6 +6,7 @@ type EditorSettings = {
showQuickEntry: boolean;
startTimeIsLastEnd: boolean;
defaultPublic: boolean;
showNif: boolean;
};
type EditorSettingsStore = {
@@ -14,12 +15,14 @@ type EditorSettingsStore = {
setShowQuickEntry: (showQuickEntry: boolean) => void;
setStartTimeIsLastEnd: (startTimeIsLastEnd: boolean) => void;
setDefaultPublic: (defaultPublic: boolean) => void;
setShowNif: (showNif: boolean) => void;
};
enum EditorSettingsKeys {
ShowQuickEntry = 'ontime-show-quick-entry',
StartTimeIsLastEnd = 'ontime-start-is-last-end',
DefaultPublic = 'ontime-default-public',
ShowNif = 'ontime-show-nif',
}
export const useEditorSettings = create<EditorSettingsStore>((set) => ({
@@ -27,6 +30,7 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => ({
showQuickEntry: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, false),
startTimeIsLastEnd: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true),
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true),
showNif: booleanFromLocalStorage(EditorSettingsKeys.ShowNif, true),
},
setLocalEventSettings: (value) =>
@@ -54,4 +58,10 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => ({
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
return { eventSettings: { ...state.eventSettings, defaultPublic } };
}),
setShowNif: (showNif) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.ShowNif, String(showNif));
return { eventSettings: { ...state.eventSettings, showNif } };
}),
}));
+43 -44
View File
@@ -1,65 +1,64 @@
import isEqual from 'react-fast-compare';
import { Playback, RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
import { createWithEqualityFn, useStoreWithEqualityFn } from 'zustand/traditional';
import { Playback, RuntimeStore } from 'ontime-types';
import { useStore } from 'zustand';
import { createStore } from 'zustand/vanilla';
export const runtimeStorePlaceholder: RuntimeStore = {
clock: 0,
timer: {
addedTime: 0,
clock: 0,
current: null,
duration: null,
elapsed: null,
expectedFinish: null,
finishedAt: null,
playback: Playback.Stop,
secondaryTimer: null,
addedTime: 0,
startedAt: null,
finishedAt: null,
secondaryTimer: null,
selectedEventId: null,
duration: null,
timerType: null,
endAction: null,
},
playback: Playback.Stop,
timerMessage: {
text: '',
visible: false,
timerBlink: false,
timerBlackout: false,
},
publicMessage: {
text: '',
visible: false,
},
lowerMessage: {
text: '',
visible: false,
},
externalMessage: {
text: '',
visible: false,
},
onAir: false,
message: {
timer: {
text: '',
visible: false,
blink: false,
blackout: false,
},
public: {
text: '',
visible: false,
},
lower: {
text: '',
visible: false,
},
external: {
text: '',
visible: false,
},
},
runtime: {
loaded: {
numEvents: 0,
selectedEventIndex: null,
selectedEventId: null,
selectedPublicEventId: null,
nextEventId: null,
nextPublicEventId: null,
},
eventNow: null,
eventNext: null,
publicEventNow: null,
publicEventNext: null,
timer1: {
current: 0,
direction: SimpleDirection.CountUp,
duration: 0,
playback: SimplePlayback.Stop,
},
};
const deepCompare = <T>(a: T, b: T) => isEqual(a, b);
export const runtime = createStore<RuntimeStore>(() => ({
...runtimeStorePlaceholder,
}));
export const runtimeStore = createWithEqualityFn<RuntimeStore>(
() => ({
...runtimeStorePlaceholder,
}),
deepCompare,
);
export const deepCompare = <T>(a: T, b: T) => isEqual(a, b);
export const useRuntimeStore = <T>(selector: (state: RuntimeStore) => T) =>
useStoreWithEqualityFn(runtimeStore, selector, deepCompare);
export const useRuntimeStore = <T>(
selector: (state: RuntimeStore) => T,
equalityFn?: (a: unknown, b: unknown) => boolean,
) => useStore(runtime, selector, equalityFn);
@@ -1,4 +1,95 @@
import { forgivingStringToMillis, millisToDelayString } from '../dateConfig';
import {
forgivingStringToMillis,
millisToDelayString,
millisToMinutes,
millisToSeconds,
secondsInMillis,
} from '../dateConfig';
describe('test secondsInMillis function', () => {
it('return 0 if value is null', () => {
expect(secondsInMillis(null)).toBe(0);
});
it('returns the seconds value of a millis date', () => {
const date = 1686255053619; // Thu Jun 08 2023 20:10:53
const seconds = secondsInMillis(date);
expect(seconds).toBe(53);
});
});
describe('test millisToSeconds function', () => {
it('test with null values', () => {
const t = { val: null, result: 0 };
expect(millisToSeconds(t.val)).toBe(t.result);
});
it('test with valid millis', () => {
const t = { val: 3600000, result: 3600 };
expect(millisToSeconds(t.val)).toBe(t.result);
});
it('test with negative millis', () => {
const t = { val: -3600000, result: -3600 };
expect(millisToSeconds(t.val)).toBe(t.result);
});
it('test with 0', () => {
const t = { val: 0, result: 0 };
expect(millisToSeconds(t.val)).toBe(t.result);
});
it('test with -0', () => {
const t = { val: -0, result: -0 };
expect(millisToSeconds(t.val, false)).toBe(t.result);
});
it('test with 86401000 (24 hours and 1 second)', () => {
const t = { val: 86401000, result: 86401 };
expect(millisToSeconds(t.val, false)).toBe(t.result);
});
it('test with -86401000 (-24 hours and 1 second)', () => {
const t = { val: -86401000, result: -86401 };
expect(millisToSeconds(t.val, false)).toBe(t.result);
});
});
describe('test millisToMinutes function', () => {
it('test with null values', () => {
const t = { val: null, result: 0 };
expect(millisToMinutes(t.val, false)).toBe(t.result);
});
it('test with valid millis', () => {
const t = { val: 3600000, result: 60 };
expect(millisToMinutes(t.val, false)).toBe(t.result);
});
it('test with negative millis', () => {
const t = { val: -3600000, result: -60 };
expect(millisToMinutes(t.val, false)).toBe(t.result);
});
it('test with 0', () => {
const t = { val: 0, result: 0 };
expect(millisToMinutes(t.val, false)).toBe(t.result);
});
it('test with -0', () => {
const t = { val: -0, result: -0 };
expect(millisToMinutes(t.val, false)).toBe(t.result);
});
it('test with 86401000 (24 hours and 1 second)', () => {
const t = { val: 86401000, result: 1440 };
expect(millisToMinutes(t.val, false)).toBe(t.result);
});
it('test with -86401000 (-24 hours and 1 second)', () => {
const t = { val: -86401000, result: -1440 };
expect(millisToMinutes(t.val, false)).toBe(t.result);
});
});
describe('test forgivingStringToMillis()', () => {
describe('function handles time with no separators', () => {
@@ -267,10 +358,10 @@ describe('millisToDelayString()', () => {
});
describe('converts values in seconds', () => {
it('shows a simple string with value in seconds', () => {
expect(millisToDelayString(10000, true)).toBe('+10 sec');
expect(millisToDelayString(10000)).toBe('+10 sec');
});
it('... and its negative counterpart', () => {
expect(millisToDelayString(-10000, true)).toBe('-10 sec');
expect(millisToDelayString(-10000)).toBe('-10 sec');
});
const underAMinute = [1, 500, 1000, 6000, 55000, 59999];
@@ -284,32 +375,32 @@ describe('millisToDelayString()', () => {
describe('converts values in minutes', () => {
it('shows a simple string with value in minutes', () => {
expect(millisToDelayString(720000, true)).toBe('+12 min');
expect(millisToDelayString(720000)).toBe('+12 min');
});
it('... and its negative counterpart', () => {
expect(millisToDelayString(-720000, true)).toBe('-12 min');
expect(millisToDelayString(-720000)).toBe('-12 min');
});
it('shows a simple string with value in minutes and seconds', () => {
expect(millisToDelayString(630000, true)).toBe('+00:10:30');
expect(millisToDelayString(630000)).toBe('+00:10:30');
});
it('... and its negative counterpart', () => {
expect(millisToDelayString(-630000, true)).toBe('-00:10:30');
expect(millisToDelayString(-630000)).toBe('-00:10:30');
});
const underAnHour = [60000, 360000, 720000];
underAnHour.forEach((value) => {
it(`handles ${value}`, () => {
expect(millisToDelayString(value, true)?.endsWith('min')).toBe(true);
expect(millisToDelayString(value)?.endsWith('min')).toBe(true);
});
});
});
describe('converts values with full time string', () => {
it('positive added time', () => {
expect(millisToDelayString(45015000, true)).toBe('+12:30:15');
expect(millisToDelayString(45015000)).toBe('+12:30:15');
});
it('negative added time', () => {
expect(millisToDelayString(-45015000, true)).toBe('-12:30:15');
expect(millisToDelayString(-45015000)).toBe('-12:30:15');
});
});
});
@@ -31,8 +31,6 @@ describe('cloneEvent()', () => {
user7: 'user7',
user8: 'user8',
user9: 'user9',
timeWarning: 120000,
timeDanger: 60000,
} as OntimeEvent;
const cloned = cloneEvent(original);
@@ -53,7 +51,5 @@ describe('cloneEvent()', () => {
expect(cloned.colour).toBe(original.colour);
expect(cloned.type).toBe(SupportedEvent.Event);
expect(cloned.revision).toBe(0);
expect(cloned.timeWarning).toBe(original.timeWarning);
expect(cloned.timeDanger).toBe(original.timeDanger);
});
});
@@ -1,4 +1,4 @@
import { isIPAddress, isOnlyNumbers, startsWithHttp, startsWithSlash } from '../regex';
import { isIPAddress, isOnlyNumbers, startsWithHttp } from '../regex';
describe('simple tests for regex', () => {
test('isOnlyNumbers', () => {
@@ -36,16 +36,4 @@ describe('simple tests for regex', () => {
expect(startsWithHttp.test(t)).toBe(false);
});
});
test('startsWithSlash', () => {
const right = ['//test'];
const wrong = ['testing', '123.0.1'];
right.forEach((t) => {
expect(startsWithSlash.test(t)).toBe(true);
});
wrong.forEach((t) => {
expect(startsWithSlash.test(t)).toBe(false);
});
});
});
@@ -1,28 +1,23 @@
import { formatTime, nowInMillis } from '../time';
describe('nowInMillis()', () => {
it('should return the current time in milliseconds', () => {
const mockDate = new Date(2022, 1, 1, 13, 0, 0); // This date corresponds to 13:00:00
const expectedMillis = 13 * 60 * 60 * 1000;
const dateSpy = vi.spyOn(global, 'Date').mockImplementation(() => mockDate as any);
const result = nowInMillis();
expect(result).toBe(expectedMillis);
dateSpy.mockRestore();
});
});
import { formatTime } from '../time';
describe('formatTime()', () => {
it('parses 24h strings', () => {
const ms = 13 * 60 * 60 * 1000;
const time = formatTime(ms, {format12: "hh:mm:ss", format24: "HH:mm:ss" }, (_format12, format24) => format24);
const options = {
showSeconds: true,
format: 'irrelevant',
};
const time = formatTime(ms, options, () => '24');
expect(time).toStrictEqual('13:00:00');
});
it('parses same string in 12h strings', () => {
const ms = 13 * 60 * 60 * 1000;
const time = formatTime(ms, {format12: "hh:mm:ss a", format24: "HH:mm:ss" }, (format12, _format24) => format12);
const options = {
showSeconds: true,
format: 'hh:mm:ss a',
};
const time = formatTime(ms, options, () => '12');
expect(time).toStrictEqual('01:00:00 PM');
});
@@ -32,9 +27,13 @@ describe('formatTime()', () => {
expect(time).toStrictEqual('...');
});
it('handles negative times', () => {
const ms = 1 * 60 * 60 * 1000;
const time = formatTime(-ms, {format12: "hh:mm a", format24: "HH:mm" }, (_format12, format24) => format24);
expect(time).toStrictEqual('-01:00');
it('shows 12h format without times', () => {
const ms = 13 * 60 * 60 * 1000;
const options = {
showSeconds: false,
format: 'hh:mm a',
};
const time = formatTime(ms, options, () => '12');
expect(time).toStrictEqual('01:00 PM');
});
});
+54 -23
View File
@@ -1,4 +1,37 @@
import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { formatFromMillis } from 'ontime-utils';
import { mth, mtm, mts } from './timeConstants';
export const timeFormat = 'HH:mm';
export const timeFormatSeconds = 'HH:mm:ss';
export function secondsInMillis(millis: number | null) {
if (!millis) {
return 0;
}
return Math.floor((millis % mtm) / mts);
}
/**
* @description Converts milliseconds to seconds
* @param {number | null} millis - time in seconds
* @returns {number} Amount in seconds
*/
export const millisToSeconds = (millis: number | null): number => {
if (millis === null) {
return 0;
}
return millis < 0 ? Math.ceil(millis / mts) : Math.floor(millis / mts);
};
/**
* @description Converts milliseconds to seconds
* @param {number} millis - time in milliseconds
* @returns {number} Amount in seconds
*/
export const millisToMinutes = (millis: number): number => {
return millis < 0 ? Math.ceil(millis / mtm) : Math.floor(millis / mtm);
};
/**
* @description safe parse string to int
@@ -55,7 +88,7 @@ function checkMatchers(value: string) {
const secondsMatchValue = secondsMatch ? parse(secondsMatch[1]) : 0;
if (hoursMatchValue > 0 || minutesMatchValue > 0 || secondsMatchValue > 0) {
return hoursMatchValue * MILLIS_PER_HOUR + minutesMatchValue * MILLIS_PER_MINUTE + secondsMatchValue * MILLIS_PER_SECOND;
return hoursMatchValue * mth + minutesMatchValue * mtm + secondsMatchValue * mts;
}
return { hoursMatchValue };
}
@@ -72,13 +105,13 @@ function inferSeparators(value: string, isAM: boolean, isPM: boolean) {
let addAM = 0;
if (length === 1) {
if (isPM || isAM) {
inferredMillis = parse(value) * MILLIS_PER_HOUR;
inferredMillis = parse(value) * mth;
if (isAM) {
// this ensures we dont add 12 hours in the end
addAM = inferredMillis;
}
} else {
inferredMillis = parse(value) * MILLIS_PER_MINUTE;
inferredMillis = parse(value) * mtm;
}
} else if (length === 2) {
if (isPM || isAM) {
@@ -88,22 +121,22 @@ function inferSeparators(value: string, isAM: boolean, isPM: boolean) {
addAM = 12;
}
} else {
inferredMillis = parse(value) * MILLIS_PER_MINUTE;
inferredMillis = parse(value) * mtm;
}
} else if (length === 3) {
inferredMillis = parse(value[0]) * MILLIS_PER_HOUR + parse(value.substring(1)) * MILLIS_PER_MINUTE;
inferredMillis = parse(value[0]) * mth + parse(value.substring(1)) * mtm;
} else if (length === 4) {
inferredMillis = parse(value.substring(0, 2)) * MILLIS_PER_HOUR + parse(value.substring(2)) * MILLIS_PER_MINUTE;
inferredMillis = parse(value.substring(0, 2)) * mth + parse(value.substring(2)) * mtm;
} else if (length === 5) {
const hours = parse(value.substring(0, 2));
const minutes = parse(value.substring(2, 4));
const seconds = parse(value.substring(4));
inferredMillis = hours * MILLIS_PER_HOUR + minutes * MILLIS_PER_MINUTE + seconds * MILLIS_PER_SECOND;
inferredMillis = hours * mth + minutes * mtm + seconds * mts;
} else if (length >= 6) {
const hours = parse(value.substring(0, 2));
const minutes = parse(value.substring(2, 4));
const seconds = parse(value.substring(4));
inferredMillis = hours * MILLIS_PER_HOUR + minutes * MILLIS_PER_MINUTE + seconds * MILLIS_PER_SECOND;
inferredMillis = hours * mth + minutes * mtm + seconds * mts;
}
return { inferredMillis, addAM };
}
@@ -134,9 +167,9 @@ export const forgivingStringToMillis = (value: string): number => {
if (first != null && second != null && third != null) {
// if string has three sections, treat as [hours] [minutes] [seconds]
millis = parse(first) * MILLIS_PER_HOUR;
millis += parse(second) * MILLIS_PER_MINUTE;
millis += parse(third) * MILLIS_PER_SECOND;
millis = parse(first) * mth;
millis += parse(second) * mtm;
millis += parse(third) * mts;
} else if (first != null && second == null && third == null) {
// we only have one section, infer separators
const { inferredMillis, addAM } = inferSeparators(first, isAM, isPM);
@@ -144,32 +177,30 @@ export const forgivingStringToMillis = (value: string): number => {
hoursMatchValue = addAM;
}
if (first != null && second != null && third == null) {
millis = parse(first) * MILLIS_PER_HOUR;
millis += parse(second) * MILLIS_PER_MINUTE;
millis = parse(first) * mth;
millis += parse(second) * mtm;
}
// Add 12 hours if it is PM
if (isPM && hoursMatchValue < 12) {
millis += 12 * MILLIS_PER_HOUR;
millis += 12 * mth;
}
return millis;
};
export function millisToDelayString(millis: number | null, small = false): undefined | string | null {
export function millisToDelayString(millis: number | null): undefined | string | null {
if (millis == null || millis === 0) {
return null;
}
const isNegative = millis < 0;
const absMillis = Math.abs(millis);
const delayed = small ? '+' : 'delayed by ';
const ahead = small ? '-' : 'ahead by ';
if (absMillis < MILLIS_PER_MINUTE) {
return `${isNegative ? ahead : delayed}${formatFromMillis(absMillis, 's')} sec`;
} else if (absMillis < MILLIS_PER_HOUR && absMillis % MILLIS_PER_MINUTE === 0) {
return `${isNegative ? ahead : delayed}${formatFromMillis(absMillis, 'm')} min`;
if (absMillis < mtm) {
return `${isNegative ? '-' : '+'}${formatFromMillis(absMillis, 's')} sec`;
} else if (absMillis < mth && absMillis % mtm === 0) {
return `${isNegative ? '-' : '+'}${formatFromMillis(absMillis, 'm')} min`;
} else {
return `${isNegative ? ahead : delayed}${formatFromMillis(absMillis, 'HH:mm:ss')}`;
return `${isNegative ? '-' : '+'}${formatFromMillis(absMillis, 'HH:mm:ss')}`;
}
}
@@ -8,12 +8,13 @@ import { OntimeEvent, SupportedEvent } from 'ontime-types';
*/
type ClonedEvent = Omit<
OntimeEvent,
'id' | 'cue' | 'user0' | 'user1' | 'user2' | 'user3' | 'user4' | 'user5' | 'user6' | 'user7' | 'user8' | 'user9'
'id' | 'user0' | 'user1' | 'user2' | 'user3' | 'user4' | 'user5' | 'user6' | 'user7' | 'user8' | 'user9'
>;
export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
return {
type: SupportedEvent.Event,
title: event.title,
cue: event.cue,
subtitle: event.subtitle,
presenter: event.presenter,
note: event.note,
@@ -21,15 +22,11 @@ export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
duration: event.duration,
timeEnd: event.timeEnd,
timerType: event.timerType,
timeStrategy: event.timeStrategy,
linkStart: event.linkStart,
endAction: event.endAction,
isPublic: event.isPublic,
skip: event.skip,
colour: event.colour,
after,
after: after,
revision: 0,
timeWarning: event.timeWarning,
timeDanger: event.timeDanger,
};
};
-9
View File
@@ -1,9 +0,0 @@
import { KeyboardEvent } from 'react';
export function isKeyEnter<T>(event: KeyboardEvent<T>): boolean {
return event.key === 'Enter';
}
export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean {
return event.key === 'Escape';
}
-6
View File
@@ -1,9 +1,3 @@
/**
* Simple regex patterns for common use cases
* mostly used in form validation
*/
export const isOnlyNumbers = /^\d+$/;
export const isIPAddress = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/;
export const startsWithHttp = /^http:\/\//;
export const startsWithSlash = /^\//;
+29 -17
View File
@@ -4,7 +4,7 @@ import { isProduction, RUNTIME, websocketUrl } from '../api/apiConstants';
import { ontimeQueryClient } from '../queryClient';
import { socketClientName } from '../stores/connectionName';
import { addLog } from '../stores/logger';
import { runtimeStore } from '../stores/runtime';
import { runtime } from '../stores/runtime';
export let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null;
@@ -63,40 +63,52 @@ export const connectSocket = (preferredClientName?: string) => {
break;
}
case 'ontime': {
runtimeStore.setState(payload as RuntimeStore);
runtime.setState(payload as RuntimeStore);
if (!isProduction) {
ontimeQueryClient.setQueryData(RUNTIME, data.payload);
}
break;
}
case 'ontime-playback': {
const state = runtimeStore.getState();
state.timer.playback = payload;
runtimeStore.setState(state);
const state = runtime.getState();
state.playback = payload;
runtime.setState(state);
break;
}
case 'ontime-timer': {
const state = runtimeStore.getState();
const state = runtime.getState();
state.timer = payload;
runtimeStore.setState(state);
runtime.setState(state);
break;
}
case 'ontime-runtime': {
const state = runtimeStore.getState();
state.runtime = payload;
runtimeStore.setState(state);
case 'ontime-loaded': {
const state = runtime.getState();
state.loaded = payload;
runtime.setState(state);
break;
}
case 'ontime-message': {
const state = runtimeStore.getState();
state.message = payload;
runtimeStore.setState(state);
case 'ontime-timerMessage': {
const state = runtime.getState();
state.timerMessage = payload;
runtime.setState(state);
break;
}
case 'ontime-publicMessage': {
const state = runtime.getState();
state.publicMessage = payload;
runtime.setState(state);
break;
}
case 'ontime-lowerMessage': {
const state = runtime.getState();
state.lowerMessage = payload;
runtime.setState(state);
break;
}
case 'ontime-onAir': {
const state = runtimeStore.getState();
const state = runtime.getState();
state.onAir = payload;
runtimeStore.setState(state);
runtime.setState(state);
break;
}
}
+19 -59
View File
@@ -1,7 +1,6 @@
import { MaybeNumber, Settings, TimeFormat } from 'ontime-types';
import { formatFromMillis } from 'ontime-utils';
import { Settings } from 'ontime-types';
import { formatFromMillis, millisToString } from 'ontime-utils';
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
import { APP_SETTINGS } from '../api/apiConstants';
import { ontimeQueryClient } from '../queryClient';
@@ -23,75 +22,36 @@ export const nowInMillis = () => {
/**
* @description Resolves format from url and store
* @return {string|null} A format string like "hh:mm:ss a" or null
* @return {string|undefined}
*/
function getFormatFromParams() {
export const resolveTimeFormat = () => {
const params = new URL(document.location.href).searchParams;
return params.get('timeformat');
}
/**
* Gets the format options from the applicaton settings
* @returns a string equivalent to the format, ie: hh:mm:ss a or HH:mm:ss
*/
export function getFormatFromSettings(): TimeFormat {
const urlOptions = params.get('format');
const settings: Settings | undefined = ontimeQueryClient.getQueryData(APP_SETTINGS);
return settings?.timeFormat ?? '24';
}
export function getDefaultFormat(
currentSettings?: TimeFormat,
format12: string = FORMAT_12,
format24: string = FORMAT_24,
): string {
if (currentSettings === '12') {
return format12;
}
return format24;
}
function resolveTimeFormat(fallback12: string, fallback24: string): string {
// if the user has an option, we use that
const formatFromParams = getFormatFromParams();
if (formatFromParams) {
return formatFromParams;
}
// otherwise we use the view defined, with respect to the 12-24 hour settings
const formatFromSettings = getFormatFromSettings();
if (formatFromSettings === '12') {
return fallback12;
}
return fallback24;
}
return urlOptions || settings?.timeFormat;
};
type FormatOptions = {
format12: string;
format24: string;
showSeconds?: boolean;
format?: string;
};
/**
* @description viewer specific utility function to format a date in 12 or 24 hour format
* @param {MaybeNumber} milliseconds
* @description utility function to format a date in 12 or 24 hour format
* @param {number | null} milliseconds
* @param {object} [options]
* @param {string} [options.format.format12] format string if 12 hour time
* @param {string} [options.format.format24] format string if 24 hour time
* @param {Function} resolver DI for testing
* @param {boolean} [options.showSeconds]
* @param {string} [options.format]
* @param {function} resolver
* @return {string}
*/
export const formatTime = (
milliseconds: MaybeNumber,
options?: FormatOptions,
resolver = resolveTimeFormat,
): string => {
export const formatTime = (milliseconds: number | null, options?: FormatOptions, resolver = resolveTimeFormat) => {
if (milliseconds === null) {
return '...';
}
const timeFormat = resolver(options?.format12 ?? FORMAT_12, options?.format24 ?? FORMAT_24);
const display = formatFromMillis(Math.abs(milliseconds), timeFormat);
const isNegative = milliseconds < 0;
return `${isNegative ? '-' : ''}${display}`;
const timeFormat = resolver();
const fallback = options?.showSeconds ? 'hh:mm:ss a' : 'hh:mm a';
const { showSeconds = false, format: formatString = fallback } = options || {};
return timeFormat === '12' ? formatFromMillis(milliseconds, formatString) : millisToString(milliseconds, showSeconds);
};
@@ -0,0 +1,17 @@
/**
* millis to seconds
* @type {number}
*/
export const mts = 1000;
/**
* millis to minutes
* @type {number}
*/
export const mtm = 1000 * 60;
/**
* millis to hours
* @type {number}
*/
export const mth = 1000 * 60 * 60;
@@ -0,0 +1 @@
export type TimeEntryField = 'timeStart' | 'timeEnd' | 'durationOverride';
-1
View File
@@ -1,5 +1,4 @@
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 gitbookUrl = 'https://ontime.gitbook.io';
@@ -1,6 +1,8 @@
@use '../theme/v2Styles' as *;
.wrapper {
background: $bg-container-l1;
width: 100%;
height: 100%;
padding: max(16px, 2vh);
}
}
@@ -1,10 +0,0 @@
.container {
grid-area: main;
width: 100%;
padding: 1rem;
display: flex;
gap: 0.25rem;
overflow: hidden;
}
@@ -1,41 +0,0 @@
import { ErrorBoundary } from '@sentry/react';
import { useKeyDown } from '../../common/hooks/useKeyDown';
import AboutPanel from './panel/about-panel/AboutPanel';
import IntegrationsPanel from './panel/integrations-panel/IntegrationsPanel';
import LogPanel from './panel/log-panel/LogPanel';
import ProjectPanel from './panel/project-panel/ProjectPanel';
import ProjectSettingsPanel from './panel/project-settings-panel/ProjectSettingsPanel';
import UrlPresetPanel from './panel/url-preset-panel/UrlPresetPanel';
import PanelContent from './panel-content/PanelContent';
import PanelList from './panel-list/PanelList';
import { useSettingsStore } from './settingsStore';
import style from './AppSettings.module.scss';
export default function AppSettings() {
const setShowSettings = useSettingsStore((state) => state.setShowSettings);
const selectedPanel = useSettingsStore((state) => state.showSettings);
const closeSettings = () => {
setShowSettings(null);
};
useKeyDown(closeSettings, 'Escape');
return (
<div className={style.container}>
<ErrorBoundary>
<PanelList />
<PanelContent onClose={closeSettings}>
{selectedPanel === 'project' && <ProjectPanel />}
{selectedPanel === 'integrations' && <IntegrationsPanel />}
{selectedPanel === 'project_settings' && <ProjectSettingsPanel />}
{selectedPanel === 'url_presets' && <UrlPresetPanel />}
{selectedPanel === 'about' && <AboutPanel />}
{selectedPanel === 'log' && <LogPanel />}
</PanelContent>
</ErrorBoundary>
</div>
);
}
@@ -1,20 +0,0 @@
.corner {
position: absolute;
top: 1rem;
right: 1rem;
}
.contentWrapper {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
position: relative;
}
.content {
margin: 1rem;
overflow-y: auto;
flex-grow: 1;
}
@@ -1,22 +0,0 @@
import { PropsWithChildren } from 'react';
import { IconButton } from '@chakra-ui/react';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import style from './PanelContent.module.scss';
interface PanelContentProps {
onClose: () => void;
}
export default function PanelContent(props: PropsWithChildren<PanelContentProps>) {
const { onClose, children } = props;
return (
<div className={style.contentWrapper}>
<div className={style.corner}>
<IconButton onClick={onClose} aria-label='close' icon={<IoClose />} variant='ontime-ghosted-white' />
</div>
<div className={style.content}>{children}</div>
</div>
);
}
@@ -1,61 +0,0 @@
.tabs,
ul {
list-style: none;
padding: 0;
margin: 0;
}
.tabs {
width: min(30vw, 300px);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.primary,
.secondary {
padding: 0.25rem 1rem;
margin-right: 1rem;
&:focus {
background-color: $gray-1000;
outline: 0;
}
&:hover {
background-color: $gray-1000;
cursor: pointer;
}
}
.primary {
font-size: 1rem;
border-radius: 2px;
display: flex;
align-items: center;
gap: 0.5rem;
&.active {
color: $blue-400;
background-color: $gray-1100;
}
&.unsaved::before {
content: '';
width: 6px;
height: 6px;
border-radius: 3px;
background-color: $blue-400;
}
&.split {
margin-top: 1rem;
}
}
.secondary {
margin-left: 1rem;
color: $secondary-text-gray;
border-left: 1px solid $white-10;
font-size: $inner-section-text-size;
}
@@ -1,54 +0,0 @@
import { Fragment } from 'react';
import { isKeyEnter } from '../../../common/utils/keyEvent';
import { cx } from '../../../common/utils/styleUtils';
import { settingPanels, SettingsOption, useSettingsStore } from '../settingsStore';
import style from './PanelList.module.scss';
export default function PanelList() {
const { showSettings, setShowSettings, hasUnsavedChanges } = useSettingsStore();
const handleSelect = (panel: SettingsOption) => {
setShowSettings(panel.id);
};
return (
<ul className={style.tabs}>
{settingPanels.map((panel) => {
const unsaved = hasUnsavedChanges(panel.id);
const classes = cx([
style.primary,
showSettings === panel.id ? style.active : null,
panel.split ? style.split : null,
unsaved ? style.unsaved : null,
]);
return (
<Fragment key={panel.id}>
<li
key={panel.id}
onClick={() => handleSelect(panel)}
onKeyDown={(event) => {
isKeyEnter(event) && handleSelect(panel);
}}
className={classes}
tabIndex={0}
role='button'
>
{panel.label}
</li>
{panel.secondary?.map((secondary) => {
return (
<li key={secondary.id} onClick={() => handleSelect(panel)} className={style.secondary}>
{secondary.label}
</li>
);
})}
</Fragment>
);
})}
</ul>
);
}
@@ -1,115 +0,0 @@
$inner-padding: 1rem;
.header {
font-size: 2rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid $white-10;
font-weight: 600;
}
.subheader {
font-size: 1.5rem;
padding-bottom: 0.5rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
font-size: 1.25rem;
padding-left: $inner-padding;
display: flex;
align-items: center;
justify-content: space-between;
}
.section {
margin-top: 2rem;
font-size: calc(1rem - 1px);
max-width: 800px;
}
.paragraph {
padding: 0.5rem 0;
}
.card {
padding: 2rem;
background-color: $white-1;
border: 1px solid $gray-1100;
border-radius: 3px;
}
.error {
font-size: $inner-section-text-size;
display: block;
color: $error-red;
}
.pad {
padding: 0 1rem;
max-height: 500px;
overflow-y: scroll;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: calc(1rem - 2px);
text-align: left;
tr {
padding: 1rem 0;
}
th {
border-bottom: 1px solid $white-10;
font-weight: 400;
color: $gray-400;
}
th,
td {
padding: 0.5rem;
}
tr:nth-child(even) {
background-color: $white-1;
}
}
.listGroup {
padding: 0.5rem 1rem 0 1rem;
> li:not(:last-child) {
border-bottom: 1px solid $white-10;
}
}
.listItem {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
padding: 0.5rem 0;
}
.fieldTitle {
color: $ui-white;
font-size: 1rem;
}
.fieldDescription {
font-size: calc(1rem - 2px);
color: $gray-400;
}
.fieldError {
font-size: calc(1rem - 2px);
color: $red-500;
}
.divider {
border-top: 1px solid $white-10;
margin: 1rem -2rem;
}
@@ -1,80 +0,0 @@
import { HTMLAttributes, ReactNode } from 'react';
import style from './Panel.module.scss';
export function Header({ children }: { children: ReactNode }) {
return <h2 className={style.header}>{children}</h2>;
}
export function SubHeader({ children }: { children: ReactNode }) {
return <h3 className={style.subheader}>{children}</h3>;
}
export function Title({ children }: { children: ReactNode }) {
return <h4 className={style.title}>{children}</h4>;
}
type AllowedTags = 'div' | 'form';
type SectionProps<C extends AllowedTags> = {
as?: C;
children: ReactNode;
} & JSX.IntrinsicElements[C];
export function Section<C extends AllowedTags = 'div'>({ as, children, ...props }: SectionProps<C>) {
const Element = as ?? 'div';
return (
<Element className={style.section} {...(props as HTMLAttributes<HTMLElement>)}>
{children}
</Element>
);
}
export function Paragraph({ children }: { children: ReactNode }) {
return <p className={style.paragraph}>{children}</p>;
}
export function Card({ children, ...props }: { children: ReactNode } & JSX.IntrinsicElements['div']) {
return (
<div className={style.card} {...props}>
{children}
</div>
);
}
export function Table({ children }: { children: ReactNode }) {
return (
<div className={style.pad}>
<table className={style.table}>{children}</table>
</div>
);
}
export function ListGroup({ children }: { children: ReactNode }) {
return <ul className={style.listGroup}>{children}</ul>;
}
export function ListItem({ children }: { children: ReactNode }) {
return <li className={style.listItem}>{children}</li>;
}
export function Field({ title, description, error }: { title: string; description: string; error?: string }) {
return (
<div className={style.fieldTitle}>
{title}
{error && <Error>{error}</Error>}
{!error && description && <Description>{description}</Description>}
</div>
);
}
export function Description({ children }: { children: ReactNode }) {
return <div className={style.fieldDescription}>{children}</div>;
}
export function Error({ children }: { children: ReactNode }) {
return <div className={style.fieldError}>{children}</div>;
}
export function Divider() {
return <hr className={style.divider} />;
}
@@ -1,35 +0,0 @@
import { version } from '../../../../../package.json';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import { gitbookUrl, githubUrl, websiteUrl } from '../../../../externals';
import * as Panel from '../PanelUtils';
import CheckUpdatesButton from './CheckUpdatesButton';
export default function AboutPanel() {
return (
<>
<Panel.Header>About Ontime</Panel.Header>
<Panel.Section>
<Panel.SubHeader>Ontime</Panel.SubHeader>
<Panel.Paragraph>
Free, open-source software for managing rundowns and event timers
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
</Panel.Paragraph>
</Panel.Section>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Links</Panel.SubHeader>
<ExternalLink href={gitbookUrl}>Read the docs over at GitBook</ExternalLink>
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
</Panel.Card>
</Panel.Section>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Current version</Panel.SubHeader>
<Panel.Paragraph>{`You are currently using Ontime ${version}`}</Panel.Paragraph>
<CheckUpdatesButton version={version} />
</Panel.Card>
</Panel.Section>
</>
);
}
@@ -1,181 +0,0 @@
import { useFieldArray, useForm } from 'react-hook-form';
import { Button, IconButton, Input, Select, Switch } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { HttpSettings } from 'ontime-types';
import { generateId } from 'ontime-utils';
import { maybeAxiosError } from '../../../../common/api/apiUtils';
import { useHttpSettings, usePostHttpSettings } from '../../../../common/hooks-query/useHttpSettings';
import { isKeyEscape } from '../../../../common/utils/keyEvent';
import { startsWithHttp } from '../../../../common/utils/regex';
import * as Panel from '../PanelUtils';
import { cycles } from './integrationUtils';
import style from './IntegrationsPanel.module.css';
export default function HttpIntegrations() {
const { data } = useHttpSettings();
const { mutateAsync } = usePostHttpSettings();
const {
control,
handleSubmit,
reset,
register,
setError,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<HttpSettings>({
mode: 'onBlur',
defaultValues: data,
values: data,
resetOptions: {
keepDirtyValues: true,
},
});
const { fields, prepend, remove } = useFieldArray({
name: 'subscriptions',
control,
});
const onSubmit = async (values: HttpSettings) => {
try {
await mutateAsync(values);
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
}
};
const preventEscape = (event: React.KeyboardEvent) => {
if (isKeyEscape(event)) {
event.preventDefault();
event.stopPropagation();
}
};
const handleAddNewSubscription = () => {
prepend({
id: generateId(),
cycle: 'onLoad',
message: '',
enabled: false,
});
};
const handleDeleteSubscription = (index: number) => {
remove(index);
};
const canSubmit = !isSubmitting && isDirty && isValid;
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>
HTTP
<div className={style.flex}>
<Button variant='ontime-ghosted' size='sm' onClick={() => reset()} isDisabled={!canSubmit}>
Reset
</Button>
<Button
variant='ontime-filled'
size='sm'
type='submit'
form='http-form'
isDisabled={!canSubmit}
isLoading={isSubmitting}
>
Save changes
</Button>
</div>
</Panel.SubHeader>
<Panel.Section as='form' id='http-form' onSubmit={handleSubmit(onSubmit)} onKeyDown={preventEscape}>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='HTTP Output' description='Provide feedback from Ontime through HTTP' />
<Switch variant='ontime' size='lg' {...register('enabledOut')} />
</Panel.ListItem>
</Panel.ListGroup>
<Panel.Divider />
<Panel.Title>
HTTP Integration
<Button variant='ontime-subtle' size='sm' rightIcon={<IoAdd />} onClick={handleAddNewSubscription}>
New
</Button>
</Panel.Title>
{fields.length > 0 && (
<Panel.Table>
<thead>
<tr>
<th>Enabled</th>
<th>Cycle</th>
<th className={style.fullWidth}>Message</th>
<th />
</tr>
</thead>
<tbody>
{fields.map((integration, index) => {
// @ts-expect-error -- not sure why it is not finding the type, it is ok
const maybeError = errors.subscriptions?.[index]?.message?.message;
return (
<tr key={integration.id}>
<td>
<Switch variant='ontime' {...register(`subscriptions.${index}.enabled`)} />
</td>
<td className={style.autoWidth}>
<Select
size='sm'
variant='ontime'
className={style.fitContents}
{...register(`subscriptions.${index}.cycle`)}
>
{cycles.map((cycle) => (
<option key={cycle.id} value={cycle.value}>
{cycle.label}
</option>
))}
</Select>
</td>
<td className={style.fullWidth}>
<Input
size='sm'
variant='ontime-filled'
autoComplete='off'
placeholder='http://third-party/vt1/{{timer.current}}'
{...register(`subscriptions.${index}.message`, {
required: { value: true, message: 'Required field' },
pattern: {
value: startsWithHttp,
message: 'HTTP messages should start with http://',
},
})}
/>
{maybeError && <Panel.Error>{maybeError}</Panel.Error>}
</td>
<td>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={() => handleDeleteSubscription(index)}
/>
</td>
</tr>
);
})}
</tbody>
</Panel.Table>
)}
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -1,12 +0,0 @@
.fullWidth {
width: 100%;
}
.fitContents {
width: max-content !important; /* override chakra */
}
.flex {
display: flex;
gap: 1rem;
}
@@ -1,33 +0,0 @@
import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import * as Panel from '../PanelUtils';
import HttpIntegrations from './HttpIntegrations';
import OscIntegrations from './OscIntegrations';
const integrationDocsUrl = 'https://ontime.gitbook.io/v2/control-and-feedback/integrations';
export default function IntegrationsPanel() {
return (
<>
<Panel.Header>Integration settings</Panel.Header>
<Panel.Section>
<Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon />
<AlertDescription>
Integrations allow Ontime to receive commands or send its data to other systems in your workflow. <br />{' '}
<br />
Currently supported protocols are OSC (Open Sound Control), HTTP and Websockets. <br />
WebSockets are used for Ontime and cannot be configured independently. <br />
<ExternalLink href={integrationDocsUrl}>See the docs</ExternalLink>
</AlertDescription>
</Alert>
</Panel.Section>
<Panel.Section>
<OscIntegrations />
<HttpIntegrations />
</Panel.Section>
</>
);
}
@@ -1,268 +0,0 @@
import { useFieldArray, useForm } from 'react-hook-form';
import { Button, IconButton, Input, Select, Switch } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { OSCSettings } from 'ontime-types';
import { generateId } from 'ontime-utils';
import { maybeAxiosError } from '../../../../common/api/apiUtils';
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
import { isKeyEscape } from '../../../../common/utils/keyEvent';
import { isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
import * as Panel from '../PanelUtils';
import { cycles } from './integrationUtils';
import style from './IntegrationsPanel.module.css';
export default function OscIntegrations() {
const { data } = useOscSettings();
const { mutateAsync } = useOscSettingsMutation();
const {
control,
handleSubmit,
reset,
register,
setError,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<OSCSettings>({
mode: 'onBlur',
defaultValues: data,
values: data,
resetOptions: {
keepDirtyValues: true,
},
});
const { fields, prepend, remove } = useFieldArray({
name: 'subscriptions',
control,
});
const onSubmit = async (values: OSCSettings) => {
if (values.portIn === values.portOut) {
setError('portIn', { message: 'OSC IN and OUT Ports cant be the same' });
return;
}
const parsedValues = { ...values, portIn: Number(values.portIn), portOut: Number(values.portOut) };
try {
await mutateAsync(parsedValues);
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
}
};
const preventEscape = (event: React.KeyboardEvent) => {
if (isKeyEscape(event)) {
event.preventDefault();
event.stopPropagation();
}
};
const handleAddNewSubscription = () => {
prepend({
id: generateId(),
cycle: 'onLoad',
message: '',
enabled: false,
});
};
const handleDeleteSubscription = (index: number) => {
remove(index);
};
const canSubmit = !isSubmitting && isDirty && isValid;
return (
<Panel.Card>
<Panel.SubHeader>
Open Sound Control
<div className={style.flex}>
<Button variant='ontime-ghosted' size='sm' onClick={() => reset()} isDisabled={!canSubmit}>
Reset
</Button>
<Button
variant='ontime-filled'
size='sm'
type='submit'
form='osc-form'
isDisabled={!canSubmit}
isLoading={isSubmitting}
>
Save changes
</Button>
</div>
</Panel.SubHeader>
<Panel.Section as='form' id='osc-form' onSubmit={handleSubmit(onSubmit)} onKeyDown={preventEscape}>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='OSC input' description='Allow control of Ontime through OSC' />
<Switch variant='ontime' size='lg' {...register('enabledIn')} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Listen on port'
description='Port for incoming OSC. Default: 8888'
error={errors.portIn?.message}
/>
<Input
id='portIn'
placeholder='8888'
width='5rem'
maxLength={5}
size='sm'
textAlign='right'
variant='ontime-filled'
type='number'
autoComplete='off'
{...register('portIn', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
pattern: {
value: isOnlyNumbers,
message: 'Value should be numeric',
},
})}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='OSC output' description='Provide feedback from Ontime with OSC' />
<Switch variant='ontime' size='lg' {...register('enabledOut')} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='OSC target IP'
description='IP address Ontime will send OSC messages to'
error={errors.targetIP?.message}
/>
<Input
id='targetIP'
placeholder='127.0.0.1'
width='9rem'
size='sm'
textAlign='right'
variant='ontime-filled'
autoComplete='off'
{...register('targetIP', {
required: { value: true, message: 'Required field' },
pattern: {
value: isIPAddress,
message: 'Invalid IP address',
},
})}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='OSC target port'
description='Port number Ontime will send OSC messages to'
error={errors.portOut?.message}
/>
<Input
id='portOut'
placeholder='8888'
width='75px'
size='sm'
textAlign='right'
variant='ontime-filled'
autoComplete='off'
{...register('portOut', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
pattern: {
value: isOnlyNumbers,
message: 'Value should be numeric',
},
})}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.Divider />
<Panel.Title>
OSC Integrations
<Button variant='ontime-subtle' size='sm' rightIcon={<IoAdd />} onClick={handleAddNewSubscription}>
Add
</Button>
</Panel.Title>
{fields.length > 0 && (
<Panel.Table>
<thead>
<tr>
<th>Enabled</th>
<th>Cycle</th>
<th className={style.fullWidth}>Message</th>
<th />
</tr>
</thead>
<tbody>
{fields.map((field, index) => {
// @ts-expect-error -- not sure why it is not finding the type, it is ok
const maybeError = errors.subscriptions?.[index]?.message?.message;
return (
<tr key={field.id}>
<td>
<Switch variant='ontime' {...register(`subscriptions.${index}.enabled`)} />
</td>
<td className={style.autoWidth}>
<Select
size='sm'
variant='ontime'
className={style.fitContents}
{...register(`subscriptions.${index}.cycle`)}
>
{cycles.map((cycle) => (
<option key={cycle.id} value={cycle.value}>
{cycle.label}
</option>
))}
</Select>
</td>
<td className={style.fullWidth}>
<Input
key={field.id}
size='sm'
variant='ontime-filled'
autoComplete='off'
placeholder='/from-ontime/{{timer.current}}'
{...register(`subscriptions.${index}.message`, {
required: { value: true, message: 'Required field' },
pattern: {
value: startsWithSlash,
message: 'OSC messages should start with a forward slash',
},
})}
/>
{maybeError && <Panel.Error>{maybeError}</Panel.Error>}
</td>
<td>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={() => handleDeleteSubscription(index)}
/>
</td>
</tr>
);
})}
</tbody>
</Panel.Table>
)}
</Panel.Section>
</Panel.Card>
);
}
@@ -1,8 +0,0 @@
export const cycles = [
{ id: 1, label: 'On Load', value: 'onLoad' },
{ id: 2, label: 'On Start', value: 'onStart' },
{ id: 3, label: 'On Pause', value: 'onPause' },
{ id: 4, label: 'On Stop', value: 'onStop' },
{ id: 5, label: 'Every second', value: 'onUpdate' },
{ id: 6, label: 'On Finish', value: 'onFinish' },
];
@@ -1,26 +0,0 @@
import * as Panel from '../PanelUtils';
import Log from './Log';
import InfoNif from './NetworkInterfaces';
export default function LogPanel() {
return (
<>
<Panel.Header>Application log</Panel.Header>
<Panel.Section>
<Panel.SubHeader>Available networks</Panel.SubHeader>
<Panel.Card>
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
<InfoNif />
</Panel.Card>
</Panel.Section>
<Panel.Section>
<Panel.SubHeader>Network log</Panel.SubHeader>
<Panel.Card>
<Log />
</Panel.Card>
</Panel.Section>
</>
);
}
@@ -1,6 +0,0 @@
.interfaces {
display: flex;
flex-wrap: wrap;
gap: $section-spacing;
row-gap: $element-inner-spacing;
}
@@ -1,19 +0,0 @@
import { serverPort } from '../../../../common/api/apiConstants';
import AppLink from '../../../../common/components/app-link/AppLink';
import useInfo from '../../../../common/hooks-query/useInfo';
import style from './NetworkInterfaces.module.scss';
export default function InfoNif() {
const { data } = useInfo();
return (
<div className={style.interfaces}>
{data?.networkInterfaces?.map((nif) => (
<AppLink key={nif.address} href={`http://${nif.address}:${serverPort}`}>
{`${nif.name} - ${nif.address}`}
</AppLink>
))}
</div>
);
}
@@ -1,136 +0,0 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Button, Input, Textarea } from '@chakra-ui/react';
import style from './ProjectPanel.module.scss';
export type ProjectCreateFormValues = {
title?: string;
description?: string;
publicInfo?: string;
publicUrl?: string;
backstageInfo?: string;
backstageUrl?: string;
};
interface ProjectCreateFormProps {
onCancel: () => void;
onSubmit: (values: ProjectCreateFormValues) => Promise<void>;
submitError: string | null;
}
export default function ProjectCreateForm({ onSubmit, onCancel, submitError }: ProjectCreateFormProps) {
const {
handleSubmit,
register,
formState: { isSubmitting, isValid },
setFocus,
} = useForm<ProjectCreateFormValues>({
defaultValues: { title: '' },
values: { title: '' },
resetOptions: {
keepDirtyValues: true,
},
});
useEffect(() => {
setFocus('title');
}, [setFocus]);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className={style.createFormInputField}>
<label>
Project title
<Input
variant='ontime-filled'
size='sm'
maxLength={50}
placeholder='Your project name'
autoComplete='off'
{...register('title')}
/>
</label>
</div>
<div className={style.createFormInputField}>
<label>
Project description
<Input
variant='ontime-filled'
size='sm'
maxLength={100}
placeholder='Euro Love, Malmö 2024'
autoComplete='off'
{...register('description')}
/>
</label>
</div>
<div className={style.createFormInputField}>
<label>
Public info
<Textarea
variant='ontime-filled'
size='sm'
maxLength={150}
placeholder='Shows always start ontime'
autoComplete='off'
{...register('publicInfo')}
/>
</label>
</div>
<div className={style.createFormInputField}>
<label>
Public QR code Url
<Input
variant='ontime-filled'
size='sm'
placeholder='www.getontime.no'
autoComplete='off'
{...register('publicUrl')}
/>
</label>
</div>
<div className={style.createFormInputField}>
<label>
Backstage info
<Textarea
variant='ontime-filled'
size='sm'
maxLength={150}
placeholder='Wi-Fi password: 1234'
autoComplete='off'
{...register('backstageInfo')}
/>
</label>
</div>
<div className={style.createFormInputField}>
<label>
Backstage QR code Url
<Input
variant='ontime-filled'
size='sm'
placeholder='www.ontime.gitbook.io'
autoComplete='off'
{...register('backstageUrl')}
/>
</label>
</div>
<div className={style.createActionButtons}>
<Button onClick={onCancel} variant='ontime-ghosted' size='sm'>
Cancel
</Button>
<Button
isDisabled={!isValid}
type='submit'
isLoading={isSubmitting}
variant='ontime-filled'
padding='0 2em'
size='sm'
>
Create
</Button>
</div>
{submitError && <span className={style.error}>{submitError}</span>}
</form>
);
}

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