Compare commits

..

48 Commits

Author SHA1 Message Date
google-labs-jules[bot] 137db18897 feat: dynamically set PWA start_url
This commit fixes an issue where the PWA would always start at the
root of the application, regardless of the page you were on when
you installed it.

This is fixed by:
- Consolidating the two manifest files into a single file.
- Adding a new route to the server that dynamically generates the
  `start_url` in the manifest based on the current route.
- Adding an e2e test to verify the changes.
2025-07-26 14:23:17 +00:00
google-labs-jules[bot] 65e8894cf5 feat: dynamically set PWA start_url
This commit fixes an issue where the PWA would always start at the
root of the application, regardless of the page you were on when
you installed it.

This is fixed by:
- Consolidating the two manifest files into a single file.
- Adding a new route to the server that dynamically generates the
  `start_url` in the manifest based on the current route.
- Adding an e2e test to verify the changes.
2025-07-26 14:22:49 +00:00
google-labs-jules[bot] 2683f1a1e0 feat: dynamically set PWA start_url
This commit fixes an issue where the PWA would always start at the
root of the application, regardless of the page you were on when
you installed it.

This is fixed by:
- Consolidating the two manifest files into a single file.
- Adding a new route to the server that dynamically generates the
  `start_url` in the manifest based on the current route.
- Adding an e2e test to verify the changes.
2025-07-26 14:22:21 +00:00
google-labs-jules[bot] 84459b06e2 feat: dynamically set PWA start_url
This commit fixes an issue where the PWA would always start at the
root of the application, regardless of the page you were on when
you installed it.

This is fixed by:
- Consolidating the two manifest files into a single file.
- Adding a new route to the server that dynamically generates the
  `start_url` in the manifest based on the current route.
- Adding an e2e test to verify the changes.
2025-07-24 17:27:02 +00:00
Carlos Valente ad2ef3b53f refactor: small fixes and style tweaks
fix: prevent reflow on editing images

fix: schedule overview

fix: report on all events

refactor: over-under time colours

