Compare commits

..

1 Commits

Author SHA1 Message Date
google-labs-jules[bot] 342533e128 refactor(CustomFields): Implement UI-only reordering with Save button
This commit refactors the custom field ordering functionality to support
UI-only reordering with an explicit "Save Order" button. This replaces
the previous approach where each move operation updated the backend.

Changes include:

- **Frontend State Management:**
    - `CustomFields.tsx` now manages a local state (`displayedFields`) for the order of custom fields.
    - Reordering operations ("Move Up"/"Move Down") update this local state directly without immediate backend calls.
- **"Save Order" Button:**
    - A "Save Order" button has been added to the UI.
    - This button is enabled only when changes to the order have been made locally.
    - Clicking "Save Order" triggers API calls to persist the new order of all changed fields to the backend.
- **API Calls:**
    - `editCustomField` is now called in a batch when "Save Order" is pressed, only for fields whose order has effectively changed.
- **Local Order Integrity:**
    - The `onMove` handler now ensures that the local `order` properties are dense and sequential (0, 1, 2...) before saving.
- **Testing Plan:** Updated manual testing steps to reflect the new UI flow.

This approach improves UX by making reordering feel instantaneous and reduces the number of API calls compared to updating on every move.
2025-07-04 17:28:29 +00:00
462 changed files with 8492 additions and 8653 deletions
+4 -4
View File
@@ -45,7 +45,7 @@ Generally we have 2 types of tests.
Unit tests are contained in mostly all the apps and packages (client, server and utils)
You can run unit tests by running `turbo run test:pipeline` from the project root.
You can run unit tests by running turbo `turbo test:pipeline` from the project root.
This will run all tests and close test runner.
Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode
@@ -76,13 +76,13 @@ You can generate a distribution for your OS by running the following steps.
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Build the UI and server__ by running `turbo run build:electron`
- __Create the package__ by running `turbo run dist-win`, `turbo run dist-mac` or `turbo run dist-linux`
- __Build the UI and server__ by running `turbo build:electron`
- __Create the package__ by running `turbo dist-win`, `turbo dist-mac` or `turbo dist-linux`
The build distribution assets will be at `.apps/electron/dist`
Note: The MacOS build will only work in CI, locally it will fail due to notarisation issues.
Use the `turbo run dist-mac:local` command to build a MacOS distribution locally.
Use the `turbo dist-mac:local` command to build a MacOS distribution locally.
## DOCKER
+61 -14
View File
@@ -43,12 +43,12 @@ Ontime is made by entertainment and broadcast engineers and used by
- Houses of worship
## Main features
- **Multiplatform**: Available as a Cloud service and for Windows, macOS, Linux, or self-hosted via Docker.
- **In any device**: Ontime is available to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers...
- **Team Collaboration**: Dedicated views for directors, operators, backstage, and signage.
- **Real-Time Updates**: Manage and communicate runtime delays effortlessly.
- **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock
- **Flexible Integrations**: Use one of the APIs provided (OSC, HTTP, Websocket) or the available [Companion module](https://bitfocus.io/connections/getontime-ontime) to integrate into your workflow (vMix, disguise, Qlab, OBS)
- [x] **Multiplatform**: Available for Windows / MacOS, Linux. You can also self host with the docker image
- [x] **In any device**: Ontime is available in the local network to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers...
- [x] **Made for teams**: Ontime caters to different roles in your production team: directors, operators, backstage and front of house signage...
- [x] **Delay workflows**: Manage and communicate runtime delays in real-time to your team
- [x] **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock
- [x] **Focus on integrations**: Use one of the APIs provided (OSC, HTTP, Websocket) or the available [Companion module](https://bitfocus.io/connections/getontime-ontime) to integrate into your workflow (vMix, disguise, Qlab, OBS)
... and a lot more ...
@@ -79,29 +79,77 @@ Ontime is made by video engineers and entertainment technicians.
## Using Ontime
### Getting started
Ontime can be started by downloading the latest release for your platform. \
Alternatively you can also use the docker image, available at [Docker Hub](https://hub.docker.com/r/getontime/ontime)
The easiest way to start with Ontime is by leveraging our [Cloud service](https://getontime.no). \
This will give you immediate access to running instances of Ontime which are available to share with anyone with an internet connection.
Once installed and running, any device in the network has access to Ontime.
Alternatively, you can run Ontime locally for free by downloading the latest release for your platform or using the docker image, available at [Docker Hub](https://hub.docker.com/r/getontime/ontime)
Ontime provides different screens which allow for different types of interactions with the data. These are called
views. \
Each view in Ontime focuses on empowering a specific role or achieving a particular task.
Once installed and running, any device that shares the same network as Ontime will have access to Ontime.
You can access the different views by reaching the ontime server, in your browser, at (_default port
4001_) `http://localhost:4001` or `http://192.168.1.3:4001`
```
For the backstage views
-------------------------------------------------------------
IP.ADDRESS:4001/timer > Presenter / Stage timer view
IP.ADDRESS:4001/minimal > Simple timer view
IP.ADDRESS:4001/clock > Simple clock view
IP.ADDRESS:4001/backstage > Stage Manager / Backstage view
IP.ADDRESS:4001/countdown > Countdown to anything
IP.ADDRESS:4001/studio > Studio Clock
IP.ADDRESS:4001/timeline > Timeline
```
```
For production views
-------------------------------------------------------------
IP.ADDRESS:4001/editor > the control interface, same as the app
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
IP.ADDRESS:4001/op > automated views for operators
```
More information is available [in our docs](https://docs.getontime.no)
## Continued development
## Roadmap
### Continued development
Ontime is under active development. We continue adding and improving features in collaboration with users.
Have an idea? Reach out via [email](mail@getontime.no)
or [open an issue](https://github.com/cpvalente/ontime/issues/new)
## Issues
### Issues
We use Github's issue tracking for bug reporting and feature requests. \
Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new).
#### Unsigned App
When installing the app you would see warning screens from the Operating System like:
in Windows
`Microsoft Defender SmartScreen prevented an unrecognised app from starting. Running this app might put your PC at risk.`
or in Linux
`Could Not Display "ontime-linux.AppImage`
We currently only sign MacOS releases. \
Purchasing the certificates for both Mac and Windows would mean a recurrent expense which we are not able to cover.
You can circumvent this by allowing the execution of the app manually.
- In Windows: click `more` -> `Run Anyway`
- In Linux: right-click the AppImage file: `Properties` -> `Permissions` -> `Allow Executing File as a Program`
If you have tips on how to improve this or would like to sponsor the code signing,
please [open an issue](https://github.com/cpvalente/ontime/issues/new)
## Contributing
Looking to contribute? All types of help are appreciated, from coding to testing and feature specification.
@@ -113,7 +161,6 @@ Information about the project setup can be found in the [development documentati
## Links
- [Ontime website](https://getontime.no)
- [Documentation](https://docs.getontime.no)
- [Video tutorials](https://www.youtube.com/@ontimeapp)
- [Ontime discord server](https://discord.com/invite/eje3CSUEXm)
## License
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.0.0-alpha.2",
"version": "3.15.2",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -16,7 +16,7 @@
<style>
body,
html {
background-color: #101010 !important;
background-color: rgba(0, 0, 0, 0) !important;
}
</style>
</head>
+12 -8
View File
@@ -1,14 +1,17 @@
{
"name": "ontime-ui",
"version": "4.0.0-alpha.2",
"version": "3.15.2",
"private": true,
"type": "module",
"dependencies": {
"@base-ui-components/react": "1.0.0-beta.1",
"@chakra-ui/react": "^2.7.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/is-prop-valid": "^1.3.1",
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^8.1.2",
"@sentry/react": "^8.43.0",
@@ -18,12 +21,12 @@
"@tanstack/react-table": "^8.21.3",
"autosize": "^6.0.1",
"axios": "^1.9.0",
"babel-plugin-react-compiler": "19.1.0-rc.2",
"csv-stringify": "^6.4.5",
"framer-motion": "^10.10.0",
"prismjs": "^1.29.0",
"react": "^19.1.0",
"react": "^18.3.1",
"react-colorful": "^5.6.1",
"react-dom": "^19.1.0",
"react-dom": "^18.3.1",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.53.1",
"react-icons": "5.4.0",
@@ -64,8 +67,8 @@
"@sentry/vite-plugin": "^2.16.1",
"@tanstack/eslint-plugin-query": "^5.8.4",
"@types/prismjs": "^1.26.5",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "4.5.1",
@@ -75,15 +78,16 @@
"eslint-plugin-prettier": "catalog:",
"eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^8.0.0",
"happy-dom": "^16.7.2",
"ontime-types": "workspace:*",
"ontime-utils": "workspace:*",
"prettier": "catalog:",
"sass": "^1.57.1",
"typescript": "catalog:",
"vite": "6.3.1",
"vite-plugin-compression2": "2.2.0",
"vite-plugin-compression2": "1.4.0",
"vite-plugin-svgr": "4.3.0",
"vite-tsconfig-paths": "5.1.4",
"vitest": "catalog:"
+8 -7
View File
@@ -1,5 +1,5 @@
import { BrowserRouter } from 'react-router-dom';
import { Tooltip } from '@base-ui-components/react/tooltip';
import { ChakraProvider } from '@chakra-ui/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
@@ -8,6 +8,7 @@ import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverla
import { AppContextProvider } from './common/context/AppContext';
import { ontimeQueryClient } from './common/queryClient';
import { connectSocket } from './common/utils/socket';
import theme from './theme/theme';
import { TranslationProvider } from './translation/TranslationProvider';
import AppRouter from './AppRouter';
import { baseURI } from './externals';
@@ -16,9 +17,9 @@ connectSocket();
function App() {
return (
<QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider>
<Tooltip.Provider>
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
<QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider>
<BrowserRouter basename={baseURI}>
<div className='App'>
<ErrorBoundary>
@@ -33,9 +34,9 @@ function App() {
<div id='identify-portal' />
</ErrorBoundary>
</BrowserRouter>
</Tooltip.Provider>
</AppContextProvider>
</QueryClientProvider>
</AppContextProvider>
</QueryClientProvider>
</ChakraProvider>
);
}
+58 -7
View File
@@ -1,29 +1,45 @@
import React from 'react';
import { Navigate, Route } from 'react-router-dom';
import {
createRoutesFromChildren,
matchRoutes,
Navigate,
Route,
Routes,
useLocation,
useNavigationType,
} from 'react-router-dom';
import * as Sentry from '@sentry/react';
import { useClientPath } from './common/hooks/useClientPath';
import Log from './features/log/Log';
import withPreset from './features/PresetWrapper';
import withData from './features/viewers/ViewWrapper';
import ViewLoader from './views/ViewLoader';
import { initializeSentry } from './sentry.config';
import { ONTIME_VERSION } from './ONTIME_VERSION';
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
const Editor = React.lazy(() => import('./views/editor/ProtectedEditor'));
const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
const TimerView = React.lazy(() => import('./views/timer/Timer'));
const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
const Countdown = React.lazy(() => import('./views/countdown/Countdown'));
const Backstage = React.lazy(() => import('./views/backstage/Backstage'));
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const StudioClock = React.lazy(() => import('./views/studio/Studio'));
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
const STimer = withPreset(withData(TimerView));
const SMinimalTimer = withPreset(withData(MinimalTimerView));
const SClock = withPreset(withData(ClockView));
const SCountdown = withPreset(withData(Countdown));
const SBackstage = withPreset(withData(Backstage));
const SProjectInfo = withPreset(ProjectInfo); // NOTE: ProjectInfo does not use the viewWrapper since it has no options
const SProjectInfo = withPreset(withData(ProjectInfo));
const SLowerThird = withPreset(withData(Lower));
const SStudio = withPreset(withData(StudioClock));
const STimeline = withPreset(withData(Timeline));
const PCuesheet = withPreset(Cuesheet);
@@ -34,8 +50,25 @@ const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport')
const TimerControl = React.lazy(() => import('./features/control/playback/TimerControlExport'));
const MessageControl = React.lazy(() => import('./features/control/message/MessageControlExport'));
// Initialize Sentry with our configuration
const SentryRouter = initializeSentry();
Sentry.init({
dsn: sentryDsn,
integrations: [
Sentry.reactRouterV6BrowserTracingIntegration({
useEffect: React.useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
}),
],
tracesSampleRate: 0.3,
release: ONTIME_VERSION,
enabled: import.meta.env.PROD,
ignoreErrors: [...sentryRecommendedIgnore, /Unable to preload CSS/i, /dynamically imported module/i],
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
});
const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes);
export default function AppRouter() {
// handle client path changes
@@ -43,7 +76,7 @@ export default function AppRouter() {
return (
<React.Suspense fallback={null}>
<SentryRouter>
<SentryRoutes>
<Route path='/' element={<Navigate to='/timer' />} />
<Route
path='/timer'
@@ -53,6 +86,22 @@ export default function AppRouter() {
</ViewLoader>
}
/>
<Route
path='/minimal'
element={
<ViewLoader>
<SMinimalTimer />
</ViewLoader>
}
/>
<Route
path='/clock'
element={
<ViewLoader>
<SClock />
</ViewLoader>
}
/>
<Route
path='/countdown'
element={
@@ -77,6 +126,8 @@ export default function AppRouter() {
</ViewLoader>
}
/>
{/*/!* Lower third cannot have a loading screen *!/*/}
<Route path='/lower' element={<SLowerThird />} />
<Route
path='/timeline'
element={
@@ -141,7 +192,7 @@ export default function AppRouter() {
/>
{/*/!* Send to default if nothing found *!/*/}
<Route path='*' element={<STimer />} />
</SentryRouter>
</SentryRoutes>
</React.Suspense>
);
}
+17 -10
View File
@@ -1,38 +1,45 @@
import axios from 'axios';
import { CustomField, CustomFieldKey, CustomFields } from 'ontime-types';
import { CustomField, CustomFieldKey } from 'ontime-types'; // Removed CustomFields
import { apiEntryUrl } from './constants';
// Define CustomFieldWithKey for client-side usage
export type CustomFieldWithKey = CustomField & { key: CustomFieldKey };
const customFieldsPath = `${apiEntryUrl}/custom-fields`;
/**
* Requests list of known custom fields
* Requests list of known custom fields, sorted by order
*/
export async function getCustomFields(): Promise<CustomFields> {
const res = await axios.get(customFieldsPath);
export async function getCustomFields(): Promise<CustomFieldWithKey[]> {
const res = await axios.get<CustomFieldWithKey[]>(customFieldsPath);
return res.data;
}
/**
* Sets list of known custom fields
* Returns the updated list, sorted by order
*/
export async function postCustomField(newField: CustomField): Promise<CustomFields> {
const res = await axios.post(customFieldsPath, { ...newField });
export async function postCustomField(newField: CustomField): Promise<CustomFieldWithKey[]> {
const res = await axios.post<CustomFieldWithKey[]>(customFieldsPath, { ...newField });
return res.data;
}
/**
* Edits single custom field
* Returns the updated list, sorted by order
*/
export async function editCustomField(key: CustomFieldKey, newField: CustomField): Promise<CustomFields> {
const res = await axios.put(`${customFieldsPath}/${key}`, { ...newField });
export async function editCustomField(key: CustomFieldKey, newField: Partial<CustomField>): Promise<CustomFieldWithKey[]> {
// Ensure newField can include 'order' by using Partial<CustomField>
const res = await axios.put<CustomFieldWithKey[]>(`${customFieldsPath}/${key}`, { ...newField });
return res.data;
}
/**
* Deletes single custom field
* Returns the updated list, sorted by order
*/
export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFields> {
const res = await axios.delete(`${customFieldsPath}/${key}`);
export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFieldWithKey[]> {
const res = await axios.delete<CustomFieldWithKey[]>(`${customFieldsPath}/${key}`);
return res.data;
}
+4 -18
View File
@@ -6,7 +6,7 @@ import { apiEntryUrl } from './constants';
const urlPresetsPath = `${apiEntryUrl}/url-presets`;
/**
* HTTP request to retrieve all presets
* HTTP request to retrieve aliases
*/
export async function getUrlPresets(): Promise<URLPreset[]> {
const res = await axios.get(urlPresetsPath);
@@ -14,22 +14,8 @@ export async function getUrlPresets(): Promise<URLPreset[]> {
}
/**
* HTTP request to add a preset
* HTTP request to mutate aliases
*/
export async function postUrlPreset(data: URLPreset): Promise<URLPreset[]> {
return (await axios.post(urlPresetsPath, data)).data;
}
/**
* HTTP request to edit a preset
*/
export async function putUrlPreset(alias: string, data: URLPreset): Promise<URLPreset[]> {
return (await axios.put(`${urlPresetsPath}/${alias}`, data)).data;
}
/**
* HTTP request to delete a preset
*/
export async function deleteUrlPreset(alias: string): Promise<URLPreset[]> {
return (await axios.delete(`${urlPresetsPath}/${alias}`)).data;
export async function postUrlPresets(data: URLPreset[]): Promise<URLPreset[]> {
return axios.post(urlPresetsPath, data);
}
-14
View File
@@ -33,20 +33,6 @@ export function maybeAxiosError(error: unknown) {
}
}
/**
* Utility unwrap a an instance of Error
*/
export function unwrapError(error: unknown) {
if (error instanceof Error) {
return error.message;
} else {
if (typeof error !== 'string') {
return JSON.stringify(error);
}
return error;
}
}
/**
* Utility unwraps a potential axios error and sends to logger
* @param prepend
+4 -13
View File
@@ -2,23 +2,14 @@ import axios from 'axios';
import type { ViewSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const viewSettingsPath = apiEntryUrl + '/view-settings';
const viewSettingsPath = `${apiEntryUrl}/view-settings`;
/**
* HTTP request to fetch view settings
* @returns
*/
export async function getViewSettings(): Promise<ViewSettings> {
export async function getViewSettings() {
const res = await axios.get(viewSettingsPath);
return res.data;
}
/**
* HTTP request to update view settings
* needs to update entire objects, not just a patch
*/
export async function postViewSettings(data: ViewSettings): Promise<ViewSettings> {
export async function postViewSettings(data: ViewSettings) {
const res = await axios.post(viewSettingsPath, data);
return res.data;
return res.data as ViewSettings;
}
@@ -1,4 +1,4 @@
@use '@/theme/viewerDefs' as *;
@use '../../../theme/viewerDefs' as *;
@import './BaseButtonStyles.module.scss';
.baseButton {
@@ -1,4 +1,4 @@
@use '@/theme/viewerDefs' as *;
@use '../../../theme/viewerDefs' as *;
@import './BaseButtonStyles.module.scss';
.baseIconButton {
@@ -11,11 +11,6 @@
cursor: pointer;
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: 2px;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
@@ -1,4 +1,4 @@
import { ButtonHTMLAttributes, forwardRef } from 'react';
import { ButtonHTMLAttributes } from 'react';
import { cx } from '../../utils/styleUtils';
@@ -17,21 +17,20 @@ interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
size?: 'small' | 'medium' | 'large' | 'xlarge';
}
const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
({ className, children, variant = 'subtle', size = 'medium', ...buttonProps }, ref) => {
return (
<button
ref={ref}
className={cx([style.baseIconButton, style[variant], style[size], className])}
type='button'
{...buttonProps}
>
{children}
</button>
);
},
);
IconButton.displayName = 'IconButton';
export default IconButton;
export default function IconButton({
className,
children,
variant = 'subtle',
size = 'medium',
...buttonProps
}: IconButtonProps) {
return (
<button
className={cx([style.baseIconButton, style[variant], style[size], className])}
type='button'
{...buttonProps}
>
{children}
</button>
);
}
@@ -0,0 +1,17 @@
import { MouseEvent } from 'react';
import { IconButton, IconButtonProps, Tooltip } from '@chakra-ui/react';
interface TooltipActionBtnProps extends IconButtonProps {
clickHandler: (event: MouseEvent) => void | Promise<void>;
tooltip: string;
openDelay?: number;
}
export default function TooltipActionBtn(props: TooltipActionBtnProps) {
const { clickHandler, icon, size = 'xs', tooltip, openDelay = 0, className, ...rest } = props;
return (
<Tooltip label={tooltip} openDelay={openDelay}>
<IconButton {...rest} size={size} icon={icon} onClick={clickHandler} className={className} />
</Tooltip>
);
}
@@ -45,8 +45,8 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
label: view.label,
})),
...enabledPresets.map((preset) => ({
value: preset.search,
label: `URL Preset: ${preset.alias}`,
value: preset.pathAndParams,
label: `Preset: ${preset.alias}`,
})),
];
@@ -64,7 +64,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
Either by selecting a URL Preset or entering a custom path.
<br />
<br />
<AppLink search='settings=sharing__presets'>Manage URL Presets</AppLink>
<AppLink search='settings=feature_settings__urlpresets'>Manage URL Presets</AppLink>
</Info>
<div>
<span className={style.label}>Select View or URL Preset</span>
@@ -91,7 +91,12 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
<span className={style.label}>Enter custom path</span>
<label className={style.textEntry}>
{origin}
<Input placeholder='eg. /timer' fluid value={path} onChange={(event) => setPath(event.target.value)} />
<Input
placeholder='eg. /minimal?key=0000ffff'
fluid
value={path}
onChange={(event) => setPath(event.target.value)}
/>
</label>
<Button
variant='primary'
@@ -0,0 +1,8 @@
.contextMenuButton {
position: absolute;
}
.contextMenuBackdrop {
position: fixed;
inset: 0;
}
@@ -0,0 +1,99 @@
// logic (with some modifications) culled from:
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
import { ReactElement } from 'react';
import { IconType } from 'react-icons';
import { Menu, MenuButton, MenuGroup, MenuList } from '@chakra-ui/react';
import { create } from 'zustand';
import { ContextMenuOption } from './ContextMenuOption';
import style from './ContextMenu.module.scss';
type ContextMenuCoords = {
x: number;
y: number;
};
export type OptionWithoutGroup = {
label: string;
isDisabled?: boolean;
icon: IconType;
onClick: () => void;
withDivider?: boolean;
};
type OptionWithGroup = {
label: string;
group: Omit<OptionWithoutGroup, 'isGroup'>[];
};
export type Option = OptionWithoutGroup | OptionWithGroup;
const isOptionWithGroup = (option: Option): option is OptionWithGroup => 'group' in option;
type ContextMenuStore = {
coords: ContextMenuCoords;
options: Option[];
isOpen: boolean;
setContextMenu: (coords: ContextMenuCoords, options: Option[]) => void;
setIsOpen: (newIsOpen: boolean) => void;
};
export const useContextMenuStore = create<ContextMenuStore>((set) => ({
coords: { x: 0, y: 0 },
options: [],
isOpen: false,
setContextMenu: (coords, options) => set(() => ({ coords, options, isOpen: true })),
setIsOpen: (newIsOpen) => set(() => ({ isOpen: newIsOpen })),
}));
interface ContextMenuProps {
// ReactElement type required due to early `return` (line 51) returning {children}
children: ReactElement;
}
export const ContextMenu = ({ children }: ContextMenuProps) => {
const { coords, options, isOpen, setIsOpen } = useContextMenuStore();
const onClose = () => {
return setIsOpen(false);
};
if (!isOpen) {
return children;
}
return (
<>
{children}
<div className={style.contextMenuBackdrop} />
<Menu isOpen size='sm' gutter={0} onClose={onClose} isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
<MenuButton
className={style.contextMenuButton}
aria-hidden
w={1}
h={1}
style={{
position: 'fixed',
left: coords.x,
top: coords.y,
}}
/>
<MenuList>
{options.map((option) =>
isOptionWithGroup(option) ? (
<MenuGroup key={option.label} title={option.label}>
{option.group.map((groupOption) => (
<ContextMenuOption key={groupOption.label} {...groupOption} />
))}
</MenuGroup>
) : (
<ContextMenuOption key={option.label} {...option} />
),
)}
</MenuList>
</Menu>
</>
);
};
@@ -0,0 +1,12 @@
import { MenuDivider, MenuItem } from '@chakra-ui/react';
import { OptionWithoutGroup } from './ContextMenu';
export const ContextMenuOption = ({ label, onClick, isDisabled, icon: Icon, withDivider }: OptionWithoutGroup) => (
<>
{withDivider && <MenuDivider />}
<MenuItem icon={<Icon style={{ fontSize: '1rem' }} />} onClick={onClick} isDisabled={isDisabled}>
{label}
</MenuItem>
</>
);
@@ -1,40 +0,0 @@
.copytag {
display: flex;
align-items: center;
}
.action {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.label {
border-radius: 3px 0 0 3px;
border: 1px solid $gray-1200;
background-color: $white-3;
padding-inline: 0.5rem 1rem;
line-height: 1em;
display: flex;
align-items: center;
color: $label-gray;
}
.small {
height: 1.5rem;
font-size: calc(1rem - 3px);
}
.medium {
height: 2rem;
font-size: calc(1rem - 2px);
}
.large {
height: 2.5rem;
font-weight: 600;
}
.copy {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
@@ -1,28 +1,22 @@
import { PropsWithChildren, useState } from 'react';
import { IoCheckmark } from 'react-icons/io5';
import { IoCopy } from 'react-icons/io5';
import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { Size } from '../../models/Util.type';
import copyToClipboard from '../../utils/copyToClipboard';
import { cx } from '../../utils/styleUtils';
import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton';
import style from './CopyTag.module.scss';
interface CopyTagProps {
copyValue: string;
label: string;
size?: Size;
disabled?: boolean;
onClick?: () => void;
size?: 'small' | 'medium' | 'large';
}
export default function CopyTag({
copyValue,
disabled,
size = 'medium',
children,
onClick,
}: PropsWithChildren<CopyTagProps>) {
export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
const { copyValue, label, size = 'xs', disabled, children, onClick } = props;
const [copied, setCopied] = useState(false);
const handleClick = () => {
@@ -34,24 +28,20 @@ export default function CopyTag({
};
return (
<div className={style.copytag}>
{onClick !== undefined ? (
<Button className={style.action} size={size} tabIndex={-1} onClick={onClick} disabled={disabled}>
<Tooltip label={label} openDelay={tooltipDelayFast}>
<ButtonGroup size={size} isAttached>
<Button variant='ontime-subtle' tabIndex={-1} onClick={onClick} isDisabled={disabled}>
{children}
</Button>
) : (
<div className={cx([style.label, style[size]])}>{children}</div>
)}
<IconButton
className={style.copy}
variant='primary'
size={size}
tabIndex={-1}
onClick={handleClick}
disabled={disabled}
>
{copied ? <IoCheckmark /> : <IoCopy />}
</IconButton>
</div>
<IconButton
aria-label={label}
icon={copied ? <IoCheckmark /> : <IoCopy />}
variant='ontime-filled'
tabIndex={-1}
onClick={handleClick}
isDisabled={disabled}
/>
</ButtonGroup>
</Tooltip>
);
}
@@ -1,7 +1,5 @@
.delaySymbol {
svg {
display: inline;
vertical-align: middle;
font-size: 1.5rem;
color: $ontime-delay;
margin: 0 auto;
@@ -1,7 +1,8 @@
import { IoChevronDown, IoChevronUp } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { millisToDelayString } from '../../utils/dateConfig';
import Tooltip from '../tooltip/Tooltip';
import style from './DelayIndicator.module.scss';
@@ -22,8 +23,8 @@ export default function DelayIndicator(props: DelayIndicatorProps) {
: millisToDelayString(delayValue);
return (
<Tooltip text={delayString} render={<span />} className={style.delaySymbol}>
{delayValue < 0 ? <IoChevronDown /> : <IoChevronUp />}
<Tooltip openDelay={tooltipDelayFast} label={delayString}>
<span className={style.delaySymbol}>{delayValue < 0 ? <IoChevronDown /> : <IoChevronUp />}</span>
</Tooltip>
);
}
@@ -3,6 +3,7 @@
top: 10%;
left: 50%;
z-index: $zindex-dialog;
transform: translateX(-50%);
padding-inline: 1rem;
@@ -18,6 +19,7 @@
.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);
@@ -1,70 +0,0 @@
.positioner {
outline: 0;
}
.popup {
box-sizing: border-box;
padding-block: 0.25rem;
color: $ui-white;
background-color: $gray-1250;
font-size: calc(1rem - 2px);
border-radius: 3px;
border: 1px solid $gray-1000;
box-shadow: $box-shadow-l1;
outline: none;
transform-origin: var(--transform-origin);
transition:
transform 150ms,
opacity 150ms;
&[data-starting-style],
&[data-ending-style] {
opacity: 0;
transform: scale(0.9);
}
}
.item {
outline: 0;
cursor: default;
padding-block: 0.5rem;
padding-inline: 1rem 2rem;
display: flex;
gap: 0.5rem;
line-height: 1em;
svg {
color: $gray-500;
}
&[data-disabled] {
opacity: 0.4;
cursor: not-allowed;
}
&[data-highlighted] {
z-index: 0;
position: relative;
}
&[data-highlighted]:not([data-disabled])::before {
content: '';
z-index: -1;
position: absolute;
inset-block: 0;
inset-inline: 0.25rem;
border-radius: 3px;
background-color: $gray-1000;
}
}
.separator {
margin: 0.25rem 0.75rem;
height: 1px;
background-color: $white-7;
}
@@ -1,82 +0,0 @@
import { PropsWithChildren } from 'react';
import { IconType } from 'react-icons';
import { Menu as BaseMenu } from '@base-ui-components/react/menu';
import style from './DropdownMenu.module.scss';
type DropdownMenuItemDivider = { type: 'divider' };
type DropdownMenuItem = {
type: 'item';
label: string;
icon?: IconType;
disabled?: boolean;
onClick: () => void;
};
export type DropdownMenuOption = DropdownMenuItemDivider | DropdownMenuItem;
interface DropdownMenuProps extends BaseMenu.Trigger.Props {
items: DropdownMenuOption[];
}
export function DropdownMenu({ items, children, ...triggerProps }: PropsWithChildren<DropdownMenuProps>) {
return (
<BaseMenu.Root>
<BaseMenu.Trigger {...triggerProps}>{children}</BaseMenu.Trigger>
<BaseMenu.Portal>
<BaseMenu.Positioner className={style.positioner} align='start' sideOffset={8}>
<BaseMenu.Popup className={style.popup}>
{items.map((item, index) => {
if (item.type === 'divider') {
return <BaseMenu.Separator key={index} className={style.separator} />;
}
return (
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
{item.icon && <item.icon />}
{item.label}
</BaseMenu.Item>
);
})}
</BaseMenu.Popup>
</BaseMenu.Positioner>
</BaseMenu.Portal>
</BaseMenu.Root>
);
}
interface PositionedDropdownMenuProps {
items: Array<DropdownMenuItemDivider | DropdownMenuItem>;
isOpen: boolean;
onClose: () => void;
position: { x: number; y: number };
}
export function PositionedDropdownMenu({ items, isOpen, position, onClose }: PositionedDropdownMenuProps) {
return (
<BaseMenu.Root
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<BaseMenu.Trigger style={{ position: 'fixed', left: position.x, top: position.y }} aria-hidden />
<BaseMenu.Portal>
<BaseMenu.Positioner className={style.positioner} align='start' sideOffset={8} alignOffset={8}>
<BaseMenu.Popup className={style.popup}>
{items.map((item, index) => {
if (item.type === 'divider') {
return <BaseMenu.Separator key={index} className={style.separator} />;
}
return (
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
{item.icon && <item.icon />}
{item.label}
</BaseMenu.Item>
);
})}
</BaseMenu.Popup>
</BaseMenu.Positioner>
</BaseMenu.Portal>
</BaseMenu.Root>
);
}
@@ -33,5 +33,5 @@ interface SeparatorProps extends HTMLAttributes<HTMLDivElement> {
}
export function Separator({ className, orientation = 'vertical', ...elementProps }: SeparatorProps) {
return <div className={cx([style.separator, style[orientation], className])} role='separator' {...elementProps} />;
return <div className={cx([style.separator, style[orientation], className])} {...elementProps} />;
}
@@ -13,13 +13,8 @@ interface FitTextProps extends HTMLAttributes<HTMLDivElement> {
max?: number; // inclusive
}
export function FitText({
children,
mode = 'multi',
min = 16,
max = 256,
...elementProps
}: PropsWithChildren<FitTextProps>) {
export function FitText(props: PropsWithChildren<FitTextProps>) {
const { children, mode = 'multi', min = 16, max = 256, ...elementProps } = props;
const ref = useRef<HTMLDivElement>(null);
const isOverflown = useCallback(() => {
@@ -15,6 +15,7 @@
display: grid;
place-content: center;
text-align: center;
z-index: $zindex-modal;
cursor: pointer;
}
@@ -0,0 +1,28 @@
import { RefObject, useEffect } from 'react';
import { Textarea, TextareaProps } from '@chakra-ui/react';
// @ts-expect-error no types from library
import autosize from 'autosize/dist/autosize';
export const AutoTextArea = (props: TextareaProps & { inputref: RefObject<unknown> }) => {
const { value, inputref } = props;
useEffect(() => {
const node = inputref.current;
autosize(inputref.current);
return () => {
autosize.destroy(node);
};
}, [inputref, value]);
return (
<Textarea
overflow='hidden'
w='100%'
ref={inputref}
resize='none'
transition='height none'
variant='ontime-transparent'
{...props}
/>
);
};
@@ -1,26 +0,0 @@
import { RefObject, useEffect } from 'react';
// @ts-expect-error no types from library
import autosize from 'autosize/dist/autosize';
import Textarea, { type TextareaProps } from '../textarea/Textarea';
interface AutoTextAreaProps extends TextareaProps {
inputref: RefObject<HTMLTextAreaElement | null>;
}
/**
* A textarea that automatically resizes based on its content
*/
export function AutoTextarea({ value, inputref, ...textAreaProps }: AutoTextAreaProps) {
// when the value changes, we use the ref to reapply autosize
useEffect(() => {
const node = inputref.current;
autosize(inputref.current);
return () => {
autosize.destroy(node);
};
}, [inputref, value]);
return <Textarea ref={inputref} value={value} {...textAreaProps} />;
}
@@ -1,8 +1,9 @@
import { useCallback } from 'react';
import { useController, UseControllerProps } from 'react-hook-form';
import { IoEyedrop } from 'react-icons/io5';
import { useDebouncedCallback } from '@mantine/hooks';
import { ViewSettings } from 'ontime-types';
import { debounce } from '../../../utils/debounce';
import { cx, getAccessibleColour } from '../../../utils/styleUtils';
import PopoverPicker from '../popover-picker/PopoverPicker';
@@ -18,9 +19,12 @@ interface SwatchPickerProps {
export default function SwatchPicker(props: SwatchPickerProps) {
const { color, onChange, isSelected, alwaysDisplayColor } = props;
const debouncedOnChange = useDebouncedCallback((newValue: string) => {
onChange(newValue);
}, 100);
const debouncedOnChange = useCallback(
debounce((newValue: string) => {
onChange(newValue);
}, 500),
[onChange],
);
const displayColor = alwaysDisplayColor || isSelected ? color : '';
const { color: iconColor } = getAccessibleColour(displayColor);
@@ -1,62 +0,0 @@
.radioGroup {
color: $gray-900;
font-size: calc(1rem - 3px);
color: $label-gray;
}
.item {
display: flex;
align-items: center;
gap: 0.5rem;
line-height: 1.2em;
&:has([data-checked]) {
color: $ui-white;
}
}
.radio {
box-sizing: border-box;
display: flex;
width: 0.75rem;
height: 0.75rem;
align-items: center;
justify-content: center;
border-radius: 100%;
outline: 0;
border: none;
&[data-unchecked] {
background-color: $gray-1200;
}
&[data-checked] {
background-color: $gray-1200;
&:hover {
border-color: $gray-1000;
}
}
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: 2px;
}
}
.indicator {
display: grid;
place-content: center;
&[data-unchecked] {
display: none;
}
&::before {
content: '';
border-radius: 100%;
width: 0.5em;
height: 0.5em;
background-color: $blue-500;
}
}
@@ -1,35 +0,0 @@
import { Radio } from '@base-ui-components/react/radio';
import { RadioGroup as BaseRadioGroup } from '@base-ui-components/react/radio-group';
import style from './BlockRadio.module.scss';
interface BlockRadioProps<T extends string | number | boolean> extends Omit<BaseRadioGroup.Props, 'onValueChange'> {
items: {
value: T;
label: string;
}[];
onValueChange?: (value: T) => void;
}
export default function BlockRadio<T extends string | number | boolean>({
items,
onValueChange,
...elementProps
}: BlockRadioProps<T>) {
return (
<BaseRadioGroup
onValueChange={(value) => onValueChange?.(value as T)}
className={style.radioGroup}
{...elementProps}
>
{items.map((item) => (
<label className={style.item} key={item.value.toString()}>
<Radio.Root value={item.value.toString()} className={style.radio}>
<Radio.Indicator className={style.indicator} />
</Radio.Root>
{item.label}
</label>
))}
</BaseRadioGroup>
);
}
@@ -1,12 +1,22 @@
$input-font-size: 15px;
.delayInput {
display: flex;
gap: $element-spacing;
align-items: center;
font-size: $text-body-size;
.inputField {
font-size: $input-font-size;
letter-spacing: 0.5px;
max-width: 7em;
padding-left: 16px;
color: $ontime-delay-text
}
}
.inputField {
text-align: center;
letter-spacing: 1px;
max-width: 7em;
color: $ontime-delay-text
.delayOptions {
display: flex;
flex-direction: column;
}
@@ -1,10 +1,8 @@
import { KeyboardEvent, useEffect, useRef, useState } from 'react';
import { Input, Radio, RadioGroup } from '@chakra-ui/react';
import { millisToString, parseUserTime } from 'ontime-utils';
import { useEntryActions } from '../../../hooks/useEntryAction';
import Input from '../input/Input';
import BlockRadio from './BlockRadio';
import style from './DelayInput.module.scss';
@@ -107,10 +105,13 @@ export default function DelayInput(props: DelayInputProps) {
return (
<div className={style.delayInput}>
<Input
size='sm'
ref={inputRef}
data-testid='delay-input'
className={style.inputField}
type='text'
placeholder='-'
variant='ontime-filled'
onFocus={handleFocus}
onChange={(event) => setValue(event.target.value)}
onBlur={(event) => validateAndSubmit(event.target.value)}
@@ -118,14 +119,16 @@ export default function DelayInput(props: DelayInputProps) {
value={value}
maxLength={9}
/>
<BlockRadio
onValueChange={handleSlipChange}
<RadioGroup
className={style.delayOptions}
onChange={handleSlipChange}
value={checkedOption}
items={[
{ value: 'add', label: 'Add time' },
{ value: 'subtract', label: 'Subtract time' },
]}
/>
variant='ontime-block'
size='sm'
>
<Radio value='add'>Add time</Radio>
<Radio value='subtract'>Subtract time</Radio>
</RadioGroup>
</div>
);
}
@@ -2,7 +2,7 @@
box-sizing: border-box;
font-size: 1rem;
font-weight: inherit;
font-weight: 400;
color: $gray-200;
border-radius: $component-border-radius-md;
border: 1px solid transparent;
@@ -10,19 +10,13 @@
padding-inline: 0.5em;
outline: none;
&::placeholder {
color: $gray-500;
letter-spacing: 0;
}
&:hover:not(:disabled):not(:read-only) {
&:hover:not(:disabled) {
background-color: $gray-1100;
}
&:focus:not(:read-only) {
background-color: $gray-1000;
outline: 2px solid $blue-500;
outline-offset: 2px;
border: 1px solid $blue-500;
}
&:disabled {
@@ -30,8 +24,9 @@
cursor: not-allowed;
}
&:read-only::placeholder {
opacity: 0;
&::placeholder {
color: $gray-500;
letter-spacing: 0;
}
}
@@ -1,36 +1,20 @@
// styles from Input.module.scss
.input {
width: 100%;
margin-top: 0.25rem;
box-sizing: border-box;
font-size: 1rem;
font-weight: 400;
color: $gray-200;
border-radius: $component-border-radius-md;
background-color: $gray-1200;
border: 1px solid transparent;
border-radius: 0 0 8px 8px;
background-color: $gray-1200;
padding: 0 0.5rem;
font-size: 1rem;
height: 2rem;
padding-inline: 0.5em;
outline: none;
&:hover:not(:disabled) {
&:hover {
background-color: $gray-1100;
}
&:focus:not(:read-only) {
&:focus {
background-color: $gray-1000;
color: $gray-50;
border: 1px solid $blue-500;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
&::placeholder {
color: $gray-500;
letter-spacing: 0;
}
}
@@ -1,8 +1,6 @@
import { PropsWithChildren } from 'react';
import { HexAlphaColorPicker, HexColorInput } from 'react-colorful';
import { Popover } from '@base-ui-components/react/popover';
import PopoverContents from '../../popover/Popover';
import { Popover, PopoverContent, PopoverTrigger } from '@chakra-ui/react';
import style from './PopoverPicker.module.scss';
@@ -11,14 +9,15 @@ interface PopoverPickerProps {
onChange: (color: string) => void;
}
export default function PopoverPicker({ color, onChange, children }: PropsWithChildren<PopoverPickerProps>) {
export default function PopoverPicker(props: PropsWithChildren<PopoverPickerProps>) {
const { color, onChange, children } = props;
return (
<Popover.Root>
<Popover.Trigger>{children}</Popover.Trigger>
<PopoverContents>
<Popover>
<PopoverTrigger>{children}</PopoverTrigger>
<PopoverContent className={style.small} style={{ borderRadius: '9px', width: 'auto' }}>
<HexAlphaColorPicker color={color} onChange={onChange} />
<HexColorInput color={color} onChange={onChange} className={style.input} prefixed />
</PopoverContents>
</Popover.Root>
</PopoverContent>
</Popover>
);
}
@@ -11,7 +11,7 @@ interface UseReactiveTextInputReturn {
export default function useReactiveTextInput(
initialText: string,
submitCallback: (newValue: string) => void,
ref: RefObject<HTMLInputElement | HTMLTextAreaElement | null>,
ref: RefObject<HTMLInputElement>,
options?: {
submitOnEnter?: boolean;
submitOnCtrlEnter?: boolean;
@@ -101,31 +101,25 @@ export default function useReactiveTextInput(
];
if (options?.submitOnEnter) {
hotKeys.push([
'Enter',
() => {
isKeyboardSubmitting.current = true;
handleSubmit(text);
// clear flag after blur has been processed
setTimeout(() => {
isKeyboardSubmitting.current = false;
}, 0);
},
]);
hotKeys.push(['Enter', () => {
isKeyboardSubmitting.current = true;
handleSubmit(text);
// clear flag after blur has been processed
setTimeout(() => {
isKeyboardSubmitting.current = false;
}, 0);
}]);
}
if (options?.submitOnCtrlEnter) {
hotKeys.push([
'mod + Enter',
() => {
isKeyboardSubmitting.current = true;
handleSubmit(text);
// clear flag after blur has been processed
setTimeout(() => {
isKeyboardSubmitting.current = false;
}, 0);
},
]);
hotKeys.push(['mod + Enter', () => {
isKeyboardSubmitting.current = true;
handleSubmit(text);
// clear flag after blur has been processed
setTimeout(() => {
isKeyboardSubmitting.current = false;
}, 0);
}]);
}
const hotKeyHandler = getHotkeyHandler(hotKeys);
@@ -1,21 +1,18 @@
@use '../../../../theme/viewerDefs' as *;
.textarea {
box-sizing: border-box;
min-height: 2rem;
display: block;
font-size: 1rem;
font-weight: inherit;
font-weight: 400;
color: $gray-200;
border-radius: $component-border-radius-md;
border: 1px solid transparent;
padding-inline: 0.5em;
outline: none;
&::placeholder {
color: $gray-500;
letter-spacing: 0;
}
&:hover:not(:disabled) {
background-color: $gray-1100;
}
@@ -30,21 +27,19 @@
cursor: not-allowed;
}
&:read-only::placeholder {
opacity: 0;
&::placeholder {
color: $gray-500;
letter-spacing: 0;
}
}
.subtle {
background-color: $gray-1200;
padding-inline: 0.5em;
padding-top: 0.25em;
}
.ghosted {
background-color: transparent;
padding: 0;
padding-top: 0.25em;
}
.fluid {
@@ -9,7 +9,7 @@ import style from './TimeInput.module.scss';
interface NullableTimeInputProps<T extends string> {
id?: T;
name: T;
submitHandler: (field: T, value: number) => void;
submitHandler: (field: T, value: string) => void;
time?: number | null;
emptyDisplay: string;
placeholder?: string;
@@ -18,20 +18,8 @@ interface NullableTimeInputProps<T extends string> {
className?: string;
}
/**
* Similar to TimeInput, but allows clearing the time value
*/
export default function NullableTimeInput<T extends string>({
id,
name,
submitHandler,
time,
emptyDisplay,
placeholder,
disabled,
align = 'center',
className,
}: NullableTimeInputProps<T>) {
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);
@@ -71,7 +59,7 @@ export default function NullableTimeInput<T extends string>({
return false;
}
submitHandler(name, valueInMillis);
submitHandler(name, newValue);
return true;
},
[name, submitHandler, time],
@@ -3,8 +3,4 @@
max-width: 7.5em;
letter-spacing: 0.5px;
font-variant-numeric: tabular-nums;
&.delayed {
border: 1px solid $ontime-delay-text;
}
}
@@ -14,21 +14,11 @@ interface TimeInputProps<T extends string> {
placeholder?: string;
disabled?: boolean;
align?: 'left' | 'center';
delayed?: boolean;
className?: string;
}
export default function TimeInput<T extends string>({
id,
name,
submitHandler,
time,
placeholder,
disabled,
align = 'center',
delayed,
className,
}: TimeInputProps<T>) {
export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
const { id, name, submitHandler, time, placeholder, disabled, align = 'center', className } = props;
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>('');
const ignoreChange = useRef(false);
@@ -132,7 +122,7 @@ export default function TimeInput<T extends string>({
disabled={disabled}
ref={inputRef}
data-testid={`time-input-${name}`}
className={cx([style.timeInput, delayed && style.delayed, className])}
className={cx([style.timeInput, className])}
placeholder={placeholder}
onFocus={handleFocus}
onChange={(event) => setValue(event.target.value)}
@@ -140,6 +130,7 @@ export default function TimeInput<T extends string>({
onKeyDown={onKeyDownHandler}
value={value}
maxLength={8}
autoComplete='off'
style={{
textAlign: align,
}}
@@ -0,0 +1,10 @@
$input-delayed-border-color: $ontime-delay-text;
.timeInput {
border: 1px solid transparent;
color: $label-gray;
&.delayed {
border: 1px solid $input-delayed-border-color;
}
}
@@ -0,0 +1,37 @@
import { PropsWithChildren } from 'react';
import { InputGroup } from '@chakra-ui/react';
import { cx } from '../../../utils/styleUtils';
import TimeInput from './TimeInput';
import style from './TimeInputWithButton.module.scss';
interface TimeInputWithButtonProps<T extends string> {
name: T;
submitHandler: (field: T, value: string) => void;
time?: number;
hasDelay?: boolean;
disabled?: boolean;
placeholder: string;
}
export default function TimeInputWithButton<T extends string>(props: PropsWithChildren<TimeInputWithButtonProps<T>>) {
const { name, submitHandler, time, hasDelay, placeholder, disabled, children } = props;
const inputClasses = cx([style.timeInput, hasDelay ? style.delayed : null]);
return (
<InputGroup size='sm' className={inputClasses} width='fit-content'>
<TimeInput<T>
name={name}
submitHandler={submitHandler}
time={time}
placeholder={placeholder}
align='left'
disabled={disabled}
/>
{children}
</InputGroup>
);
}
@@ -7,7 +7,6 @@
transition-property: color;
transition-duration: $transition-time-action;
width: fit-content;
text-decoration: none;
&.inline {
display: inline-flex;
@@ -16,9 +15,4 @@
&:hover {
color: $ontime-color;
}
&:focus {
outline: none;
box-shadow: 0 1px 0 0 currentColor;
}
}
}
@@ -7,14 +7,13 @@
padding-inline: 1rem;
min-width: min(680px, 90vw);
min-height: min(200px, 10vh);
max-width: min(800px, 90vw);
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;
outline: none;
}
.backdrop {
@@ -1,4 +1,4 @@
@use '@/theme/viewerDefs' as *;
@use '../../../theme/viewerDefs' as *;
$progress-bar-size: 1.5rem;
$progress-bar-br: 3px;
@@ -5,6 +5,7 @@
.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);
@@ -20,6 +21,7 @@
top: 0;
left: 0;
bottom: 0;
z-index: $zindex-dialog;
width: 22rem;
height: 100vh;
@@ -64,8 +66,3 @@
flex: 1;
overflow-y: auto;
}
.note {
margin-left: auto;
color: $white-20;
}
@@ -57,10 +57,9 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
Toggle Fullscreen
{fullscreen ? <IoContract /> : <IoExpand />}
</NavigationMenuItem>
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
<NavigationMenuItem active={mirror} onClick={toggleMirror}>
Flip Screen
<IoSwapVertical />
{mirror && <span className={style.note}>Active</span>}
</NavigationMenuItem>
<NavigationMenuItem onClick={handlers.open}>Rename Client</NavigationMenuItem>
@@ -8,11 +8,11 @@ import useViewEditor from './useViewEditor';
interface ViewNavigationMenuProps {
isLockable?: boolean;
suppressSettings?: boolean;
supressSettings?: boolean;
}
export default memo(ViewNavigationMenu);
function ViewNavigationMenu({ isLockable, suppressSettings }: ViewNavigationMenuProps) {
function ViewNavigationMenu({ isLockable, supressSettings }: ViewNavigationMenuProps) {
const [isMenuOpen, menuHandler] = useDisclosure();
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable });
@@ -28,7 +28,7 @@ function ViewNavigationMenu({ isLockable, suppressSettings }: ViewNavigationMenu
[
'mod + ,',
() => {
if (isViewLocked || suppressSettings) return;
if (isViewLocked || supressSettings) return;
showEditFormDrawer();
},
{ preventDefault: true },
@@ -43,7 +43,7 @@ function ViewNavigationMenu({ isLockable, suppressSettings }: ViewNavigationMenu
<>
<FloatingNavigation
toggleMenu={menuHandler.toggle}
toggleSettings={suppressSettings ? undefined : () => showEditFormDrawer()}
toggleSettings={supressSettings ? undefined : () => showEditFormDrawer()}
/>
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
</>
@@ -25,8 +25,9 @@
}
&:focus {
outline: 2px solid $blue-500;
background-color: $gray-1350;
outline: none;
background-color: $gray-1300;
border: 2px solid $ui-white;
}
&.current {
@@ -5,17 +5,12 @@
}
.interfaces {
padding: 0.5rem 0.5rem;
padding: 0.5rem 1rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.interfaceCopy {
display: flex;
align-items: center;
}
.goIcon {
@include rotate-fourty-five;
margin-left: 0.25rem;
@@ -30,10 +30,13 @@ export default function OtherAddresses({ currentLocation }: OtherAddressesProps)
const address = linkToOtherHost(nif.address, currentLocation);
return (
<CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)} size='small'>
<span className={style.interfaceCopy}>
{nif.address} <IoArrowUp className={style.goIcon} />
</span>
<CopyTag
key={nif.name}
copyValue={address}
onClick={() => openLink(address)}
label='Copy IP or navigate to address'
>
{nif.address} <IoArrowUp className={style.goIcon} />
</CopyTag>
);
})}
@@ -1,16 +1,19 @@
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
interface EditorVisibilityOptions {
isLockable?: boolean;
}
export default function useViewEditor({ isLockable }: EditorVisibilityOptions) {
const [searchParams] = useSearchParams();
const { open: showEditFormDrawer } = useViewParamsEditorStore();
const [searchParams, setSearchParams] = useSearchParams();
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
const isViewLocked = useMemo(() => {
if (!isLockable) {
@@ -1,14 +1,16 @@
.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: 1px solid $gray-1000;
border: 2px solid $gray-1200;
//width: 32rem;
max-width: 90vw;
transform-origin: var(--transform-origin);
@@ -18,9 +18,7 @@ export default function PopoverContents({
<Popover.Positioner sideOffset={8} {...popoverProps}>
<Popover.Popup className={style.popup}>
{title && <Popover.Title className={style.title}>{title}</Popover.Title>}
<Popover.Description className={className} render={<div />}>
{children}
</Popover.Description>
<Popover.Description className={className}>{children}</Popover.Description>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
@@ -1,4 +1,4 @@
@use '@/theme/viewerDefs' as *;
@use '../../../theme/viewerDefs' as *;
$progress-bar-size: 12px;
$progress-bar-br: 3px;
@@ -1,50 +0,0 @@
.container {
width: 100%;
height: 100%;
padding-top: 25vh;
background: $ui-black;
color: $ontime-color;
font-size: 3rem;
text-align: center;
}
.pin {
display: flex;
align-items: center;
justify-content: center;
input {
font-size: 4rem;
height: 4rem;
width: 4em;
text-align: center;
letter-spacing: 0.25em;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
button {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
height: 4rem;
width: 4rem;
font-size: 4rem;
}
}
.pinFailed {
input {
animation: redFlash 1.5s ease;
}
}
@keyframes redFlash {
from {
background: $red-500;
}
to {
background: rgba($red-500, 0);
}
}
@@ -1,55 +1,71 @@
import { PropsWithChildren, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { IoCheckmark } from 'react-icons/io5';
import { IconButton, PinInput, PinInputField } from '@chakra-ui/react';
import { cx } from '../../utils/styleUtils';
import IconButton from '../buttons/IconButton';
import Input from '../input/input/Input';
import style from './PinPage.module.scss';
import style from './ProtectRoute.module.scss';
interface PinPageProps {
permission: 'editor' | 'operator';
handleValidation: (pin: string) => boolean;
}
export default function PinPage({ permission, handleValidation }: PropsWithChildren<PinPageProps>) {
export default function PinPage(props: PinPageProps) {
const { permission, handleValidation } = props;
const [pin, setPin] = useState('');
const [failed, setFailed] = useState(false);
const validate = () => {
const validate = useCallback(() => {
const isValid = handleValidation(pin);
if (!isValid) {
setFailed(true);
setPin('');
}
};
}, [handleValidation, pin]);
const handleInputChange = (value: string) => {
setPin(value);
if (failed) setFailed(false);
};
useEffect(() => {
const handleKeyPress = (event: KeyboardEvent) => {
if (event.repeat) return;
if (event.key === 'Enter') {
validate();
}
};
document.addEventListener('keydown', handleKeyPress);
return () => {
document.removeEventListener('keydown', handleKeyPress);
};
}, [validate]);
return (
<div className={style.container}>
{`Ontime ${permission}`}
<form
onSubmit={(event) => {
event.preventDefault();
validate();
}}
className={cx([style.pin, failed && style.pinFailed])}
>
<Input
type='password'
maxLength={4}
height='large'
<div className={failed ? style.pin__failed : style.pin}>
<PinInput
type='alphanumeric'
size='lg'
mask
autoFocus
value={pin}
onChange={(e) => handleInputChange(e.target.value)}
onChange={(value) => {
setFailed(false);
setPin(value);
}}
>
<PinInputField />
<PinInputField />
<PinInputField />
<PinInputField />
</PinInput>
<IconButton
variant='ontime-filled'
aria-label='Enter'
size='lg'
isRound
icon={<IoCheckmark />}
onClick={validate}
/>
<IconButton type='submit' variant='primary' aria-label='Enter'>
<IoCheckmark />
</IconButton>
</form>
</div>
</div>
);
}
@@ -0,0 +1,49 @@
.container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 25vh;
background: $ui-black;
color: $ontime-color;
font-weight: 200;
font-size: 3rem;
}
.pin,
.pin__failed {
display: flex;
gap: 0.125em;
padding-block: 0.5em;
input {
border-radius: 99px;
border-color: $gray-500;
&:hover {
border-color: $blue-500;
}
}
button {
margin-left: 1em;
}
}
.pin__failed {
input {
animation: colourFade 1.5s ease;
}
}
@keyframes colourFade {
from {
background: $red-500;
}
to {
background: rgba($red-500, 0);
}
}
@@ -1,76 +0,0 @@
.radioGroup {
display: flex;
gap: 0.25rem;
color: $gray-900;
font-size: calc(1rem - 2px);
color: $ui-white;
}
.horizontal {
align-items: center;
flex-direction: row;
gap: 1rem;
width: 100%;
}
.vertical {
align-items: start;
flex-direction: column;
}
.item {
display: flex;
align-items: center;
gap: 0.5rem;
height: 2rem;
&:has([data-checked]) {
color: $ui-white;
}
}
.radio {
box-sizing: border-box;
display: flex;
width: 1rem;
height: 1rem;
align-items: center;
justify-content: center;
border-radius: 100%;
outline: 0;
border: none;
&[data-unchecked] {
border: 1px solid $gray-1200;
background-color: $gray-1200;
}
&[data-checked] {
background-color: $ui-white;
&:hover {
border-color: $gray-1000;
}
}
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: 2px;
}
}
.indicator {
display: grid;
place-content: center;
&[data-unchecked] {
display: none;
}
&::before {
content: '';
border-radius: 100%;
width: 0.5rem;
height: 0.5rem;
background-color: $gray-1200;
}
}
@@ -1,40 +0,0 @@
import { Radio } from '@base-ui-components/react/radio';
import { RadioGroup as BaseRadioGroup } from '@base-ui-components/react/radio-group';
import { cx } from '../../utils/styleUtils';
import style from './RadioGroup.module.scss';
interface RadioGroupProps<T extends string | number | boolean> extends Omit<BaseRadioGroup.Props, 'onValueChange'> {
items: {
value: T;
label: string;
}[];
onValueChange?: (value: T) => void;
orientation?: 'horizontal' | 'vertical';
}
export default function RadioGroup<T extends string | number | boolean>({
items,
className,
orientation = 'vertical',
onValueChange,
...elementProps
}: RadioGroupProps<T>) {
return (
<BaseRadioGroup
onValueChange={(value) => onValueChange?.(value as T)}
className={cx([style.radioGroup, style[orientation], className])}
{...elementProps}
>
{items.map((item) => (
<label className={style.item} key={item.value.toString()}>
<Radio.Root value={item.value.toString()} className={style.radio}>
<Radio.Indicator className={style.indicator} />
</Radio.Root>
{item.label}
</label>
))}
</BaseRadioGroup>
);
}
@@ -14,6 +14,7 @@
border: 1px solid transparent;
padding-inline: 0.5rem;
font-size: calc(1rem - 2px);
white-space: nowrap;
&:hover:not([data-disabled]) {
@@ -33,30 +34,17 @@
cursor: not-allowed;
}
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: 2px;
&.fluid {
width: 100%;
}
}
.medium {
height: 2rem;
}
.large {
height: 2.5rem;
}
.fluid {
width: 100%;
}
.selectIcon {
color: $gray-200;
}
.popup {
font-size: 1rem;
font-size: calc(1rem - 2px);
background-color: $gray-1200;
box-sizing: border-box;
padding: 2px;
@@ -6,22 +6,19 @@ import { cx } from '../../utils/styleUtils';
import styles from './Select.module.scss';
export type SelectOption<T = string> = {
value: T;
label: string;
disabled?: boolean;
};
interface SelectProps<T> extends Omit<BaseSelect.Root.Props<T>, 'items'> {
options: SelectOption<T>[];
// overload items to not allow undefined values
options: {
value: T;
label: string;
}[];
fluid?: boolean;
size?: 'medium' | 'large';
}
export default function Select<T>({ options, fluid, size = 'medium', ...selectRootProps }: SelectProps<T>) {
export default function Select<T>({ options, fluid, ...selectRootProps }: SelectProps<T>) {
return (
<BaseSelect.Root items={options} {...selectRootProps}>
<BaseSelect.Trigger className={cx([styles.select, styles[size], fluid && styles.fluid])}>
<BaseSelect.Trigger className={cx([styles.select, fluid && styles.fluid])}>
<BaseSelect.Value />
<BaseSelect.Icon className={styles.selectIcon}>
<LuChevronsUpDown />
@@ -31,8 +28,8 @@ export default function Select<T>({ options, fluid, size = 'medium', ...selectRo
<BaseSelect.Positioner side='bottom' align='start'>
<BaseSelect.ScrollUpArrow className={styles.scrollArrow} />
<BaseSelect.Popup className={styles.popup}>
{options.map(({ disabled, label, value }) => (
<BaseSelect.Item key={String(value)} className={styles.item} value={value} disabled={disabled}>
{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>
@@ -11,7 +11,8 @@ interface EmptyProps {
className?: string;
}
export default function Empty({ text, className, ...rest }: EmptyProps) {
export default function Empty(props: EmptyProps) {
const { text, className, ...rest } = props;
return (
<div className={cx([style.emptyContainer, className])} {...rest}>
<EmptyImage className={style.empty} />
@@ -1,4 +1,4 @@
@use '@/theme/viewerDefs' as *;
@use '../../../theme/viewerDefs' as *;
/* share the same style as a page layout */
.page {
@@ -1,19 +0,0 @@
.emptyContainer {
width: 100%;
color: $white-10;
.emptyCell {
margin-inline: auto;
text-align: center;
}
.empty {
width: 100%;
opacity: 0.8;
}
.text {
font-weight: 600;
font-size: 2em;
}
}
@@ -1,20 +0,0 @@
import EmptyImage from '../../../assets/images/empty.svg?react';
import style from './EmptyTableBody.module.scss';
interface EmptyTableBodyProps {
text: string;
}
export default function EmptyTableBody({ text }: EmptyTableBodyProps) {
return (
<tbody className={style.emptyContainer}>
<tr>
<td colSpan={99} className={style.emptyCell}>
<EmptyImage className={style.empty} />
{text && <span className={style.text}>{text}</span>}
</td>
</tr>
</tbody>
);
}
@@ -19,13 +19,7 @@
}
&:focus {
outline: 2px solid $blue-500;
outline-offset: 2px;
}
&[data-disabled] {
opacity: $opacity-disabled;
cursor: not-allowed;
border-color: $blue-500;
}
}
@@ -55,5 +49,4 @@
&[data-checked] {
translate: calc(var(--width) - var(--height)) 0;
}
}
@@ -1,4 +1,4 @@
@use '@/theme/viewerDefs' as *;
@use '../../../theme/viewerDefs' as *;
.title-card {
position: relative;
@@ -1,24 +0,0 @@
.tooltip {
font-size: calc(1rem - 3px);
background-color: $ui-white;
color: $ui-black;
padding: 0.125rem 0.5rem;
border-radius: 2px;
line-height: 1.25em;
max-width: 200px;
transform-origin: var(--transform-origin);
transition:
transform 150ms,
opacity 150ms;
&[data-starting-style],
&[data-ending-style] {
opacity: 0;
transform: scale(0.9);
}
&[data-instant] {
transition-duration: 0ms;
}
}
@@ -1,24 +0,0 @@
import { PropsWithChildren } from 'react';
import { Tooltip as BaseTooltip } from '@base-ui-components/react/tooltip';
import style from './Tooltip.module.scss';
interface TooltipProps extends BaseTooltip.Trigger.Props {
text: string;
}
export default function Tooltip({ text, children, ...triggerProps }: PropsWithChildren<TooltipProps>) {
return (
<BaseTooltip.Root>
<BaseTooltip.Trigger {...triggerProps}>{children}</BaseTooltip.Trigger>
<BaseTooltip.Portal>
<BaseTooltip.Positioner side='bottom' sideOffset={4}>
<BaseTooltip.Popup className={style.tooltip}>
<BaseTooltip.Arrow />
{text}
</BaseTooltip.Popup>
</BaseTooltip.Positioner>
</BaseTooltip.Portal>
</BaseTooltip.Root>
);
}
@@ -1,7 +1,7 @@
.inline {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 1rem;
}
// attempt to match with ontimeTextInputs
@@ -1,42 +0,0 @@
.inline {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
// styles from subtle button
.toggleSelect {
display: flex;
align-items: center;
gap: 0.25rem;
padding-inline: 0.5rem;
height: 2.5rem;
background: $gray-1050;
color: $ui-white;
line-height: 1em;
border-radius: $component-border-radius-md;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&::after {
content: '';
margin-left: 0.25rem;
width: 0.75em;
height: 0.75em;
background: var(--user-bg);
border-radius: 50%;
}
}
.empty {
color: $ui-white;
}
@@ -1,17 +1,25 @@
import { useState } from 'react';
import { IoChevronDown } from 'react-icons/io5';
import { useSearchParams } from 'react-router-dom';
import {
Button,
Input,
InputGroup,
InputLeftElement,
Menu,
MenuButton,
MenuItemOption,
MenuList,
MenuOptionGroup,
Select,
Switch,
} from '@chakra-ui/react';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import Checkbox from '../checkbox/Checkbox';
import Input from '../input/input/Input';
import Select from '../select/Select';
import Switch from '../switch/Switch';
import InlineColourPicker from './InlineColourPicker';
import { ParamField } from './viewParams.types';
import style from './ParamInput.module.scss';
interface ParamInputProps {
paramField: ParamField;
}
@@ -31,11 +39,20 @@ export default function ParamInput({ paramField }: ParamInputProps) {
const optionFromParams = searchParams.get(id);
const defaultOptionValue = optionFromParams || defaultValue;
if (paramField.values.length === 0) {
return <span className={style.empty}>No options available</span>;
}
return <Select size='large' name={id} defaultValue={defaultOptionValue} options={paramField.values} />;
return (
<Select
placeholder={defaultValue ? undefined : 'Select an option'}
variant='ontime'
name={id}
defaultValue={defaultOptionValue}
>
{Object.entries(paramField.values).map(([key, value]) => (
<option key={key} value={key}>
{value}
</option>
))}
</Select>
);
}
if (type === 'multi-option') {
@@ -43,22 +60,28 @@ export default function ParamInput({ paramField }: ParamInputProps) {
}
if (type === 'boolean') {
return <ControlledSwitch id={id} initialValue={isStringBoolean(searchParams.get(id)) || defaultValue} />;
const defaultCheckedValue = isStringBoolean(searchParams.get(id)) || defaultValue;
// checked value should be 'true', so it can be captured by the form event
return <Switch variant='ontime' name={id} defaultChecked={defaultCheckedValue} value='true' />;
}
if (type === 'number') {
const { placeholder } = paramField;
const { prefix, placeholder } = paramField;
const defaultNumberValue = searchParams.get(id) ?? defaultValue;
return (
<Input
height='large'
type='number'
step='any'
name={id}
defaultValue={defaultNumberValue}
placeholder={placeholder}
/>
<InputGroup variant='ontime-filled'>
{prefix && <InputLeftElement pointerEvents='none'>{prefix}</InputLeftElement>}
<Input
type='number'
step='any'
variant='ontime-filled'
name={id}
defaultValue={defaultNumberValue}
placeholder={placeholder}
/>
</InputGroup>
);
}
@@ -69,65 +92,52 @@ export default function ParamInput({ paramField }: ParamInputProps) {
}
const defaultStringValue = searchParams.get(id) ?? defaultValue;
const { placeholder } = paramField;
const { prefix, placeholder } = paramField;
return <Input height='large' name={id} defaultValue={defaultStringValue} placeholder={placeholder} />;
return (
<InputGroup variant='ontime-filled'>
{prefix && <InputLeftElement pointerEvents='none'>{prefix}</InputLeftElement>}
<Input name={id} defaultValue={defaultStringValue} placeholder={placeholder} />
</InputGroup>
);
}
interface EditFormMultiOptionProps {
paramField: ParamField & { type: 'multi-option' };
}
function MultiOption({ paramField }: EditFormMultiOptionProps) {
function MultiOption(props: EditFormMultiOptionProps) {
const [searchParams] = useSearchParams();
const { id, values, defaultValue = [''] } = paramField;
const { paramField } = props;
const { id, defaultValue } = paramField;
const optionFromParams = searchParams.getAll(id);
const [paramState, setParamState] = useState<string[]>(optionFromParams || defaultValue);
const toggleValue = (value: string, checked: boolean) => {
if (checked) {
setParamState((prev) => [...prev, value]);
} else {
setParamState((prev) => prev.filter((v) => v !== value));
}
};
if (values.length === 0) {
return <span className={style.empty}>No options available</span>;
}
const [paramState, setParamState] = useState<string[]>(optionFromParams || defaultValue || ['']);
return (
<>
<input name={id} hidden readOnly value={paramState.join(',')} />
<div className={style.inline}>
{values.map((option) => {
return (
<label
key={option.value}
className={style.toggleSelect}
style={{
'--user-bg': option.colour,
}}
>
<Checkbox
checked={paramState.includes(option.value)}
onCheckedChange={(checked) => toggleValue(option.value, checked as boolean)}
/>
{option.label}
</label>
);
})}
</div>
<input name={id} hidden readOnly value={paramState} />
<Menu isLazy closeOnSelect={false} variant='ontime-on-dark'>
<MenuButton as={Button} variant='ontime-subtle-white' position='relative' width='fit-content' fontWeight={400}>
{paramField.title} <IoChevronDown style={{ display: 'inline' }} />
</MenuButton>
<MenuList overflow='auto' maxHeight='200px'>
<MenuOptionGroup
type='checkbox'
value={paramState}
onChange={(value) => setParamState(Array.isArray(value) ? value : [value])}
>
{Object.values(paramField.values).map((option) => {
const { value, label, colour } = option;
return (
<MenuItemOption value={value} key={value} style={{ borderRight: `8px solid ${colour}` }}>
{label}
</MenuItemOption>
);
})}
</MenuOptionGroup>
</MenuList>
</Menu>
</>
);
}
interface ControlledSwitchProps {
id: string;
initialValue: boolean;
}
function ControlledSwitch({ id, initialValue }: ControlledSwitchProps) {
const [checked, setChecked] = useState(initialValue);
return <Switch size='large' name={id} checked={checked} onCheckedChange={setChecked} />;
}
@@ -11,6 +11,7 @@
.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);
@@ -26,13 +27,14 @@
top: 0;
right: 0;
bottom: 0;
z-index: $zindex-dialog;
width: 40rem;
height: 100vh;
display: flex;
flex-direction: column;
padding-block: 1rem 1.5rem;
padding: 1rem 1.5rem;
background-color: $gray-1250;
color: $ui-white;
@@ -56,7 +58,6 @@
}
.header {
padding-inline: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
@@ -68,14 +69,12 @@
.body {
flex: 1;
padding-inline: 1rem;
padding-bottom: 10vh;
overflow-y: auto;
}
.footer {
display: flex;
padding-inline: 1rem;
gap: 1rem;
align-items: center;
justify-content: flex-end;
@@ -1,7 +1,8 @@
import { FormEvent, memo } from 'react';
import { FormEvent, memo, useEffect } from 'react';
import { IoClose } from 'react-icons/io5';
import { useSearchParams } from 'react-router-dom';
import { Dialog } from '@base-ui-components/react/dialog';
import { useDisclosure } from '@mantine/hooks';
import useViewSettings from '../../hooks-query/useViewSettings';
import Button from '../buttons/Button';
@@ -10,7 +11,6 @@ import Info from '../info/Info';
import { ViewOption } from './viewParams.types';
import { getURLSearchParamsFromObj } from './viewParams.utils';
import { useViewParamsEditorStore } from './viewParamsEditor.store';
import ViewParamsSection from './ViewParamsSection';
import style from './ViewParamsEditor.module.scss';
@@ -22,16 +22,30 @@ interface EditFormDrawerProps {
export default memo(ViewParamsEditor);
function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const [_, setSearchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings();
const { isOpen, close } = useViewParamsEditorStore();
const [isOpen, handlers] = useDisclosure(false);
// handle opening the drawer
useEffect(() => {
const isEditing = searchParams.get('edit');
if (isEditing === 'true') {
return handlers.open();
}
}, [searchParams, handlers]);
const handleClose = () => {
close();
searchParams.delete('edit');
setSearchParams(searchParams);
handlers.close();
};
const resetParams = () => {
setSearchParams();
handlers.close();
};
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
@@ -1,4 +1,3 @@
import { memo } from 'react';
import { IoChevronDown } from 'react-icons/io5';
import { useLocalStorage } from '@mantine/hooks';
@@ -16,8 +15,7 @@ interface ViewParamsSectionProps {
options: ParamField[];
}
export default memo(ViewParamsSection);
function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
const handleCollapse = () => {
@@ -5,32 +5,32 @@ import { OptionTitle } from '../constants';
import type { ViewOption } from '../viewParams.types';
import { getURLSearchParamsFromObj, makeOptionsFromCustomFields } from '../viewParams.utils';
describe('makeOptionsFromCustomFields()', () => {
describe('makeOptionsFromCustomFields', () => {
const testCustomFields: CustomFields = {
field1: { label: 'Field 1', colour: 'red', type: 'text' },
field2: { label: 'Field 2', colour: 'blue', type: 'text' },
field1: { label: 'Field 1', colour: 'red', type: 'string' },
field2: { label: 'Field 2', colour: 'blue', type: 'string' },
};
it('creates an array of options to use in a select', () => {
it('creates a record of keys for the given custom fields', () => {
const result = makeOptionsFromCustomFields(testCustomFields);
expect(result).toStrictEqual([
{ value: 'custom-field1', label: 'Custom: Field 1' },
{ value: 'custom-field2', label: 'Custom: Field 2' },
]);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
});
});
it('appends additional data', () => {
const additionalData = [
{ value: 'test1', label: 'Test 1' },
{ value: 'test2', label: 'Test 2' },
];
const additionalData = {
test1: 'test1',
test2: 'test2',
};
const result = makeOptionsFromCustomFields(testCustomFields, additionalData);
expect(result).toStrictEqual([
{ value: 'test1', label: 'Test 1' },
{ value: 'test2', label: 'Test 2' },
{ value: 'custom-field1', label: 'Custom: Field 1' },
{ value: 'custom-field2', label: 'Custom: Field 2' },
]);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
test1: 'test1',
test2: 'test2',
});
});
it('filtersImageTypes', () => {
@@ -40,14 +40,14 @@ describe('makeOptionsFromCustomFields()', () => {
};
const result = makeOptionsFromCustomFields(customFieldsWIthImage);
expect(result).toStrictEqual([
{ value: 'custom-field1', label: 'Custom: Field 1' },
{ value: 'custom-field2', label: 'Custom: Field 2' },
]);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
});
});
});
describe('getURLSearchParamsFromObj()', () => {
describe('getURLSearchParamsFromObj', () => {
// Mock view options for testing
const mockViewOptions: ViewOption[] = [
{
@@ -73,10 +73,7 @@ describe('getURLSearchParamsFromObj()', () => {
title: 'Multi Select',
description: 'A multi-select field',
type: 'option',
values: [
{ value: 'value1', label: 'Value 1' },
{ value: 'value2', label: 'Value 2' },
],
values: { value1: 'Value 1', value2: 'Value 2' },
defaultValue: '',
},
],
@@ -204,35 +201,4 @@ describe('getURLSearchParamsFromObj()', () => {
// Should only include unique values while maintaining order
expect(result.getAll('sub')).toEqual(['value1', 'value2', 'value3']);
});
it('converts on-off from toggle to boolean', () => {
const mockOptionsWithBooleans: ViewOption[] = [
{
title: OptionTitle.StyleOverride,
options: [
{
id: 'bool1',
title: 'bool1',
description: 'Bool1',
type: 'boolean',
defaultValue: true,
},
{
id: 'bool2',
title: 'bool2',
description: 'Bool2',
type: 'boolean',
defaultValue: false,
},
],
},
];
const params = {
bool1: 'off',
bool2: 'on',
};
const result = getURLSearchParamsFromObj(params, mockOptionsWithBooleans);
expect(result.get('bool1')).toBe('false');
expect(result.get('bool2')).toBe('true');
});
});
@@ -4,8 +4,8 @@ export const getTimeOption = (timeFormat: string): ParamField => {
const placeholder = `${timeFormat} (default)`;
return {
id: 'timeformat',
title: 'Clock time format, defaults to value from the Settings',
description: 'Format for the Time Now field, eg. HH:mm:ss or hh:mm:ss a, see docs for help',
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,
};
@@ -8,19 +8,20 @@ type BaseField = {
type OptionsField = {
type: 'option';
values: { value: string; label: string }[];
values: Record<string, string>;
defaultValue?: string;
};
export type MultiselectOption = { value: string; label: string; colour: string };
type MultiselectOption = { value: string; label: string; colour: string };
export type MultiselectOptions = Record<string, MultiselectOption>;
type MultiOptionsField = {
type: 'multi-option';
values: MultiselectOption[];
values: MultiselectOptions;
defaultValue?: string;
};
type StringField = { type: 'string'; defaultValue?: string; placeholder?: string };
type NumberField = { type: 'number'; defaultValue?: number; placeholder?: string };
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
type BooleanField = { type: 'boolean'; defaultValue: boolean };
type ColourField = { type: 'colour'; defaultValue: string; placeholder?: string };
type PersistedField = { type: 'persist'; defaultValue?: string[]; values: string[] };
@@ -1,8 +1,6 @@
import type { CustomFields, ProjectData } from 'ontime-types';
import type { CustomFields } from 'ontime-types';
import type { SelectOption } from '../select/Select';
import type { MultiselectOption, ViewOption } from './viewParams.types';
import type { MultiselectOptions, ViewOption } from './viewParams.types';
/**
* Creates a list of custom fields for a select
@@ -10,23 +8,17 @@ import type { MultiselectOption, ViewOption } from './viewParams.types';
*/
export function makeOptionsFromCustomFields(
customFields: CustomFields,
additionalOptions: SelectOption[] = [],
additionalOptions: Readonly<Record<string, string>> = {},
filterImageType = true,
): SelectOption[] {
const options: SelectOption[] = [...additionalOptions];
// Add custom fields first
): Record<string, string> {
const options = { ...additionalOptions };
for (const [key, value] of Object.entries(customFields)) {
if (filterImageType && value.type === 'image') {
continue;
}
options.push({
value: `custom-${key}`,
label: `Custom: ${value.label}`,
});
options[`custom-${key}`] = `Custom: ${value.label}`;
}
return options;
}
@@ -34,37 +26,17 @@ export function makeOptionsFromCustomFields(
* Creates data for a multiselect component from custom fields
* Filters out image type custom fields
*/
export function makeCustomFieldSelectOptions(customFields: CustomFields, filterImageType = true): MultiselectOption[] {
const options: MultiselectOption[] = [];
// Add custom fields first
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.push({
value: key,
label: value.label,
colour: value.colour || 'transparent',
});
options[key] = { value: key, label: value.label, colour: value.colour };
}
return options;
}
/**
* Creates data for a select element that displays project custom data
*/
export function makeProjectDataOptions(projectData: ProjectData): SelectOption[] {
return projectData.custom.map((entry, index) => {
return {
value: `${index}-${entry.title}`,
label: entry.title,
};
});
}
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
/**
@@ -80,22 +52,17 @@ function sanitiseColour(colour: string) {
type FieldMetadata = {
defaultValues: Record<string, string>;
colorFields: Set<string>;
booleanFields: Set<string>;
isPersistedField: Set<string>;
persistedValues: Record<string, string[]>;
};
/**
* Utility collects metadata about fields from view options
* - where are the default values
* - which fields are colours
* - which fields are persisted
*/
function collectFieldMetadata(paramFields: ViewOption[]): FieldMetadata {
const metadata: FieldMetadata = {
defaultValues: {},
colorFields: new Set(),
booleanFields: new Set(),
isPersistedField: new Set(),
persistedValues: {},
};
@@ -113,8 +80,6 @@ function collectFieldMetadata(paramFields: ViewOption[]): FieldMetadata {
if (option.type === 'colour') {
metadata.colorFields.add(option.id);
} else if (option.type === 'boolean') {
metadata.booleanFields.add(option.id);
}
});
});
@@ -167,16 +132,7 @@ export function getURLSearchParamsFromObj(paramsObj: ViewParamsObj, paramFields:
// Process and add new values
value.split(',').forEach((v) => {
// some field types need extra processing
const processedValue = (() => {
if (metadata.colorFields.has(id)) {
return sanitiseColour(v);
}
if (metadata.booleanFields.has(id)) {
return v === 'on' ? 'true' : 'false';
}
return v;
})();
const processedValue = metadata.colorFields.has(id) ? sanitiseColour(v) : v;
if (metadata.isPersistedField.has(id) || metadata.defaultValues[id] !== processedValue) {
addUniqueParam(id, processedValue);
}
@@ -1,15 +0,0 @@
import { create } from 'zustand';
interface ViewParamsEditorStore {
isOpen: boolean;
open: () => void;
close: () => void;
toggle: () => void;
}
export const useViewParamsEditorStore = create<ViewParamsEditorStore>((set) => ({
isOpen: false,
open: () => set({ isOpen: true }),
close: () => set({ isOpen: false }),
toggle: () => set((state) => ({ isOpen: !state.isOpen })),
}));
@@ -1,17 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import { CustomFields } from 'ontime-types';
// CustomFields record type is no longer used here
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CUSTOM_FIELDS } from '../api/constants';
import { getCustomFields } from '../api/customFields';
import { getCustomFields, CustomFieldWithKey } from '../api/customFields'; // Import CustomFieldWithKey
const placeholder: CustomFields = {};
const placeholder: CustomFieldWithKey[] = []; // Placeholder is now an empty array
export default function useCustomFields() {
const { data, status, isFetching, isError, refetch } = useQuery({
// Explicitly type the useQuery hook
const { data, status, isFetching, isError, refetch } = useQuery<CustomFieldWithKey[], Error>({
queryKey: CUSTOM_FIELDS,
queryFn: getCustomFields,
placeholderData: (previousData, _previousQuery) => previousData,
placeholderData: (previousData, _previousQuery) => previousData ?? placeholder,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -1,8 +1,8 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_DATA } from '../api/constants';
import { getProjectData, postProjectData } from '../api/project';
import { getProjectData } from '../api/project';
import { projectDataPlaceholder } from '../models/ProjectData';
export default function useProjectData() {
@@ -10,25 +10,11 @@ export default function useProjectData() {
queryKey: PROJECT_DATA,
queryFn: getProjectData,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? projectDataPlaceholder, status, isFetching, isError, refetch };
}
export function useUpdateProjectData() {
const queryClient = useQueryClient();
const updateFn = useMutation({
mutationFn: postProjectData,
onSuccess: (newProjectData) => {
queryClient.setQueryData(PROJECT_DATA, newProjectData);
},
});
return {
updateProjectData: updateFn.mutateAsync,
isMutating: updateFn.isPending,
isMutatingError: updateFn.isError,
};
}
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
import { OntimeEntry, Rundown } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { RUNDOWN } from '../api/constants';
@@ -56,7 +56,6 @@ export function useFlatRundown() {
}, [data.entries, data.flatOrder, data.revision, prevRevision]);
// TODO: should we have a project id field?
// TODO(v4): cleanup as part of load multiple rundowns
// invalidate current version if project changes
useEffect(() => {
if (projectData?.title !== loadedProject.current) {
@@ -65,7 +64,7 @@ export function useFlatRundown() {
}
}, [projectData]);
return { data: flatRundown, rundownId: data.id, status };
return { data: flatRundown, status };
}
/**
@@ -79,18 +78,3 @@ export function usePartialRundown(cb: (event: OntimeEntry) => boolean) {
return { data: filteredData, status };
}
/**
* Hook to get a specific entry by ID from the rundown
*/
export function useEntry(entryId: EntryId | null): OntimeEntry | null {
const { data: rundown } = useRundown();
// track the specific entry we care about
const entry = useMemo(() => {
if (entryId === null) return null;
return rundown.entries[entryId];
}, [entryId, rundown.entries]);
return entry;
}
@@ -1,9 +1,8 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { URLPreset } from 'ontime-types';
import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { URL_PRESETS } from '../api/constants';
import { deleteUrlPreset, getUrlPresets, postUrlPreset, putUrlPreset } from '../api/urlPresets';
import { getUrlPresets } from '../api/urlPresets';
interface FetchProps {
skip?: boolean;
@@ -14,42 +13,12 @@ export default function useUrlPresets({ skip = false }: FetchProps = {}) {
queryKey: URL_PRESETS,
queryFn: getUrlPresets,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
enabled: !skip,
});
return { data: data ?? [], status, isError, refetch };
}
export function useUpdateUrlPreset() {
const queryClient = useQueryClient();
const addFn = useMutation({
mutationFn: postUrlPreset,
onSuccess: (newPresets) => {
queryClient.setQueryData(URL_PRESETS, newPresets);
},
});
const updateFn = useMutation({
mutationFn: ({ alias, data }: { alias: string; data: URLPreset }) => putUrlPreset(alias, data),
onSuccess: (newPresets) => {
queryClient.setQueryData(URL_PRESETS, newPresets);
},
});
const deleteFn = useMutation({
mutationFn: deleteUrlPreset,
onSuccess: (newPresets) => {
queryClient.setQueryData(URL_PRESETS, newPresets);
},
});
return {
addPreset: addFn.mutateAsync,
updatePreset: (alias: string, data: URLPreset) => updateFn.mutateAsync({ alias, data }),
deletePreset: deleteFn.mutateAsync,
isMutating: addFn.isPending || updateFn.isPending || deleteFn.isPending,
isMutationError: addFn.isError || updateFn.isError || deleteFn.isError,
};
}
@@ -1,10 +1,9 @@
import { MouseEvent } from 'react';
import { useContextMenuStore } from '../../features/rundown/rundown-context-menu/RundownContextMenu';
import { DropdownMenuOption } from '../components/dropdown-menu/DropdownMenu';
import { Option, useContextMenuStore } from '../components/context-menu/ContextMenu';
export const useContextMenu = <T extends HTMLElement>(options: DropdownMenuOption[]) => {
const setContextMenu = useContextMenuStore((state) => state.setContextMenu);
export const useContextMenu = <T extends HTMLElement>(options: Option[]) => {
const { setContextMenu } = useContextMenuStore();
const localCreateContextMenu = (contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => {
// prevent browser default context menu from showing up
+19 -25
View File
@@ -15,7 +15,7 @@ import {
} from 'ontime-types';
import { dayInMs, generateId, MILLIS_PER_SECOND, parseUserTime, swapEventData } from 'ontime-utils';
import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils';
import { moveDown, moveUp } from '../../features/rundown/rundown.utils';
import { RUNDOWN } from '../api/constants';
import {
deleteEntries,
@@ -89,17 +89,25 @@ export const useEntryActions = () => {
// ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEntry)) {
if (options?.lastEventId) {
// merge creation time options with event settings
const applicationOptions = {
after: options?.after,
before: options?.before,
lastEventId: options?.lastEventId,
linkPrevious: options?.linkPrevious ?? linkPrevious,
};
if (applicationOptions?.lastEventId) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
const rundownData = queryClient.getQueryData<Rundown>(RUNDOWN)!;
const previousEvent = rundownData.entries[options?.lastEventId];
const previousEvent = rundownData.entries[applicationOptions.lastEventId];
if (isOntimeEvent(previousEvent)) {
newEntry.timeStart = previousEvent.timeEnd;
}
}
// Override event with options from editor settings
newEntry.linkStart = options?.linkPrevious ?? linkPrevious;
newEntry.linkStart = applicationOptions.linkPrevious;
if (newEntry.duration === undefined && newEntry.timeEnd === undefined) {
newEntry.duration = parseUserTime(defaultDuration);
@@ -224,9 +232,9 @@ export const useEntryActions = () => {
* Updates existing entry
*/
const updateEntry = useCallback(
async (entry: Partial<OntimeEntry>) => {
async (event: Partial<OntimeEntry>) => {
try {
await updateEntryMutation(entry);
await updateEntryMutation(event);
} catch (error) {
logAxiosError('Error updating event', error);
}
@@ -581,7 +589,7 @@ export const useEntryActions = () => {
try {
await ungroupMutation(blockId);
} catch (error) {
logAxiosError('Error dissolving group', error);
logAxiosError('Error dissolving block', error);
}
},
[ungroupMutation],
@@ -614,22 +622,13 @@ export const useEntryActions = () => {
*/
const groupEntries = useCallback(
async (entryIds: EntryId[]) => {
if (entryIds.length === 0) return;
try {
if (entryIds.length === 1) {
await groupEntriesMutation(entryIds);
} else {
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!rundown) return;
const orderedIds = orderEntries(entryIds, rundown.flatOrder);
await groupEntriesMutation(orderedIds);
}
await groupEntriesMutation(entryIds);
} catch (error) {
logAxiosError('Error grouping entries', error);
}
},
[groupEntriesMutation, queryClient],
[groupEntriesMutation],
);
/**
@@ -693,7 +692,6 @@ export const useEntryActions = () => {
await reorderEntryMutation(reorderObject);
} catch (error) {
logAxiosError('Error re-ordering event', error);
throw error; // rethrow to handle in the component
}
},
[reorderEntryMutation],
@@ -801,12 +799,8 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
if (isOntimeBlock(entry) || !entry.parent) {
order = order.filter((id) => id !== entry.id);
} else {
const parent = entries[entry.parent];
if ('parent' in entries) {
(parent as OntimeBlock).entries = (parent as OntimeBlock).entries.filter(
(parentEntry) => parentEntry !== entry.id,
);
}
const parent = entries[entry.parent] as OntimeBlock;
parent.entries = parent.entries.filter((parentEntry) => parentEntry !== entry.id);
}
delete entries[entry.id];
@@ -1,10 +1,10 @@
import { RefObject, useCallback, useEffect, useRef } from 'react';
import { MutableRefObject, useCallback, useEffect, useRef } from 'react';
import { useSelectedEventId } from './useSocket';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: RefObject<ComponentRef>,
scrollRef: RefObject<ScrollRef>,
componentRef: MutableRefObject<ComponentRef>,
scrollRef: MutableRefObject<ScrollRef>,
topOffset: number,
) {
if (!componentRef.current || !scrollRef.current) {
@@ -19,8 +19,8 @@ function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends H
}
function snapToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: RefObject<ComponentRef>,
scrollRef: RefObject<ScrollRef>,
componentRef: MutableRefObject<ComponentRef>,
scrollRef: MutableRefObject<ScrollRef>,
topOffset: number,
) {
if (!componentRef.current || !scrollRef.current) {
@@ -36,8 +36,8 @@ function snapToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTM
}
interface UseFollowComponentProps {
followRef: RefObject<HTMLElement | null>;
scrollRef: RefObject<HTMLElement | null>;
followRef: MutableRefObject<HTMLElement | null>;
scrollRef: MutableRefObject<HTMLElement | null>;
doFollow: boolean;
topOffset?: number;
setScrollFlag?: (newValue: boolean) => void;
@@ -56,7 +56,11 @@ export default function useFollowComponent(props: UseFollowComponentProps) {
setScrollFlag?.(true);
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, topOffset);
scrollToComponent(
followRef as MutableRefObject<HTMLElement>,
scrollRef as MutableRefObject<HTMLElement>,
topOffset,
);
setScrollFlag?.(false);
});
}
@@ -93,8 +97,8 @@ export function useFollowSelected(doFollow: boolean, topOffset = 100) {
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
snapToComponent(
{ current: selectedRef.current } as RefObject<HTMLElement>,
{ current: scrollRef.current } as RefObject<HTMLElement>,
{ current: selectedRef.current } as MutableRefObject<HTMLElement>,
{ current: scrollRef.current } as MutableRefObject<HTMLElement>,
topOffset,
);
});
@@ -1,78 +0,0 @@
import { RefObject, useCallback, useEffect } from 'react';
import { EntryId } from 'ontime-types';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: RefObject<ComponentRef> | null,
scrollRef: RefObject<ScrollRef>,
leftOffset: number,
) {
if (!scrollRef.current) {
return;
}
if (!componentRef?.current) {
// If no target component, scroll to start
scrollRef.current.scrollTo({ left: 0, behavior: 'smooth' });
return;
}
const componentRect = componentRef.current.getBoundingClientRect();
const scrollRect = scrollRef.current.getBoundingClientRect();
const left = componentRect.left - scrollRect.left + scrollRef.current.scrollLeft - leftOffset;
scrollRef.current.scrollTo({ left, behavior: 'smooth' });
}
interface UseHorizontalFollowComponentOptions {
followRef: RefObject<HTMLElement | null>;
scrollRef: RefObject<HTMLElement | null>;
doFollow: boolean;
selectedEventId: EntryId | null;
leftOffset?: number;
setScrollFlag?: (newValue: boolean) => void;
}
/**
* This is a copy of useFollowComponent, but for horizontal scrolling
* Designed with the timeline component in mind
*/
export default function useHorizontalFollowComponent({
followRef,
scrollRef,
doFollow,
selectedEventId,
leftOffset = 0,
setScrollFlag,
}: UseHorizontalFollowComponentOptions) {
useEffect(() => {
if (!doFollow || !scrollRef.current) {
return;
}
setScrollFlag?.(true);
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
scrollToComponent(
selectedEventId !== null ? (followRef as RefObject<HTMLElement>) : null,
scrollRef as RefObject<HTMLElement>,
leftOffset,
);
setScrollFlag?.(false);
});
}, [followRef, scrollRef, doFollow, leftOffset, setScrollFlag, selectedEventId]);
const scrollToRefComponent = useCallback(
(componentRef = followRef, containerRef = scrollRef, offset = leftOffset) => {
if (containerRef.current) {
scrollToComponent(
selectedEventId !== null ? (componentRef as RefObject<HTMLElement>) : null,
containerRef as RefObject<HTMLElement>,
offset,
);
}
},
[followRef, scrollRef, leftOffset, selectedEventId],
);
return scrollToRefComponent;
}
@@ -0,0 +1,64 @@
import { MouseEvent, SyntheticEvent, TouchEvent, useMemo, useRef } from 'react';
type LongPressOptions = {
threshold?: number;
onStart?: (e: SyntheticEvent) => void;
onFinish?: (e: SyntheticEvent) => void;
onCancel?: (e: SyntheticEvent) => void;
};
type LongPressFns = {
onMouseDown: (e: MouseEvent) => void;
onMouseUp: (e: MouseEvent) => void;
onMouseLeave: (e: MouseEvent) => void;
onTouchStart: (e: TouchEvent) => void;
onTouchEnd: (e: TouchEvent) => void;
};
export default function useLongPress(callback: () => void, options: LongPressOptions = {}): LongPressFns {
const { threshold = 400, onStart, onFinish, onCancel } = options;
const isLongPressActive = useRef(false);
const isPressed = useRef(false);
const timerId = useRef<NodeJS.Timeout>();
return useMemo(() => {
const start = (event: SyntheticEvent) => {
if (onStart) {
onStart(event);
}
isPressed.current = true;
timerId.current = setTimeout(() => {
callback();
isLongPressActive.current = true;
}, threshold);
};
const cancel = (event: SyntheticEvent) => {
if (isLongPressActive.current) {
if (onFinish) {
onFinish(event);
}
} else if (isPressed.current) {
if (onCancel) {
onCancel(event);
}
}
isLongPressActive.current = false;
isPressed.current = false;
if (timerId.current) {
clearTimeout(timerId.current);
}
};
return {
onMouseDown: start,
onMouseUp: cancel,
onMouseLeave: cancel,
onTouchStart: start,
onTouchEnd: cancel,
};
}, [callback, threshold, onCancel, onFinish, onStart]);
}
@@ -27,7 +27,7 @@ export default function useMemoisedFn<T extends noop>(fn: T) {
// https://github.com/alibaba/hooks/issues/728
fnRef.current = useMemo(() => fn, [fn]);
const memoizedFn = useRef<PickFunction<T>>(undefined);
const memoizedFn = useRef<PickFunction<T>>();
if (!memoizedFn.current) {
memoizedFn.current = function (this, ...args) {
return fnRef.current.apply(this, args);
+7 -12
View File
@@ -150,11 +150,6 @@ export const useClock = createSelector((state: RuntimeStore) => ({
clock: state.clock,
}));
export const useNextFlag = createSelector((state: RuntimeStore) => ({
id: state.nextFlag?.id ?? null,
expectedStart: state.nextFlag?.expectedStart ?? null,
}));
/** Used by the progress bar components */
export const useProgressData = createSelector((state: RuntimeStore) => ({
current: state.timer.current,
@@ -176,23 +171,23 @@ export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) =
numEvents: state.runtime.numEvents,
selectedEventIndex: state.runtime.selectedEventIndex,
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offsetAbs : state.runtime.offsetRel,
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
blockExpectedEnd: state.blockNow?.expectedEnd ?? null,
blockStartedAt: state.blockNow?.startedAt ?? null,
}));
export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
clock: state.clock,
offset: state.runtime.offsetAbs,
offset: state.runtime.offset,
}));
export const useExpectedStartData = createSelector((state: RuntimeStore) => ({
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offsetAbs : state.runtime.offsetRel,
export const useTimeUntilData = createSelector((state: RuntimeStore) => ({
clock: state.clock,
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
offsetMode: state.runtime.offsetMode,
currentDay: state.eventNow?.dayOffset ?? 0,
actualStart: state.runtime.actualStart,
plannedStart: state.runtime.plannedStart,
clock: state.clock,
}));
export const useCurrentDay = createSelector((state: RuntimeStore) => ({
@@ -200,7 +195,7 @@ export const useCurrentDay = createSelector((state: RuntimeStore) => ({
}));
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
offset: state.runtime.offsetAbs,
offset: state.runtime.offset,
}));
export const usePing = createSelector((state: RuntimeStore) => ({
+3 -3
View File
@@ -3,8 +3,8 @@ import { ProjectData } from 'ontime-types';
export const projectDataPlaceholder: ProjectData = {
title: '',
description: '',
url: '',
info: '',
logo: null,
backstageUrl: '',
backstageInfo: '',
projectLogo: null,
custom: [],
};
@@ -1,8 +1,10 @@
import { ViewSettings } from 'ontime-types';
export const viewsSettingsPlaceholder: ViewSettings = {
dangerColor: '#ff7300',
dangerColor: '#ED3333',
endMessage: '',
freezeEnd: false,
normalColor: '#ffffffcc',
overrideStyles: false,
warningColor: '#ffa528',
warningColor: '#FFAB33',
};
@@ -0,0 +1,32 @@
import { create } from 'zustand';
export enum AppMode {
Run = 'run',
Edit = 'edit',
}
const appModeKey = 'ontime-app-mode';
function getModeFromSession() {
return sessionStorage.getItem(appModeKey) === AppMode.Run ? AppMode.Run : AppMode.Edit;
}
function persistModeToSession(mode: AppMode) {
sessionStorage.setItem(appModeKey, mode);
}
type AppModeStore = {
mode: AppMode;
setMode: (mode: AppMode) => void;
};
export const useAppMode = create<AppModeStore>()((set) => ({
mode: getModeFromSession(),
setMode: (mode: AppMode) => {
persistModeToSession(mode);
return set(() => {
return { mode };
});
},
}));
@@ -7,7 +7,6 @@ describe('cloneEvent()', () => {
const original: OntimeEvent = {
id: 'unique',
type: SupportedEntry.Event,
flag: false,
title: 'title',
cue: 'cue',
note: 'note',
@@ -41,7 +40,6 @@ describe('cloneEvent()', () => {
expect(cloned).toMatchObject({
type: SupportedEntry.Event,
flag: original.flag,
title: original.title,
note: original.note,
timeStart: original.timeStart,

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