Compare commits

..

11 Commits

Author SHA1 Message Date
Carlos Valente 1a95bc43a3 folow selected 2025-06-17 06:31:46 +02:00
Carlos Valente 1604762f2b style tweaks 2025-06-17 06:31:46 +02:00
Carlos Valente 2f8f4ad129 wip: countdown view redesign 2025-06-17 06:31:46 +02:00
Carlos Valente 0c7c90cae4 refactor: update flat rundown 2025-06-17 06:31:46 +02:00
Carlos Valente 1148ac1924 refactor: remove public view 2025-06-17 06:31:46 +02:00
Carlos Valente 100fbabfc6 refactor: inactivity checks keyboard and has initial state 2025-06-17 06:31:46 +02:00
Carlos Valente 42810a97ca refactor: small behaviour tweaks 2025-06-17 06:31:46 +02:00
Carlos Valente 05a9b2af1a refactor: action-blue is from blue scale 2025-06-17 06:31:46 +02:00
Carlos Valente 1bac3a7fd0 refactor: style tweaks to view styles 2025-06-17 06:31:46 +02:00
Carlos Valente 8281314ede refactor: use ontime button in view 2025-06-17 06:31:46 +02:00
Carlos Valente 3e0b3f100e refactor: create reusable buttons 2025-06-17 06:31:46 +02:00
491 changed files with 10524 additions and 13203 deletions
+8 -1
View File
@@ -103,12 +103,19 @@ IP.ADDRESS:4001/studio > Studio Clock
IP.ADDRESS:4001/timeline > Timeline IP.ADDRESS:4001/timeline > Timeline
``` ```
```
For the public views
-------------------------------------------------------------
IP.ADDRESS:4001/public > Public / Foyer view
IP.ADDRESS:4001/lower > Lower Thirds
```
``` ```
For production views For production views
------------------------------------------------------------- -------------------------------------------------------------
IP.ADDRESS:4001/editor > the control interface, same as the app IP.ADDRESS:4001/editor > the control interface, same as the app
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
IP.ADDRESS:4001/op > automated views for operators IP.ADDRESS:4001/operator > automated views for operators
``` ```
More information is available [in our docs](https://docs.getontime.no) More information is available [in our docs](https://docs.getontime.no)
-2
View File
@@ -15,12 +15,10 @@
], ],
"plugins": [ "plugins": [
"react", "react",
"react-compiler",
"simple-import-sort", "simple-import-sort",
"@tanstack/query" "@tanstack/query"
], ],
"rules": { "rules": {
"react-compiler/react-compiler": "warn",
"react/jsx-no-bind": [ "react/jsx-no-bind": [
"error", "error",
{ {
+3 -3
View File
@@ -4,7 +4,6 @@
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@base-ui-components/react": "1.0.0-beta.1",
"@chakra-ui/react": "^2.7.0", "@chakra-ui/react": "^2.7.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@@ -13,7 +12,7 @@
"@emotion/react": "^11.10.6", "@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6", "@emotion/styled": "^11.10.6",
"@fontsource/open-sans": "^5.0.28", "@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^8.1.2", "@mantine/hooks": "^7.17.2",
"@sentry/react": "^8.43.0", "@sentry/react": "^8.43.0",
"@table-nav/react": "^0.0.7", "@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.62.7", "@tanstack/react-query": "^5.62.7",
@@ -21,6 +20,7 @@
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.9.0", "axios": "^1.9.0",
"color": "^4.2.3",
"csv-stringify": "^6.4.5", "csv-stringify": "^6.4.5",
"framer-motion": "^10.10.0", "framer-motion": "^10.10.0",
"prismjs": "^1.29.0", "prismjs": "^1.29.0",
@@ -66,6 +66,7 @@
"devDependencies": { "devDependencies": {
"@sentry/vite-plugin": "^2.16.1", "@sentry/vite-plugin": "^2.16.1",
"@tanstack/eslint-plugin-query": "^5.8.4", "@tanstack/eslint-plugin-query": "^5.8.4",
"@types/color": "^3.0.3",
"@types/prismjs": "^1.26.5", "@types/prismjs": "^1.26.5",
"@types/react": "^18.0.26", "@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10", "@types/react-dom": "^18.0.10",
@@ -77,7 +78,6 @@
"eslint-plugin-jest": "^28.6.0", "eslint-plugin-jest": "^28.6.0",
"eslint-plugin-prettier": "catalog:", "eslint-plugin-prettier": "catalog:",
"eslint-plugin-react": "^7.32.0", "eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^8.0.0", "eslint-plugin-simple-import-sort": "^8.0.0",
"happy-dom": "^16.7.2", "happy-dom": "^16.7.2",
+2 -2
View File
@@ -18,7 +18,7 @@ import ViewLoader from './views/ViewLoader';
import { ONTIME_VERSION } from './ONTIME_VERSION'; import { ONTIME_VERSION } from './ONTIME_VERSION';
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config'; import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
const Editor = React.lazy(() => import('./views/editor/ProtectedEditor')); const Editor = React.lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet')); const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
const Operator = React.lazy(() => import('./features/operator/OperatorExport')); const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
@@ -30,7 +30,7 @@ const Countdown = React.lazy(() => import('./views/countdown/Countdown'));
const Backstage = React.lazy(() => import('./views/backstage/Backstage')); const Backstage = React.lazy(() => import('./views/backstage/Backstage'));
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage')); const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird')); const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const StudioClock = React.lazy(() => import('./views/studio/Studio')); const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo')); const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
const STimer = withPreset(withData(TimerView)); const STimer = withPreset(withData(TimerView));
@@ -1,5 +0,0 @@
import axios from 'axios';
axios.defaults.validateStatus = (status) => {
return (status >= 200 && status < 300) || status === 304;
};
-1
View File
@@ -8,7 +8,6 @@ export const AUTOMATION = ['automation'];
export const CUSTOM_FIELDS = ['customFields']; export const CUSTOM_FIELDS = ['customFields'];
export const PROJECT_DATA = ['project']; export const PROJECT_DATA = ['project'];
export const PROJECT_LIST = ['projectList']; export const PROJECT_LIST = ['projectList'];
export const PROJECT_RUNDOWNS = ['projectRundowns'];
export const RUNDOWN = ['rundown']; export const RUNDOWN = ['rundown'];
export const RUNTIME = ['runtimeStore']; export const RUNTIME = ['runtimeStore'];
export const URL_PRESETS = ['urlpresets']; export const URL_PRESETS = ['urlpresets'];
+10 -17
View File
@@ -1,45 +1,38 @@
import axios from 'axios'; import axios from 'axios';
import { CustomField, CustomFieldKey } from 'ontime-types'; // Removed CustomFields import { CustomField, CustomFieldKey, CustomFields } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
// Define CustomFieldWithKey for client-side usage
export type CustomFieldWithKey = CustomField & { key: CustomFieldKey };
const customFieldsPath = `${apiEntryUrl}/custom-fields`; const customFieldsPath = `${apiEntryUrl}/custom-fields`;
/** /**
* Requests list of known custom fields, sorted by order * Requests list of known custom fields
*/ */
export async function getCustomFields(): Promise<CustomFieldWithKey[]> { export async function getCustomFields(): Promise<CustomFields> {
const res = await axios.get<CustomFieldWithKey[]>(customFieldsPath); const res = await axios.get(customFieldsPath);
return res.data; return res.data;
} }
/** /**
* Sets list of known custom fields * Sets list of known custom fields
* Returns the updated list, sorted by order
*/ */
export async function postCustomField(newField: CustomField): Promise<CustomFieldWithKey[]> { export async function postCustomField(newField: CustomField): Promise<CustomFields> {
const res = await axios.post<CustomFieldWithKey[]>(customFieldsPath, { ...newField }); const res = await axios.post(customFieldsPath, { ...newField });
return res.data; return res.data;
} }
/** /**
* Edits single custom field * Edits single custom field
* Returns the updated list, sorted by order
*/ */
export async function editCustomField(key: CustomFieldKey, newField: Partial<CustomField>): Promise<CustomFieldWithKey[]> { export async function editCustomField(key: CustomFieldKey, newField: CustomField): Promise<CustomFields> {
// Ensure newField can include 'order' by using Partial<CustomField> const res = await axios.put(`${customFieldsPath}/${key}`, { ...newField });
const res = await axios.put<CustomFieldWithKey[]>(`${customFieldsPath}/${key}`, { ...newField });
return res.data; return res.data;
} }
/** /**
* Deletes single custom field * Deletes single custom field
* Returns the updated list, sorted by order
*/ */
export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFieldWithKey[]> { export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFields> {
const res = await axios.delete<CustomFieldWithKey[]>(`${customFieldsPath}/${key}`); const res = await axios.delete(`${customFieldsPath}/${key}`);
return res.data; return res.data;
} }
+11 -5
View File
@@ -1,15 +1,21 @@
import axios from 'axios'; import axios from 'axios';
import type { ViewSettings } from 'ontime-types'; import { ViewSettings } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
const viewSettingsPath = apiEntryUrl + '/view-settings';
export async function getViewSettings() { const viewSettingsPath = `${apiEntryUrl}/view-settings`;
/**
* HTTP request to retrieve view settings
*/
export async function getView(): Promise<ViewSettings> {
const res = await axios.get(viewSettingsPath); const res = await axios.get(viewSettingsPath);
return res.data; return res.data;
} }
/**
* HTTP request to mutate view settings
*/
export async function postViewSettings(data: ViewSettings) { export async function postViewSettings(data: ViewSettings) {
const res = await axios.post(viewSettingsPath, data); return axios.post(viewSettingsPath, data);
return res.data as ViewSettings;
} }
@@ -1,140 +0,0 @@
.subtle {
background: $gray-1050;
color: $blue-400;
line-height: 1em;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.subtle-white {
background: $gray-1050;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
background: $gray-1050;
}
}
.primary {
background: $blue-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $blue-600;
}
&:active:not(:disabled) {
background: $blue-800;
border-color: $blue-900;
}
&:disabled {
opacity: $viewer-opacity-disabled;
}
}
.destructive {
background: $red-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $red-600;
color: $ui-white;
}
&:active:not(:disabled) {
background: $red-800;
border-color: $red-900;
}
}
.subtle-destructive {
background: $gray-1050;
color: $red-400;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.ghosted {
background: transparent;
color: $blue-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-white {
background: transparent;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-destructive {
background: transparent;
color: $red-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
@@ -1,19 +1,15 @@
@use '../../../theme/viewerDefs' as *; @use '../../../theme/viewerDefs' as *;
@import './BaseButtonStyles.module.scss';
.baseButton { .baseButton {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
gap: 0.5em; gap: 0.5em;
width: fit-content; width: fit-content;
padding-inline: 1em; padding-inline: 1em;
white-space: nowrap;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: $component-border-radius-md; border-radius: 3px;
cursor: pointer; cursor: pointer;
@@ -21,64 +17,45 @@
opacity: 0.4; opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
} }
}
&:focus-visible { .subtle {
outline: 2px solid $blue-500; background: $gray-1050;
outline-offset: 2px; color: $blue-400;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
} }
&.loading { &:active:not(:disabled) {
cursor: wait; background: $gray-1100;
.content { border-color: $gray-1250;
opacity: 0;
}
} }
} }
.content { .primary {
display: flex; background: $blue-700;
align-items: center; color: $ui-white;
gap: inherit;
}
.loadingOverlay { &:hover:not(:disabled):not(:active) {
position: absolute; background: $blue-600;
display: grid;
place-content: center;
}
.spinner {
animation: spin 1s linear infinite;
stroke-dasharray: 8;
}
@keyframes spin {
100% {
transform: rotate(360deg);
} }
}
.small { &:active:not(:disabled) {
height: 1.5rem; background: $blue-800;
font-size: calc(1rem - 3px); border-color: $blue-900;
}
&:disabled {
opacity: $viewer-opacity-disabled;
}
} }
.medium { .medium {
height: 2rem; height: 2rem;
font-size: calc(1rem - 2px);
} }
.large { .large {
height: 2.5rem; height: 3.5rem;
font-weight: 600;
} }
.xlarge {
height: 3rem;
font-weight: 600;
}
.fluid {
width: 100%;
}
@@ -1,53 +1,20 @@
import { ButtonHTMLAttributes, forwardRef } from 'react'; import { ButtonHTMLAttributes } from 'react';
import { IoEllipseOutline } from 'react-icons/io5';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import style from './Button.module.scss'; import style from './Button.module.scss';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: variant?: 'subtle' | 'primary';
| 'primary' size?: 'medium' | 'large';
| 'subtle'
| 'subtle-white'
| 'destructive'
| 'subtle-destructive'
| 'ghosted'
| 'ghosted-white'
| 'ghosted-destructive';
size?: 'small' | 'medium' | 'large' | 'xlarge';
fluid?: boolean;
loading?: boolean;
} }
const Button = forwardRef<HTMLButtonElement, ButtonProps>( export default function Button(props: ButtonProps) {
({ className, children, variant = 'subtle', size = 'medium', fluid, loading, ...buttonProps }, ref) => { const { className, children, variant = 'subtle', size = 'medium', ...buttonProps } = props;
return (
<button
ref={ref}
className={cx([
style.baseButton,
style[variant],
style[size],
fluid && style.fluid,
loading && style.loading,
className,
])}
type='button'
disabled={loading || buttonProps.disabled}
{...buttonProps}
>
<span className={style.content}>{children}</span>
{loading && (
<div className={style.loadingOverlay}>
<IoEllipseOutline className={style.spinner} />
</div>
)}
</button>
);
},
);
Button.displayName = 'Button'; return (
<button className={cx([style.baseButton, style[variant], style[size], className])} type='button' {...buttonProps}>
export default Button; {children}
</button>
);
}
@@ -1,39 +1,67 @@
@use '../../../theme/viewerDefs' as *; @use '../../../theme/viewerDefs' as *;
@import './BaseButtonStyles.module.scss';
.baseIconButton { .baseIconButton {
aspect-ratio: 1; aspect-ratio: 1;
height: 2rem;
width: 2rem;
display: grid; display: grid;
place-content: center; place-content: center;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: $component-border-radius-md; border-radius: 3px;
cursor: pointer; cursor: pointer;
}
.subtle {
background: $gray-1050;
color: $blue-400;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled){
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled { &:disabled {
opacity: 0.4; background: $gray-1050;
cursor: not-allowed;
} }
} }
.small { .subtle-white {
height: 1.5rem; background: $gray-1050;
font-size: calc(1rem - 3px); color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
background: $gray-1050;
}
} }
.medium { .destructive {
height: 2rem; background: $red-700;
width: 2rem; color: $ui-white;
}
.large { &:hover:not(:disabled):not(:active) {
height: 2.5rem; background: $red-600;
width: 2.5rem; color: $ui-white;
} }
.xlarge { &:active:not(:disabled) {
height: 3rem; background: $red-800;
height: 3rem; border-color: $red-900;
font-size: 1.5rem; }
} }
@@ -5,31 +5,14 @@ import { cx } from '../../utils/styleUtils';
import style from './IconButton.module.scss'; import style from './IconButton.module.scss';
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: variant?: 'subtle' | 'subtle-white' | 'destructive';
| 'primary'
| 'subtle'
| 'subtle-white'
| 'destructive'
| 'subtle-destructive'
| 'ghosted'
| 'ghosted-white'
| 'ghosted-destructive';
size?: 'small' | 'medium' | 'large' | 'xlarge';
} }
export default function IconButton({ export default function IconButton(props: IconButtonProps) {
className, const { className, children, variant = 'subtle', ...buttonProps } = props;
children,
variant = 'subtle',
size = 'medium',
...buttonProps
}: IconButtonProps) {
return ( return (
<button <button className={cx([style.baseIconButton, style[variant], className])} type='button' {...buttonProps}>
className={cx([style.baseIconButton, style[variant], style[size], className])}
type='button'
{...buttonProps}
>
{children} {children}
</button> </button>
); );
@@ -1,50 +0,0 @@
.checkbox {
box-sizing: border-box;
display: flex;
width: 1rem;
height: 1rem;
align-items: center;
justify-content: center;
border-radius: $component-border-radius-sm;
&[data-unchecked] {
background-color: $gray-1100;
&:hover:not(:disabled) {
background: $gray-1000;
}
&:active:not(:disabled) {
background: $gray-1200;
}
}
&[data-checked] {
background-color: $blue-500;
&:hover:not(:disabled) {
background: $blue-400;
}
&:active:not(:disabled) {
background: $blue-600;
}
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: 2px;
}
}
.indicator {
display: flex;
color: $ui-white;
&[data-unchecked] {
display: none;
}
}
@@ -1,14 +0,0 @@
import { IoCheckmark } from 'react-icons/io5';
import { Checkbox as BaseCheckbox } from '@base-ui-components/react/checkbox';
import style from './Checkbox.module.scss';
export default function Checkbox(checkboxProps: BaseCheckbox.Root.Props) {
return (
<BaseCheckbox.Root className={style.checkbox} {...checkboxProps}>
<BaseCheckbox.Indicator className={style.indicator}>
<IoCheckmark className={style.icon} />
</BaseCheckbox.Indicator>
</BaseCheckbox.Root>
);
}
@@ -1,15 +1,22 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoArrowForward } from 'react-icons/io5'; import { IoArrowForward } from 'react-icons/io5';
import {
IconButton,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
Select,
} from '@chakra-ui/react';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import { setClientRemote } from '../../hooks/useSocket'; import { setClientRemote } from '../../hooks/useSocket';
import useUrlPresets from '../../hooks-query/useUrlPresets'; import useUrlPresets from '../../hooks-query/useUrlPresets';
import Button from '../buttons/Button';
import Info from '../info/Info'; import Info from '../info/Info';
import Input from '../input/input/Input';
import AppLink from '../link/app-link/AppLink'; import AppLink from '../link/app-link/AppLink';
import Modal from '../modal/Modal';
import Select from '../select/Select';
import style from './RedirectClientModal.module.scss'; import style from './RedirectClientModal.module.scss';
@@ -22,7 +29,8 @@ interface RedirectClientModalProps {
onClose: () => void; onClose: () => void;
} }
export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onClose }: RedirectClientModalProps) { export function RedirectClientModal(props: RedirectClientModalProps) {
const { id, isOpen, name, currentPath, origin, onClose } = props;
const { data } = useUrlPresets(); const { data } = useUrlPresets();
const [path, setPath] = useState(currentPath); const [path, setPath] = useState(currentPath);
const [selected, setSelected] = useState('/'); const [selected, setSelected] = useState('/');
@@ -39,26 +47,13 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
const enabledPresets = data.filter((preset) => preset.enabled); const enabledPresets = data.filter((preset) => preset.enabled);
const viewOptions = [
...navigatorConstants.map((view) => ({
value: `/${view.url}`,
label: view.label,
})),
...enabledPresets.map((preset) => ({
value: preset.pathAndParams,
label: `Preset: ${preset.alias}`,
})),
];
return ( return (
<Modal <Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
isOpen={isOpen} <ModalOverlay />
onClose={onClose} <ModalContent maxWidth='max(480px, 35vw)'>
showCloseButton <ModalHeader>Redirect: {name}</ModalHeader>
showBackdrop <ModalCloseButton />
title={`Redirect: ${name}`} <ModalBody>
bodyElements={
<>
<Info> <Info>
Remotely redirect the client to a different URL. <br /> Remotely redirect the client to a different URL. <br />
Either by selecting a URL Preset or entering a custom path. Either by selecting a URL Preset or entering a custom path.
@@ -70,21 +65,36 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
<span className={style.label}>Select View or URL Preset</span> <span className={style.label}>Select View or URL Preset</span>
<div className={style.textEntry}> <div className={style.textEntry}>
<Select <Select
fluid size='md'
options={viewOptions} variant='ontime'
defaultValue={viewOptions[0].value} isDisabled={enabledPresets.length === 0}
onValueChange={(value) => setSelected(value)} onChange={(event) => setSelected(event.target.value)}
disabled={enabledPresets.length === 0} >
/> <option value='/'>Select view or preset</option>
<Button {navigatorConstants.map((view) => {
variant='primary' return (
<option key={view.url} value={`/${view.url}`}>
{view.label}
</option>
);
})}
{enabledPresets.map((preset) => {
return (
<option key={preset.pathAndParams} value={preset.pathAndParams}>
{`Preset: ${preset.alias}`}
</option>
);
})}
</Select>
<IconButton
variant='ontime-filled'
size='md'
aria-label='Redirect to preset' aria-label='Redirect to preset'
className={style.redirect} className={style.redirect}
disabled={enabledPresets.length === 0 || selected === '/'} icon={<IoArrowForward />}
isDisabled={enabledPresets.length === 0 || selected === '/'}
onClick={() => handleRedirect(selected)} onClick={() => handleRedirect(selected)}
> />
Redirect <IoArrowForward />
</Button>
</div> </div>
</div> </div>
<div className={style.inlineEntry}> <div className={style.inlineEntry}>
@@ -92,24 +102,25 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
<label className={style.textEntry}> <label className={style.textEntry}>
{origin} {origin}
<Input <Input
variant='ontime-filled'
size='md'
placeholder='eg. /minimal?key=0000ffff' placeholder='eg. /minimal?key=0000ffff'
fluid
value={path} value={path}
onChange={(event) => setPath(event.target.value)} onChange={(event) => setPath(event.target.value)}
/> />
</label> </label>
<Button <IconButton
variant='primary' variant='ontime-filled'
size='md'
aria-label='Redirect' aria-label='Redirect'
disabled={path === currentPath || path === ''} isDisabled={path === currentPath || path === ''}
className={style.redirect} className={style.redirect}
icon={<IoArrowForward />}
onClick={() => handleRedirect(path)} onClick={() => handleRedirect(path)}
> />
Redirect <IoArrowForward />
</Button>
</div> </div>
</> </ModalBody>
} </ModalContent>
/> </Modal>
); );
} }
@@ -1,9 +1,17 @@
import { useState } from 'react'; import { useState } from 'react';
import {
Button,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react';
import Button from '../../../common/components/buttons/Button';
import { setClientRemote } from '../../hooks/useSocket'; import { setClientRemote } from '../../hooks/useSocket';
import Dialog from '../dialog/Dialog';
import Input from '../input/input/Input';
interface RenameClientModalProps { interface RenameClientModalProps {
id: string; id: string;
@@ -12,7 +20,8 @@ interface RenameClientModalProps {
onClose: () => void; onClose: () => void;
} }
export function RenameClientModal({ id, name: currentName = '', isOpen, onClose }: RenameClientModalProps) { export function RenameClientModal(props: RenameClientModalProps) {
const { id, name: currentName = '', isOpen, onClose } = props;
const [name, setName] = useState(currentName); const [name, setName] = useState(currentName);
const { setClientName } = setClientRemote; const { setClientName } = setClientRemote;
@@ -27,24 +36,29 @@ export function RenameClientModal({ id, name: currentName = '', isOpen, onClose
const canSubmit = name !== currentName && name !== ''; const canSubmit = name !== currentName && name !== '';
return ( return (
<Dialog <Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
isOpen={isOpen} <ModalOverlay />
title={`Rename Client: ${currentName}`} <ModalContent>
showCloseButton <ModalHeader>Rename: {currentName}</ModalHeader>
onClose={onClose} <ModalCloseButton />
bodyElements={ <ModalBody>
<Input height='large' placeholder='New name' value={name} onChange={(event) => setName(event.target.value)} /> <Input
} variant='ontime-filled'
footerElements={ size='md'
<> placeholder='new name'
<Button variant='subtle' size='large' onClick={onClose}> value={name}
onChange={(event) => setName(event.target.value)}
/>
</ModalBody>
<ModalFooter>
<Button size='md' variant='ontime-subtle' onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button variant='primary' size='large' onClick={handleRename} disabled={!canSubmit}> <Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}>
Submit Submit
</Button> </Button>
</> </ModalFooter>
} </ModalContent>
/> </Modal>
); );
} }
@@ -1,57 +0,0 @@
.dialog {
position: fixed;
top: 10%;
left: 50%;
z-index: $zindex-dialog;
transform: translateX(-50%);
padding-inline: 1rem;
min-width: min(420px, 90vw);
background-color: $gray-1250;
color: $ui-white;
border-radius: 3px;
box-shadow: $box-shadow-l1;
border: 1px solid $gray-1100;
}
.backdrop {
position: fixed;
inset: 0;
z-index: $zindex-backdrop;
background-color: $backdrop-color;
transition: opacity 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
&[data-starting-style],
&[data-ending-style] {
opacity: 0;
}
}
.title {
font-size: 1rem;
font-weight: 600;
padding-block: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.body {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: min(80vh, 600px);
overflow-y: auto;
}
.footer {
padding-block: 1rem;
display: flex;
align-items: center;
justify-content: end;
gap: 1rem;
}
@@ -1,52 +0,0 @@
import type { ReactNode } from 'react';
import { IoClose } from 'react-icons/io5';
import { Dialog as BaseDialog } from '@base-ui-components/react/dialog';
import IconButton from '../buttons/IconButton';
import style from './Dialog.module.scss';
interface DialogProps {
isOpen: boolean;
title: string;
showCloseButton?: boolean;
showBackdrop?: boolean;
bodyElements: ReactNode;
footerElements: ReactNode;
onClose: () => void;
}
export default function Dialog({
isOpen,
title,
showCloseButton,
showBackdrop,
bodyElements,
footerElements,
onClose,
}: DialogProps) {
return (
<BaseDialog.Root
open={isOpen}
onOpenChange={(isOpen) => {
if (!isOpen) onClose();
}}
>
<BaseDialog.Portal>
{showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />}
<BaseDialog.Popup className={style.dialog}>
<div className={style.title}>
{title}
{showCloseButton && (
<IconButton variant='subtle-white' onClick={onClose}>
<IoClose />
</IconButton>
)}
</div>
<div className={style.body}>{bodyElements}</div>
<div className={style.footer}>{footerElements}</div>
</BaseDialog.Popup>
</BaseDialog.Portal>
</BaseDialog.Root>
);
}
@@ -1,6 +0,0 @@
import { IconBaseProps } from 'react-icons';
import { IoLink } from 'react-icons/io5';
export default function RotatedLink(linkProps: IconBaseProps) {
return <IoLink style={{ transform: 'rotate(-45deg)' }} {...linkProps} />;
}
@@ -15,7 +15,7 @@
display: grid; display: grid;
place-content: center; place-content: center;
text-align: center; text-align: center;
z-index: $zindex-modal; z-index: 100;
cursor: pointer; cursor: pointer;
} }
@@ -7,29 +7,15 @@
border-radius: 3px; border-radius: 3px;
font-size: $text-body-size; font-size: $text-body-size;
padding: 1rem; padding: 1rem;
color: $gray-200;
.content {
color: $gray-200;
}
svg { svg {
min-width: 1.5rem; min-width: 1.5rem;
align-self: start; align-self: start;
font-size: 1.5rem; font-size: 1.5rem;
}
}
.info {
svg {
color: $info-blue; color: $info-blue;
} }
} }
.warning {
svg {
color: $orange-500;
}
}
.error {
svg {
color: $red-500;
}
}
@@ -1,5 +1,5 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { IoAlertCircle, IoWarning } from 'react-icons/io5'; import { IoAlertCircle } from 'react-icons/io5';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -7,15 +7,14 @@ import style from './Info.module.scss';
interface InfoProps { interface InfoProps {
className?: string; className?: string;
type?: 'info' | 'warning' | 'error';
} }
export default function Info({ className, type = 'info', children }: PropsWithChildren<InfoProps>) { export default function Info(props: PropsWithChildren<InfoProps>) {
const { className, children } = props;
return ( return (
<div className={cx([style.infoLabel, style[type], className])}> <div className={cx([style.infoLabel, className])}>
{type === 'info' && <IoAlertCircle />} <IoAlertCircle />
{type === 'warning' && <IoWarning />}
{type === 'error' && <IoWarning />}
<div>{children}</div> <div>{children}</div>
</div> </div>
); );
@@ -9,7 +9,7 @@ $input-font-size: 15px;
.inputField { .inputField {
font-size: $input-font-size; font-size: $input-font-size;
letter-spacing: 0.5px; letter-spacing: 1px;
max-width: 7em; max-width: 7em;
padding-left: 16px; padding-left: 16px;
color: $ontime-delay-text color: $ontime-delay-text
@@ -1,52 +0,0 @@
.input {
box-sizing: border-box;
font-size: 1rem;
font-weight: 400;
color: $gray-200;
border-radius: $component-border-radius-md;
border: 1px solid transparent;
padding-inline: 0.5em;
outline: none;
&:hover:not(:disabled) {
background-color: $gray-1100;
}
&:focus:not(:read-only) {
background-color: $gray-1000;
border: 1px solid $blue-500;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
&::placeholder {
color: $gray-500;
letter-spacing: 0;
}
}
.subtle {
background-color: $gray-1200;
}
.ghosted {
background-color: transparent;
padding: 0;
}
.medium {
height: 2rem;
}
.large {
height: 2.5rem;
}
.fluid {
width: 100%;
}
@@ -1,30 +0,0 @@
import { forwardRef, InputHTMLAttributes } from 'react';
import { cx } from '../../../utils/styleUtils';
import style from './Input.module.scss';
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
variant?: 'subtle' | 'ghosted';
height?: 'medium' | 'large';
fluid?: boolean;
}
const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ className, variant = 'subtle', height = 'medium', fluid, ...inputProps },
ref,
) {
return (
<input
ref={ref}
type='text'
autoCorrect='off'
autoComplete='off'
spellCheck='false'
className={cx([style.input, style[variant], style[height], fluid && style.fluid, className])}
{...inputProps}
/>
);
});
export default Input;
@@ -1,4 +1,4 @@
import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useState } from 'react';
import { getHotkeyHandler, HotkeyItem } from '@mantine/hooks'; import { getHotkeyHandler, HotkeyItem } from '@mantine/hooks';
interface UseReactiveTextInputReturn { interface UseReactiveTextInputReturn {
@@ -21,8 +21,6 @@ export default function useReactiveTextInput(
}, },
): UseReactiveTextInputReturn { ): UseReactiveTextInputReturn {
const [text, setText] = useState<string>(initialText); const [text, setText] = useState<string>(initialText);
// track whether we are submitting via a submit key (eg enter) and avoid submitting again on blur
const isKeyboardSubmitting = useRef(false);
useEffect(() => { useEffect(() => {
if (typeof initialText === 'undefined') { if (typeof initialText === 'undefined') {
@@ -101,25 +99,11 @@ export default function useReactiveTextInput(
]; ];
if (options?.submitOnEnter) { if (options?.submitOnEnter) {
hotKeys.push(['Enter', () => { hotKeys.push(['Enter', () => handleSubmit(text)]);
isKeyboardSubmitting.current = true;
handleSubmit(text);
// clear flag after blur has been processed
setTimeout(() => {
isKeyboardSubmitting.current = false;
}, 0);
}]);
} }
if (options?.submitOnCtrlEnter) { if (options?.submitOnCtrlEnter) {
hotKeys.push(['mod + Enter', () => { hotKeys.push(['mod + Enter', () => handleSubmit(text)]);
isKeyboardSubmitting.current = true;
handleSubmit(text);
// clear flag after blur has been processed
setTimeout(() => {
isKeyboardSubmitting.current = false;
}, 0);
}]);
} }
const hotKeyHandler = getHotkeyHandler(hotKeys); const hotKeyHandler = getHotkeyHandler(hotKeys);
@@ -142,11 +126,7 @@ export default function useReactiveTextInput(
return { return {
value: text, value: text,
onChange: (event: ChangeEvent) => handleChange((event.target as HTMLInputElement).value), onChange: (event: ChangeEvent) => handleChange((event.target as HTMLInputElement).value),
onBlur: (event: ChangeEvent) => { onBlur: (event: ChangeEvent) => handleSubmit((event.target as HTMLInputElement).value),
if (!isKeyboardSubmitting.current) {
handleSubmit((event.target as HTMLInputElement).value);
}
},
onKeyDown: keyHandler, onKeyDown: keyHandler,
}; };
} }
@@ -1,47 +0,0 @@
@use '../../../../theme/viewerDefs' as *;
.textarea {
box-sizing: border-box;
display: block;
font-size: 1rem;
font-weight: 400;
color: $gray-200;
border-radius: $component-border-radius-md;
border: 1px solid transparent;
padding-inline: 0.5em;
outline: none;
&:hover:not(:disabled) {
background-color: $gray-1100;
}
&:focus:not(:read-only) {
background-color: $gray-1000;
border: 1px solid $blue-500;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
&::placeholder {
color: $gray-500;
letter-spacing: 0;
}
}
.subtle {
background-color: $gray-1200;
}
.ghosted {
background-color: transparent;
padding: 0;
}
.fluid {
width: 100%;
}
@@ -1,31 +0,0 @@
import { forwardRef, TextareaHTMLAttributes } from 'react';
import { cx } from '../../../utils/styleUtils';
import style from './Textarea.module.scss';
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
variant?: 'subtle' | 'ghosted';
fluid?: boolean;
resize?: 'none' | 'both' | 'horizontal' | 'vertical';
}
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function TextArea(
{ className, variant = 'subtle', fluid, rows = 5, resize = 'none', style: customStyle, ...textareaProps },
ref,
) {
return (
<textarea
ref={ref}
autoCorrect='off'
autoComplete='off'
spellCheck='false'
rows={rows}
style={{ ...customStyle, resize }}
className={cx([style.textarea, style[variant], fluid && style.fluid, className])}
{...textareaProps}
/>
);
});
export default Textarea;
@@ -1,135 +0,0 @@
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { millisToString, parseUserTime } from 'ontime-utils';
import { cx } from '../../../utils/styleUtils';
import Input from '../input/Input';
import style from './TimeInput.module.scss';
interface NullableTimeInputProps<T extends string> {
id?: T;
name: T;
submitHandler: (field: T, value: string) => void;
time?: number | null;
emptyDisplay: string;
placeholder?: string;
disabled?: boolean;
align?: 'left' | 'center';
className?: string;
}
export default function NullableTimeInput<T extends string>(props: NullableTimeInputProps<T>) {
const { id, name, submitHandler, time, emptyDisplay, placeholder, disabled, align = 'center', className } = props;
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>('');
const ignoreChange = useRef(false);
/**
* @description Resets input value to given
*/
const resetValue = useCallback(() => {
if (typeof time !== 'number' || isNaN(time)) {
setValue(emptyDisplay);
} else {
setValue(millisToString(time));
}
}, [emptyDisplay, time]);
/**
* @description Selects input text on focus
*/
const handleFocus = useCallback(() => {
inputRef.current?.select();
}, []);
/**
* @description Submit handler
* @param {string} newValue
*/
const handleSubmit = useCallback(
(newValue: string) => {
// skip if user deleted and time is already null
if (newValue === '' && time === null) {
return false;
}
// skip if the value evaluates to the same thing
const valueInMillis = parseUserTime(newValue);
if (valueInMillis === time) {
return false;
}
submitHandler(name, newValue);
return true;
},
[name, submitHandler, time],
);
/**
* @description Prepare time fields
* @param {string} value string to be parsed
*/
const validateAndSubmit = useCallback(
(newValue: string) => {
const success = handleSubmit(newValue);
if (!success) {
resetValue();
}
},
[handleSubmit, resetValue],
);
/**
* @description Handles common keys for submit and cancel
* @param {KeyboardEvent} event
*/
const onKeyDownHandler = useCallback(
(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
inputRef.current?.blur();
}
if (event.key === 'Escape') {
ignoreChange.current = true;
inputRef.current?.blur();
resetValue();
}
},
[resetValue],
);
const onBlurHandler = useCallback(
(event: FocusEvent<HTMLInputElement>) => {
if (ignoreChange.current) {
ignoreChange.current = false;
return;
}
validateAndSubmit((event.target as HTMLInputElement).value);
},
[validateAndSubmit],
);
useEffect(() => {
resetValue();
}, [resetValue]);
return (
<Input
id={id}
disabled={disabled}
ref={inputRef}
data-testid={`time-input-${name}`}
className={cx([style.timeInput, className])}
placeholder={placeholder}
onFocus={handleFocus}
onChange={(event) => setValue(event.target.value)}
onBlur={onBlurHandler}
onKeyDown={onKeyDownHandler}
value={value}
maxLength={8}
autoComplete='off'
style={{
textAlign: align,
}}
/>
);
}
@@ -1,6 +1,4 @@
.timeInput { .timeInput {
width: 100%; letter-spacing: 1px;
max-width: 7.5em; width: 6.5em;
letter-spacing: 0.5px;
font-variant-numeric: tabular-nums;
} }
@@ -1,24 +1,23 @@
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react'; import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { Input } from '@chakra-ui/react';
import { millisToString, parseUserTime } from 'ontime-utils'; import { millisToString, parseUserTime } from 'ontime-utils';
import { cx } from '../../../utils/styleUtils'; import { useEmitLog } from '../../../stores/logger';
import Input from '../input/Input';
import style from './TimeInput.module.scss';
interface TimeInputProps<T extends string> { interface TimeInputProps<T extends string> {
id?: T; id?: T;
name: T; name: T;
submitHandler: (field: T, value: string) => void; submitHandler: (field: T, value: string) => void;
time?: number; time?: number;
placeholder?: string; placeholder: string;
disabled?: boolean; disabled?: boolean;
align?: 'left' | 'center'; align?: 'left' | 'center';
className?: string; className?: string;
} }
export default function TimeInput<T extends string>(props: TimeInputProps<T>) { export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
const { id, name, submitHandler, time, placeholder, disabled, align = 'center', className } = props; const { id, name, submitHandler, time = 0, placeholder, disabled, align = 'center', className } = props;
const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>(''); const [value, setValue] = useState<string>('');
const ignoreChange = useRef(false); const ignoreChange = useRef(false);
@@ -27,12 +26,13 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
* @description Resets input value to given * @description Resets input value to given
*/ */
const resetValue = useCallback(() => { const resetValue = useCallback(() => {
if (typeof time !== 'number' || isNaN(time)) { try {
setValue('00:00:00');
} else {
setValue(millisToString(time)); setValue(millisToString(time));
} catch (error) {
setValue(millisToString(0));
emitError(`Unable to parse time ${time}: ${error}`);
} }
}, [time]); }, [emitError, time]);
/** /**
* @description Selects input text on focus * @description Selects input text on focus
@@ -113,27 +113,32 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
); );
useEffect(() => { useEffect(() => {
if (time == null) return;
resetValue(); resetValue();
}, [resetValue]); }, [resetValue, time]);
return ( return (
<Input <Input
id={id} id={id}
disabled={disabled} disabled={disabled}
size='sm'
ref={inputRef} ref={inputRef}
data-testid={`time-input-${name}`} data-testid={`time-input-${name}`}
className={cx([style.timeInput, className])} className={className}
fontSize='1rem'
type='text'
placeholder={placeholder} placeholder={placeholder}
variant='ontime-filled'
onFocus={handleFocus} onFocus={handleFocus}
onChange={(event) => setValue(event.target.value)} onChange={(event) => setValue(event.target.value)}
onBlur={onBlurHandler} onBlur={onBlurHandler}
onKeyDown={onKeyDownHandler} onKeyDown={onKeyDownHandler}
value={value} value={value}
maxLength={8} maxLength={8}
maxWidth='7.5em'
letterSpacing='1px'
autoComplete='off' autoComplete='off'
style={{ textAlign={align}
textAlign: align,
}}
/> />
); );
} }
@@ -1,56 +0,0 @@
.modal {
position: fixed;
top: 10vh;
left: 50%;
transform: translateX(-50%);
padding-inline: 1rem;
min-width: min(680px, 90vw);
min-height: min(200px, 10vh);
max-width: min(680px, 90vw);
background-color: $gray-1250;
color: $ui-white;
border-radius: 3px;
box-shadow: $box-shadow-l1;
border: 1px solid $gray-1100;
}
.backdrop {
position: fixed;
inset: 0;
background-color: $backdrop-color;
transition: opacity 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
&[data-starting-style],
&[data-ending-style] {
opacity: 0;
}
}
.title {
font-size: 1rem;
font-weight: 600;
padding-block: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.body {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 60vh;
overflow-y: auto;
}
.footer {
padding-block: 1rem;
display: flex;
align-items: center;
justify-content: end;
gap: 1rem;
}
@@ -1,53 +0,0 @@
import type { ReactNode } from 'react';
import { IoClose } from 'react-icons/io5';
import { Dialog as BaseDialog } from '@base-ui-components/react/dialog';
import IconButton from '../buttons/IconButton';
import style from './Modal.module.scss';
interface ModalProps {
isOpen: boolean;
title?: string;
showCloseButton?: boolean;
showBackdrop?: boolean;
bodyElements: ReactNode;
footerElements?: ReactNode;
onClose: () => void;
}
export default function Modal({
isOpen,
title,
showCloseButton,
showBackdrop,
bodyElements,
footerElements,
onClose,
}: ModalProps) {
return (
<BaseDialog.Root
open={isOpen}
onOpenChange={(isOpen) => {
if (!isOpen) onClose();
}}
dismissible={false}
>
<BaseDialog.Portal>
{showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />}
<BaseDialog.Popup className={style.modal}>
<div className={style.title}>
{title}
{showCloseButton && (
<IconButton variant='subtle-white' onClick={onClose}>
<IoClose />
</IconButton>
)}
</div>
<div className={style.body}>{bodyElements}</div>
<div className={style.footer}>{footerElements}</div>
</BaseDialog.Popup>
</BaseDialog.Portal>
</BaseDialog.Root>
);
}
@@ -0,0 +1,41 @@
import { IoApps } from 'react-icons/io5';
import { IoSettingsOutline } from 'react-icons/io5';
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
import { cx } from '../../utils/styleUtils';
import IconButton from '../buttons/IconButton';
import style from './NavigationMenu.module.scss';
interface FloatingNavigationProps {
toggleMenu: () => void;
toggleSettings: () => void;
}
export default function FloatingNavigation(props: FloatingNavigationProps) {
const { toggleMenu, toggleSettings } = props;
const isButtonShown = useFadeOutOnInactivity();
return (
<div className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}>
<IconButton
variant='subtle-white'
className={style.navButton}
onClick={toggleMenu}
aria-label='toggle menu'
data-testid='navigation__toggle-menu'
>
<IoApps />
</IconButton>
<IconButton
variant='subtle-white'
className={style.navButton}
onClick={toggleSettings}
aria-label='toggle settings'
data-testid='navigation__toggle-settings'
>
<IoSettingsOutline />
</IconButton>
</div>
);
}
@@ -1,68 +1,107 @@
@use '../../../theme/mixins' as *;
$menu-hover-bg: $gray-1350;
$menu-focus-bg: $gray-1300;
$button-size: 3rem;
.fadeable {
opacity: 1;
transition-property: opacity;
transition-duration: 0.3s;
&.hidden {
opacity: 0;
pointer-events: none;
}
}
.lockIcon {
font-size: 1.5rem;
color: $active-indicator;
padding: 0.5em;
position: fixed;
left: 0;
top: 0;
z-index: 12;
}
.buttonContainer {
display: flex;
flex-direction: column;
row-gap: 1rem;
padding: 0.5em;
position: fixed;
left: 0;
top: 0;
z-index: 12;
}
.navButton {
font-size: 1.5rem;
height: $button-size;
width: $button-size;;
}
.link {
@include action-link;
padding: 0.75rem 1.5rem;
gap: 0.5rem;
width: 100%;
cursor: pointer;
&:hover {
background-color: $menu-hover-bg;
}
&:active {
background-color: $border-color-ondark;
}
&:focus {
outline: none;
background-color: $menu-focus-bg;
border-left: 2px solid $action-text-color;
}
&.current {
background-color: $menu-hover-bg;
border-left: 4px solid $action-text-color;
}
}
.linkIcon {
margin-left: auto;
@include rotate-fourty-five;
}
.separator { .separator {
border-color: $border-color-ondark; border-color: $border-color-ondark;
} }
.backdrop { .sectionHeader {
position: fixed; font-size: calc(1rem - 2px);
inset: 0; margin-left: 1rem;
z-index: $zindex-backdrop; color: $gray-700;
background-color: $backdrop-color;
transition: opacity 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
&[data-starting-style],
&[data-ending-style] {
opacity: 0;
}
} }
.drawer { .bottom {
box-sizing: border-box; margin-top: auto;
position: fixed; margin-bottom: 1rem;
top: 0; }
left: 0;
bottom: 0;
z-index: $zindex-dialog;
width: 22rem;
height: 100vh;
.interfaces {
padding: 0.5rem 1rem;
display: flex; display: flex;
flex-direction: column; flex-wrap: wrap;
padding-block: 1rem; gap: 0.5rem;
background-color: $gray-1250;
color: $ui-white;
border-right: 1px solid $gray-1100;
&[data-open] {
transform: translateX(0%);
transition: transform 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
}
&[data-starting-style],
&[data-ending-style] {
transform: translateX(-100%);
transition: transform 500ms cubic-bezier(0.45, 1.005, 0, 1.005);
}
// take the whole screen in mobile devices
@media (max-width: $min-tablet) {
width: 100vw;
}
} }
.header { .goIcon {
display: flex; @include rotate-fourty-five;
align-items: center; margin-left: 0.25rem;
justify-content: space-between; margin-bottom: 0.25rem;
height: 3.5rem;
padding-inline: 1.5rem;
font-weight: 600;
font-size: 1.25rem;
}
.body {
flex: 1;
overflow-y: auto;
} }
@@ -1,20 +1,29 @@
import { memo } from 'react'; import { memo, PropsWithChildren } from 'react';
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5'; import { createPortal } from 'react-dom';
import { useLocation } from 'react-router-dom'; import { IoArrowUp, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
import { Dialog } from '@base-ui-components/react/dialog'; import { Link, useLocation } from 'react-router-dom';
import { useDisclosure, useFullscreen } from '@mantine/hooks'; import {
Drawer,
DrawerBody,
DrawerCloseButton,
DrawerContent,
DrawerHeader,
DrawerOverlay,
useDisclosure,
} from '@chakra-ui/react';
import { useFullscreen } from '@mantine/hooks';
import { isLocalhost } from '../../../externals'; import { isLocalhost } from '../../../externals';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import { useElectronEvent } from '../../hooks/useElectronEvent';
import useInfo from '../../hooks-query/useInfo';
import { useClientStore } from '../../stores/clientStore'; import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
import IconButton from '../buttons/IconButton'; import { isKeyEnter } from '../../utils/keyEvent';
import { handleLinks, linkToOtherHost, openLink } from '../../utils/linkUtils';
import { cx } from '../../utils/styleUtils';
import { RenameClientModal } from '../client-modal/RenameClientModal'; import { RenameClientModal } from '../client-modal/RenameClientModal';
import CopyTag from '../copy-tag/CopyTag';
import ClientLink from './client-link/ClientLink';
import EditorNavigation from './editor-navigation/EditorNavigation';
import NavigationMenuItem from './navigation-menu-item/NavigationMenuItem';
import OtherAddresses from './other-addresses/OtherAddresses';
import style from './NavigationMenu.module.scss'; import style from './NavigationMenu.module.scss';
@@ -23,49 +32,74 @@ interface NavigationMenuProps {
onClose: () => void; onClose: () => void;
} }
export default memo(NavigationMenu); function NavigationMenu(props: NavigationMenuProps) {
function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) { const { isOpen, onClose } = props;
const id = useClientStore((store) => store.id); const id = useClientStore((store) => store.id);
const name = useClientStore((store) => store.name); const name = useClientStore((store) => store.name);
const [isRenameOpen, handlers] = useDisclosure(false); const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
const { fullscreen, toggle } = useFullscreen(); const { fullscreen, toggle } = useFullscreen();
const { mirror, toggleMirror } = useViewOptionsStore(); const { mirror, toggleMirror } = useViewOptionsStore();
const location = useLocation(); const location = useLocation();
return ( return createPortal(
<Dialog.Root <div id='navigation-menu-portal'>
open={isOpen} <RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
onOpenChange={(open) => { <Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
if (!open) { <DrawerOverlay />
onClose(); <DrawerContent maxWidth='22rem'>
} <DrawerHeader>
}} <DrawerCloseButton size='lg' />
> Ontime
<Dialog.Portal> </DrawerHeader>
<Dialog.Backdrop className={style.backdrop} /> <DrawerBody padding={0}>
<RenameClientModal id={id} name={name} isOpen={isRenameOpen} onClose={handlers.close} /> <div className={style.buttonsContainer}>
<Dialog.Popup className={style.drawer}> <div
<div className={style.header}> className={cx([style.link, fullscreen && style.current])}
<Dialog.Title>Ontime</Dialog.Title> tabIndex={0}
<IconButton variant='subtle-white' size='large' onClick={onClose}> role='button'
<IoClose /> onClick={toggle}
</IconButton> onKeyDown={(event) => {
</div> isKeyEnter(event) && toggle();
<div className={style.body}> }}
<NavigationMenuItem active={fullscreen} onClick={toggle}> >
Toggle Fullscreen Toggle Fullscreen
{fullscreen ? <IoContract /> : <IoExpand />} {fullscreen ? <IoContract /> : <IoExpand />}
</NavigationMenuItem> </div>
<NavigationMenuItem active={mirror} onClick={toggleMirror}> <div
Flip Screen className={cx([style.link, mirror && style.current])}
<IoSwapVertical /> tabIndex={0}
</NavigationMenuItem> role='button'
<NavigationMenuItem onClick={handlers.open}>Rename Client</NavigationMenuItem> onClick={() => toggleMirror()}
onKeyDown={(event) => {
isKeyEnter(event) && toggleMirror();
}}
>
Flip Screen
<IoSwapVertical />
</div>
<div
className={style.link}
tabIndex={0}
role='button'
onClick={onRenameOpen}
onKeyDown={(event) => {
isKeyEnter(event) && onRenameOpen();
}}
>
Rename Client
</div>
</div>
<hr className={style.separator} /> <hr className={style.separator} />
<Link
<EditorNavigation /> to='/editor'
tabIndex={0}
className={`${style.link} ${location.pathname === '/editor' && style.current}`}
>
<IoLockClosedOutline />
Editor
</Link>
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}> <ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}>
<IoLockClosedOutline /> <IoLockClosedOutline />
Cuesheet Cuesheet
@@ -74,23 +108,86 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<IoLockClosedOutline /> <IoLockClosedOutline />
Operator Operator
</ClientLink> </ClientLink>
<hr className={style.separator} /> <hr className={style.separator} />
{navigatorConstants.map((route) => ( {navigatorConstants.map((route) => (
<ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}> <ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}>
{route.label} {route.label}
</ClientLink> </ClientLink>
))} ))}
</div> {isLocalhost && <OtherAddresses currentLocation={location.pathname} />}
</DrawerBody>
{isLocalhost && ( </DrawerContent>
<div> </Drawer>
<OtherAddresses currentLocation={location.pathname} /> </div>,
</div> document.body,
)}
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
); );
} }
interface OtherAddressesProps {
currentLocation: string;
}
function OtherAddresses(props: OtherAddressesProps) {
const { currentLocation } = props;
const { data } = useInfo();
// there is no point showing this if we only have one interface
if (data.networkInterfaces.length < 2) {
return null;
}
return (
<div className={style.bottom}>
<div className={style.sectionHeader}>Accessible on external networks</div>
<div className={style.interfaces}>
{data?.networkInterfaces?.map((nif) => {
if (nif.name === 'localhost') {
return null;
}
const address = linkToOtherHost(nif.address, currentLocation);
return (
<CopyTag
key={nif.name}
copyValue={address}
onClick={() => openLink(address)}
label='Copy IP or navigate to address'
>
{nif.address} <IoArrowUp className={style.goIcon} />
</CopyTag>
);
})}
</div>
</div>
);
}
interface ClientLinkProps {
current: boolean;
to: string;
}
function ClientLink(props: PropsWithChildren<ClientLinkProps>) {
const { current, to, children } = props;
const { isElectron } = useElectronEvent();
const classes = cx([style.link, current && style.current]);
if (isElectron) {
return (
<button className={classes} tabIndex={0} onClick={(event) => handleLinks(event, to)}>
{children}
<IoArrowUp className={style.linkIcon} />
</button>
);
}
return (
<Link to={`/${to}`} className={classes} tabIndex={0}>
{children}
</Link>
);
}
export default memo(NavigationMenu);
@@ -1,9 +1,9 @@
import { IoLockClosedOutline } from 'react-icons/io5'; import { IoLockClosedOutline } from 'react-icons/io5';
import { useFadeOutOnInactivity } from '../../../hooks/useFadeOutOnInactivity'; import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import style from './ViewLockedIcon.module.scss'; import style from './NavigationMenu.module.scss';
export default function ViewLockedIcon() { export default function ViewLockedIcon() {
const isLockIconShown = useFadeOutOnInactivity(); const isLockIconShown = useFadeOutOnInactivity();
@@ -1,39 +1,22 @@
import { memo } from 'react'; import { memo } from 'react';
import { useDisclosure, useHotkeys } from '@mantine/hooks'; import { useDisclosure } from '@chakra-ui/react';
import FloatingNavigation from './floating-navigation/FloatingNavigation'; import FloatingNavigation from './FloatingNavigation';
import ViewLockedIcon from './view-locked-icon/ViewLockedIcon';
import NavigationMenu from './NavigationMenu'; import NavigationMenu from './NavigationMenu';
import useViewEditor from './useViewEditor'; import useViewEditor from './useViewEditor';
import ViewLockedIcon from './ViewLockedIcon';
interface ViewNavigationMenuProps { interface ViewNavigationMenuProps {
isLockable?: boolean; isLockable?: boolean;
supressSettings?: boolean;
} }
export default memo(ViewNavigationMenu); function ViewNavigationMenu(props: ViewNavigationMenuProps) {
function ViewNavigationMenu({ isLockable, supressSettings }: ViewNavigationMenuProps) { const { isLockable } = props;
const [isMenuOpen, menuHandler] = useDisclosure();
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable }); const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable });
useHotkeys([ const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
[
'Space',
() => {
if (isViewLocked) return;
menuHandler.toggle();
},
{ preventDefault: true },
],
[
'mod + ,',
() => {
if (isViewLocked || supressSettings) return;
showEditFormDrawer();
},
{ preventDefault: true },
],
]);
if (isViewLocked) { if (isViewLocked) {
return <ViewLockedIcon />; return <ViewLockedIcon />;
@@ -41,11 +24,10 @@ function ViewNavigationMenu({ isLockable, supressSettings }: ViewNavigationMenuP
return ( return (
<> <>
<FloatingNavigation <FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
toggleMenu={menuHandler.toggle} <NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />
toggleSettings={supressSettings ? undefined : () => showEditFormDrawer()}
/>
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
</> </>
); );
} }
export default memo(ViewNavigationMenu);
@@ -1,4 +0,0 @@
.linkIcon {
margin-left: auto;
@include rotate-fourty-five;
}
@@ -1,34 +0,0 @@
import { PropsWithChildren } from 'react';
import { IoArrowUp } from 'react-icons/io5';
import { useNavigate } from 'react-router-dom';
import { useElectronEvent } from '../../../hooks/useElectronEvent';
import { handleLinks } from '../../../utils/linkUtils';
import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
import style from './ClientLink.module.scss';
interface ClientLinkProps {
current: boolean;
to: string;
}
export default function ClientLink({ current, to, children }: PropsWithChildren<ClientLinkProps>) {
const { isElectron } = useElectronEvent();
const navigate = useNavigate();
if (isElectron) {
return (
<NavigationMenuItem active={current} onClick={() => handleLinks(to)}>
{children}
<IoArrowUp className={style.linkIcon} />
</NavigationMenuItem>
);
}
return (
<NavigationMenuItem active={current} onClick={() => navigate(`/${to}`)}>
{children}
</NavigationMenuItem>
);
}
@@ -1,38 +0,0 @@
import { IoLockClosedOutline } from 'react-icons/io5';
import { useNavigate } from 'react-router-dom';
import { useIsSmallDevice } from '../../../hooks/useIsSmallDevice';
import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
export default function EditorNavigation() {
const navigate = useNavigate();
const isSmallDevice = useIsSmallDevice();
if (!isSmallDevice) {
return (
<NavigationMenuItem active={location.pathname === '/editor'} onClick={() => navigate('/editor')}>
<IoLockClosedOutline />
Editor
</NavigationMenuItem>
);
}
return (
<>
<NavigationMenuItem active={location.pathname === '/timercontrol'} onClick={() => navigate('/timercontrol')}>
<IoLockClosedOutline />
Timer Controls
</NavigationMenuItem>
<NavigationMenuItem active={location.pathname === '/messagecontrol'} onClick={() => navigate('/messagecontrol')}>
<IoLockClosedOutline />
Message Controls
</NavigationMenuItem>
<NavigationMenuItem active={location.pathname === '/rundown'} onClick={() => navigate('/rundown')}>
<IoLockClosedOutline />
Rundown
</NavigationMenuItem>
</>
);
}
@@ -1,23 +0,0 @@
.fadeable {
opacity: 1;
transition-property: opacity;
transition-duration: 0.3s;
&.hidden {
opacity: 0;
pointer-events: none;
}
}
.buttonContainer {
display: flex;
flex-direction: column;
row-gap: 1rem;
padding: 0.5em;
position: fixed;
left: 0;
top: 0;
z-index: $zindex-nav;
}
@@ -1,45 +0,0 @@
import { IoApps } from 'react-icons/io5';
import { IoSettingsOutline } from 'react-icons/io5';
import { useFadeOutOnInactivity } from '../../../hooks/useFadeOutOnInactivity';
import { cx } from '../../../utils/styleUtils';
import IconButton from '../../buttons/IconButton';
import style from './FloatingNavigation.module.scss';
interface FloatingNavigationProps {
toggleMenu: () => void;
toggleSettings?: () => void;
}
export default function FloatingNavigation({ toggleMenu, toggleSettings }: FloatingNavigationProps) {
const isButtonShown = useFadeOutOnInactivity(true);
return (
<div
id='fadeable-navigation'
className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}
>
<IconButton
variant='subtle-white'
size='xlarge'
onClick={toggleMenu}
aria-label='toggle menu'
data-testid='navigation__toggle-menu'
>
<IoApps />
</IconButton>
{toggleSettings && (
<IconButton
variant='subtle-white'
size='xlarge'
onClick={toggleSettings}
aria-label='toggle settings'
data-testid='navigation__toggle-settings'
>
<IoSettingsOutline />
</IconButton>
)}
</div>
);
}
@@ -1,37 +0,0 @@
.link {
display: flex;
align-items: center;
padding: 0.75rem 1.5rem;
gap: 0.5rem;
width: 100%;
border-left: 4px solid transparent;
color: $action-text-color;
white-space: nowrap;
cursor: pointer;
transition-property: color;
transition-duration: $transition-time-action;
&:hover {
color: $ontime-color;
background-color: $gray-1350;
}
&:active {
background-color: $border-color-ondark;
}
&:focus {
outline: none;
background-color: $gray-1300;
border: 2px solid $ui-white;
}
&.current {
background-color: $gray-1300;
border-left: 4px solid $action-text-color;
}
}
@@ -1,33 +0,0 @@
import { PropsWithChildren } from 'react';
import { isKeyEnter } from '../../../utils/keyEvent';
import { cx } from '../../../utils/styleUtils';
import style from './NavigationMenuItem.module.scss';
interface NavigationMenuItemProps {
active?: boolean;
className?: string;
onClick: () => void;
}
export default function NavigationMenuItem({
active,
className,
children,
onClick,
}: PropsWithChildren<NavigationMenuItemProps>) {
return (
<div
className={cx([style.link, active && style.current, className])}
tabIndex={0}
role='button'
onClick={onClick}
onKeyDown={(event) => {
isKeyEnter(event) && onClick();
}}
>
{children}
</div>
);
}
@@ -1,18 +0,0 @@
.header {
font-size: calc(1rem - 2px);
margin-left: 1rem;
color: $gray-700;
}
.interfaces {
padding: 0.5rem 1rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.goIcon {
@include rotate-fourty-five;
margin-left: 0.25rem;
margin-bottom: 0.25rem;
}
@@ -1,46 +0,0 @@
import { IoArrowUp } from 'react-icons/io5';
import useInfo from '../../../hooks-query/useInfo';
import { linkToOtherHost, openLink } from '../../../utils/linkUtils';
import CopyTag from '../../copy-tag/CopyTag';
import style from './OtherAddresses.module.scss';
interface OtherAddressesProps {
currentLocation: string;
}
export default function OtherAddresses({ currentLocation }: OtherAddressesProps) {
const { data } = useInfo();
// there is no point showing this if we only have one interface
if (data.networkInterfaces.length < 2) {
return null;
}
return (
<>
<div className={style.header}>Accessible on external networks</div>
<div className={style.interfaces}>
{data?.networkInterfaces?.map((nif) => {
if (nif.name === 'localhost') {
return null;
}
const address = linkToOtherHost(nif.address, currentLocation);
return (
<CopyTag
key={nif.name}
copyValue={address}
onClick={() => openLink(address)}
label='Copy IP or navigate to address'
>
{nif.address} <IoArrowUp className={style.goIcon} />
</CopyTag>
);
})}
</div>
</>
);
}
@@ -1,22 +0,0 @@
.fadeable {
opacity: 1;
transition-property: opacity;
transition-duration: 0.3s;
&.hidden {
opacity: 0;
pointer-events: none;
}
}
.lockIcon {
font-size: 1.5rem;
color: $active-indicator;
padding: 0.5em;
position: fixed;
left: 0;
top: 0;
z-index: $zindex-nav;
}
@@ -0,0 +1,40 @@
import { IoPause, IoPlay, IoStop } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react';
import { Playback } from 'ontime-types';
import { tooltipDelayFast } from '../../../ontimeConfig';
interface PlaybackIconProps {
state: Playback;
skipTooltip?: boolean;
className?: string;
}
export default function PlaybackIcon(props: PlaybackIconProps) {
const { state, skipTooltip, className } = props;
// if timer is Pause or Armed
let label = 'Timer Paused';
let Icon = IoPause;
if (state === Playback.Roll) {
label = 'Timer Rolling';
Icon = IoPlay;
} else if (state === Playback.Play) {
label = 'Timer Playing';
Icon = IoPlay;
} else if (state === Playback.Stop) {
label = 'Timer Stopped';
Icon = IoStop;
}
if (skipTooltip) {
return <Icon className={className} />;
}
return (
<Tooltip openDelay={tooltipDelayFast} label={label} shouldWrapChildren>
<Icon className={className} />
</Tooltip>
);
}
@@ -1,32 +0,0 @@
.popup {
box-sizing: border-box;
padding: 1rem 1.5rem;
z-index: $zindex-dialog;
color: $ui-white;
background-color: $gray-1250;
border-radius: 3px;
box-shadow: $box-shadow-l1;
border: 2px solid $gray-1200;
//width: 32rem;
max-width: 90vw;
transform-origin: var(--transform-origin);
transition:
transform 150ms,
opacity 150ms;
&[data-starting-style],
&[data-ending-style] {
opacity: 0;
transform: scale(0.9);
}
}
.title {
font-size: 1rem;
margin-bottom: 0.5rem;
}
@@ -1,26 +0,0 @@
import { PropsWithChildren } from 'react';
import { Popover } from '@base-ui-components/react/popover';
import style from './Popover.module.scss';
interface PopoverContentsProps extends Popover.Positioner.Props {
title?: string;
className?: string;
}
export default function PopoverContents({
title,
className,
children,
...popoverProps
}: PropsWithChildren<PopoverContentsProps>) {
return (
<Popover.Portal>
<Popover.Positioner sideOffset={8} {...popoverProps}>
<Popover.Popup className={style.popup}>
{title && <Popover.Title className={style.title}>{title}</Popover.Title>}
<Popover.Description className={className}>{children}</Popover.Description>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
);
}
@@ -1,132 +0,0 @@
.select {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
height: 2rem;
background-color: $gray-1200;
font-size: 1rem;
font-weight: 400;
color: $gray-200;
border-radius: $component-border-radius-md;
border: 1px solid transparent;
padding-inline: 0.5rem;
font-size: calc(1rem - 2px);
white-space: nowrap;
&:hover:not([data-disabled]) {
background-color: $gray-1100;
}
&:active:not([data-disabled]) {
background-color: $gray-1000;
}
&[data-popup-open] {
background-color: $gray-1000;
}
&[data-disabled] {
opacity: 0.4;
cursor: not-allowed;
}
&.fluid {
width: 100%;
}
}
.selectIcon {
color: $gray-200;
}
.popup {
font-size: calc(1rem - 2px);
background-color: $gray-1200;
box-sizing: border-box;
padding: 2px;
border-radius: $component-border-radius-md;
color: $ui-white;
overflow-y: auto;
max-height: 20rem;
border: 1px solid $gray-1000;
&[data-side='start'] {
transition: 50%;
transform: 100%;
opacity: 1;
}
}
.item {
box-sizing: border-box;
outline: 0;
line-height: 1rem;
padding: 0.25rem 0.5rem;
min-width: var(--anchor-width);
display: grid;
gap: 0.25rem;
align-items: center;
grid-template-columns: 0.75rem 1fr;
scroll-margin-block: 1rem;
&[data-highlighted] {
z-index: 0;
position: relative;
background-color: $blue-700;
}
}
.itemIndicator {
grid-column-start: 1;
}
.itemIndicatorIcon {
display: block;
width: 0.75rem;
height: 0.75rem;
}
.itemLabel {
grid-column-start: 2;
}
.scrollArrow {
background-color: $gray-1000;
color: $ui-white;
width: 100%;
z-index: 1;
height: 1rem;
font-size: 0.5rem;
display: grid;
place-content: center;
&::before {
content: '';
position: absolute;
width: 100%;
height: 100%;
left: 0;
}
&[data-direction='up'] {
border-radius: 3px 3px 0 0;
&::before {
top: -100%;
}
}
&[data-direction='down'] {
bottom: 0;
border-radius: 0 0 3px 3px;
&::before {
bottom: -100%;
}
}
}
@@ -1,45 +0,0 @@
import { IoCheckmark } from 'react-icons/io5';
import { LuChevronsUpDown } from 'react-icons/lu';
import { Select as BaseSelect } from '@base-ui-components/react/select';
import { cx } from '../../utils/styleUtils';
import styles from './Select.module.scss';
interface SelectProps<T> extends Omit<BaseSelect.Root.Props<T>, 'items'> {
// overload items to not allow undefined values
options: {
value: T;
label: string;
}[];
fluid?: boolean;
}
export default function Select<T>({ options, fluid, ...selectRootProps }: SelectProps<T>) {
return (
<BaseSelect.Root items={options} {...selectRootProps}>
<BaseSelect.Trigger className={cx([styles.select, fluid && styles.fluid])}>
<BaseSelect.Value />
<BaseSelect.Icon className={styles.selectIcon}>
<LuChevronsUpDown />
</BaseSelect.Icon>
</BaseSelect.Trigger>
<BaseSelect.Portal>
<BaseSelect.Positioner side='bottom' align='start'>
<BaseSelect.ScrollUpArrow className={styles.scrollArrow} />
<BaseSelect.Popup className={styles.popup}>
{options.map(({ label, value }) => (
<BaseSelect.Item key={String(value)} className={styles.item} value={value}>
<BaseSelect.ItemIndicator className={styles.itemIndicator}>
<IoCheckmark className={styles.itemIndicatorIcon} />
</BaseSelect.ItemIndicator>
<BaseSelect.ItemText className={styles.itemLabel}>{label}</BaseSelect.ItemText>
</BaseSelect.Item>
))}
</BaseSelect.Popup>
<BaseSelect.ScrollDownArrow className={styles.scrollArrow} />
</BaseSelect.Positioner>
</BaseSelect.Portal>
</BaseSelect.Root>
);
}
@@ -1,52 +0,0 @@
.switch {
position: relative;
display: flex;
align-items: center;
/* Reset and base styles */
appearance: none;
border: 1px solid transparent;
border-radius: 99px;
background-color: $gray-1100;
/* Transitions */
transition: background-color 125ms cubic-bezier(0.26, 0.75, 0.38, 0.45);
/* States */
&[data-checked] {
background-color: $blue-700;
box-shadow: none;
}
&:focus {
border-color: $blue-500;
}
}
.medium {
padding: 2px;
--width: calc(2.5rem + 4px);
--height: 1.5rem;
width: var(--width);
height: var(--height);
}
.large {
padding: 3px;
--width: calc(2.75rem + 6px);
--height: 1.75rem;
width: var(--width);
height: var(--height);
}
.thumb {
aspect-ratio: 1 / 1;
height: 100%;
border-radius: 99px;
background-color: $ui-white;
transition: translate 150ms ease;
&[data-checked] {
translate: calc(var(--width) - var(--height)) 0;
}
}
@@ -1,17 +0,0 @@
import { Switch as BaseSwitch } from '@base-ui-components/react/switch';
import { cx } from '../../utils/styleUtils';
import style from './Switch.module.scss';
interface SwitchProps extends BaseSwitch.Root.Props {
size?: 'medium' | 'large';
}
export default function Switch({ size = 'medium', ...switchProps }: SwitchProps) {
return (
<BaseSwitch.Root className={cx([style.switch, style[size]])} {...switchProps}>
<BaseSwitch.Thumb className={style.thumb} />
</BaseSwitch.Root>
);
}
@@ -18,21 +18,19 @@ import {
import { isStringBoolean } from '../../../features/viewers/common/viewUtils'; import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import InlineColourPicker from './InlineColourPicker'; import InlineColourPicker from './InlineColourPicker';
import { ParamField } from './viewParams.types'; import { ParamField } from './types';
interface ParamInputProps { interface EditFormInputProps {
paramField: ParamField; paramField: ParamField;
} }
export default function ParamInput({ paramField }: ParamInputProps) { export default function ParamInput(props: EditFormInputProps) {
const { paramField } = props;
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { id, type, defaultValue } = paramField; const { id, type, defaultValue } = paramField;
if (type === 'persist') { if (type === 'persist') {
if (!paramField.values || !paramField.values.length) { return null;
return null;
}
return <input hidden name={id} readOnly value={paramField.values.join(',')} />;
} }
if (type === 'option') { if (type === 'option') {
@@ -8,82 +8,12 @@
} }
} }
.backdrop {
position: fixed;
inset: 0;
z-index: $zindex-backdrop;
background-color: $backdrop-color;
transition: opacity 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
&[data-starting-style],
&[data-ending-style] {
opacity: 0;
}
}
.drawer {
box-sizing: border-box;
position: fixed;
top: 0;
right: 0;
bottom: 0;
z-index: $zindex-dialog;
width: 40rem;
height: 100vh;
display: flex;
flex-direction: column;
padding: 1rem 1.5rem;
background-color: $gray-1250;
color: $ui-white;
border-left: 1px solid $gray-1100;
&[data-open] {
transform: translateX(0%);
transition: transform 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
}
&[data-starting-style],
&[data-ending-style] {
transform: translateX(100%);
transition: transform 500ms cubic-bezier(0.45, 1.005, 0, 1.005);
}
// take the whole screen in mobile devices
@media (max-width: $min-tablet) {
width: 100vw;
}
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
height: 3.5rem;
font-weight: 600;
font-size: 1.25rem;
}
.body {
flex: 1;
padding-bottom: 10vh;
overflow-y: auto;
}
.footer {
display: flex;
gap: 1rem;
align-items: center;
justify-content: flex-end;
}
.sectionList { .sectionList {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100%;
gap: 2rem; gap: 2rem;
overflow-y: scroll;
padding-right: 0.5rem; padding-right: 0.5rem;
} }
@@ -1,51 +1,117 @@
import { FormEvent, memo, useEffect } from 'react'; import { FormEvent, useEffect } from 'react';
import { IoClose } from 'react-icons/io5';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { Dialog } from '@base-ui-components/react/dialog'; import {
import { useDisclosure } from '@mantine/hooks'; Button,
Drawer,
DrawerBody,
DrawerCloseButton,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerOverlay,
useDisclosure,
} from '@chakra-ui/react';
import useViewSettings from '../../hooks-query/useViewSettings'; import useViewSettings from '../../hooks-query/useViewSettings';
import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton';
import Info from '../info/Info'; import Info from '../info/Info';
import { ViewOption } from './viewParams.types'; import { ViewOption } from './types';
import { getURLSearchParamsFromObj } from './viewParams.utils';
import ViewParamsSection from './ViewParamsSection'; import ViewParamsSection from './ViewParamsSection';
import style from './ViewParamsEditor.module.scss'; import style from './ViewParamsEditor.module.scss';
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
/**
* Utility remove the # character from a hex string
*/
function sanitiseColour(colour: string) {
if (colour.startsWith('#')) {
return colour.substring(1);
}
return colour;
}
/**
* Makes a new URLSearchParams object from the given params object
*/
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOption[]) => {
const newSearchParams = new URLSearchParams();
// Convert paramFields to an object that contains default values
const defaultValues: Record<string, string> = {};
paramFields.forEach((section) => {
section.options.forEach((option) => {
defaultValues[option.id] = String(option.defaultValue);
// extract persisted values
if ('type' in option && option.type === 'persist') {
newSearchParams.set(option.id, option.value);
}
});
});
// compare which values are different from the default values
Object.entries(paramsObj).forEach(([id, value]) => {
if (typeof value === 'string' && value.length) {
// we dont know which values contain colours
// unfortunately this means we run all the strings through the sanitation
const valueWithoutHash = sanitiseColour(value);
if (defaultValues[id] !== valueWithoutHash) {
handleValueString(id, valueWithoutHash);
}
}
});
/** Utility function contains logic to add a value into the searchParams object */
function handleValueString(id: string, value: string) {
const maybeMultipleValues = value.split(',');
// we need to check if the value contains comma separated list, for the case of the multi-select data
if (Array.isArray(maybeMultipleValues) && maybeMultipleValues.length > 1) {
const added = new Set();
maybeMultipleValues.forEach((v) => {
if (!added.has(v)) {
added.add(v);
newSearchParams.append(id, v);
}
});
} else {
newSearchParams.set(id, value);
}
}
return newSearchParams;
};
interface EditFormDrawerProps { interface EditFormDrawerProps {
viewOptions: ViewOption[]; viewOptions: ViewOption[];
} }
export default memo(ViewParamsEditor); // TODO: this is a good candidate for memoisation, but needs the paramFields to be stable
export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings(); const { data: viewSettings } = useViewSettings();
const [isOpen, handlers] = useDisclosure(false); const { isOpen, onClose, onOpen } = useDisclosure();
// handle opening the drawer
useEffect(() => { useEffect(() => {
const isEditing = searchParams.get('edit'); const isEditing = searchParams.get('edit');
if (isEditing === 'true') { if (isEditing === 'true') {
return handlers.open(); return onOpen();
} }
}, [searchParams, handlers]); }, [searchParams, onOpen]);
const handleClose = () => { const handleClose = () => {
searchParams.delete('edit'); searchParams.delete('edit');
setSearchParams(searchParams); setSearchParams(searchParams);
handlers.close(); onClose();
}; };
const resetParams = () => { const resetParams = () => {
setSearchParams(); setSearchParams();
handlers.close(); onClose();
}; };
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => { const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
@@ -57,48 +123,39 @@ function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
}; };
return ( return (
<Dialog.Root <Drawer isOpen={isOpen} placement='right' onClose={handleClose} variant='ontime' size='lg'>
open={isOpen} <DrawerOverlay />
onOpenChange={(open) => { <DrawerContent>
if (!open) { <DrawerHeader>
handleClose(); <DrawerCloseButton size='lg' />
} Customise
}} </DrawerHeader>
>
<Dialog.Portal> <DrawerBody>
<Dialog.Backdrop className={style.backdrop} /> {viewSettings.overrideStyles && (
<Dialog.Popup className={style.drawer}> <Info className={style.info}>This view style is being modified by a custom CSS file.</Info>
<div className={style.header}> )}
<Dialog.Title>Customise</Dialog.Title> <form id='edit-params-form' onSubmit={onParamsFormSubmit} className={style.sectionList}>
<IconButton variant='subtle-white' size='large' onClick={handleClose}> {viewOptions.map((section) => (
<IoClose /> <ViewParamsSection
</IconButton> key={section.title}
</div> title={section.title}
<div className={style.body}> collapsible={section.collapsible}
{viewSettings.overrideStyles && ( options={section.options}
<Info className={style.info}>This view style is being modified by a custom CSS file.</Info> />
)} ))}
<form id='edit-params-form' onSubmit={onParamsFormSubmit} className={style.sectionList}> </form>
{viewOptions.map((section) => ( </DrawerBody>
<ViewParamsSection
key={section.title} <DrawerFooter className={style.drawerFooter}>
title={section.title} <Button variant='ontime-ghosted' onClick={resetParams} type='reset'>
collapsible={section.collapsible} Reset to default
options={section.options} </Button>
/> <Button variant='ontime-filled' form='edit-params-form' type='submit'>
))} Save
</form> </Button>
</div> </DrawerFooter>
<div className={style.footer}> </DrawerContent>
<Button variant='subtle' size='large' onClick={resetParams} type='reset'> </Drawer>
Reset to default
</Button>
<Button variant='primary' size='large' form='edit-params-form' type='submit'>
Save
</Button>
</div>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
); );
} }
@@ -11,6 +11,10 @@
&.collapsible { &.collapsible {
cursor: pointer; cursor: pointer;
&:hover {
border-bottom: 1px solid $white-10;
}
} }
} }
@@ -3,9 +3,8 @@ import { useLocalStorage } from '@mantine/hooks';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import { OptionTitle } from './constants';
import ParamInput from './ParamInput'; import ParamInput from './ParamInput';
import { type ParamField } from './viewParams.types'; import { type ParamField } from './types';
import style from './ViewParamsSection.module.scss'; import style from './ViewParamsSection.module.scss';
@@ -15,7 +14,9 @@ interface ViewParamsSectionProps {
options: ParamField[]; options: ParamField[];
} }
export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) { export default function ViewParamsSection(props: ViewParamsSectionProps) {
const { title, collapsible, options } = props;
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false }); const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
const handleCollapse = () => { const handleCollapse = () => {
@@ -26,52 +27,28 @@ export default function ViewParamsSection({ title, collapsible, options }: ViewP
return ( return (
<section className={style.section}> <section className={style.section}>
{title === OptionTitle.Hidden ? ( <div className={cx([style.sectionHeader, collapsible && style.collapsible])} onClick={handleCollapse}>
<HiddenContents options={options} /> {title}
) : ( {collapsible && <IoChevronDown className={cx([collapsed ? style.closed : style.open])} />}
</div>
{!collapsed && (
<> <>
<div className={cx([style.sectionHeader, collapsible && style.collapsible])} onClick={handleCollapse}> {options.map((option) => {
{title} if (option.type === 'persist') {
{collapsible && <IoChevronDown className={cx([collapsed ? style.closed : style.open])} />} return null;
</div> }
<SectionContents options={options} collapsed={collapsed} />
return (
<label key={option.title} className={style.label}>
<span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span>
<ParamInput paramField={option} />
</label>
);
})}
</> </>
)} )}
</section> </section>
); );
} }
interface SectionContentsProps {
options: ParamField[];
collapsed: boolean;
}
function SectionContents({ options, collapsed }: SectionContentsProps) {
if (collapsed) {
return null;
}
return (
<>
{options.map((option) => {
return (
<label key={option.title} className={style.label}>
<span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span>
<ParamInput paramField={option} />
</label>
);
})}
</>
);
}
function HiddenContents({ options }: { options: ParamField[] }) {
return (
<>
{options.map((option, index) => {
return <ParamInput key={option.title + index} paramField={option} />;
})}
</>
);
}
@@ -0,0 +1,45 @@
import { CustomFields } from 'ontime-types';
import { makeOptionsFromCustomFields } from '../constants';
describe('makeOptionsFromCustomFields', () => {
const testCustomFields: CustomFields = {
field1: { label: 'Field 1', colour: 'red', type: 'string' },
field2: { label: 'Field 2', colour: 'blue', type: 'string' },
};
it('creates a record of keys for the given custom fields', () => {
const result = makeOptionsFromCustomFields(testCustomFields);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
});
});
it('appends additional data', () => {
const additionalData = {
test1: 'test1',
test2: 'test2',
};
const result = makeOptionsFromCustomFields(testCustomFields, additionalData);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
test1: 'test1',
test2: 'test2',
});
});
it('filtersImageTypes', () => {
const customFieldsWIthImage: CustomFields = {
...testCustomFields,
field3: { label: 'Field 3', colour: 'green', type: 'image' },
};
const result = makeOptionsFromCustomFields(customFieldsWIthImage);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
});
});
});
@@ -1,204 +0,0 @@
import { CustomFields } from 'ontime-types';
import { describe, expect, it } from 'vitest';
import { OptionTitle } from '../constants';
import type { ViewOption } from '../viewParams.types';
import { getURLSearchParamsFromObj, makeOptionsFromCustomFields } from '../viewParams.utils';
describe('makeOptionsFromCustomFields', () => {
const testCustomFields: CustomFields = {
field1: { label: 'Field 1', colour: 'red', type: 'string' },
field2: { label: 'Field 2', colour: 'blue', type: 'string' },
};
it('creates a record of keys for the given custom fields', () => {
const result = makeOptionsFromCustomFields(testCustomFields);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
});
});
it('appends additional data', () => {
const additionalData = {
test1: 'test1',
test2: 'test2',
};
const result = makeOptionsFromCustomFields(testCustomFields, additionalData);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
test1: 'test1',
test2: 'test2',
});
});
it('filtersImageTypes', () => {
const customFieldsWIthImage: CustomFields = {
...testCustomFields,
field3: { label: 'Field 3', colour: 'green', type: 'image' },
};
const result = makeOptionsFromCustomFields(customFieldsWIthImage);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
});
});
});
describe('getURLSearchParamsFromObj', () => {
// Mock view options for testing
const mockViewOptions: ViewOption[] = [
{
title: OptionTitle.DataSources,
collapsible: true,
options: [
{
id: 'color',
title: 'Color',
description: 'The color value',
type: 'colour',
defaultValue: 'ff0000',
},
{
id: 'persist-field',
title: 'Persistent Field',
description: 'A field that persists',
type: 'persist',
values: ['persisted-value'],
},
{
id: 'multi-select',
title: 'Multi Select',
description: 'A multi-select field',
type: 'option',
values: { value1: 'Value 1', value2: 'Value 2' },
defaultValue: '',
},
],
},
];
it('should handle empty params object', () => {
const params = {};
const result = getURLSearchParamsFromObj(params, mockViewOptions);
// Should only include persisted values
expect(result.get('persist-field')).toBe('persisted-value');
expect(result.get('color')).toBeNull();
});
it('should not include values that match defaults', () => {
const params = {
color: 'ff0000', // same as the default
'other-param': 'value',
};
const result = getURLSearchParamsFromObj(params, mockViewOptions);
expect(result.get('color')).toBeNull();
expect(result.get('other-param')).toBe('value');
});
it('should sanitize color values with #', () => {
const params = {
color: '#00ff00', // different from default and includes #
};
const result = getURLSearchParamsFromObj(params, mockViewOptions);
expect(result.get('color')).toBe('00ff00');
});
it('should handle multi-select values', () => {
const params = {
'multi-select': 'value1,value2,value3',
};
const result = getURLSearchParamsFromObj(params, mockViewOptions);
// Should have multiple entries for the same key
const values = result.getAll('multi-select');
expect(values).toEqual(['value1', 'value2', 'value3']);
});
it('should not include empty string values', () => {
const params = {
'empty-param': '',
};
const result = getURLSearchParamsFromObj(params, mockViewOptions);
expect(result.get('empty-param')).toBeNull();
});
it('should allow multiple values for persisted fields', () => {
const mockOptionsWithMultiPersist: ViewOption[] = [
{
title: OptionTitle.DataSources,
collapsible: true,
options: [
{
id: 'sub',
title: 'Event subscription',
description: 'The events to follow',
values: [],
type: 'persist',
},
],
},
];
const params = {
sub: 'value1,value2,value3',
};
const result = getURLSearchParamsFromObj(params, mockOptionsWithMultiPersist);
const values = result.getAll('sub');
expect(values).toStrictEqual(['value1', 'value2', 'value3']);
});
it('should allow adding multiple values for any field', () => {
const params = {
'regular-field': 'value1,value2,value3',
};
const result = getURLSearchParamsFromObj(params, mockViewOptions);
const values = result.getAll('regular-field');
expect(values).toHaveLength(3);
expect(values).toEqual(['value1', 'value2', 'value3']);
});
it('should deduplicate repeated key-value pairs', () => {
const params = {
multiValue: 'value1,value1,value2,value2,value3',
repeatedField: 'same,same,same',
};
const result = getURLSearchParamsFromObj(params, mockViewOptions);
// Check multiValue field has unique values
expect(result.getAll('multiValue')).toEqual(['value1', 'value2', 'value3']);
// Check repeatedField has only one instance of the value
expect(result.getAll('repeatedField')).toEqual(['same']);
});
it('should deduplicate persisted values while maintaining order', () => {
const mockOptionsWithDuplicates: ViewOption[] = [
{
title: OptionTitle.StyleOverride,
options: [
{
id: 'sub',
title: 'Subscription',
description: 'Persisted subscription values',
type: 'persist',
values: ['value1', 'value1', 'value2', 'value2', 'value3', 'value1'],
},
],
},
];
const result = getURLSearchParamsFromObj({}, mockOptionsWithDuplicates);
// Should only include unique values while maintaining order
expect(result.getAll('sub')).toEqual(['value1', 'value2', 'value3']);
});
});
@@ -1,28 +0,0 @@
import type { ParamField } from './viewParams.types';
export 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 hideTimerSeconds: ParamField = {
id: 'hideTimerSeconds',
title: 'Hide seconds in timer',
description: 'Whether to hide seconds in the running timer',
type: 'boolean',
defaultValue: false,
};
export const showLeadingZeros: ParamField = {
id: 'showLeadingZeros',
title: 'Show leading zeros in timer',
description: 'Whether to show leading zeros in the running timer',
type: 'boolean',
defaultValue: false,
};
@@ -1,6 +1,61 @@
/** import { CustomFields } from 'ontime-types';
* Gathers possible titles for view options
*/ import type { MultiselectOptions, ParamField } from './types';
export const makeOptionsFromCustomFields = (
customFields: CustomFields,
additionalOptions: Readonly<Record<string, string>> = {},
filterImageType = true,
) => {
const options = { ...additionalOptions };
for (const [key, value] of Object.entries(customFields)) {
if (filterImageType && value.type === 'image') {
continue;
}
options[`custom-${key}`] = `Custom: ${value.label}`;
}
return options;
};
export function makeCustomFieldSelectOptions(customFields: CustomFields, filterImageType = true): MultiselectOptions {
const options: MultiselectOptions = {};
for (const [key, value] of Object.entries(customFields)) {
if (filterImageType && value.type === 'image') {
continue;
}
options[key] = { value: key, label: value.label, colour: value.colour };
}
return options;
}
export 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 hideTimerSeconds: ParamField = {
id: 'hideTimerSeconds',
title: 'Hide seconds in timer',
description: 'Whether to hide seconds in the running timer',
type: 'boolean',
defaultValue: false,
};
export const showLeadingZeros: ParamField = {
id: 'showLeadingZeros',
title: 'Show leading zeros in timer',
description: 'Whether to show leading zeros in the running timer',
type: 'boolean',
defaultValue: false,
};
export enum OptionTitle { export enum OptionTitle {
ClockOptions = 'Clock Options', ClockOptions = 'Clock Options',
TimerOptions = 'Timer Options', TimerOptions = 'Timer Options',
@@ -10,7 +65,4 @@ export enum OptionTitle {
StyleOverride = 'View style override', StyleOverride = 'View style override',
Animation = 'View animation', Animation = 'View animation',
Schedule = 'Schedule options', Schedule = 'Schedule options',
/** rendered as hidden inputs */
Hidden = 'Hidden options',
} }
@@ -24,7 +24,7 @@ type StringField = { type: 'string'; defaultValue?: string; prefix?: string; pla
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string }; type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
type BooleanField = { type: 'boolean'; defaultValue: boolean }; type BooleanField = { type: 'boolean'; defaultValue: boolean };
type ColourField = { type: 'colour'; defaultValue: string; placeholder?: string }; type ColourField = { type: 'colour'; defaultValue: string; placeholder?: string };
type PersistedField = { type: 'persist'; defaultValue?: string[]; values: string[] }; type PersistedField = { type: 'persist'; defaultValue?: string; value: string };
export type ParamField = BaseField & export type ParamField = BaseField &
(OptionsField | MultiOptionsField | StringField | NumberField | BooleanField | ColourField | PersistedField); (OptionsField | MultiOptionsField | StringField | NumberField | BooleanField | ColourField | PersistedField);
@@ -1,144 +0,0 @@
import type { CustomFields } from 'ontime-types';
import type { MultiselectOptions, ViewOption } from './viewParams.types';
/**
* Creates a list of custom fields for a select
* Filters out image type custom fields
*/
export function makeOptionsFromCustomFields(
customFields: CustomFields,
additionalOptions: Readonly<Record<string, string>> = {},
filterImageType = true,
): Record<string, string> {
const options = { ...additionalOptions };
for (const [key, value] of Object.entries(customFields)) {
if (filterImageType && value.type === 'image') {
continue;
}
options[`custom-${key}`] = `Custom: ${value.label}`;
}
return options;
}
/**
* Creates data for a multiselect component from custom fields
* Filters out image type custom fields
*/
export function makeCustomFieldSelectOptions(customFields: CustomFields, filterImageType = true): MultiselectOptions {
const options: MultiselectOptions = {};
for (const [key, value] of Object.entries(customFields)) {
if (filterImageType && value.type === 'image') {
continue;
}
options[key] = { value: key, label: value.label, colour: value.colour };
}
return options;
}
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
/**
* Utility remove the # character from a hex string
*/
function sanitiseColour(colour: string) {
if (colour.startsWith('#')) {
return colour.substring(1);
}
return colour;
}
type FieldMetadata = {
defaultValues: Record<string, string>;
colorFields: Set<string>;
isPersistedField: Set<string>;
persistedValues: Record<string, string[]>;
};
/**
* Utility collects metadata about fields from view options
*/
function collectFieldMetadata(paramFields: ViewOption[]): FieldMetadata {
const metadata: FieldMetadata = {
defaultValues: {},
colorFields: new Set(),
isPersistedField: new Set(),
persistedValues: {},
};
paramFields.forEach((section) => {
section.options.forEach((option) => {
if (option.type === 'persist') {
metadata.isPersistedField.add(option.id);
if (option.values) {
metadata.persistedValues[option.id] = option.values;
}
} else {
metadata.defaultValues[option.id] = String(option.defaultValue);
}
if (option.type === 'colour') {
metadata.colorFields.add(option.id);
}
});
});
return metadata;
}
/**
* Makes a new URLSearchParams object from the given params object
* @param paramsObj - The object containing parameters to be converted
* @param paramFields - The view options that define the parameters
* @returns A new URLSearchParams object with the parameters
*/
export function getURLSearchParamsFromObj(paramsObj: ViewParamsObj, paramFields: ViewOption[]) {
const newSearchParams = new URLSearchParams();
const addedPairs = new Set<string>();
const metadata = collectFieldMetadata(paramFields);
// Utility function to safely add params without duplicates
const addUniqueParam = (id: string, value: string) => {
const pair = `${id}:${value}`;
if (!addedPairs.has(pair)) {
addedPairs.add(pair);
newSearchParams.append(id, value);
}
};
// First add all persisted values
Object.entries(metadata.persistedValues).forEach(([id, values]) => {
values.forEach((value) => {
if (value) {
addUniqueParam(id, value);
}
});
});
// Then process user-provided values
Object.entries(paramsObj).forEach(([id, value]) => {
if (typeof value === 'string' && value.length) {
// For persisted fields, clear existing values before adding new ones
if (metadata.isPersistedField.has(id)) {
// Clear tracking of previous values for this field
Array.from(addedPairs).forEach((pair) => {
if (pair.startsWith(`${id}:`)) {
addedPairs.delete(pair);
}
});
newSearchParams.delete(id);
}
// Process and add new values
value.split(',').forEach((v) => {
const processedValue = metadata.colorFields.has(id) ? sanitiseColour(v) : v;
if (metadata.isPersistedField.has(id) || metadata.defaultValues[id] !== processedValue) {
addUniqueParam(id, processedValue);
}
});
}
});
return newSearchParams;
}
@@ -1,17 +1,17 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
// CustomFields record type is no longer used here import { CustomFields } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CUSTOM_FIELDS } from '../api/constants'; import { CUSTOM_FIELDS } from '../api/constants';
import { getCustomFields, CustomFieldWithKey } from '../api/customFields'; // Import CustomFieldWithKey import { getCustomFields } from '../api/customFields';
const placeholder: CustomFieldWithKey[] = []; // Placeholder is now an empty array const placeholder: CustomFields = {};
export default function useCustomFields() { export default function useCustomFields() {
// Explicitly type the useQuery hook const { data, status, isFetching, isError, refetch } = useQuery({
const { data, status, isFetching, isError, refetch } = useQuery<CustomFieldWithKey[], Error>({
queryKey: CUSTOM_FIELDS, queryKey: CUSTOM_FIELDS,
queryFn: getCustomFields, queryFn: getCustomFields,
placeholderData: (previousData, _previousQuery) => previousData ?? placeholder, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
@@ -1,22 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { ProjectRundownsList } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_RUNDOWNS } from '../api/constants';
import { fetchProjectRundownList } from '../api/rundown';
/**
* Project rundowns
*/
export function useProjectRundowns() {
const { data, status, isError, refetch, isFetching } = useQuery<ProjectRundownsList>({
queryKey: PROJECT_RUNDOWNS,
queryFn: fetchProjectRundownList,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? { loaded: '', rundowns: [] }, status, isError, refetch, isFetching };
}
@@ -4,11 +4,7 @@ import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { URL_PRESETS } from '../api/constants'; import { URL_PRESETS } from '../api/constants';
import { getUrlPresets } from '../api/urlPresets'; import { getUrlPresets } from '../api/urlPresets';
interface FetchProps { export default function useUrlPresets() {
skip?: boolean;
}
export default function useUrlPresets({ skip = false }: FetchProps = {}) {
const { data, status, isError, refetch } = useQuery({ const { data, status, isError, refetch } = useQuery({
queryKey: URL_PRESETS, queryKey: URL_PRESETS,
queryFn: getUrlPresets, queryFn: getUrlPresets,
@@ -17,7 +13,6 @@ export default function useUrlPresets({ skip = false }: FetchProps = {}) {
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always', networkMode: 'always',
enabled: !skip,
}); });
return { data: data ?? [], status, isError, refetch }; return { data: data ?? [], status, isError, refetch };
@@ -1,28 +1,20 @@
import { useMutation, useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { getViewSettings, postViewSettings } from '../../common/api/viewSettings'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { ontimeQueryClient } from '../../common/queryClient';
import { VIEW_SETTINGS } from '../api/constants'; import { VIEW_SETTINGS } from '../api/constants';
import { getView } from '../api/viewSettings';
import { viewsSettingsPlaceholder } from '../models/ViewSettings.type'; import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
export default function useViewSettings() { export default function useViewSettings() {
const { data, isPending } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: VIEW_SETTINGS, queryKey: VIEW_SETTINGS,
queryFn: getViewSettings, queryFn: getView,
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
staleTime: MILLIS_PER_HOUR, retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
}); });
const { mutateAsync } = useMutation({ return { data: data ?? viewsSettingsPlaceholder, status, isError, refetch, isFetching };
mutationFn: postViewSettings,
onMutate: () => {
ontimeQueryClient.cancelQueries({ queryKey: VIEW_SETTINGS });
},
onSuccess: (data) => {
ontimeQueryClient.setQueryData(VIEW_SETTINGS, data);
},
});
return { data: data ?? viewsSettingsPlaceholder, mutateAsync, isPending };
} }
@@ -1,10 +1,9 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { MessageTag } from 'ontime-types';
import { useShallow } from 'zustand/shallow'; import { useShallow } from 'zustand/shallow';
import { useClientStore } from '../stores/clientStore'; import { useClientStore } from '../stores/clientStore';
import { sendSocket } from '../utils/socket'; import { socketSendJson } from '../utils/socket';
import { useIsOnline } from './useSocket'; import { useIsOnline } from './useSocket';
@@ -23,7 +22,7 @@ export const useClientPath = () => {
useEffect(() => { useEffect(() => {
if (!isOnline) return; if (!isOnline) return;
sendSocket(MessageTag.ClientSetPath, pathname + search); socketSendJson('set-client-path', pathname + search);
}, [pathname, search, isOnline]); }, [pathname, search, isOnline]);
// navigate to new path when received from server // navigate to new path when received from server
+67 -72
View File
@@ -36,10 +36,11 @@ import { logAxiosError } from '../api/utils';
import { useEditorSettings } from '../stores/editorSettings'; import { useEditorSettings } from '../stores/editorSettings';
export type EventOptions = Partial<{ export type EventOptions = Partial<{
// options of any new entries (event / delay / block) // options to any new block (event / delay / block)
after: MaybeString; after: MaybeString;
before: MaybeString; before: MaybeString;
// options of entries of type OntimeEvent // options to blocks of type OntimeEvent
defaultPublic: boolean;
linkPrevious: boolean; linkPrevious: boolean;
lastEventId: MaybeString; lastEventId: MaybeString;
}>; }>;
@@ -50,6 +51,7 @@ export type EventOptions = Partial<{
export const useEntryActions = () => { export const useEntryActions = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { const {
defaultPublic,
linkPrevious, linkPrevious,
defaultTimeStrategy, defaultTimeStrategy,
defaultDuration, defaultDuration,
@@ -74,7 +76,7 @@ export const useEntryActions = () => {
* Calls mutation to add new entry * Calls mutation to add new entry
* @private * @private
*/ */
const { mutateAsync: addEntryMutation } = useMutation({ const _addEntryMutation = useMutation({
// TODO(v4): optimistic create entry // TODO(v4): optimistic create entry
mutationFn: postAddEntry, mutationFn: postAddEntry,
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
@@ -93,6 +95,7 @@ export const useEntryActions = () => {
const applicationOptions = { const applicationOptions = {
after: options?.after, after: options?.after,
before: options?.before, before: options?.before,
defaultPublic: options?.defaultPublic ?? defaultPublic,
lastEventId: options?.lastEventId, lastEventId: options?.lastEventId,
linkPrevious: options?.linkPrevious ?? linkPrevious, linkPrevious: options?.linkPrevious ?? linkPrevious,
}; };
@@ -108,6 +111,7 @@ export const useEntryActions = () => {
// Override event with options from editor settings // Override event with options from editor settings
newEntry.linkStart = applicationOptions.linkPrevious; newEntry.linkStart = applicationOptions.linkPrevious;
newEntry.isPublic = applicationOptions.defaultPublic;
if (newEntry.duration === undefined && newEntry.timeEnd === undefined) { if (newEntry.duration === undefined && newEntry.timeEnd === undefined) {
newEntry.duration = parseUserTime(defaultDuration); newEntry.duration = parseUserTime(defaultDuration);
@@ -143,16 +147,17 @@ export const useEntryActions = () => {
} }
try { try {
await addEntryMutation(newEntry); await _addEntryMutation.mutateAsync(newEntry);
} catch (error) { } catch (error) {
logAxiosError('Failed adding event', error); logAxiosError('Failed adding event', error);
} }
}, },
[ [
addEntryMutation, _addEntryMutation,
defaultDangerTime, defaultDangerTime,
defaultDuration, defaultDuration,
defaultEndAction, defaultEndAction,
defaultPublic,
defaultTimerType, defaultTimerType,
defaultTimeStrategy, defaultTimeStrategy,
defaultWarnTime, defaultWarnTime,
@@ -165,7 +170,7 @@ export const useEntryActions = () => {
* Calls mutation to clone a selection * Calls mutation to clone a selection
* @private * @private
*/ */
const { mutateAsync: cloneEntryMutation } = useMutation({ const _cloneMutation = useMutation({
mutationFn: postCloneEntry, mutationFn: postCloneEntry,
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
}); });
@@ -176,19 +181,19 @@ export const useEntryActions = () => {
const clone = useCallback( const clone = useCallback(
async (entryId: EntryId) => { async (entryId: EntryId) => {
try { try {
await cloneEntryMutation(entryId); await _cloneMutation.mutateAsync(entryId);
} catch (error) { } catch (error) {
logAxiosError('Error cloning entry', error); logAxiosError('Error cloning entry', error);
} }
}, },
[cloneEntryMutation], [_cloneMutation],
); );
/** /**
* Calls mutation to update existing entry * Calls mutation to update existing entry
* @private * @private
*/ */
const { mutateAsync: updateEntryMutation } = useMutation({ const _updateEntryMutation = useMutation({
mutationFn: putEditEntry, mutationFn: putEditEntry,
// we optimistically update here // we optimistically update here
onMutate: async (newEvent) => { onMutate: async (newEvent) => {
@@ -234,12 +239,12 @@ export const useEntryActions = () => {
const updateEntry = useCallback( const updateEntry = useCallback(
async (event: Partial<OntimeEntry>) => { async (event: Partial<OntimeEntry>) => {
try { try {
await updateEntryMutation(event); await _updateEntryMutation.mutateAsync(event);
} catch (error) { } catch (error) {
logAxiosError('Error updating event', error); logAxiosError('Error updating event', error);
} }
}, },
[updateEntryMutation], [_updateEntryMutation],
); );
const updateCustomField = useCallback( const updateCustomField = useCallback(
@@ -287,7 +292,7 @@ export const useEntryActions = () => {
} }
try { try {
await updateEntryMutation(newEvent); await _updateEntryMutation.mutateAsync(newEvent);
} catch (error) { } catch (error) {
logAxiosError('Error updating event', error); logAxiosError('Error updating event', error);
} }
@@ -339,14 +344,14 @@ export const useEntryActions = () => {
return previousEnd; return previousEnd;
} }
}, },
[updateEntryMutation, queryClient], [_updateEntryMutation, queryClient],
); );
/** /**
* Calls mutation to edit multiple events * Calls mutation to edit multiple events
* @private * @private
*/ */
const { mutateAsync: batchUpdateEventsMutation } = useMutation({ const _batchUpdateEventsMutation = useMutation({
mutationFn: putBatchEditEvents, mutationFn: putBatchEditEvents,
onMutate: async ({ ids, data }) => { onMutate: async ({ ids, data }) => {
// cancel ongoing queries // cancel ongoing queries
@@ -405,19 +410,19 @@ export const useEntryActions = () => {
const batchUpdateEvents = useCallback( const batchUpdateEvents = useCallback(
async (data: Partial<OntimeEvent>, eventIds: string[]) => { async (data: Partial<OntimeEvent>, eventIds: string[]) => {
try { try {
await batchUpdateEventsMutation({ ids: eventIds, data }); await _batchUpdateEventsMutation.mutateAsync({ ids: eventIds, data });
} catch (error) { } catch (error) {
logAxiosError('Error updating events', error); logAxiosError('Error updating events', error);
} }
}, },
[batchUpdateEventsMutation], [_batchUpdateEventsMutation],
); );
/** /**
* Calls mutation to delete an entry * Calls mutation to delete an entry
* @private * @private
*/ */
const { mutateAsync: deleteEntryMutation } = useMutation({ const _deleteEntryMutation = useMutation({
mutationFn: deleteEntries, mutationFn: deleteEntries,
// we optimistically update here // we optimistically update here
onMutate: async (entryIds: EntryId[]) => { onMutate: async (entryIds: EntryId[]) => {
@@ -462,19 +467,19 @@ export const useEntryActions = () => {
const deleteEntry = useCallback( const deleteEntry = useCallback(
async (entryIds: EntryId[]) => { async (entryIds: EntryId[]) => {
try { try {
await deleteEntryMutation(entryIds); await _deleteEntryMutation.mutateAsync(entryIds);
} catch (error) { } catch (error) {
logAxiosError('Error deleting event', error); logAxiosError('Error deleting event', error);
} }
}, },
[deleteEntryMutation], [_deleteEntryMutation],
); );
/** /**
* Calls mutation to delete all events * Calls mutation to delete all events
* @private * @private
*/ */
const { mutateAsync: deleteAllEntriesMutation } = useMutation({ const _deleteAllEntriesMutation = useMutation({
mutationFn: requestDeleteAll, mutationFn: requestDeleteAll,
// we optimistically update here // we optimistically update here
onMutate: async () => { onMutate: async () => {
@@ -514,17 +519,17 @@ export const useEntryActions = () => {
*/ */
const deleteAllEntries = useCallback(async () => { const deleteAllEntries = useCallback(async () => {
try { try {
await deleteAllEntriesMutation(); await _deleteAllEntriesMutation.mutateAsync();
} catch (error) { } catch (error) {
logAxiosError('Error deleting events', error); logAxiosError('Error deleting events', error);
} }
}, [deleteAllEntriesMutation]); }, [_deleteAllEntriesMutation]);
/** /**
* Calls mutation to apply a delay * Calls mutation to apply a delay
* @private * @private
*/ */
const { mutateAsync: applyDelayMutation } = useMutation({ const _applyDelayMutation = useMutation({
mutationFn: requestApplyDelay, mutationFn: requestApplyDelay,
onSuccess: (response) => { onSuccess: (response) => {
if (!response.data) return; if (!response.data) return;
@@ -551,19 +556,19 @@ export const useEntryActions = () => {
const applyDelay = useCallback( const applyDelay = useCallback(
async (delayEventId: EntryId) => { async (delayEventId: EntryId) => {
try { try {
await applyDelayMutation(delayEventId); await _applyDelayMutation.mutateAsync(delayEventId);
} catch (error) { } catch (error) {
logAxiosError('Error applying delay', error); logAxiosError('Error applying delay', error);
} }
}, },
[applyDelayMutation], [_applyDelayMutation],
); );
/** /**
* Calls mutation to dissolve a block * Calls mutation to dissolve a block
* @private * @private
*/ */
const { mutateAsync: ungroupMutation } = useMutation({ const _ungroupMutation = useMutation({
mutationFn: requestUngroup, mutationFn: requestUngroup,
onSuccess: (response) => { onSuccess: (response) => {
if (!response.data) return; if (!response.data) return;
@@ -587,19 +592,19 @@ export const useEntryActions = () => {
const ungroup = useCallback( const ungroup = useCallback(
async (blockId: EntryId) => { async (blockId: EntryId) => {
try { try {
await ungroupMutation(blockId); await _ungroupMutation.mutateAsync(blockId);
} catch (error) { } catch (error) {
logAxiosError('Error dissolving block', error); logAxiosError('Error dissolving block', error);
} }
}, },
[ungroupMutation], [_ungroupMutation],
); );
/** /**
* Calls mutation to create a block with a selection * Calls mutation to create a block with a selection
* @private * @private
*/ */
const { mutateAsync: groupEntriesMutation } = useMutation({ const _groupEntriesMutation = useMutation({
mutationFn: requestGroupEntries, mutationFn: requestGroupEntries,
onSuccess: (response) => { onSuccess: (response) => {
if (!response.data) return; if (!response.data) return;
@@ -623,19 +628,19 @@ export const useEntryActions = () => {
const groupEntries = useCallback( const groupEntries = useCallback(
async (entryIds: EntryId[]) => { async (entryIds: EntryId[]) => {
try { try {
await groupEntriesMutation(entryIds); await _groupEntriesMutation.mutateAsync(entryIds);
} catch (error) { } catch (error) {
logAxiosError('Error grouping entries', error); logAxiosError('Error grouping entries', error);
} }
}, },
[groupEntriesMutation], [_groupEntriesMutation],
); );
/** /**
* Calls mutation to reorder an entry * Calls mutation to reorder an entry
* @private * @private
*/ */
const { mutateAsync: reorderEntryMutation } = useMutation({ const _reorderEntryMutation = useMutation({
mutationFn: patchReorderEntry, mutationFn: patchReorderEntry,
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
@@ -644,40 +649,6 @@ export const useEntryActions = () => {
}, },
}); });
/**
* Reorders a given entry one step up or down in the timeline
*/
const move = useCallback(
async (entryId: EntryId, direction: 'up' | 'down') => {
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!rundown) {
return;
}
const { destinationId, order } =
direction === 'up'
? moveUp(entryId, rundown.flatOrder, rundown.entries)
: moveDown(entryId, rundown.flatOrder, rundown.entries);
if (!destinationId) {
return; // noop
}
try {
const reorderObject: ReorderEntry = {
entryId,
destinationId,
order,
};
await reorderEntryMutation(reorderObject);
} catch (error) {
logAxiosError('Error re-ordering event', error);
}
// the rundown needs to know whether we moved into a block
return rundown.entries[destinationId]?.type === 'block' ? destinationId : undefined;
},
[queryClient, reorderEntryMutation],
);
/** /**
* Reorders a given entry * Reorders a given entry
*/ */
@@ -689,19 +660,43 @@ export const useEntryActions = () => {
destinationId, destinationId,
order, order,
}; };
await reorderEntryMutation(reorderObject); await _reorderEntryMutation.mutateAsync(reorderObject);
} catch (error) { } catch (error) {
logAxiosError('Error re-ordering event', error); logAxiosError('Error re-ordering event', error);
} }
}, },
[reorderEntryMutation], [_reorderEntryMutation],
); );
const move = useCallback(async (entryId: EntryId, direction: 'up' | 'down') => {
const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!cachedRundown?.order) {
return;
}
const { destinationId, order } =
direction === 'up'
? moveUp(entryId, cachedRundown.order, cachedRundown.entries)
: moveDown(entryId, cachedRundown.order, cachedRundown.entries);
if (destinationId) {
try {
const reorderObject: ReorderEntry = {
entryId,
destinationId,
order: order as 'before' | 'after' | 'insert',
};
await _reorderEntryMutation.mutateAsync(reorderObject);
} catch (error) {
logAxiosError('Error re-ordering event', error);
}
}
}, []);
/** /**
* Calls mutation to swap events * Calls mutation to swap events
* @private * @private
*/ */
const { mutateAsync: swapEventsMutation } = useMutation({ const _swapEvents = useMutation({
mutationFn: requestEventSwap, mutationFn: requestEventSwap,
// we optimistically update here // we optimistically update here
onMutate: async ({ from, to }) => { onMutate: async ({ from, to }) => {
@@ -755,12 +750,12 @@ export const useEntryActions = () => {
const swapEvents = useCallback( const swapEvents = useCallback(
async ({ from, to }: SwapEntry) => { async ({ from, to }: SwapEntry) => {
try { try {
await swapEventsMutation({ from, to }); await _swapEvents.mutateAsync({ from, to });
} catch (error) { } catch (error) {
logAxiosError('Error re-ordering event', error); logAxiosError('Error re-ordering event', error);
} }
}, },
[swapEventsMutation], [_swapEvents],
); );
return { return {
@@ -800,7 +795,7 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
order = order.filter((id) => id !== entry.id); order = order.filter((id) => id !== entry.id);
} else { } else {
const parent = entries[entry.parent] as OntimeBlock; const parent = entries[entry.parent] as OntimeBlock;
parent.entries = parent.entries.filter((parentEntry) => parentEntry !== entry.id); parent.events = parent.events.filter((event) => event !== entry.id);
} }
delete entries[entry.id]; delete entries[entry.id];
@@ -1,6 +1,4 @@
import { MutableRefObject, useCallback, useEffect, useRef } from 'react'; import { MutableRefObject, useCallback, useEffect } from 'react';
import { useSelectedEventId } from './useSocket';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>( function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: MutableRefObject<ComponentRef>, componentRef: MutableRefObject<ComponentRef>,
@@ -18,23 +16,6 @@ function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends H
scrollRef.current.scrollTo({ top, behavior: 'smooth' }); scrollRef.current.scrollTo({ top, behavior: 'smooth' });
} }
function snapToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: MutableRefObject<ComponentRef>,
scrollRef: MutableRefObject<ScrollRef>,
topOffset: number,
) {
if (!componentRef.current || !scrollRef.current) {
return;
}
const componentRect = componentRef.current.getBoundingClientRect();
const scrollRect = scrollRef.current.getBoundingClientRect();
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - topOffset;
// maintain current x scroll position
scrollRef.current.scrollTo(scrollRef.current.scrollLeft, top);
}
interface UseFollowComponentProps { interface UseFollowComponentProps {
followRef: MutableRefObject<HTMLElement | null>; followRef: MutableRefObject<HTMLElement | null>;
scrollRef: MutableRefObject<HTMLElement | null>; scrollRef: MutableRefObject<HTMLElement | null>;
@@ -81,32 +62,3 @@ export default function useFollowComponent(props: UseFollowComponentProps) {
return scrollToRefComponent; return scrollToRefComponent;
} }
export function useFollowSelected(doFollow: boolean, topOffset = 100) {
const selectedEvenId = useSelectedEventId();
const selectedRef = useRef<HTMLTableRowElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!doFollow) {
return;
}
if (selectedEvenId && selectedRef.current && scrollRef.current) {
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
snapToComponent(
{ current: selectedRef.current } as MutableRefObject<HTMLElement>,
{ current: scrollRef.current } as MutableRefObject<HTMLElement>,
topOffset,
);
});
}
}, [doFollow, selectedEvenId, topOffset]);
return {
selectedRef,
scrollRef,
};
}
@@ -1,9 +0,0 @@
import { useMemo } from 'react';
import { useOs, useViewportSize } from '@mantine/hooks';
export function useIsMobileDevice(): boolean {
const { width } = useViewportSize();
const os = useOs();
return useMemo(() => (os === 'ios' || os === 'android') && width < 800, [width, os]);
}
@@ -1,8 +0,0 @@
import { useMemo } from 'react';
import { useViewportSize } from '@mantine/hooks';
export function useIsMobileScreen(): boolean {
const { width } = useViewportSize();
return useMemo(() => width < 800, [width]);
}
@@ -1,9 +0,0 @@
import { useMemo } from 'react';
import { useOs, useViewportSize } from '@mantine/hooks';
export function useIsSmallDevice(): boolean {
const { width } = useViewportSize();
const os = useOs();
return useMemo(() => (os === 'ios' || os === 'android') && width < 1300, [width, os]);
}
@@ -1,8 +0,0 @@
import { useMemo } from 'react';
import { useViewportSize } from '@mantine/hooks';
export function useIsSmallScreen(): boolean {
const { width } = useViewportSize();
return useMemo(() => width < 1300, [width]);
}
@@ -0,0 +1,46 @@
import { useEffect, useState } from 'react';
const scriptTagId = 'ontime-override';
export const useRuntimeStylesheet = (pathToFile) => {
const [shouldRender, setShouldRender] = useState(false);
useEffect(() => {
const fetchData = async () => {
const response = await fetch(pathToFile);
if (response.ok) {
return response.text();
}
};
if (!pathToFile) {
document.getElementById(scriptTagId)?.remove();
setShouldRender(true);
return;
}
if (document.getElementById(scriptTagId)) {
setShouldRender(true);
return;
}
setShouldRender(false);
const styleSheet = document.createElement('style');
styleSheet.rel = 'stylesheet';
styleSheet.setAttribute('id', scriptTagId);
fetchData()
.then((data) => {
styleSheet.innerHTML = data;
document.head.append(styleSheet);
})
.catch((error) => {
console.error(`Error loading stylesheet: ${error}`);
})
.finally(() => {
// schedule render for next tick
setTimeout(() => setShouldRender(true), 0);
});
}, [pathToFile]);
return { shouldRender };
};
@@ -1,75 +0,0 @@
import { useEffect, useState } from 'react';
const scriptTagId = 'ontime-override';
export const useRuntimeStylesheet = (pathToFile?: string): { shouldRender: boolean } => {
const [shouldRender, setShouldRender] = useState(false);
/**
* When a view mounts or the stylesheet path changes we need to handle potentially loading a new stylesheet
* - if no path is given, ensure there is no stylesheet loaded
* - if a path is given, fetch the stylesheet and inject it into the document head
* @returns { shouldRender: boolean } - after the stylesheet is handled and the clients are ready to render
*/
useEffect(() => {
if (!pathToFile) {
handleNoStylesheet();
return;
}
// there is already a stylesheet loaded, nothing further to do
if (document.getElementById(scriptTagId)) {
setShouldRender(true);
return;
}
setShouldRender(false);
fetchStylesheetData(pathToFile)
.then((data: string | undefined) => {
if (!data) {
console.error('Error loading stylesheet: no data');
return;
}
return injectStylesheet(data);
})
.catch((error: unknown) => {
console.error(`Error loading stylesheet: ${error}`);
})
.finally(() => {
// schedule render for next tick
setTimeout(() => setShouldRender(true), 0);
});
/**
* No stylesheet was provided, remove any existing stylesheet
*/
function handleNoStylesheet() {
document.getElementById(scriptTagId)?.remove();
setShouldRender(true);
}
/**
* Get data from backend
*/
async function fetchStylesheetData(path: string) {
const response = await fetch(path);
if (response.ok) {
return response.text();
}
return undefined;
}
/**
* Add a stylesheet with given content to the document head
*/
async function injectStylesheet(styleContent: string) {
const styleSheet = document.createElement('style');
styleSheet.setAttribute('id', scriptTagId);
styleSheet.innerHTML = styleContent;
document.head.append(styleSheet);
}
}, [pathToFile]);
return { shouldRender };
};
+46 -67
View File
@@ -1,7 +1,7 @@
import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types'; import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
import { useRuntimeStore } from '../stores/runtime'; import { useRuntimeStore } from '../stores/runtime';
import { sendSocket } from '../utils/socket'; import { socketSendJson } from '../utils/socket';
const createSelector = const createSelector =
<T>(selector: (state: RuntimeStore) => T) => <T>(selector: (state: RuntimeStore) => T) =>
@@ -9,18 +9,22 @@ const createSelector =
useRuntimeStore(selector); useRuntimeStore(selector);
export const setClientRemote = { export const setClientRemote = {
setIdentify: (payload: { target: string; identify: boolean }) => sendSocket('client', payload), setIdentify: (payload: { target: string; identify: boolean }) => socketSendJson('client', payload),
setRedirect: (payload: { target: string; redirect: string }) => sendSocket('client', payload), setRedirect: (payload: { target: string; redirect: string }) => socketSendJson('client', payload),
setClientName: (payload: { target: string; rename: string }) => sendSocket('client', payload), setClientName: (payload: { target: string; rename: string }) => socketSendJson('client', payload),
}; };
export const useRundownEditor = createSelector((state: RuntimeStore) => ({ export const useRundownEditor = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback, playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null, selectedEventId: state.eventNow?.id ?? null,
selectedBlockId: state.blockNow?.id ?? null,
nextEventId: state.eventNext?.id ?? null, nextEventId: state.eventNext?.id ?? null,
})); }));
export const useOperator = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null,
}));
export const useTimerViewControl = createSelector((state: RuntimeStore) => ({ export const useTimerViewControl = createSelector((state: RuntimeStore) => ({
blackout: state.message.timer.blackout, blackout: state.message.timer.blackout,
blink: state.message.timer.blink, blink: state.message.timer.blink,
@@ -33,28 +37,29 @@ export const useTimerMessageInput = createSelector((state: RuntimeStore) => ({
})); }));
export const useExternalMessageInput = createSelector((state: RuntimeStore) => ({ export const useExternalMessageInput = createSelector((state: RuntimeStore) => ({
text: state.message.secondary, text: state.message.external,
visible: state.message.timer.secondarySource === 'secondary', visible: state.message.timer.secondarySource === 'external',
})); }));
export const useMessagePreview = createSelector((state: RuntimeStore) => ({ export const useMessagePreview = createSelector((state: RuntimeStore) => ({
blink: state.message.timer.blink, blink: state.message.timer.blink,
blackout: state.message.timer.blackout, blackout: state.message.timer.blackout,
phase: state.timer.phase, phase: state.timer.phase,
secondarySource: state.message.timer.secondarySource, showAuxTimer: state.message.timer.secondarySource === 'aux',
showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external),
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text), showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
timerType: state.eventNow?.timerType ?? null, timerType: state.eventNow?.timerType ?? null,
countToEnd: state.eventNow?.countToEnd ?? false, countToEnd: state.eventNow?.countToEnd ?? false,
})); }));
export const setMessage = { export const setMessage = {
timerText: (payload: string) => sendSocket('message', { timer: { text: payload } }), timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }),
timerVisible: (payload: boolean) => sendSocket('message', { timer: { visible: payload } }), timerVisible: (payload: boolean) => socketSendJson('message', { timer: { visible: payload } }),
secondaryMessage: (payload: string) => sendSocket('message', { secondary: payload }), externalText: (payload: string) => socketSendJson('message', { external: payload }),
timerBlink: (payload: boolean) => sendSocket('message', { timer: { blink: payload } }), timerBlink: (payload: boolean) => socketSendJson('message', { timer: { blink: payload } }),
timerBlackout: (payload: boolean) => sendSocket('message', { timer: { blackout: payload } }), timerBlackout: (payload: boolean) => socketSendJson('message', { timer: { blackout: payload } }),
timerSecondarySource: (payload: TimerMessage['secondarySource']) => timerSecondary: (payload: TimerMessage['secondarySource']) =>
sendSocket('message', { timer: { secondarySource: payload } }), socketSendJson('message', { timer: { secondarySource: payload } }),
}; };
export const usePlaybackControl = createSelector((state: RuntimeStore) => ({ export const usePlaybackControl = createSelector((state: RuntimeStore) => ({
@@ -65,66 +70,40 @@ export const usePlaybackControl = createSelector((state: RuntimeStore) => ({
})); }));
export const setPlayback = { export const setPlayback = {
start: () => sendSocket('start', undefined), start: () => socketSendJson('start'),
pause: () => sendSocket('pause', undefined), pause: () => socketSendJson('pause'),
roll: () => sendSocket('roll', undefined), roll: () => socketSendJson('roll'),
startNext: () => sendSocket('start', 'next'), startNext: () => socketSendJson('start', 'next'),
previous: () => { previous: () => {
sendSocket('load', 'previous'); socketSendJson('load', 'previous');
}, },
next: () => { next: () => {
sendSocket('load', 'next'); socketSendJson('load', 'next');
}, },
stop: () => { stop: () => {
sendSocket('stop', undefined); socketSendJson('stop');
}, },
reload: () => { reload: () => {
sendSocket('reload', undefined); socketSendJson('reload');
}, },
addTime: (amount: number) => { addTime: (amount: number) => {
sendSocket('addtime', amount); socketSendJson('addtime', amount);
}, },
}; };
export const useAuxTimersTime = createSelector((state: RuntimeStore) => { export const useAuxTimerTime = createSelector((state: RuntimeStore) => state.auxtimer1.current);
return {
aux1: state.auxtimer1.current,
aux2: state.auxtimer2.current,
aux3: state.auxtimer3.current,
};
});
export const useAuxTimerTime = (index: number) => export const useAuxTimerControl = createSelector((state: RuntimeStore) => ({
createSelector((state: RuntimeStore) => { playback: state.auxtimer1.playback,
if (index === 1) return state.auxtimer1.current; direction: state.auxtimer1.direction,
if (index === 2) return state.auxtimer2.current; }));
return state.auxtimer3.current;
})();
export const useAuxTimerControl = (index: number) =>
createSelector((state: RuntimeStore) => {
if (index === 1)
return {
playback: state.auxtimer1.playback,
direction: state.auxtimer1.direction,
};
if (index === 2)
return {
playback: state.auxtimer2.playback,
direction: state.auxtimer2.direction,
};
return {
playback: state.auxtimer3.playback,
direction: state.auxtimer3.direction,
};
})();
export const setAuxTimer = { export const setAuxTimer = {
start: (index: number) => sendSocket('auxtimer', { [index]: SimplePlayback.Start }), start: () => socketSendJson('auxtimer', { '1': SimplePlayback.Start }),
pause: (index: number) => sendSocket('auxtimer', { [index]: SimplePlayback.Pause }), pause: () => socketSendJson('auxtimer', { '1': SimplePlayback.Pause }),
stop: (index: number) => sendSocket('auxtimer', { [index]: SimplePlayback.Stop }), stop: () => socketSendJson('auxtimer', { '1': SimplePlayback.Stop }),
setDirection: (index: number, direction: SimpleDirection) => sendSocket('auxtimer', { [index]: { direction } }), setDirection: (direction: SimpleDirection) => socketSendJson('auxtimer', { '1': { direction } }),
setDuration: (index: number, time: number) => sendSocket('auxtimer', { [index]: { duration: time } }), setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
}; };
export const useSelectedEventId = createSelector((state: RuntimeStore) => ({ export const useSelectedEventId = createSelector((state: RuntimeStore) => ({
@@ -132,14 +111,14 @@ export const useSelectedEventId = createSelector((state: RuntimeStore) => ({
})); }));
export const useCurrentBlockId = createSelector((state: RuntimeStore) => ({ export const useCurrentBlockId = createSelector((state: RuntimeStore) => ({
currentBlockId: state.blockNow?.id ?? null, currentBlockId: state.currentBlock.block?.id ?? null,
})); }));
export const setEventPlayback = { export const setEventPlayback = {
loadEvent: (id: string) => sendSocket('load', { id }), loadEvent: (id: string) => socketSendJson('load', { id }),
startEvent: (id: string) => sendSocket('start', { id }), startEvent: (id: string) => socketSendJson('start', { id }),
start: () => sendSocket('start', undefined), start: () => socketSendJson('start'),
pause: () => sendSocket('pause', undefined), pause: () => socketSendJson('pause'),
}; };
export const useTimer = createSelector((state: RuntimeStore) => ({ export const useTimer = createSelector((state: RuntimeStore) => ({
@@ -173,7 +152,7 @@ export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) =
selectedEventIndex: state.runtime.selectedEventIndex, selectedEventIndex: state.runtime.selectedEventIndex,
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset, offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
blockStartedAt: state.blockNow?.startedAt ?? null, currentBlock: state.currentBlock,
})); }));
export const useTimelineStatus = createSelector((state: RuntimeStore) => ({ export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
@@ -211,7 +190,7 @@ export const useOffsetMode = createSelector((state: RuntimeStore) => ({
offsetMode: state.runtime.offsetMode, offsetMode: state.runtime.offsetMode,
})); }));
export const setOffsetMode = (payload: OffsetMode) => sendSocket('offsetmode', payload); export const setOffsetMode = (payload: OffsetMode) => socketSendJson('offsetmode', payload);
export const usePlayback = () => { export const usePlayback = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
@@ -3,6 +3,8 @@ import { ProjectData } from 'ontime-types';
export const projectDataPlaceholder: ProjectData = { export const projectDataPlaceholder: ProjectData = {
title: '', title: '',
description: '', description: '',
publicUrl: '',
publicInfo: '',
backstageUrl: '', backstageUrl: '',
backstageInfo: '', backstageInfo: '',
projectLogo: null, projectLogo: null,
+1 -7
View File
@@ -1,17 +1,11 @@
import { QueryClient } from '@tanstack/react-query'; import { QueryClient } from '@tanstack/react-query';
import { MILLIS_PER_MINUTE } from 'ontime-utils';
import { isOntimeCloud } from '../externals'; import { isOntimeCloud } from '../externals';
export const ontimeQueryClient = new QueryClient({ export const ontimeQueryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
gcTime: 10 * MILLIS_PER_MINUTE, gcTime: 1000 * 60 * 10, // 10 min
// staleTime: MILLIS_PER_HOUR, //TODO: when all routes have implemented refetch signal from server, we can the assume that the data is not stale until we get the signal
networkMode: 'always',
refetchOnWindowFocus: false,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
}, },
mutations: { mutations: {
/** /**
@@ -3,6 +3,7 @@ import { create } from 'zustand';
export enum AppMode { export enum AppMode {
Run = 'run', Run = 'run',
Edit = 'edit', Edit = 'edit',
Freeze = 'freeze',
} }
const appModeKey = 'ontime-app-mode'; const appModeKey = 'ontime-app-mode';
@@ -10,6 +10,7 @@ type EditorSettingsStore = {
defaultTimeStrategy: TimeStrategy; defaultTimeStrategy: TimeStrategy;
defaultWarnTime: string; defaultWarnTime: string;
defaultDangerTime: string; defaultDangerTime: string;
defaultPublic: boolean;
defaultTimerType: TimerType; defaultTimerType: TimerType;
defaultEndAction: EndAction; defaultEndAction: EndAction;
setDefaultDuration: (defaultDuration: string) => void; setDefaultDuration: (defaultDuration: string) => void;
@@ -17,6 +18,7 @@ type EditorSettingsStore = {
setTimeStrategy: (timeStrategy: TimeStrategy) => void; setTimeStrategy: (timeStrategy: TimeStrategy) => void;
setWarnTime: (warnTime: string) => void; setWarnTime: (warnTime: string) => void;
setDangerTime: (dangerTime: string) => void; setDangerTime: (dangerTime: string) => void;
setDefaultPublic: (defaultPublic: boolean) => void;
setDefaultTimerType: (defaultTimerType: TimerType) => void; setDefaultTimerType: (defaultTimerType: TimerType) => void;
setDefaultEndAction: (defaultEndAction: EndAction) => void; setDefaultEndAction: (defaultEndAction: EndAction) => void;
}; };
@@ -27,6 +29,7 @@ export const editorSettingsDefaults = {
timeStrategy: TimeStrategy.LockDuration, timeStrategy: TimeStrategy.LockDuration,
warnTime: '00:02:00', // 120000 same as backend warnTime: '00:02:00', // 120000 same as backend
dangerTime: '00:01:00', // 60000 same as backend dangerTime: '00:01:00', // 60000 same as backend
isPublic: true,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
endAction: EndAction.None, endAction: EndAction.None,
}; };
@@ -37,6 +40,7 @@ enum EditorSettingsKeys {
DefaultTimeStrategy = 'ontime-time-strategy', DefaultTimeStrategy = 'ontime-time-strategy',
DefaultWarnTime = 'ontime-default-warn-time', DefaultWarnTime = 'ontime-default-warn-time',
DefaultDangerTime = 'ontime-default-danger-time', DefaultDangerTime = 'ontime-default-danger-time',
DefaultPublic = 'ontime-default-public',
DefaultTimerType = 'ontime-default-timer-type', DefaultTimerType = 'ontime-default-timer-type',
DefaultEndAction = 'ontime-default-end-action', DefaultEndAction = 'ontime-default-end-action',
} }
@@ -51,6 +55,7 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
), ),
defaultWarnTime: localStorage.getItem(EditorSettingsKeys.DefaultWarnTime) ?? editorSettingsDefaults.warnTime, defaultWarnTime: localStorage.getItem(EditorSettingsKeys.DefaultWarnTime) ?? editorSettingsDefaults.warnTime,
defaultDangerTime: localStorage.getItem(EditorSettingsKeys.DefaultDangerTime) ?? editorSettingsDefaults.dangerTime, defaultDangerTime: localStorage.getItem(EditorSettingsKeys.DefaultDangerTime) ?? editorSettingsDefaults.dangerTime,
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, editorSettingsDefaults.isPublic),
defaultTimerType: validateTimerType( defaultTimerType: validateTimerType(
localStorage.getItem(EditorSettingsKeys.DefaultTimerType), localStorage.getItem(EditorSettingsKeys.DefaultTimerType),
editorSettingsDefaults.timerType, editorSettingsDefaults.timerType,
@@ -87,6 +92,11 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
localStorage.setItem(EditorSettingsKeys.DefaultDangerTime, String(defaultDangerTime)); localStorage.setItem(EditorSettingsKeys.DefaultDangerTime, String(defaultDangerTime));
return { defaultDangerTime }; return { defaultDangerTime };
}), }),
setDefaultPublic: (defaultPublic) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
return { defaultPublic };
}),
setDefaultTimerType: (defaultTimerType) => setDefaultTimerType: (defaultTimerType) =>
set(() => { set(() => {
localStorage.setItem(EditorSettingsKeys.DefaultTimerType, String(defaultTimerType)); localStorage.setItem(EditorSettingsKeys.DefaultTimerType, String(defaultTimerType));
+3 -3
View File
@@ -1,10 +1,10 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { Log, LogLevel, LogOrigin, MessageTag } from 'ontime-types'; import { Log, LogLevel, LogOrigin } from 'ontime-types';
import { generateId, millisToString } from 'ontime-utils'; import { generateId, millisToString } from 'ontime-utils';
import { useStore } from 'zustand'; import { useStore } from 'zustand';
import { createStore } from 'zustand/vanilla'; import { createStore } from 'zustand/vanilla';
import { sendSocket } from '../utils/socket'; import { socketSendJson } from '../utils/socket';
import { nowInMillis } from '../utils/time'; import { nowInMillis } from '../utils/time';
type LogStore = { type LogStore = {
@@ -40,7 +40,7 @@ export function useEmitLog() {
text, text,
}; };
sendSocket(MessageTag.Log, log); socketSendJson('ontime-log', log);
}, []); }, []);
/** /**
@@ -19,6 +19,7 @@ describe('cloneEvent()', () => {
linkStart: false, linkStart: false,
countToEnd: false, countToEnd: false,
endAction: EndAction.None, endAction: EndAction.None,
isPublic: false,
skip: false, skip: false,
colour: 'F00', colour: 'F00',
revision: 10, revision: 10,
@@ -51,6 +52,7 @@ describe('cloneEvent()', () => {
countToEnd: original.countToEnd, countToEnd: original.countToEnd,
linkStart: original.linkStart, linkStart: original.linkStart,
endAction: original.endAction, endAction: original.endAction,
isPublic: original.isPublic,
skip: original.skip, skip: original.skip,
colour: original.colour, colour: original.colour,
revision: 0, revision: 0,
@@ -18,19 +18,19 @@ describe('getAccessibleColour()', () => {
it('handles named colours', () => { it('handles named colours', () => {
const colour = 'red'; const colour = 'red';
const { backgroundColor, color } = getAccessibleColour(colour); const { backgroundColor, color } = getAccessibleColour(colour);
expect(backgroundColor).toBe('#ff0000ff'); expect(backgroundColor).toBe('#FF0000FF');
expect(color).toBe('#fffffa'); expect(color).toBe('#fffffa');
}); });
it('handles hex colours', () => { it('handles hex colours', () => {
const colour = '#0F0'; const colour = '#0F0';
const { backgroundColor, color } = getAccessibleColour(colour); const { backgroundColor, color } = getAccessibleColour(colour);
expect(backgroundColor).toBe('#00ff00ff'); expect(backgroundColor).toBe('#00FF00FF');
expect(color).toBe('black'); expect(color).toBe('black');
}); });
it('handles transparens', () => { it('handles transparens', () => {
const colour = '#0F08'; const colour = '#0F08';
const { backgroundColor, color } = getAccessibleColour(colour); const { backgroundColor, color } = getAccessibleColour(colour);
expect(backgroundColor).toBe('#0c940cff'); expect(backgroundColor).toBe('#0C940CFF');
expect(color).toBe('#fffffa'); expect(color).toBe('#fffffa');
}); });
}); });
+1
View File
@@ -20,6 +20,7 @@ export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
countToEnd: event.countToEnd, countToEnd: event.countToEnd,
linkStart: event.linkStart, linkStart: event.linkStart,
endAction: event.endAction, endAction: event.endAction,
isPublic: event.isPublic,
skip: event.skip, skip: event.skip,
colour: event.colour, colour: event.colour,
parent: event.parent, parent: event.parent,
+2 -2
View File
@@ -20,13 +20,13 @@ export function openLink(url: string) {
* serverUrl and baseURI are used for testing * serverUrl and baseURI are used for testing
*/ */
export function handleLinks( export function handleLinks(
event: MouseEvent,
location: string, location: string,
event?: MouseEvent,
externalServerUrl: string = serverURL, externalServerUrl: string = serverURL,
externalBaseURI: string = baseURI, externalBaseURI: string = baseURI,
) { ) {
// we handle the link manually // we handle the link manually
event?.preventDefault(); event.preventDefault();
const destination = new URL(externalServerUrl); const destination = new URL(externalServerUrl);
destination.pathname = externalBaseURI ? `${externalBaseURI}/${location}` : location; destination.pathname = externalBaseURI ? `${externalBaseURI}/${location}` : location;
+64 -67
View File
@@ -1,16 +1,7 @@
import { import { Log, Rundown, RuntimeStore } from 'ontime-types';
ApiAction,
Log,
MessageTag,
RefetchKey,
Rundown,
RuntimeStore,
WsPacketToClient,
WsPacketToServer,
} from 'ontime-types';
import { isProduction, websocketUrl } from '../../externals'; import { isProduction, websocketUrl } from '../../externals';
import { CLIENT_LIST, CUSTOM_FIELDS, REPORT, RUNDOWN, RUNTIME, VIEW_SETTINGS } from '../api/constants'; import { CLIENT_LIST, CUSTOM_FIELDS, REPORT, RUNDOWN, RUNTIME } from '../api/constants';
import { invalidateAllCaches } from '../api/utils'; import { invalidateAllCaches } from '../api/utils';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
import { import {
@@ -42,14 +33,16 @@ export const connectSocket = () => {
hasConnected = true; hasConnected = true;
reconnectAttempts = 0; reconnectAttempts = 0;
sendSocket(MessageTag.ClientSet, { socketSendJson('set-client-patch', {
type: 'ontime', type: 'ontime',
origin: window.location.origin, origin: window.location.origin,
path: window.location.pathname + window.location.search, path: window.location.pathname + window.location.search,
name: preferredClientName,
}); });
invalidateAllCaches(); // assume all data to be stale after a reconnect
setOnlineStatus(true); setOnlineStatus(true);
if (preferredClientName) {
socketSendJson('set-client-name', preferredClientName);
}
}; };
websocket.onclose = () => { websocket.onclose = () => {
@@ -72,48 +65,56 @@ export const connectSocket = () => {
console.error('WebSocket error:', error); console.error('WebSocket error:', error);
}; };
websocket.onmessage = async (event) => { websocket.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data) as WsPacketToClient; const data = JSON.parse(event.data);
const { tag, payload } = data; const { type, payload } = data;
if (!tag) { if (!type) {
return; return;
} }
switch (tag) { switch (type) {
case MessageTag.Pong: { case 'pong': {
const offset = (new Date().getTime() - new Date(payload).getTime()) * 0.5; const offset = (new Date().getTime() - new Date(payload).getTime()) * 0.5;
patchRuntimeProperty('ping', offset); patchRuntimeProperty('ping', offset);
updateDevTools({ ping: offset }); updateDevTools({ ping: offset });
break; break;
} }
case MessageTag.ClientInit: { case 'client': {
setClientId(payload.clientId); if (typeof payload === 'object' || payload !== null) {
if (!preferredClientName) { if (payload.clientId && payload.clientName) {
setClientName(payload.clientName); setClientId(payload.clientId);
if (!preferredClientName) {
setClientName(payload.clientName);
}
}
} }
break; break;
} }
case MessageTag.ClientRename: { case 'client-rename': {
const id = getClientId(); if (typeof payload === 'object') {
if (payload.target === id) { const id = getClientId();
setClientName(payload.name); if (payload.target && payload.target === id) {
setClientName(payload.name);
}
} }
break; break;
} }
case MessageTag.ClientRedirect: { case 'client-redirect': {
const id = getClientId(); if (typeof payload === 'object') {
if (payload.target === id) { const id = getClientId();
setClientRedirect(payload.path); if (payload.target && payload.target === id) {
setClientRedirect(payload.path);
}
} }
break; break;
} }
case MessageTag.ClientList: { case 'client-list': {
setClients(payload); setClients(payload);
if (!isProduction) { if (!isProduction) {
ontimeQueryClient.setQueryData(CLIENT_LIST, payload); ontimeQueryClient.setQueryData(CLIENT_LIST, payload);
@@ -121,60 +122,50 @@ export const connectSocket = () => {
break; break;
} }
case MessageTag.Dialog: { case 'dialog': {
if (payload.dialog === 'welcome') { if (payload.dialog === 'welcome') {
addDialog('welcome'); addDialog('welcome');
} }
break; break;
} }
case MessageTag.Log: { case 'ontime-log': {
addLog(payload as Log); addLog(payload as Log);
break; break;
} }
case MessageTag.RuntimeData: { case 'ontime': {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- removing the key from the payload // eslint-disable-next-line @typescript-eslint/no-unused-vars -- removing the key from the payload
const { ping, ...serverPayload } = payload; const { ping, ...serverPayload } = payload as Partial<RuntimeStore>;
patchRuntime(serverPayload); patchRuntime(serverPayload);
updateDevTools(serverPayload); updateDevTools(serverPayload);
break; break;
} }
case MessageTag.RuntimePatch: { case 'ontime-patch': {
const patch = payload; const patch = payload as Partial<RuntimeStore>;
patchRuntime(patch); patchRuntime(patch);
updateDevTools(patch); updateDevTools(patch);
break; break;
} }
case MessageTag.Refetch: { case 'ontime-refetch': {
// the refetch message signals that the rundown has changed in the server side // the refetch message signals that the rundown has changed in the server side
const { target, revision } = payload; const { reload, target } = payload;
switch (target) { if (reload) {
case RefetchKey.All: invalidateAllCaches();
invalidateAllCaches(); } else if (target === 'RUNDOWN') {
break; const { revision } = payload;
case RefetchKey.CustomFields: const currentRevision = ontimeQueryClient.getQueryData<Rundown>(RUNDOWN)?.revision ?? -1;
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS }); if (revision > currentRevision) {
break;
case RefetchKey.Rundown:
if (revision === (ontimeQueryClient.getQueryData(RUNDOWN) as Rundown).revision) break;
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN }); ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS }); ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
break;
case RefetchKey.ViewSettings:
ontimeQueryClient.invalidateQueries({ queryKey: VIEW_SETTINGS });
break;
case RefetchKey.Report:
ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
break;
default: {
target satisfies never;
break;
} }
} else if (target === 'REPORT') {
ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
} }
break; break;
} }
default: { default: {
tag satisfies never; console.log('unknown WS message', type);
break; break;
} }
} }
@@ -184,14 +175,20 @@ export const connectSocket = () => {
}; };
}; };
export function sendSocket<T extends MessageTag | ApiAction>( export const socketSend = (message: any) => {
tag: T,
payload: T extends MessageTag ? Pick<WsPacketToServer & { tag: T }, 'payload'>['payload'] : unknown,
): void {
if (websocket && websocket.readyState === WebSocket.OPEN) { if (websocket && websocket.readyState === WebSocket.OPEN) {
websocket.send(JSON.stringify({ tag, payload })); websocket.send(message);
} }
} };
export const socketSendJson = (type: string, payload?: unknown) => {
socketSend(
JSON.stringify({
type,
payload,
}),
);
};
function updateDevTools(newData: Partial<RuntimeStore>) { function updateDevTools(newData: Partial<RuntimeStore>) {
if (!isProduction) { if (!isProduction) {
+19 -16
View File
@@ -1,27 +1,27 @@
import { RGBColour } from 'ontime-types'; import Color from 'color';
import { colourToHex, cssOrHexToColour, isLightColour, mixColours } from 'ontime-utils';
type ColourCombination = { type ColourCombination = {
backgroundColor: string; backgroundColor: string;
color: string; color: string;
}; };
const defaultUiBackground: RGBColour = { red: 26, green: 26, blue: 26, alpha: 1 };
/** /**
* @description Selects text colour to maintain accessible contrast * @description Selects text colour to maintain accessible contrast
* @param bgColour * @param bgColour
* @return {{backgroundColor, color: string}} * @return {{backgroundColor, color: string}}
*/ */
export const getAccessibleColour = (bgColour?: string): ColourCombination => { export const getAccessibleColour = (bgColour?: string): ColourCombination => {
if (!bgColour) return { backgroundColor: '#1a1a1a', color: '#fffffa' }; if (bgColour) {
try {
const originalColour = cssOrHexToColour(bgColour); const originalColour = Color(bgColour);
if (!originalColour) return { backgroundColor: '#1a1a1a', color: '#fffffa' }; const backgroundColorMix = originalColour.alpha(1).mix(Color('#1a1a1a'), 1 - originalColour.alpha());
const textColor = backgroundColorMix.isLight() ? 'black' : '#fffffa';
const backgroundColorMix = mixColours(defaultUiBackground, originalColour, 1 - originalColour.alpha); return { backgroundColor: backgroundColorMix.hexa(), color: textColor };
const textColor = isLightColour(backgroundColorMix) ? 'black' : '#fffffa'; } catch (_error) {
/* we do not handle errors here */
return { backgroundColor: colourToHex(backgroundColorMix), color: textColor }; }
}
return { backgroundColor: '#1a1a1a', color: '#fffffa' };
}; };
/** /**
@@ -39,8 +39,11 @@ export const timerPlaceholderMin = '––:––';
* Adds opacity to a given colour if possible * Adds opacity to a given colour if possible
*/ */
export function alpha(colour: string, amount: number): string { export function alpha(colour: string, amount: number): string {
const originalColour = cssOrHexToColour(colour); try {
if (!originalColour) return colour; const withAlpha = Color(colour).alpha(amount).hexa();
originalColour.alpha = amount; return withAlpha;
return colourToHex(originalColour); } catch (_error) {
/* we do not handle errors here */
}
return colour;
} }
@@ -2,7 +2,7 @@ import { PropsWithChildren } from 'react';
import ProtectRoute from '../common/components/protect-route/ProtectRoute'; import ProtectRoute from '../common/components/protect-route/ProtectRoute';
import style from './EditorFeatureWrapper.module.scss'; import style from './FeatureWrapper.module.scss';
export default function EditorFeatureWrapper({ children }: PropsWithChildren) { export default function EditorFeatureWrapper({ children }: PropsWithChildren) {
return ( return (
@@ -4,12 +4,12 @@ import { useKeyDown } from '../../common/hooks/useKeyDown';
import AboutPanel from './panel/about-panel/AboutPanel'; import AboutPanel from './panel/about-panel/AboutPanel';
import AutomationPanel from './panel/automations-panel/AutomationPanel'; import AutomationPanel from './panel/automations-panel/AutomationPanel';
import FeaturePanel from './panel/feature-panel/FeaturePanel'; import FeatureSettingsPanel from './panel/feature-settings-panel/FeatureSettingsPanel';
import ManagePanel from './panel/manage-panel/ManagePanel'; import GeneralPanel from './panel/general-panel/GeneralPanel';
import NetworkLogPanel from './panel/network-panel/NetworkLogPanel'; import NetworkLogPanel from './panel/network-panel/NetworkLogPanel';
import ProjectPanel from './panel/project-panel/ProjectPanel'; import ProjectPanel from './panel/project-panel/ProjectPanel';
import SettingsPanel from './panel/settings-panel/SettingsPanel';
import ShutdownPanel from './panel/shutdown-panel/ShutdownPanel'; import ShutdownPanel from './panel/shutdown-panel/ShutdownPanel';
import SourcesPanel from './panel/sources-panel/SourcesPanel';
import PanelContent from './panel-content/PanelContent'; import PanelContent from './panel-content/PanelContent';
import PanelList from './panel-list/PanelList'; import PanelList from './panel-list/PanelList';
import useAppSettingsNavigation from './useAppSettingsNavigation'; import useAppSettingsNavigation from './useAppSettingsNavigation';
@@ -25,11 +25,11 @@ export default function AppSettings() {
<ErrorBoundary> <ErrorBoundary>
<PanelList selectedPanel={panel} location={location} /> <PanelList selectedPanel={panel} location={location} />
<PanelContent onClose={close}> <PanelContent onClose={close}>
{panel === 'settings' && <SettingsPanel location={location} />}
{panel === 'project' && <ProjectPanel location={location} setLocation={setLocation} />} {panel === 'project' && <ProjectPanel location={location} setLocation={setLocation} />}
{panel === 'manage' && <ManagePanel location={location} />} {panel === 'general' && <GeneralPanel location={location} />}
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
{panel === 'sources' && <SourcesPanel />}
{panel === 'automation' && <AutomationPanel location={location} />} {panel === 'automation' && <AutomationPanel location={location} />}
{panel === 'sharing' && <FeaturePanel location={location} />}
{panel === 'network' && <NetworkLogPanel location={location} />} {panel === 'network' && <NetworkLogPanel location={location} />}
{panel === 'about' && <AboutPanel />} {panel === 'about' && <AboutPanel />}
{panel === 'shutdown' && <ShutdownPanel />} {panel === 'shutdown' && <ShutdownPanel />}
@@ -2,7 +2,7 @@
position: absolute; position: absolute;
top: 1rem; top: 1rem;
right: 2rem; right: 2rem;
z-index: $zindex-floating; z-index: 100;
} }
.contentWrapper { .contentWrapper {
@@ -1,7 +1,6 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { IoClose } from 'react-icons/io5'; import { IoClose } from 'react-icons/io5';
import { Button } from '@chakra-ui/react';
import Button from '../../../common/components/buttons/Button';
import style from './PanelContent.module.scss'; import style from './PanelContent.module.scss';
@@ -9,12 +8,14 @@ interface PanelContentProps {
onClose: () => void; onClose: () => void;
} }
export default function PanelContent({ onClose, children }: PropsWithChildren<PanelContentProps>) { export default function PanelContent(props: PropsWithChildren<PanelContentProps>) {
const { onClose, children } = props;
return ( return (
<div className={style.contentWrapper}> <div className={style.contentWrapper}>
<div className={style.corner}> <div className={style.corner}>
<Button size='large' onClick={onClose}> <Button onClick={onClose} aria-label='close' rightIcon={<IoClose />} variant='ontime-subtle'>
Close settings <IoClose /> Close settings
</Button> </Button>
</div> </div>
<div className={style.content}>{children}</div> <div className={style.content}>{children}</div>
@@ -68,12 +68,12 @@ function PanelListItem(props: PanelListItemProps) {
> >
{panel.label} {panel.label}
</li> </li>
{panel.secondary?.map((secondary, index) => { {panel.secondary?.map((secondary) => {
const id = secondary.id.split('__')[1]; const id = secondary.id.split('__')[1];
const secondaryClasses = cx([style.secondary, isSelected && location === id ? style.active : null]); const secondaryClasses = cx([style.secondary, isSelected && location === id ? style.active : null]);
return ( return (
<li <li
key={secondary.id + index} key={secondary.id}
onClick={() => setLocation(secondary.id as SettingsOptionId)} onClick={() => setLocation(secondary.id as SettingsOptionId)}
onKeyDown={(event) => { onKeyDown={(event) => {
isKeyEnter(event) && setLocation(secondary.id as SettingsOptionId); isKeyEnter(event) && setLocation(secondary.id as SettingsOptionId);

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