fix: prevent edit on delete
2025-07-14 16:08:40 +02:00
Carlos Valente 1e1f547ce1 breaking: convert offset > over-under
change offset direction and relabel to over-under
2025-07-14 11:39:16 +02:00
Carlos Valente 92a34cf135 fix: group expected end 2025-07-14 11:39:16 +02:00
Carlos Valente 1e5081f4a2 fix: parse nested events in rundown 2025-07-14 11:39:16 +02:00
Carlos Valente 6f9e13948d refactor: timeline design review 2025-07-14 11:39:16 +02:00
Carlos Valente 55e34b5f9a fix: guard corrupted parent on delete 2025-07-14 11:39:16 +02:00
Carlos Valente dd47d2b7e7 refactor: always reload on changes 2025-07-14 11:39:16 +02:00
Carlos Valente 445cc9e845 feat: time to next flag 2025-07-14 11:39:16 +02:00
Carlos Valente 637454f73d feat: add flag property 2025-07-14 11:38:27 +02:00
Alex Christoffer Rasmussen e9dda817e5 block end calculation (#1683) 2025-07-14 11:38:27 +02:00
Carlos Valente e811a82be2 refactor: make minimal and clock as presets 2025-07-14 11:38:27 +02:00
Carlos Valente fd9bf0120c refactor: disable react compiler 2025-07-14 11:38:27 +02:00
Carlos Valente e5f8a99bff refactor: extract sentry 2025-07-13 12:33:50 +02:00
Carlos Valente 7305edc398 refactor: review timer styles 2025-07-13 12:33:50 +02:00
Carlos Valente a69c5f354c react 19 type migration 2025-07-13 12:33:50 +02:00
Carlos Valente 0eaec1f88f refactor: timeline style tweaks 2025-07-13 12:33:50 +02:00
google-labs-jules[bot] cdd021e76d Docs: Update DEVELOPMENT.MD with explicit turbo run commands
Updated DEVELOPMENT.MD to use `turbo run <task>` instead of `turbo <task>` for clarity and consistency when referring to Turborepo tasks that don't have a direct pnpm script alias.

Also, added `dependsOn: ["build"]` to `dist-*` tasks in `turbo.json` to ensure correct build order before packaging.
2025-07-12 22:12:17 +02:00
Carlos Valente 05d01e0863 refactor: timer design review 2025-07-12 18:41:17 +02:00
Carlos Valente 2aac4cb06d refactor: remove deprecated views 2025-07-12 18:41:17 +02:00
Carlos Valente 1c6adbd2fd refactor: upgrade react and add compiler 2025-07-12 18:41:17 +02:00
Carlos Valente d24d5c1365 refactor: freeze is view option 2025-07-12 18:41:17 +02:00
Carlos Valente 138c6523d2 feat: implement custom project data image 2025-07-12 18:41:17 +02:00
Carlos Valente d8c1c38123 refactor: fixes to custom field 2025-07-12 18:41:17 +02:00
Carlos Valente 4d419c0877 refactor: style tweaks and bug fixes
- italic and bold for rundown metadata entries
- allow deleting groups
- make groups in order
- correct style for milestones
- open playing groups
- style tweaks
2025-07-12 18:41:17 +02:00
Carlos Valente 50b603f668 create release 4.0.0-alpha.0 2025-07-11 19:46:25 +02:00
Carlos Valente e1e8410ba4 refactor: migrate UI components and remove chakra
migrate pin page
migrate copy tag
migrate pin input
migrate dropdown menus
migrate radio buttons
migrate switch
migrate select
migrate textarea
migrate colour picker
migrate select
migrate context menu
migrate viewparams
remove chakra
2025-07-11 08:43:38 +02:00
Alex Christoffer Rasmussen 4d359445a7 Fix ontime aux actions (#1684)
* switch to base ui

* ensure all action types is part of the valitation

* update test

* atempt to do it in the base-ui way

* fix fixing

* allow undefide visible on set timer message

* fix set time not showing

* correct time data for aux set automation

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2025-07-09 13:14:23 +02:00
Carlos Valente 8d68a4554d refactor: deprecate languages 2025-07-08 20:14:49 +02:00
Carlos Valente 6f34e1006b refactor: migrate project data 2025-07-08 20:14:49 +02:00
Carlos Valente 1fc6f9b3ea refactor: inline quick add 2025-07-08 16:34:23 +02:00
Carlos Valente a1ad1d47a4 refactor: review action menu 2025-07-08 16:34:23 +02:00
Carlos Valente 9281c0b51d refactor: update app menu links 2025-07-08 16:34:23 +02:00
Carlos Valente 4365c19498 refactor: review event entry 2025-07-08 16:34:23 +02:00
Carlos Valente 8728c1450b refactor: rename block > group in UI elements 2025-07-08 16:34:23 +02:00
Carlos Valente a98c14406d refactor: remove isNextDay block property 2025-07-08 16:34:23 +02:00
Alex Christoffer Rasmussen ab8bfbcd59 handle rearange in block (#1682) 2025-07-07 23:15:37 +02:00
Carlos Valente f2913971db feat: milestones 2025-07-07 18:22:10 +02:00
Carlos Valente a7ae8598db feat: add group metadata to overview 2025-07-07 18:22:10 +02:00
Carlos Valente 17a7d035bf refactor: style cleanups and migrations
fix: close modal with button
refactor: small type improvements
refactor: migrate time inputs
refactor: migrate tooltips
refactor: prevent component resizing
2025-07-07 18:22:10 +02:00
Carlos Valente 6facd6a666 refactor: improve editor and cuesheet headers
- consistent behaviour for app mode
- improve navigation
2025-07-07 18:22:10 +02:00
Carlos Valente 79bcc179ba refactor: style tweaks and ux improvements 2025-07-07 18:22:10 +02:00
Carlos Valente 3790681bec fix: reordering out of a block 2025-07-07 18:22:10 +02:00
Carlos Valente c32203c759 fix: search params editor conflicts with alias 2025-07-07 18:22:10 +02:00
Carlos Valente ce449408f0 Chore/remove got dependency (#1667)
* Refactor: Remove GOT dependency, use simplified fetch wrapper

Replaced GOT with a lightweight, functional, fetch-based utility (`appClient.ts`) for handling JSON POST requests in `SheetService.ts`.

- Created `appClient.ts` to encapsulate fetch logic. It mimics GOT's behavior for sending JSON payloads and parsing JSON responses.
- Error handling in `appClient.ts` uses standard `Error` objects with descriptive messages, including status and error body details, instead of custom error types.
- Updated `SheetService.ts` to use the new `appClient.ts`.
- Removed `got` from server dependencies.

* cleanup

* dont throw when we know the error

* rearange logging

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: arc-alex <ac@omnivox.dk>
2025-07-07 15:54:26 +02:00
409 changed files with 6491 additions and 6896 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 `turbo test:pipeline` from the project root.
You can run unit tests by running `turbo run 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 build:electron`
- __Create the package__ by running `turbo dist-win`, `turbo dist-mac` or `turbo dist-linux`
- __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`
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 dist-mac:local` command to build a MacOS distribution locally.
Use the `turbo run dist-mac:local` command to build a MacOS distribution locally.
## DOCKER
-2
View File
@@ -95,8 +95,6 @@ You can access the different views by reaching the ontime server, in your browse
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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "3.15.2",
"version": "4.0.0-alpha.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+2 -3
View File
@@ -9,14 +9,13 @@
<meta name="ontime" content="ontime - time keeping for live events" />
<link rel="apple-touch-icon" href="/ontime-logo.png" />
<link rel="icon" type="image/png" href="/ontime-logo.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="manifest" href="/manifest.json" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="robots" content="noindex" />
<title>ontime</title>
<style>
body,
html {
background-color: rgba(0, 0, 0, 0) !important;
background-color: #101010 !important;
}
</style>
</head>
+8 -12
View File
@@ -1,17 +1,14 @@
{
"name": "ontime-ui",
"version": "3.15.2",
"version": "4.0.0-alpha.0",
"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",
@@ -21,12 +18,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": "^18.3.1",
"react": "^19.1.0",
"react-colorful": "^5.6.1",
"react-dom": "^18.3.1",
"react-dom": "^19.1.0",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.53.1",
"react-icons": "5.4.0",
@@ -67,8 +64,8 @@
"@sentry/vite-plugin": "^2.16.1",
"@tanstack/eslint-plugin-query": "^5.8.4",
"@types/prismjs": "^1.26.5",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "4.5.1",
@@ -78,16 +75,15 @@
"eslint-plugin-prettier": "catalog:",
"eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-hooks": "^5.2.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": "1.4.0",
"vite-plugin-compression2": "2.2.0",
"vite-plugin-svgr": "4.3.0",
"vite-tsconfig-paths": "5.1.4",
"vitest": "catalog:"
-19
View File
@@ -1,19 +0,0 @@
{
"name": "ontime",
"short_name": "ontime",
"icons": [
{
"src": "favicon.ico",
"type": "image/x-icon"
},
{
"src": "ontime-logo.png",
"type": "image/png"
}
],
"scope": "/",
"start_url": "/",
"display": "",
"theme_color": "#121212",
"background_color": "#ffffff"
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "ontime",
"short_name": "ontime",
"icons": [
{
"src": "/ontime-logo.png",
"sizes": "295x295",
"type": "image/png"
}
],
"scope": "/",
"display": "standalone",
"theme_color": "#2B5ABC",
"background_color": "#101010"
}
-10
View File
@@ -1,10 +0,0 @@
{
"name": "",
"short_name": "",
"icons": [
{ "src": "/ontime-logo.png", "sizes": "295x295", "type": "image/png" }
],
"theme_color": "#2B5ABC",
"background_color": "#101010",
"display": "standalone"
}
+7 -8
View File
@@ -1,5 +1,5 @@
import { BrowserRouter } from 'react-router-dom';
import { ChakraProvider } from '@chakra-ui/react';
import { Tooltip } from '@base-ui-components/react/tooltip';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
@@ -8,7 +8,6 @@ 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';
@@ -17,9 +16,9 @@ connectSocket();
function App() {
return (
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
<QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider>
<QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider>
<Tooltip.Provider>
<BrowserRouter basename={baseURI}>
<div className='App'>
<ErrorBoundary>
@@ -34,9 +33,9 @@ function App() {
<div id='identify-portal' />
</ErrorBoundary>
</BrowserRouter>
</AppContextProvider>
</QueryClientProvider>
</ChakraProvider>
</Tooltip.Provider>
</AppContextProvider>
</QueryClientProvider>
);
}
+7 -58
View File
@@ -1,45 +1,29 @@
import React from 'react';
import {
createRoutesFromChildren,
matchRoutes,
Navigate,
Route,
Routes,
useLocation,
useNavigationType,
} from 'react-router-dom';
import * as Sentry from '@sentry/react';
import { Navigate, Route } from 'react-router-dom';
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 { ONTIME_VERSION } from './ONTIME_VERSION';
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
import { initializeSentry } 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(withData(ProjectInfo));
const SLowerThird = withPreset(withData(Lower));
const SProjectInfo = withPreset(ProjectInfo); // NOTE: ProjectInfo does not use the viewWrapper since it has no options
const SStudio = withPreset(withData(StudioClock));
const STimeline = withPreset(withData(Timeline));
const PCuesheet = withPreset(Cuesheet);
@@ -50,25 +34,8 @@ 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'));
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);
// Initialize Sentry with our configuration
const SentryRouter = initializeSentry();
export default function AppRouter() {
// handle client path changes
@@ -76,7 +43,7 @@ export default function AppRouter() {
return (
<React.Suspense fallback={null}>
<SentryRoutes>
<SentryRouter>
<Route path='/' element={<Navigate to='/timer' />} />
<Route
path='/timer'
@@ -86,22 +53,6 @@ export default function AppRouter() {
</ViewLoader>
}
/>
<Route
path='/minimal'
element={
<ViewLoader>
<SMinimalTimer />
</ViewLoader>
}
/>
<Route
path='/clock'
element={
<ViewLoader>
<SClock />
</ViewLoader>
}
/>
<Route
path='/countdown'
element={
@@ -126,8 +77,6 @@ export default function AppRouter() {
</ViewLoader>
}
/>
{/*/!* Lower third cannot have a loading screen *!/*/}
<Route path='/lower' element={<SLowerThird />} />
<Route
path='/timeline'
element={
@@ -192,7 +141,7 @@ export default function AppRouter() {
/>
{/*/!* Send to default if nothing found *!/*/}
<Route path='*' element={<STimer />} />
</SentryRoutes>
</SentryRouter>
</React.Suspense>
);
}
+10 -17
View File
@@ -1,45 +1,38 @@
import axios from 'axios';
import { CustomField, CustomFieldKey } from 'ontime-types'; // Removed CustomFields
import { CustomField, CustomFieldKey, CustomFields } from 'ontime-types';
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, sorted by order
* Requests list of known custom fields
*/
export async function getCustomFields(): Promise<CustomFieldWithKey[]> {
const res = await axios.get<CustomFieldWithKey[]>(customFieldsPath);
export async function getCustomFields(): Promise<CustomFields> {
const res = await axios.get(customFieldsPath);
return res.data;
}
/**
* Sets list of known custom fields
* Returns the updated list, sorted by order
*/
export async function postCustomField(newField: CustomField): Promise<CustomFieldWithKey[]> {
const res = await axios.post<CustomFieldWithKey[]>(customFieldsPath, { ...newField });
export async function postCustomField(newField: CustomField): Promise<CustomFields> {
const res = await axios.post(customFieldsPath, { ...newField });
return res.data;
}
/**
* Edits single custom field
* Returns the updated list, sorted by order
*/
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 });
export async function editCustomField(key: CustomFieldKey, newField: CustomField): Promise<CustomFields> {
const res = await axios.put(`${customFieldsPath}/${key}`, { ...newField });
return res.data;
}
/**
* Deletes single custom field
* Returns the updated list, sorted by order
*/
export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFieldWithKey[]> {
const res = await axios.delete<CustomFieldWithKey[]>(`${customFieldsPath}/${key}`);
export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFields> {
const res = await axios.delete(`${customFieldsPath}/${key}`);
return res.data;
}
+13 -4
View File
@@ -2,14 +2,23 @@ import axios from 'axios';
import type { ViewSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const viewSettingsPath = apiEntryUrl + '/view-settings';
export async function getViewSettings() {
const viewSettingsPath = `${apiEntryUrl}/view-settings`;
/**
* HTTP request to fetch view settings
* @returns
*/
export async function getViewSettings(): Promise<ViewSettings> {
const res = await axios.get(viewSettingsPath);
return res.data;
}
export async function postViewSettings(data: ViewSettings) {
/**
* HTTP request to update view settings
* needs to update entire objects, not just a patch
*/
export async function postViewSettings(data: ViewSettings): Promise<ViewSettings> {
const res = await axios.post(viewSettingsPath, data);
return res.data as ViewSettings;
return res.data;
}
@@ -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,6 +11,11 @@
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 } from 'react';
import { ButtonHTMLAttributes, forwardRef } from 'react';
import { cx } from '../../utils/styleUtils';
@@ -17,20 +17,21 @@ interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
size?: 'small' | 'medium' | 'large' | 'xlarge';
}
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>
);
}
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;
@@ -1,17 +0,0 @@
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>
);
}
@@ -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=feature_settings__urlpresets'>Manage URL Presets</AppLink>
<AppLink search='settings=sharing__presets'>Manage URL Presets</AppLink>
</Info>
<div>
<span className={style.label}>Select View or URL Preset</span>
@@ -91,12 +91,7 @@ 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. /minimal?key=0000ffff'
fluid
value={path}
onChange={(event) => setPath(event.target.value)}
/>
<Input placeholder='eg. /timer' fluid value={path} onChange={(event) => setPath(event.target.value)} />
</label>
<Button
variant='primary'
@@ -1,8 +0,0 @@
.contextMenuButton {
position: absolute;
}
.contextMenuBackdrop {
position: fixed;
inset: 0;
}
@@ -1,99 +0,0 @@
// 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>
</>
);
};
@@ -1,12 +0,0 @@
import { MenuDivider, MenuItem } from '@chakra-ui/react';
import { OptionWithoutGroup } from './ContextMenu';
export const ContextMenuOption = ({ label, onClick, isDisabled, icon: Icon, withDivider }: OptionWithoutGroup) => (
<>
{withDivider && <MenuDivider />}
<MenuItem icon={<Icon style={{ fontSize: '1rem' }} />} onClick={onClick} isDisabled={isDisabled}>
{label}
</MenuItem>
</>
);
@@ -0,0 +1,40 @@
.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,22 +1,28 @@
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(props: PropsWithChildren<CopyTagProps>) {
const { copyValue, label, size = 'xs', disabled, children, onClick } = props;
export default function CopyTag({
copyValue,
disabled,
size = 'medium',
children,
onClick,
}: PropsWithChildren<CopyTagProps>) {
const [copied, setCopied] = useState(false);
const handleClick = () => {
@@ -28,20 +34,24 @@ export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
};
return (
<Tooltip label={label} openDelay={tooltipDelayFast}>
<ButtonGroup size={size} isAttached>
<Button variant='ontime-subtle' tabIndex={-1} onClick={onClick} isDisabled={disabled}>
<div className={style.copytag}>
{onClick !== undefined ? (
<Button className={style.action} size={size} tabIndex={-1} onClick={onClick} disabled={disabled}>
{children}
</Button>
<IconButton
aria-label={label}
icon={copied ? <IoCheckmark /> : <IoCopy />}
variant='ontime-filled'
tabIndex={-1}
onClick={handleClick}
isDisabled={disabled}
/>
</ButtonGroup>
</Tooltip>
) : (
<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>
);
}
@@ -1,8 +1,7 @@
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';
@@ -23,8 +22,8 @@ export default function DelayIndicator(props: DelayIndicatorProps) {
: millisToDelayString(delayValue);
return (
<Tooltip openDelay={tooltipDelayFast} label={delayString}>
<span className={style.delaySymbol}>{delayValue < 0 ? <IoChevronDown /> : <IoChevronUp />}</span>
<Tooltip text={delayString} render={<span />} className={style.delaySymbol}>
{delayValue < 0 ? <IoChevronDown /> : <IoChevronUp />}
</Tooltip>
);
}
@@ -3,7 +3,6 @@
top: 10%;
left: 50%;
z-index: $zindex-dialog;
transform: translateX(-50%);
padding-inline: 1rem;
@@ -19,7 +18,6 @@
.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);
@@ -0,0 +1,70 @@
.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;
}
@@ -0,0 +1,82 @@
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])} {...elementProps} />;
return <div className={cx([style.separator, style[orientation], className])} role='separator' {...elementProps} />;
}
@@ -13,8 +13,13 @@ interface FitTextProps extends HTMLAttributes<HTMLDivElement> {
max?: number; // inclusive
}
export function FitText(props: PropsWithChildren<FitTextProps>) {
const { children, mode = 'multi', min = 16, max = 256, ...elementProps } = props;
export function FitText({
children,
mode = 'multi',
min = 16,
max = 256,
...elementProps
}: PropsWithChildren<FitTextProps>) {
const ref = useRef<HTMLDivElement>(null);
const isOverflown = useCallback(() => {
@@ -15,7 +15,6 @@
display: grid;
place-content: center;
text-align: center;
z-index: $zindex-modal;
cursor: pointer;
}
@@ -1,28 +0,0 @@
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}
/>
);
};
@@ -0,0 +1,26 @@
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,9 +1,8 @@
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';
@@ -19,12 +18,9 @@ interface SwatchPickerProps {
export default function SwatchPicker(props: SwatchPickerProps) {
const { color, onChange, isSelected, alwaysDisplayColor } = props;
const debouncedOnChange = useCallback(
debounce((newValue: string) => {
onChange(newValue);
}, 500),
[onChange],
);
const debouncedOnChange = useDebouncedCallback((newValue: string) => {
onChange(newValue);
}, 100);
const displayColor = alwaysDisplayColor || isSelected ? color : '';
const { color: iconColor } = getAccessibleColour(displayColor);
@@ -0,0 +1,62 @@
.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;
}
}
@@ -0,0 +1,35 @@
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,22 +1,12 @@
$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
}
}
.delayOptions {
display: flex;
flex-direction: column;
.inputField {
text-align: center;
letter-spacing: 1px;
max-width: 7em;
color: $ontime-delay-text
}
@@ -1,8 +1,10 @@
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';
@@ -105,13 +107,10 @@ 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)}
@@ -119,16 +118,14 @@ export default function DelayInput(props: DelayInputProps) {
value={value}
maxLength={9}
/>
<RadioGroup
className={style.delayOptions}
onChange={handleSlipChange}
<BlockRadio
onValueChange={handleSlipChange}
value={checkedOption}
variant='ontime-block'
size='sm'
>
<Radio value='add'>Add time</Radio>
<Radio value='subtract'>Subtract time</Radio>
</RadioGroup>
items={[
{ value: 'add', label: 'Add time' },
{ value: 'subtract', label: 'Subtract time' },
]}
/>
</div>
);
}
@@ -2,7 +2,7 @@
box-sizing: border-box;
font-size: 1rem;
font-weight: 400;
font-weight: inherit;
color: $gray-200;
border-radius: $component-border-radius-md;
border: 1px solid transparent;
@@ -1,20 +1,36 @@
// styles from Input.module.scss
.input {
color: $gray-200;
border: 1px solid transparent;
border-radius: 0 0 8px 8px;
background-color: $gray-1200;
padding: 0 0.5rem;
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;
height: 2rem;
padding-inline: 0.5em;
outline: none;
&:hover {
&:hover:not(:disabled) {
background-color: $gray-1100;
}
&:focus {
&:focus:not(:read-only) {
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,6 +1,8 @@
import { PropsWithChildren } from 'react';
import { HexAlphaColorPicker, HexColorInput } from 'react-colorful';
import { Popover, PopoverContent, PopoverTrigger } from '@chakra-ui/react';
import { Popover } from '@base-ui-components/react/popover';
import PopoverContents from '../../popover/Popover';
import style from './PopoverPicker.module.scss';
@@ -9,15 +11,14 @@ interface PopoverPickerProps {
onChange: (color: string) => void;
}
export default function PopoverPicker(props: PropsWithChildren<PopoverPickerProps>) {
const { color, onChange, children } = props;
export default function PopoverPicker({ color, onChange, children }: PropsWithChildren<PopoverPickerProps>) {
return (
<Popover>
<PopoverTrigger>{children}</PopoverTrigger>
<PopoverContent className={style.small} style={{ borderRadius: '9px', width: 'auto' }}>
<Popover.Root>
<Popover.Trigger>{children}</Popover.Trigger>
<PopoverContents>
<HexAlphaColorPicker color={color} onChange={onChange} />
<HexColorInput color={color} onChange={onChange} className={style.input} prefixed />
</PopoverContent>
</Popover>
</PopoverContents>
</Popover.Root>
);
}
@@ -11,7 +11,7 @@ interface UseReactiveTextInputReturn {
export default function useReactiveTextInput(
initialText: string,
submitCallback: (newValue: string) => void,
ref: RefObject<HTMLInputElement>,
ref: RefObject<HTMLInputElement | HTMLTextAreaElement | null>,
options?: {
submitOnEnter?: boolean;
submitOnCtrlEnter?: boolean;
@@ -101,25 +101,31 @@ 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,11 +1,10 @@
@use '../../../../theme/viewerDefs' as *;
.textarea {
box-sizing: border-box;
min-height: 2rem;
display: block;
font-size: 1rem;
font-weight: 400;
font-weight: inherit;
color: $gray-200;
border-radius: $component-border-radius-md;
border: 1px solid transparent;
@@ -35,11 +34,13 @@
.subtle {
background-color: $gray-1200;
padding-top: 0.5em;
}
.ghosted {
background-color: transparent;
padding: 0;
padding-top: 0.5em;
}
.fluid {
@@ -18,8 +18,20 @@ interface NullableTimeInputProps<T extends string> {
className?: string;
}
export default function NullableTimeInput<T extends string>(props: NullableTimeInputProps<T>) {
const { id, name, submitHandler, time, emptyDisplay, placeholder, disabled, align = 'center', className } = props;
/**
* 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>) {
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>('');
const ignoreChange = useRef(false);
@@ -3,4 +3,8 @@
max-width: 7.5em;
letter-spacing: 0.5px;
font-variant-numeric: tabular-nums;
&.delayed {
border: 1px solid $ontime-delay-text;
}
}
@@ -14,11 +14,21 @@ interface TimeInputProps<T extends string> {
placeholder?: string;
disabled?: boolean;
align?: 'left' | 'center';
delayed?: boolean;
className?: string;
}
export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
const { id, name, submitHandler, time, placeholder, disabled, align = 'center', className } = props;
export default function TimeInput<T extends string>({
id,
name,
submitHandler,
time,
placeholder,
disabled,
align = 'center',
delayed,
className,
}: TimeInputProps<T>) {
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>('');
const ignoreChange = useRef(false);
@@ -122,7 +132,7 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
disabled={disabled}
ref={inputRef}
data-testid={`time-input-${name}`}
className={cx([style.timeInput, className])}
className={cx([style.timeInput, delayed && style.delayed, className])}
placeholder={placeholder}
onFocus={handleFocus}
onChange={(event) => setValue(event.target.value)}
@@ -130,7 +140,6 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
onKeyDown={onKeyDownHandler}
value={value}
maxLength={8}
autoComplete='off'
style={{
textAlign: align,
}}
@@ -1,10 +0,0 @@
$input-delayed-border-color: $ontime-delay-text;
.timeInput {
border: 1px solid transparent;
color: $label-gray;
&.delayed {
border: 1px solid $input-delayed-border-color;
}
}
@@ -1,37 +0,0 @@
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,6 +7,7 @@
transition-property: color;
transition-duration: $transition-time-action;
width: fit-content;
text-decoration: none;
&.inline {
display: inline-flex;
@@ -15,4 +16,9 @@
&:hover {
color: $ontime-color;
}
}
&:focus {
outline: none;
box-shadow: 0 1px 0 0 currentColor;
}
}
@@ -7,13 +7,14 @@
padding-inline: 1rem;
min-width: min(680px, 90vw);
min-height: min(200px, 10vh);
max-width: min(680px, 90vw);
max-width: min(800px, 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,7 +5,6 @@
.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);
@@ -21,7 +20,6 @@
top: 0;
left: 0;
bottom: 0;
z-index: $zindex-dialog;
width: 22rem;
height: 100vh;
@@ -8,11 +8,11 @@ import useViewEditor from './useViewEditor';
interface ViewNavigationMenuProps {
isLockable?: boolean;
supressSettings?: boolean;
suppressSettings?: boolean;
}
export default memo(ViewNavigationMenu);
function ViewNavigationMenu({ isLockable, supressSettings }: ViewNavigationMenuProps) {
function ViewNavigationMenu({ isLockable, suppressSettings }: ViewNavigationMenuProps) {
const [isMenuOpen, menuHandler] = useDisclosure();
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable });
@@ -28,7 +28,7 @@ function ViewNavigationMenu({ isLockable, supressSettings }: ViewNavigationMenuP
[
'mod + ,',
() => {
if (isViewLocked || supressSettings) return;
if (isViewLocked || suppressSettings) return;
showEditFormDrawer();
},
{ preventDefault: true },
@@ -43,7 +43,7 @@ function ViewNavigationMenu({ isLockable, supressSettings }: ViewNavigationMenuP
<>
<FloatingNavigation
toggleMenu={menuHandler.toggle}
toggleSettings={supressSettings ? undefined : () => showEditFormDrawer()}
toggleSettings={suppressSettings ? undefined : () => showEditFormDrawer()}
/>
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
</>
@@ -25,9 +25,8 @@
}
&:focus {
outline: none;
background-color: $gray-1300;
border: 2px solid $ui-white;
outline: 2px solid $blue-500;
background-color: $gray-1350;
}
&.current {
@@ -30,12 +30,7 @@ export default function OtherAddresses({ currentLocation }: OtherAddressesProps)
const address = linkToOtherHost(nif.address, currentLocation);
return (
<CopyTag
key={nif.name}
copyValue={address}
onClick={() => openLink(address)}
label='Copy IP or navigate to address'
>
<CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)}>
{nif.address} <IoArrowUp className={style.goIcon} />
</CopyTag>
);
@@ -1,19 +1,16 @@
import { useCallback, useMemo } from 'react';
import { 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, setSearchParams] = useSearchParams();
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
const [searchParams] = useSearchParams();
const { open: showEditFormDrawer } = useViewParamsEditorStore();
const isViewLocked = useMemo(() => {
if (!isLockable) {
@@ -1,16 +1,14 @@
.popup {
box-sizing: border-box;
padding: 1rem 1.5rem;
z-index: $zindex-dialog;
color: $ui-white;
background-color: $gray-1250;
border-radius: 3px;
box-shadow: $box-shadow-l1;
border: 2px solid $gray-1200;
border: 1px solid $gray-1000;
//width: 32rem;
max-width: 90vw;
transform-origin: var(--transform-origin);
@@ -18,7 +18,9 @@ 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}>{children}</Popover.Description>
<Popover.Description className={className} render={<div />}>
{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;
@@ -0,0 +1,50 @@
.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,71 +1,55 @@
import { useCallback, useEffect, useState } from 'react';
import { PropsWithChildren, useState } from 'react';
import { IoCheckmark } from 'react-icons/io5';
import { IconButton, PinInput, PinInputField } from '@chakra-ui/react';
import style from './ProtectRoute.module.scss';
import { cx } from '../../utils/styleUtils';
import IconButton from '../buttons/IconButton';
import Input from '../input/input/Input';
import style from './PinPage.module.scss';
interface PinPageProps {
permission: 'editor' | 'operator';
handleValidation: (pin: string) => boolean;
}
export default function PinPage(props: PinPageProps) {
const { permission, handleValidation } = props;
export default function PinPage({ permission, handleValidation }: PropsWithChildren<PinPageProps>) {
const [pin, setPin] = useState('');
const [failed, setFailed] = useState(false);
const validate = useCallback(() => {
const validate = () => {
const isValid = handleValidation(pin);
if (!isValid) {
setFailed(true);
setPin('');
}
}, [handleValidation, pin]);
};
useEffect(() => {
const handleKeyPress = (event: KeyboardEvent) => {
if (event.repeat) return;
if (event.key === 'Enter') {
validate();
}
};
document.addEventListener('keydown', handleKeyPress);
return () => {
document.removeEventListener('keydown', handleKeyPress);
};
}, [validate]);
const handleInputChange = (value: string) => {
setPin(value);
if (failed) setFailed(false);
};
return (
<div className={style.container}>
{`Ontime ${permission}`}
<div className={failed ? style.pin__failed : style.pin}>
<PinInput
type='alphanumeric'
size='lg'
mask
autoFocus
<form
onSubmit={(event) => {
event.preventDefault();
validate();
}}
className={cx([style.pin, failed && style.pinFailed])}
>
<Input
type='password'
maxLength={4}
height='large'
value={pin}
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}
onChange={(e) => handleInputChange(e.target.value)}
/>
</div>
<IconButton type='submit' variant='primary' aria-label='Enter'>
<IoCheckmark />
</IconButton>
</form>
</div>
);
}
@@ -1,49 +0,0 @@
.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);
}
}
@@ -0,0 +1,76 @@
.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;
}
}
@@ -0,0 +1,40 @@
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>
);
}
@@ -34,11 +34,24 @@
cursor: not-allowed;
}
&.fluid {
width: 100%;
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: 2px;
}
}
.medium {
height: 2rem;
}
.large {
height: 2.5rem;
}
.fluid {
width: 100%;
}
.selectIcon {
color: $gray-200;
}
@@ -6,19 +6,22 @@ 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'> {
// overload items to not allow undefined values
options: {
value: T;
label: string;
}[];
options: SelectOption<T>[];
fluid?: boolean;
size?: 'medium' | 'large';
}
export default function Select<T>({ options, fluid, ...selectRootProps }: SelectProps<T>) {
export default function Select<T>({ options, fluid, size = 'medium', ...selectRootProps }: SelectProps<T>) {
return (
<BaseSelect.Root items={options} {...selectRootProps}>
<BaseSelect.Trigger className={cx([styles.select, fluid && styles.fluid])}>
<BaseSelect.Trigger className={cx([styles.select, styles[size], fluid && styles.fluid])}>
<BaseSelect.Value />
<BaseSelect.Icon className={styles.selectIcon}>
<LuChevronsUpDown />
@@ -28,8 +31,8 @@ export default function Select<T>({ options, fluid, ...selectRootProps }: Select
<BaseSelect.Positioner side='bottom' align='start'>
<BaseSelect.ScrollUpArrow className={styles.scrollArrow} />
<BaseSelect.Popup className={styles.popup}>
{options.map(({ label, value }) => (
<BaseSelect.Item key={String(value)} className={styles.item} value={value}>
{options.map(({ disabled, label, value }) => (
<BaseSelect.Item key={String(value)} className={styles.item} value={value} disabled={disabled}>
<BaseSelect.ItemIndicator className={styles.itemIndicator}>
<IoCheckmark className={styles.itemIndicatorIcon} />
</BaseSelect.ItemIndicator>
@@ -1,4 +1,4 @@
@use '../../../theme/viewerDefs' as *;
@use '@/theme/viewerDefs' as *;
/* share the same style as a page layout */
.page {
@@ -19,7 +19,8 @@
}
&:focus {
border-color: $blue-500;
outline: 2px solid $blue-500;
outline-offset: 2px;
}
}
@@ -1,4 +1,4 @@
@use '../../../theme/viewerDefs' as *;
@use '@/theme/viewerDefs' as *;
.title-card {
position: relative;
@@ -0,0 +1,24 @@
.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;
}
}
@@ -0,0 +1,24 @@
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: 1rem;
gap: 0.5rem;
}
// attempt to match with ontimeTextInputs
@@ -0,0 +1,42 @@
.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,25 +1,17 @@
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;
}
@@ -39,20 +31,11 @@ export default function ParamInput({ paramField }: ParamInputProps) {
const optionFromParams = searchParams.get(id);
const defaultOptionValue = optionFromParams || defaultValue;
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 (paramField.values.length === 0) {
return <span className={style.empty}>No options available</span>;
}
return <Select size='large' name={id} defaultValue={defaultOptionValue} options={paramField.values} />;
}
if (type === 'multi-option') {
@@ -60,28 +43,22 @@ export default function ParamInput({ paramField }: ParamInputProps) {
}
if (type === 'boolean') {
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' />;
return <ControlledSwitch id={id} initialValue={isStringBoolean(searchParams.get(id)) || defaultValue} />;
}
if (type === 'number') {
const { prefix, placeholder } = paramField;
const { placeholder } = paramField;
const defaultNumberValue = searchParams.get(id) ?? defaultValue;
return (
<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>
<Input
height='large'
type='number'
step='any'
name={id}
defaultValue={defaultNumberValue}
placeholder={placeholder}
/>
);
}
@@ -92,52 +69,65 @@ export default function ParamInput({ paramField }: ParamInputProps) {
}
const defaultStringValue = searchParams.get(id) ?? defaultValue;
const { prefix, placeholder } = paramField;
const { placeholder } = paramField;
return (
<InputGroup variant='ontime-filled'>
{prefix && <InputLeftElement pointerEvents='none'>{prefix}</InputLeftElement>}
<Input name={id} defaultValue={defaultStringValue} placeholder={placeholder} />
</InputGroup>
);
return <Input height='large' name={id} defaultValue={defaultStringValue} placeholder={placeholder} />;
}
interface EditFormMultiOptionProps {
paramField: ParamField & { type: 'multi-option' };
}
function MultiOption(props: EditFormMultiOptionProps) {
function MultiOption({ paramField }: EditFormMultiOptionProps) {
const [searchParams] = useSearchParams();
const { paramField } = props;
const { id, defaultValue } = paramField;
const { id, values, defaultValue = [''] } = paramField;
const optionFromParams = searchParams.getAll(id);
const [paramState, setParamState] = useState<string[]>(optionFromParams || defaultValue || ['']);
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>;
}
return (
<>
<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>
<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>
</>
);
}
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,7 +11,6 @@
.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);
@@ -27,14 +26,13 @@
top: 0;
right: 0;
bottom: 0;
z-index: $zindex-dialog;
width: 40rem;
height: 100vh;
display: flex;
flex-direction: column;
padding: 1rem 1.5rem;
padding-block: 1rem 1.5rem;
background-color: $gray-1250;
color: $ui-white;
@@ -58,6 +56,7 @@
}
.header {
padding-inline: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
@@ -69,12 +68,14 @@
.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,8 +1,7 @@
import { FormEvent, memo, useEffect } from 'react';
import { FormEvent, memo } 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';
@@ -11,6 +10,7 @@ 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,30 +22,16 @@ interface EditFormDrawerProps {
export default memo(ViewParamsEditor);
function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams();
const [_, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings();
const [isOpen, handlers] = useDisclosure(false);
// handle opening the drawer
useEffect(() => {
const isEditing = searchParams.get('edit');
if (isEditing === 'true') {
return handlers.open();
}
}, [searchParams, handlers]);
const { isOpen, close } = useViewParamsEditorStore();
const handleClose = () => {
searchParams.delete('edit');
setSearchParams(searchParams);
handlers.close();
close();
};
const resetParams = () => {
setSearchParams();
handlers.close();
};
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
@@ -1,3 +1,4 @@
import { memo } from 'react';
import { IoChevronDown } from 'react-icons/io5';
import { useLocalStorage } from '@mantine/hooks';
@@ -15,7 +16,8 @@ interface ViewParamsSectionProps {
options: ParamField[];
}
export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
export default memo(ViewParamsSection);
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: 'string' },
field2: { label: 'Field 2', colour: 'blue', type: 'string' },
field1: { label: 'Field 1', colour: 'red', type: 'text' },
field2: { label: 'Field 2', colour: 'blue', type: 'text' },
};
it('creates a record of keys for the given custom fields', () => {
it('creates an array of options to use in a select', () => {
const result = makeOptionsFromCustomFields(testCustomFields);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
});
expect(result).toStrictEqual([
{ value: 'custom-field1', label: 'Custom: Field 1' },
{ value: 'custom-field2', label: 'Custom: Field 2' },
]);
});
it('appends additional data', () => {
const additionalData = {
test1: 'test1',
test2: 'test2',
};
const additionalData = [
{ value: 'test1', label: 'Test 1' },
{ value: 'test2', label: 'Test 2' },
];
const result = makeOptionsFromCustomFields(testCustomFields, additionalData);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
test1: 'test1',
test2: 'test2',
});
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' },
]);
});
it('filtersImageTypes', () => {
@@ -40,14 +40,14 @@ describe('makeOptionsFromCustomFields', () => {
};
const result = makeOptionsFromCustomFields(customFieldsWIthImage);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
});
expect(result).toStrictEqual([
{ value: 'custom-field1', label: 'Custom: Field 1' },
{ value: 'custom-field2', label: 'Custom: Field 2' },
]);
});
});
describe('getURLSearchParamsFromObj', () => {
describe('getURLSearchParamsFromObj()', () => {
// Mock view options for testing
const mockViewOptions: ViewOption[] = [
{
@@ -73,7 +73,10 @@ describe('getURLSearchParamsFromObj', () => {
title: 'Multi Select',
description: 'A multi-select field',
type: 'option',
values: { value1: 'Value 1', value2: 'Value 2' },
values: [
{ value: 'value1', label: 'Value 1' },
{ value: 'value2', label: 'Value 2' },
],
defaultValue: '',
},
],
@@ -201,4 +204,36 @@ 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);
console.log('Result:', result.toString());
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: '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',
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',
type: 'string',
placeholder,
};
@@ -8,20 +8,19 @@ type BaseField = {
type OptionsField = {
type: 'option';
values: Record<string, string>;
values: { value: string; label: string }[];
defaultValue?: string;
};
type MultiselectOption = { value: string; label: string; colour: string };
export type MultiselectOptions = Record<string, MultiselectOption>;
export type MultiselectOption = { value: string; label: string; colour: string };
type MultiOptionsField = {
type: 'multi-option';
values: MultiselectOptions;
values: MultiselectOption[];
defaultValue?: string;
};
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
type StringField = { type: 'string'; defaultValue?: string; placeholder?: string };
type NumberField = { type: 'number'; defaultValue?: number; 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,6 +1,8 @@
import type { CustomFields } from 'ontime-types';
import type { MultiselectOptions, ViewOption } from './viewParams.types';
import type { SelectOption } from '../select/Select';
import type { MultiselectOption, ViewOption } from './viewParams.types';
/**
* Creates a list of custom fields for a select
@@ -8,17 +10,23 @@ import type { MultiselectOptions, ViewOption } from './viewParams.types';
*/
export function makeOptionsFromCustomFields(
customFields: CustomFields,
additionalOptions: Readonly<Record<string, string>> = {},
additionalOptions: SelectOption[] = [],
filterImageType = true,
): Record<string, string> {
const options = { ...additionalOptions };
): SelectOption[] {
const options: SelectOption[] = [...additionalOptions];
// Add custom fields first
for (const [key, value] of Object.entries(customFields)) {
if (filterImageType && value.type === 'image') {
continue;
}
options[`custom-${key}`] = `Custom: ${value.label}`;
options.push({
value: `custom-${key}`,
label: `Custom: ${value.label}`,
});
}
return options;
}
@@ -26,14 +34,22 @@ 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): MultiselectOptions {
const options: MultiselectOptions = {};
export function makeCustomFieldSelectOptions(customFields: CustomFields, filterImageType = true): MultiselectOption[] {
const options: MultiselectOption[] = [];
// Add custom fields first
for (const [key, value] of Object.entries(customFields)) {
if (filterImageType && value.type === 'image') {
continue;
}
options[key] = { value: key, label: value.label, colour: value.colour };
options.push({
value: key,
label: value.label,
colour: value.colour || 'transparent',
});
}
return options;
}
@@ -52,17 +68,22 @@ 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: {},
};
@@ -80,6 +101,8 @@ function collectFieldMetadata(paramFields: ViewOption[]): FieldMetadata {
if (option.type === 'colour') {
metadata.colorFields.add(option.id);
} else if (option.type === 'boolean') {
metadata.booleanFields.add(option.id);
}
});
});
@@ -132,7 +155,16 @@ export function getURLSearchParamsFromObj(paramsObj: ViewParamsObj, paramFields:
// Process and add new values
value.split(',').forEach((v) => {
const processedValue = metadata.colorFields.has(id) ? sanitiseColour(v) : 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;
})();
if (metadata.isPersistedField.has(id) || metadata.defaultValues[id] !== processedValue) {
addUniqueParam(id, processedValue);
}
@@ -0,0 +1,15 @@
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';
// CustomFields record type is no longer used here
import { CustomFields } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CUSTOM_FIELDS } from '../api/constants';
import { getCustomFields, CustomFieldWithKey } from '../api/customFields'; // Import CustomFieldWithKey
import { getCustomFields } from '../api/customFields';
const placeholder: CustomFieldWithKey[] = []; // Placeholder is now an empty array
const placeholder: CustomFields = {};
export default function useCustomFields() {
// Explicitly type the useQuery hook
const { data, status, isFetching, isError, refetch } = useQuery<CustomFieldWithKey[], Error>({
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: CUSTOM_FIELDS,
queryFn: getCustomFields,
placeholderData: (previousData, _previousQuery) => previousData ?? placeholder,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { OntimeEntry, Rundown } from 'ontime-types';
import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { RUNDOWN } from '../api/constants';
@@ -78,3 +78,18 @@ 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,10 @@
import { MouseEvent } from 'react';
import { Option, useContextMenuStore } from '../components/context-menu/ContextMenu';
import { useContextMenuStore } from '../../features/rundown/rundown-context-menu/RundownContextMenu';
import { DropdownMenuOption } from '../components/dropdown-menu/DropdownMenu';
export const useContextMenu = <T extends HTMLElement>(options: Option[]) => {
const { setContextMenu } = useContextMenuStore();
export const useContextMenu = <T extends HTMLElement>(options: DropdownMenuOption[]) => {
const setContextMenu = useContextMenuStore((state) => state.setContextMenu);
const localCreateContextMenu = (contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => {
// prevent browser default context menu from showing up
+21 -8
View File
@@ -15,7 +15,7 @@ import {
} from 'ontime-types';
import { dayInMs, generateId, MILLIS_PER_SECOND, parseUserTime, swapEventData } from 'ontime-utils';
import { moveDown, moveUp } from '../../features/rundown/rundown.utils';
import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils';
import { RUNDOWN } from '../api/constants';
import {
deleteEntries,
@@ -232,9 +232,9 @@ export const useEntryActions = () => {
* Updates existing entry
*/
const updateEntry = useCallback(
async (event: Partial<OntimeEntry>) => {
async (entry: Partial<OntimeEntry>) => {
try {
await updateEntryMutation(event);
await updateEntryMutation(entry);
} catch (error) {
logAxiosError('Error updating event', error);
}
@@ -589,7 +589,7 @@ export const useEntryActions = () => {
try {
await ungroupMutation(blockId);
} catch (error) {
logAxiosError('Error dissolving block', error);
logAxiosError('Error dissolving group', error);
}
},
[ungroupMutation],
@@ -622,13 +622,22 @@ export const useEntryActions = () => {
*/
const groupEntries = useCallback(
async (entryIds: EntryId[]) => {
if (entryIds.length === 0) return;
try {
await groupEntriesMutation(entryIds);
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);
}
} catch (error) {
logAxiosError('Error grouping entries', error);
}
},
[groupEntriesMutation],
[groupEntriesMutation, queryClient],
);
/**
@@ -799,8 +808,12 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
if (isOntimeBlock(entry) || !entry.parent) {
order = order.filter((id) => id !== entry.id);
} else {
const parent = entries[entry.parent] as OntimeBlock;
parent.entries = parent.entries.filter((parentEntry) => parentEntry !== entry.id);
const parent = entries[entry.parent];
if ('parent' in entries) {
(parent as OntimeBlock).entries = (parent as OntimeBlock).entries.filter(
(parentEntry) => parentEntry !== entry.id,
);
}
}
delete entries[entry.id];
@@ -1,10 +1,10 @@
import { MutableRefObject, useCallback, useEffect, useRef } from 'react';
import { RefObject, useCallback, useEffect, useRef } from 'react';
import { useSelectedEventId } from './useSocket';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: MutableRefObject<ComponentRef>,
scrollRef: MutableRefObject<ScrollRef>,
componentRef: RefObject<ComponentRef>,
scrollRef: RefObject<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: MutableRefObject<ComponentRef>,
scrollRef: MutableRefObject<ScrollRef>,
componentRef: RefObject<ComponentRef>,
scrollRef: RefObject<ScrollRef>,
topOffset: number,
) {
if (!componentRef.current || !scrollRef.current) {
@@ -36,8 +36,8 @@ function snapToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTM
}
interface UseFollowComponentProps {
followRef: MutableRefObject<HTMLElement | null>;
scrollRef: MutableRefObject<HTMLElement | null>;
followRef: RefObject<HTMLElement | null>;
scrollRef: RefObject<HTMLElement | null>;
doFollow: boolean;
topOffset?: number;
setScrollFlag?: (newValue: boolean) => void;
@@ -56,11 +56,7 @@ export default function useFollowComponent(props: UseFollowComponentProps) {
setScrollFlag?.(true);
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
scrollToComponent(
followRef as MutableRefObject<HTMLElement>,
scrollRef as MutableRefObject<HTMLElement>,
topOffset,
);
scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, topOffset);
setScrollFlag?.(false);
});
}
@@ -97,8 +93,8 @@ export function useFollowSelected(doFollow: boolean, topOffset = 100) {
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
snapToComponent(
{ current: selectedRef.current } as MutableRefObject<HTMLElement>,
{ current: scrollRef.current } as MutableRefObject<HTMLElement>,
{ current: selectedRef.current } as RefObject<HTMLElement>,
{ current: scrollRef.current } as RefObject<HTMLElement>,
topOffset,
);
});
@@ -0,0 +1,78 @@
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;
}
@@ -1,64 +0,0 @@
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>>();
const memoizedFn = useRef<PickFunction<T>>(undefined);
if (!memoizedFn.current) {
memoizedFn.current = function (this, ...args) {
return fnRef.current.apply(this, args);
@@ -150,6 +150,10 @@ export const useClock = createSelector((state: RuntimeStore) => ({
clock: state.clock,
}));
export const useNextFlag = createSelector((state: RuntimeStore) => ({
nextFlag: state.nextFlag,
}));
/** Used by the progress bar components */
export const useProgressData = createSelector((state: RuntimeStore) => ({
current: state.timer.current,
@@ -174,6 +178,7 @@ export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) =
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
blockStartedAt: state.blockNow?.startedAt ?? null,
blockExpectedEnd: state.blockNow?.expectedEnd ?? null,
}));
export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
+3 -3
View File
@@ -3,8 +3,8 @@ import { ProjectData } from 'ontime-types';
export const projectDataPlaceholder: ProjectData = {
title: '',
description: '',
backstageUrl: '',
backstageInfo: '',
projectLogo: null,
url: '',
info: '',
logo: null,
custom: [],
};
@@ -1,10 +1,8 @@
import { ViewSettings } from 'ontime-types';
export const viewsSettingsPlaceholder: ViewSettings = {
dangerColor: '#ED3333',
endMessage: '',
freezeEnd: false,
dangerColor: '#ff7300',
normalColor: '#ffffffcc',
overrideStyles: false,
warningColor: '#FFAB33',
warningColor: '#ffa528',
};
@@ -1,32 +0,0 @@
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,6 +7,7 @@ describe('cloneEvent()', () => {
const original: OntimeEvent = {
id: 'unique',
type: SupportedEntry.Event,
flag: false,
title: 'title',
cue: 'cue',
note: 'note',
@@ -40,6 +41,7 @@ describe('cloneEvent()', () => {
expect(cloned).toMatchObject({
type: SupportedEntry.Event,
flag: original.flag,
title: original.title,
note: original.note,
timeStart: original.timeStart,
@@ -1,7 +1,4 @@
import { OffsetMode } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import { calculateTimeUntilStart, formatTime, nowInMillis } from '../time';
import { formatTime, nowInMillis } from '../time';
describe('nowInMillis()', () => {
it('should return the current time in milliseconds', () => {
@@ -41,257 +38,3 @@ describe('formatTime()', () => {
expect(time).toStrictEqual('-01:00');
});
});
describe('calculateTimeUntilStart()', () => {
describe('Absolute offset mode', () => {
test('ontime', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 90,
offset: 0,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(10);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
});
test('running behind', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 90,
offset: -20,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(30);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(30);
});
test('running ahead', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 80,
offset: 10,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // <-- when running ahead the unlinked timer stays put
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
});
test('running behind with enough gaps', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 20,
clock: 50,
offset: -20,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(50); // <-- when gap is enough to compensate for the running behind
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
});
test('running behind with too little gaps', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 10,
clock: 50,
offset: -20,
offsetMode: OffsetMode.Absolute,
actualStart: 0,
plannedStart: 0,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(60); // <-- when gap is not enough to compensate for the running behind it absorbs at much as possible
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
});
});
describe('Relative offset mode', () => {
test('basic function', () => {
const test = {
timeStart: 0,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
const timeStartEvent2 = 10;
const timeStartEvent3 = 20;
//event 1 is the currently running event
//event 2
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(10);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(10);
//event 3
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(20);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(20);
// When clock advances by 5ms, time until start should decrease by 5ms
test.clock = 105;
//event 2
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(5);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(5);
//event 3
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(15);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(15);
});
test('gaps', () => {
const test = {
timeStart: 20,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 10,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
// When clock advances by 5ms, time until start should decrease by 5ms
test.clock = 105;
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(15);
});
test('added/remove time', () => {
const test = {
timeStart: 20,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
test.offset = 5; // remove 5 with addtime - we are ahead of time
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // unlocked evets will stay on schedule
test.offset = -5; // add 5 with addtime - we are behind
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(25);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(25);
});
test('next day', () => {
const test = {
delay: 0,
currentDay: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
// this event will start the current day
expect(
calculateTimeUntilStart({
...test,
timeStart: 10,
dayOffset: 0,
totalGap: 0,
isLinkedToLoaded: false,
}),
).toBe(10);
// this event will start the next day
// in absolute mode this would start in dayInMs - 100 since the gap would compensate
// but in relative mode with and actual start that is 100 offset it starts in dayInMs
expect(
calculateTimeUntilStart({
...test,
timeStart: 0,
dayOffset: 1,
totalGap: dayInMs - 20,
isLinkedToLoaded: false,
}),
).toBe(dayInMs);
// advancing 100ms
test.clock = 200;
expect(
calculateTimeUntilStart({
...test,
timeStart: 0,
dayOffset: 1,
totalGap: dayInMs - 20,
isLinkedToLoaded: false,
}),
).toBe(dayInMs - 100);
});
});
test('overlap with negative total gap', () => {
const test = {
dayOffset: 0,
delay: 0,
currentDay: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
isLinkedToLoaded: false,
};
// the overlap will be pushed out to the expected available time
expect(calculateTimeUntilStart({ ...test, timeStart: 5, totalGap: -5 })).toBe(10);
test.clock = 105;
});
});
+1
View File
@@ -10,6 +10,7 @@ type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>;
export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
return {
type: SupportedEntry.Event,
flag: event.flag,
title: event.title,
note: event.note,
timeStart: event.timeStart,
+25
View File
@@ -0,0 +1,25 @@
import { MaybeNumber } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { enDash } from './styleUtils';
/**
* Formats offset text
*/
export function getOffsetText(offset: MaybeNumber): string {
if (offset === null) {
return enDash;
}
let offsetText = '';
if (offset < 0) offsetText += '-';
if (offset > 0) offsetText += '+';
offsetText += millisToString(Math.abs(offset));
return offsetText;
}
export function getOffsetState(offset: MaybeNumber): 'over' | 'under' | 'muted' | null {
if (offset === null) return null;
if (offset === 0) return 'muted';
return offset < 0 ? 'over' : 'under';
}
+7 -4
View File
@@ -10,7 +10,7 @@ import {
} from 'ontime-types';
import { isProduction, websocketUrl } from '../../externals';
import { CLIENT_LIST, CUSTOM_FIELDS, REPORT, RUNDOWN, RUNTIME, VIEW_SETTINGS } from '../api/constants';
import { CLIENT_LIST, CUSTOM_FIELDS, PROJECT_DATA, REPORT, RUNDOWN, RUNTIME, VIEW_SETTINGS } from '../api/constants';
import { invalidateAllCaches } from '../api/utils';
import { ontimeQueryClient } from '../queryClient';
import {
@@ -155,6 +155,12 @@ export const connectSocket = () => {
case RefetchKey.CustomFields:
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
break;
case RefetchKey.ProjectData:
ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_DATA });
break;
case RefetchKey.Report:
ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
break;
case RefetchKey.Rundown:
if (revision === (ontimeQueryClient.getQueryData(RUNDOWN) as Rundown).revision) break;
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
@@ -163,9 +169,6 @@ export const connectSocket = () => {
case RefetchKey.ViewSettings:
ontimeQueryClient.invalidateQueries({ queryKey: VIEW_SETTINGS });
break;
case RefetchKey.Report:
ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
break;
default: {
target satisfies never;
break;
+8 -71
View File
@@ -1,5 +1,11 @@
import { MaybeNumber, OffsetMode, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
import { dayInMs, formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { MaybeNumber, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
import {
calculateTimeUntilStart,
formatFromMillis,
MILLIS_PER_HOUR,
MILLIS_PER_MINUTE,
MILLIS_PER_SECOND,
} from 'ontime-utils';
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
import { APP_SETTINGS } from '../api/constants';
@@ -143,72 +149,3 @@ export function useTimeUntilStart(
const { offset, clock, currentDay, offsetMode, actualStart, plannedStart } = useTimeUntilData();
return calculateTimeUntilStart({ ...data, currentDay, clock, offset, offsetMode, actualStart, plannedStart });
}
/**
*
* @param currentDay the day offset of the urrently running event
* @param totalGap accumulated gap from the current event
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
* @param clock
* @param offset
* @returns
*/
export function calculateTimeUntilStart(
data: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'> & {
currentDay: number;
totalGap: number;
isLinkedToLoaded: boolean;
clock: number;
offset: number;
offsetMode: OffsetMode;
actualStart: MaybeNumber;
plannedStart: MaybeNumber;
},
): number {
const {
timeStart,
dayOffset,
currentDay,
totalGap,
isLinkedToLoaded,
clock,
offset,
delay,
offsetMode,
actualStart,
plannedStart,
} = data;
//How many days from the currently running event to this one
const relativeDayOffset = dayOffset - currentDay;
const delayedStart = Math.max(0, timeStart + delay);
//The normalised start time of this event relative to the currently running event
const normalisedTimeStart = delayedStart + relativeDayOffset * dayInMs;
let relativeStartOffset = 0;
if (offsetMode === OffsetMode.Relative) {
relativeStartOffset = (actualStart ?? 0) - (plannedStart ?? 0);
}
const scheduledTimeUntil = normalisedTimeStart - clock + relativeStartOffset;
const offsetTimeUntil = scheduledTimeUntil - offset;
if (isLinkedToLoaded) {
//if we are directly linked back to the loaded event we just follow the offset
return offsetTimeUntil;
}
const gapsCanCompensateForOffset = totalGap + offset >= 0;
if (gapsCanCompensateForOffset) {
// if we are ahead of schedule or the gap can compensate for the amount we are behind then expect to start at the scheduled time
return scheduledTimeUntil;
}
// otherwise consume as much of the offset as possible with the gap
const offsetTimeUntilBufferedByGaps = offsetTimeUntil - totalGap;
return offsetTimeUntilBufferedByGaps;
}
@@ -1,7 +1,7 @@
.corner {
position: absolute;
top: 1rem;
right: 2rem;
position: fixed;
top: 6rem;
right: 4rem;
z-index: $zindex-floating;
}
@@ -1,9 +1,8 @@
import { Fragment } from 'react';
import { Tooltip } from '@chakra-ui/react';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { isKeyEnter } from '../../../common/utils/keyEvent';
import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { SettingsOption, SettingsOptionId, useAppSettingsMenu } from '../useAppSettingsMenu';
import useAppSettingsNavigation from '../useAppSettingsNavigation';
@@ -26,7 +25,7 @@ export default function PanelList({ selectedPanel, location }: PanelListProps) {
const isSelected = selectedPanel === panel.id;
if (panel.highlight) {
return (
<Tooltip key={panel.id} label={panel.highlight} openDelay={tooltipDelayFast} shouldWrapChildren>
<Tooltip key={panel.id} text={panel.highlight} render={<span />}>
<PanelListItem panel={panel} location={location} isSelected={isSelected} />
</Tooltip>
);

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