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