Compare commits

..

5 Commits

Author SHA1 Message Date
arc-alex 71317f5e40 bump to v3.4.2 2024-08-19 17:26:15 +02:00
Alex Christoffer Rasmussen cc14445252 swap shouldThrottle check
Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2024-08-19 17:24:51 +02:00
arc-alex 31316a6661 remove async from parseProperty 2024-08-19 17:24:42 +02:00
arc-alex a8bc4eb098 bump to version 3.4.1 2024-07-29 12:52:58 +02:00
arc-alex 807698aee1 restore rundownGetAll 2024-07-29 12:51:44 +02:00
333 changed files with 5472 additions and 9212 deletions
+3
View File
@@ -40,6 +40,9 @@ dist/
ontime-db ontime-db
ontime-external/ ontime-external/
# working database
apps/server/src/preloaded-db/db.json
# versioning file # versioning file
**/ONTIME_VERSION.js **/ONTIME_VERSION.js
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm lint-staged
+2 -2
View File
@@ -22,8 +22,8 @@ COPY --from=builder /app/apps/client/build ./client/
# Prepare Backend # Prepare Backend
COPY --from=builder /app/apps/server/dist/ ./server/ COPY --from=builder /app/apps/server/dist/ ./server/
COPY ./demo-db/ ./preloaded-db/
COPY --from=builder /app/apps/server/src/external/ ./external/ COPY --from=builder /app/apps/server/src/external/ ./external/
COPY --from=builder /app/apps/server/src/user/ ./user/
# Export default ports # Export default ports
EXPOSE 4001/tcp 8888/udp 9999/udp EXPOSE 4001/tcp 8888/udp 9999/udp
@@ -33,4 +33,4 @@ CMD ["node", "server/docker.cjs"]
# Build and run commands # Build and run commands
# !!! Note that this command needs pre-build versions of the UI and server apps # !!! Note that this command needs pre-build versions of the UI and server apps
# docker buildx build . -t getontime/ontime # docker buildx build . -t getontime/ontime
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime # docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/external/db/ -v ./ontime-styles:/external/styles/ getontime/ontime
+18 -19
View File
@@ -14,10 +14,8 @@
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a> - Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a> - Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
## Need help?
We do our best to have most topics covered by the documentation. However, if your question is not covered, you are welcome to [fill in a bug report in an issue](https://github.com/cpvalente/ontime/issues), [ask a question in GitHub discussions](https://github.com/cpvalente/ontime/discussions) or hop in the [discord server](https://discord.com/invite/eje3CSUEXm) for a chat.
## Using Ontime? ## Using Ontime?
Let us know! Let us know!
Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback. Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback.
@@ -37,16 +35,6 @@ Ontime is made by entertainment and broadcast engineers and used by
- Theatres and opera houses - Theatres and opera houses
- Houses of worship - Houses of worship
## Main features
- [x] **Multiplatform**: Available for Windows / MacOS, Linux. You can also self host with the docker image
- [x] **In any device**: Ontime is available in the local network to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers...
- [x] **Made for teams**: Ontime caters to different roles in your production team: directors, operators, backstage and front of house signage...
- [x] **Delay workflows**: Manage and communicate runtime delays in real-time to your team
- [x] **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock
- [x] **Focus on integrations**: Use one of the APIs provided (OSC, HTTP, Websocket) or the available [Companion module](https://bitfocus.io/connections/getontime-ontime) to integrate into your workflow (vMix, disguise, Qlab, OBS)
... and a lot more ...
### For live environments ### For live environments
Ontime is designed for use in live environments. \ Ontime is designed for use in live environments. \
@@ -95,7 +83,6 @@ IP.ADDRESS:4001/clock > Simple clock view
IP.ADDRESS:4001/backstage > Stage Manager / Backstage view IP.ADDRESS:4001/backstage > Stage Manager / Backstage view
IP.ADDRESS:4001/countdown > Countdown to anything IP.ADDRESS:4001/countdown > Countdown to anything
IP.ADDRESS:4001/studio > Studio Clock IP.ADDRESS:4001/studio > Studio Clock
IP.ADDRESS:4001/timeline > Timeline
``` ```
``` ```
@@ -113,13 +100,26 @@ IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
IP.ADDRESS:4001/operator > automated views for operators IP.ADDRESS:4001/operator > automated views for operators
``` ```
More information is available [in our docs](https://docs.getontime.no) More documentation is available [in our docs](https://docs.getontime.no)
## Main features
- [x] Distribute data over network and render it in the browser
- [x] Collaborative
- [x] Extendable
- [x] Send messages to different screen types
- [x] Differentiate between backstage and public data
- [x] Workflow for managing delays
- [x] Rich protocol integrations for Control and Feedback
- [x] For servers: use docker to run Ontime in in a server, configure from a browser anywhere
- [x] Multi-platform (available on Windows, MacOS and Linux)
- [x] Companion integration [follow link](https://bitfocus.io/connections/getontime-ontime)
## Roadmap ## Roadmap
### Continued development ### Continued development
Ontime is under active development. We continue adding and improving features in collaboration with users. Ontime is under active development. We continue adding and tweaking features in collaboration with users.
Have an idea? Reach out via [email](mail@getontime.no) Have an idea? Reach out via [email](mail@getontime.no)
or [open an issue](https://github.com/cpvalente/ontime/issues/new) or [open an issue](https://github.com/cpvalente/ontime/issues/new)
@@ -161,9 +161,8 @@ If you are a developer and would like to contribute with code, please open an is
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md) Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
## Links ## Links
- [Ontime website](https://getontime.no)
- [Documentation](https://docs.getontime.no) See the [Ontime website](https://getontime.no) here and the link to the [documentation](https://docs.getontime.no)
- [Ontime discord server](https://discord.com/invite/eje3CSUEXm)
## License ## License
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "3.7.0", "version": "3.4.2",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
@@ -20,8 +20,8 @@
"server" "server"
], ],
"devDependencies": { "devDependencies": {
"eslint": "catalog:", "eslint": "^8.53.0",
"eslint-config-prettier": "catalog:", "eslint-config-prettier": "^9.0.0",
"prettier": "catalog:" "prettier": "^3.0.3"
} }
} }
+14 -13
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "3.7.0", "version": "3.4.2",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
@@ -11,9 +11,9 @@
"@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.13.3", "@mantine/hooks": "^7.6.2",
"@react-icons/all-files": "^4.1.0", "@react-icons/all-files": "^4.1.0",
"@sentry/react": "^8.19.0", "@sentry/react": "^7.92.0",
"@tanstack/react-query": "^5.17.9", "@tanstack/react-query": "^5.17.9",
"@tanstack/react-query-devtools": "^5.17.9", "@tanstack/react-query-devtools": "^5.17.9",
"@tanstack/react-table": "^8.11.3", "@tanstack/react-table": "^8.11.3",
@@ -22,11 +22,11 @@
"color": "^4.2.3", "color": "^4.2.3",
"csv-stringify": "^6.4.5", "csv-stringify": "^6.4.5",
"framer-motion": "^10.10.0", "framer-motion": "^10.10.0",
"react": "^18.3.1", "react": "^18.2.0",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-dom": "^18.3.1", "react-dom": "^18.2.0",
"react-fast-compare": "^3.2.2", "react-fast-compare": "^3.2.2",
"react-hook-form": "^7.53.1", "react-hook-form": "^7.49.2",
"react-qr-code": "^2.0.12", "react-qr-code": "^2.0.12",
"react-router-dom": "^6.3.0", "react-router-dom": "^6.3.0",
"web-vitals": "^3.1.1", "web-vitals": "^3.1.1",
@@ -42,6 +42,7 @@
"build:docker": "vite build", "build:docker": "vite build",
"build:localdocker": "cross-env NODE_ENV=local vite build", "build:localdocker": "cross-env NODE_ENV=local vite build",
"lint": "eslint . --quiet", "lint": "eslint . --quiet",
"lint-staged": "eslint",
"test": "vitest", "test": "vitest",
"test:pipeline": "vitest run", "test:pipeline": "vitest run",
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build", "cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build",
@@ -69,13 +70,13 @@
"@types/react": "^18.0.26", "@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10", "@types/react-dom": "^18.0.10",
"@types/testing-library__jest-dom": "^5.14.5", "@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "catalog:", "@typescript-eslint/eslint-plugin": "^v7.16.1",
"@typescript-eslint/parser": "catalog:", "@typescript-eslint/parser": "^7.16.1",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"eslint": "catalog:", "eslint": "^8.56.0",
"eslint-config-prettier": "catalog:", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-jest": "^28.6.0", "eslint-plugin-jest": "^28.6.0",
"eslint-plugin-prettier": "catalog:", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-react": "^7.32.0", "eslint-plugin-react": "^7.32.0",
"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",
@@ -83,9 +84,9 @@
"jsdom": "^21.1.0", "jsdom": "^21.1.0",
"ontime-types": "workspace:*", "ontime-types": "workspace:*",
"ontime-utils": "workspace:*", "ontime-utils": "workspace:*",
"prettier": "catalog:", "prettier": "^3.3.1",
"sass": "^1.57.1", "sass": "^1.57.1",
"typescript": "catalog:", "typescript": "^5.5.3",
"vite": "^5.2.11", "vite": "^5.2.11",
"vite-plugin-compression2": "^0.12.0", "vite-plugin-compression2": "^0.12.0",
"vite-plugin-svgr": "^4.2.0", "vite-plugin-svgr": "^4.2.0",
+37 -121
View File
@@ -1,37 +1,24 @@
import React from 'react'; import { lazy, Suspense } from 'react';
import { import { Navigate, Route, Routes } from 'react-router-dom';
createRoutesFromChildren,
matchRoutes,
Navigate,
Route,
Routes,
useLocation,
useNavigationType,
} from 'react-router-dom';
import * as Sentry from '@sentry/react';
import { useClientPath } from './common/hooks/useClientPath'; import { useClientPath } from './common/hooks/useClientPath';
import Log from './features/log/Log'; import Log from './features/log/Log';
import withPreset from './features/PresetWrapper'; import withPreset from './features/PresetWrapper';
import withData from './features/viewers/ViewWrapper'; import withData from './features/viewers/ViewWrapper';
import ViewLoader from './views/ViewLoader';
import { ONTIME_VERSION } from './ONTIME_VERSION';
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
const Editor = React.lazy(() => import('./features/editors/ProtectedEditor')); const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = React.lazy(() => import('./features/cuesheet/ProtectedCuesheet')); const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
const Operator = React.lazy(() => import('./features/operator/OperatorExport')); const Operator = lazy(() => import('./features/operator/OperatorExport'));
const TimerView = React.lazy(() => import('./features/viewers/timer/Timer')); const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer')); const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock')); const ClockView = lazy(() => import('./features/viewers/clock/Clock'));
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown')); const Countdown = lazy(() => import('./features/viewers/countdown/Countdown'));
const Backstage = React.lazy(() => import('./features/viewers/backstage/Backstage')); const Backstage = lazy(() => import('./features/viewers/backstage/Backstage'));
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage')); const Public = lazy(() => import('./features/viewers/public/Public'));
const Public = React.lazy(() => import('./features/viewers/public/Public')); const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird')); const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock'));
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
const STimer = withPreset(withData(TimerView)); const STimer = withPreset(withData(TimerView));
const SMinimalTimer = withPreset(withData(MinimalTimerView)); const SMinimalTimer = withPreset(withData(MinimalTimerView));
@@ -41,112 +28,41 @@ const SBackstage = withPreset(withData(Backstage));
const SPublic = withPreset(withData(Public)); 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 EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper')); const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport')); const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
const TimerControl = React.lazy(() => import('./features/control/playback/TimerControlExport')); const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport'));
const MessageControl = React.lazy(() => import('./features/control/message/MessageControlExport')); const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
Sentry.init({
dsn: sentryDsn,
integrations: [
Sentry.reactRouterV6BrowserTracingIntegration({
useEffect: React.useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
}),
],
tracesSampleRate: 0.3,
release: ONTIME_VERSION,
enabled: import.meta.env.PROD,
ignoreErrors: [...sentryRecommendedIgnore, /Unable to preload CSS/i, /dynamically imported module/i],
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
});
const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes);
export default function AppRouter() { export default function AppRouter() {
// handle client path changes // handle client path changes
useClientPath(); useClientPath();
return ( return (
<React.Suspense fallback={null}> <Suspense fallback={null}>
<SentryRoutes> <Routes>
<Route path='/' element={<Navigate to='/timer' />} /> <Route path='/' element={<Navigate to='/timer' />} />
<Route <Route path='/timer' element={<STimer />} />
path='/timer'
element={ <Route path='/minimal' element={<SMinimalTimer />} />
<ViewLoader>
<STimer /> <Route path='/clock' element={<SClock />} />
</ViewLoader>
} <Route path='/countdown' element={<SCountdown />} />
/>
<Route <Route path='/backstage' element={<SBackstage />} />
path='/public'
element={ <Route path='/public' element={<SPublic />} />
<ViewLoader>
<SPublic /> <Route path='/studio' element={<SStudio />} />
</ViewLoader>
}
/>
<Route
path='/minimal'
element={
<ViewLoader>
<SMinimalTimer />
</ViewLoader>
}
/>
<Route
path='/clock'
element={
<ViewLoader>
<SClock />
</ViewLoader>
}
/>
<Route
path='/countdown'
element={
<ViewLoader>
<SCountdown />
</ViewLoader>
}
/>
<Route
path='/backstage'
element={
<ViewLoader>
<SBackstage />
</ViewLoader>
}
/>
<Route
path='/studio'
element={
<ViewLoader>
<SStudio />
</ViewLoader>
}
/>
{/*/!* Lower third cannot have a loading screen *!/*/}
<Route path='/lower' element={<SLowerThird />} /> <Route path='/lower' element={<SLowerThird />} />
<Route
path='/timeline' <Route path='/op' element={<Operator />} />
element={
<ViewLoader>
<STimeline />
</ViewLoader>
}
/>
{/*/!* Protected Routes *!/*/} {/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} /> <Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<Cuesheet />} /> <Route path='/cuesheet' element={<Cuesheet />} />
<Route path='/op' element={<Operator />} />
{/*/!* Protected Routes - Elements *!/*/} {/*/!* Protected Routes - Elements *!/*/}
<Route <Route
@@ -183,7 +99,7 @@ export default function AppRouter() {
/> />
{/*/!* Send to default if nothing found *!/*/} {/*/!* Send to default if nothing found *!/*/}
<Route path='*' element={<STimer />} /> <Route path='*' element={<STimer />} />
</SentryRoutes> </Routes>
</React.Suspense> </Suspense>
); );
} }
+2 -5
View File
@@ -1,7 +1,6 @@
// keys in tanstack store // keys in tanstack store
export const APP_INFO = ['appinfo']; export const APP_INFO = ['appinfo'];
export const APP_SETTINGS = ['appSettings']; export const APP_SETTINGS = ['appSettings'];
export const APP_VERSION = ['appVersion'];
export const CUSTOM_FIELDS = ['customFields']; export const CUSTOM_FIELDS = ['customFields'];
export const HTTP_SETTINGS = ['httpSettings']; export const HTTP_SETTINGS = ['httpSettings'];
export const OSC_SETTINGS = ['oscSettings']; export const OSC_SETTINGS = ['oscSettings'];
@@ -19,7 +18,6 @@ const location = window.location;
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws'; const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
export const isProduction = import.meta.env.MODE === 'production'; export const isProduction = import.meta.env.MODE === 'production';
export const isDev = !isProduction; export const isDev = !isProduction;
export const isLocalhost = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
// resolve port // resolve port
const STATIC_PORT = 4001; const STATIC_PORT = 4001;
@@ -33,6 +31,5 @@ export const projectDataURL = `${serverURL}/project`;
export const rundownURL = `${serverURL}/events`; export const rundownURL = `${serverURL}/events`;
export const ontimeURL = `${serverURL}/ontime`; export const ontimeURL = `${serverURL}/ontime`;
export const userAssetsPath = 'user'; export const stylesPath = 'external/styles/override.css';
export const cssOverridePath = 'styles/override.css'; export const overrideStylesURL = `${serverURL}/${stylesPath}`;
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
+10 -10
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types'; import { DatabaseModel, GetInfo, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types';
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils'; import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
@@ -11,7 +11,7 @@ const dbPath = `${apiEntryUrl}/db`;
/** /**
* HTTP request to the current DB * HTTP request to the current DB
*/ */
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> { async function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
return axios.post(`${dbPath}/download/`, { filename }); return axios.post(`${dbPath}/download/`, { filename });
} }
@@ -86,14 +86,6 @@ export async function createProject(
return res.data; return res.data;
} }
/**
* HTTP request to create a project file
*/
export async function quickProject(data: QuickStartData): Promise<MessageResponse> {
const res = await axios.post(`${dbPath}/quick`, data);
return res.data;
}
/** /**
* HTTP request to get the list of available project files * HTTP request to get the list of available project files
*/ */
@@ -146,6 +138,14 @@ export async function deleteProject(filename: string): Promise<MessageResponse>
return res.data; return res.data;
} }
/**
* HTTP request to retrieve application info
*/
export async function getInfo(): Promise<GetInfo> {
const res = await axios.get(`${dbPath}/info`);
return res.data;
}
/** /**
* Utility function gets project from db * Utility function gets project from db
* @param fileName * @param fileName
-14
View File
@@ -1,14 +0,0 @@
import axios from 'axios';
import { GetInfo } from 'ontime-types';
import { apiEntryUrl } from './constants';
const sessionPath = `${apiEntryUrl}/session`;
/**
* HTTP request to retrieve application info
*/
export async function getInfo(): Promise<GetInfo> {
const res = await axios.get(`${sessionPath}/info`);
return res.data;
}
-2
View File
@@ -23,8 +23,6 @@ export function maybeAxiosError(error: unknown) {
} }
} }
return `${statusText}: ${data}`; return `${statusText}: ${data}`;
} else if (error instanceof Error) {
return error.message;
} else { } else {
if (typeof error !== 'string') { if (typeof error !== 'string') {
return JSON.stringify(error); return JSON.stringify(error);
@@ -8,5 +8,5 @@
.linkIcon { .linkIcon {
margin-left: $element-inner-spacing; margin-left: $element-inner-spacing;
display: inline-block; display: inline-block;
@include rotate-fourty-five; transform: rotate(45deg);
} }
@@ -0,0 +1,6 @@
.buttonSection {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $element-spacing;
margin-top: -$element-spacing;
}
@@ -15,6 +15,8 @@ import {
import { setClientRemote } from '../../hooks/useSocket'; import { setClientRemote } from '../../hooks/useSocket';
import style from './ClientModal.module.scss';
interface RedirectClientModalProps { interface RedirectClientModalProps {
id: string; id: string;
name?: string; name?: string;
@@ -36,7 +38,7 @@ export function RedirectClientModal(props: RedirectClientModalProps) {
onClose(); onClose();
}; };
const host = window.location.origin; const host = `${window.location.origin}/`;
const canSubmit = path !== currentPath && path !== ''; const canSubmit = path !== currentPath && path !== '';
return ( return (
@@ -52,12 +54,14 @@ export function RedirectClientModal(props: RedirectClientModalProps) {
</InputGroup> </InputGroup>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button size='md' variant='ontime-subtle' onClick={onClose}> <div className={style.buttonSection}>
Cancel <Button size='md' variant='ontime-subtle' onClick={onClose}>
</Button> Cancel
<Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}> </Button>
Submit <Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}>
</Button> Submit
</Button>
</div>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>
</Modal> </Modal>
@@ -13,6 +13,8 @@ import {
import { setClientRemote } from '../../hooks/useSocket'; import { setClientRemote } from '../../hooks/useSocket';
import style from './ClientModal.module.scss';
interface RenameClientModalProps { interface RenameClientModalProps {
id: string; id: string;
name?: string; name?: string;
@@ -51,12 +53,14 @@ export function RenameClientModal(props: RenameClientModalProps) {
/> />
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button size='md' variant='ontime-subtle' onClick={onClose}> <div className={style.buttonSection}>
Cancel <Button size='md' variant='ontime-subtle' onClick={onClose}>
</Button> Cancel
<Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}> </Button>
Submit <Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}>
</Button> Submit
</Button>
</div>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>
</Modal> </Modal>
@@ -1,6 +1,5 @@
import { PropsWithChildren, useState } from 'react'; import { PropsWithChildren } from 'react';
import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react'; import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { IoCopy } from '@react-icons/all-files/io5/IoCopy'; import { IoCopy } from '@react-icons/all-files/io5/IoCopy';
import { tooltipDelayFast } from '../../../ontimeConfig'; import { tooltipDelayFast } from '../../../ontimeConfig';
@@ -8,34 +7,26 @@ import { Size } from '../../models/Util.type';
import copyToClipboard from '../../utils/copyToClipboard'; import copyToClipboard from '../../utils/copyToClipboard';
interface CopyTagProps { interface CopyTagProps {
copyValue: string;
label: string; label: string;
className?: string;
size?: Size; size?: Size;
disabled?: boolean; disabled?: boolean;
onClick?: () => void;
} }
export default function CopyTag(props: PropsWithChildren<CopyTagProps>) { export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
const { copyValue, label, size = 'xs', disabled, children, onClick } = props; const { label, className, size = 'xs', disabled, children } = props;
const [copied, setCopied] = useState(false);
const handleClick = () => { const handleClick = () => copyToClipboard(children as string);
copyToClipboard(copyValue);
setCopied(true);
// reset copied state
setTimeout(() => setCopied(false), 1000);
};
return ( return (
<Tooltip label={label} openDelay={tooltipDelayFast}> <Tooltip label={label} openDelay={tooltipDelayFast}>
<ButtonGroup size={size} isAttached> <ButtonGroup size={size} isAttached className={className}>
<Button variant='ontime-subtle' tabIndex={-1} onClick={onClick} isDisabled={disabled}> <Button variant='ontime-subtle' tabIndex={-1} isDisabled={disabled}>
{children} {children}
</Button> </Button>
<IconButton <IconButton
aria-label={label} aria-label={label}
icon={copied ? <IoCheckmark /> : <IoCopy />} icon={<IoCopy />}
variant='ontime-filled' variant='ontime-filled'
tabIndex={-1} tabIndex={-1}
onClick={handleClick} onClick={handleClick}
@@ -28,11 +28,9 @@ class ErrorBoundary extends React.Component {
}); });
Sentry.withScope((scope) => { Sentry.withScope((scope) => {
scope.setExtras({ scope.setExtras('error', error);
error, scope.setExtras('store', runtimeStore.getState());
store: runtimeStore.getState(), scope.setExtras('hasSocket', { hasConnected, shouldReconnect, reconnectAttempts });
hasSocket: { hasConnected, shouldReconnect, reconnectAttempts },
});
const eventId = Sentry.captureException(error); const eventId = Sentry.captureException(error);
this.setState({ eventId, info }); this.setState({ eventId, info });
}); });
@@ -1,63 +0,0 @@
/**
* Copied from
* https://github.com/namhong2001/react-textfit/blob/master/index.tsx
*/
import { HTMLAttributes, PropsWithChildren, useCallback, useEffect, useRef } from 'react';
import { bsearch } from './fitText.utils';
interface FitTextProps extends HTMLAttributes<HTMLDivElement> {
mode?: 'single' | 'multi';
min?: number; // inclusive
max?: number; // inclusive
}
export function FitText(props: PropsWithChildren<FitTextProps>) {
const { children, mode = 'multi', min = 16, max = 256, ...elementProps } = props;
const ref = useRef<HTMLDivElement>(null);
const isOverflown = useCallback(() => {
const el = ref.current;
if (!el) return false;
return el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth;
}, []);
const setFontSize = useCallback(() => {
const el = ref.current;
if (!el) return;
const originVisibility = el.style.visibility;
el.style.visibility = 'hidden';
const fontSize = bsearch(min, max + 1, (mid) => {
el.style.fontSize = `${mid}px`;
return !isOverflown();
});
el.style.fontSize = `${fontSize}px`;
el.style.visibility = originVisibility;
}, [isOverflown, min, max]);
useEffect(() => {
const el = ref.current;
if (!el) return;
setFontSize();
const observer = new ResizeObserver(setFontSize);
observer.observe(el);
return () => observer.disconnect();
}, [children, mode, setFontSize]);
return (
<div
ref={ref}
style={{
whiteSpace: mode === 'single' ? 'nowrap' : 'normal',
}}
{...elementProps}
>
{children}
</div>
);
}
@@ -1,18 +0,0 @@
/**
* @param low inclusive, must be true on predicate function
* @param high exclusive,
* @param predicate predicate function
*/
export const bsearch = (low: number, high: number, predicate: (mid: number) => boolean): number => {
while (low < high) {
const mid = Math.floor((low + high) / 2);
if (mid === low) break;
if (predicate(mid)) {
low = mid;
} else {
high = mid;
}
}
return low;
};
@@ -54,6 +54,7 @@ function Overlay() {
}; };
}, [showOverlay, id, setIdentify, handleClose]); }, [showOverlay, id, setIdentify, handleClose]);
console.log('here2');
return ( return (
<div className={style.overlay} data-testid='identify-overlay' onClick={handleClose}> <div className={style.overlay} data-testid='identify-overlay' onClick={handleClose}>
<div className={style.name}>{name}</div> <div className={style.name}>{name}</div>
@@ -1,12 +1,12 @@
import { MaybeNumber } from 'ontime-types'; import { MaybeNumber } from 'ontime-types';
import { getProgress } from '../../utils/getProgress'; import { clamp } from '../../utils/math';
import './MultiPartProgressBar.scss'; import './MultiPartProgressBar.scss';
interface MultiPartProgressBar { interface MultiPartProgressBar {
now: MaybeNumber; now: MaybeNumber;
complete: MaybeNumber; complete: number;
normalColor: string; normalColor: string;
warning?: MaybeNumber; warning?: MaybeNumber;
warningColor: string; warningColor: string;
@@ -31,9 +31,10 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
className = '', className = '',
} = props; } = props;
const percentRemaining = 100 - getProgress(now, complete); const percentRemaining = complete === 0 ? 0 : 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0; const dangerWidth = danger ? clamp((danger / complete) * 100, 0, 100) : 0;
const warningWidth = warning ? clamp((warning / complete) * 100 - dangerWidth, 0, 100) : 0;
return ( return (
<div <div
@@ -47,8 +47,6 @@ $button-size: 3rem;
@include action-link; @include action-link;
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
gap: 0.5rem; gap: 0.5rem;
width: 100%;
cursor: pointer;
&:hover { &:hover {
background-color: $menu-hover-bg; background-color: $menu-hover-bg;
@@ -72,33 +70,9 @@ $button-size: 3rem;
.linkIcon { .linkIcon {
margin-left: auto; margin-left: auto;
@include rotate-fourty-five; transform: rotate(45deg);
} }
.separator { .separator {
border-color: $border-color-ondark; border-color: $border-color-ondark;
} }
.sectionHeader {
font-size: calc(1rem - 2px);
margin-left: 1rem;
color: $gray-700;
}
.bottom {
margin-top: auto;
margin-bottom: 1rem;
}
.interfaces {
padding: 0.5rem 1rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.goIcon {
@include rotate-fourty-five;
margin-left: 0.25rem;
margin-bottom: 0.25rem;
}
@@ -1,6 +1,6 @@
import { memo, PropsWithChildren, useRef } from 'react'; import { memo, useRef } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Link, useLocation } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { import {
Drawer, Drawer,
DrawerBody, DrawerBody,
@@ -18,17 +18,11 @@ import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutl
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical'; import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import { isLocalhost, serverPort } from '../../api/constants';
import useClickOutside from '../../hooks/useClickOutside'; import useClickOutside from '../../hooks/useClickOutside';
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 { isKeyEnter } from '../../utils/keyEvent';
import { handleLinks, 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 style from './NavigationMenu.module.scss'; import style from './NavigationMenu.module.scss';
@@ -46,7 +40,6 @@ function NavigationMenu(props: NavigationMenuProps) {
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure(); const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
const { fullscreen, toggle } = useFullscreen(); const { fullscreen, toggle } = useFullscreen();
const { toggleMirror } = useViewOptionsStore(); const { toggleMirror } = useViewOptionsStore();
const location = useLocation();
const menuRef = useRef<HTMLDivElement | null>(null); const menuRef = useRef<HTMLDivElement | null>(null);
@@ -57,7 +50,7 @@ function NavigationMenu(props: NavigationMenuProps) {
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} /> <RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'> <Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
<DrawerOverlay /> <DrawerOverlay />
<DrawerContent maxWidth='22rem'> <DrawerContent>
<DrawerHeader> <DrawerHeader>
<DrawerCloseButton size='lg' /> <DrawerCloseButton size='lg' />
Ontime Ontime
@@ -103,27 +96,39 @@ function NavigationMenu(props: NavigationMenuProps) {
<hr className={style.separator} /> <hr className={style.separator} />
<Link <Link
to='/editor' to='/editor'
className={`${style.link} ${location.pathname === '/editor' ? style.current : ''}`}
tabIndex={0} tabIndex={0}
className={`${style.link} ${location.pathname === '/editor' && style.current}`}
> >
<IoLockClosedOutline /> <IoLockClosedOutline />
Editor Editor
<IoArrowUp className={style.linkIcon} />
</Link> </Link>
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}> <Link
to='/cuesheet'
className={`${style.link} ${location.pathname === '/cuesheet' ? style.current : ''}`}
tabIndex={0}
>
<IoLockClosedOutline /> <IoLockClosedOutline />
Cuesheet Cuesheet
</ClientLink> <IoArrowUp className={style.linkIcon} />
<ClientLink to='op' current={location.pathname === '/op'}> </Link>
<Link to='/op' className={`${style.link} ${location.pathname === '/op' ? style.current : ''}`} tabIndex={0}>
<IoLockClosedOutline /> <IoLockClosedOutline />
Operator Operator
</ClientLink> <IoArrowUp className={style.linkIcon} />
</Link>
<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}`}> <Link
key={route.url}
to={route.url}
className={`${style.link} ${route.url === location.pathname ? style.current : undefined}`}
tabIndex={0}
>
{route.label} {route.label}
</ClientLink> <IoArrowUp className={style.linkIcon} />
</Link>
))} ))}
{isLocalhost && <OtherAddresses currentLocation={location.pathname} />}
</DrawerBody> </DrawerBody>
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
@@ -132,70 +137,4 @@ function NavigationMenu(props: NavigationMenuProps) {
); );
} }
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 = `http://${nif.address}:${serverPort}${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); export default memo(NavigationMenu);
@@ -1,23 +1,22 @@
import { MaybeNumber } from 'ontime-types'; import { clamp } from '../../utils/math';
import { getProgress } from '../../utils/getProgress';
import './ProgressBar.scss'; import './ProgressBar.scss';
interface ProgressBarProps { interface ProgressBarProps {
current: MaybeNumber; now?: number;
duration: MaybeNumber; complete?: number;
hidden?: boolean; hidden?: boolean;
className?: string; className?: string;
} }
export default function ProgressBar(props: ProgressBarProps) { export default function ProgressBar(props: ProgressBarProps) {
const { current, duration, hidden, className = '' } = props; const { now = 0, complete = 100, hidden, className = '' } = props;
const progress = getProgress(current, duration);
const percentComplete = clamp(100 - (Math.max(now, 0) * 100) / complete, 0, 100);
return ( return (
<div className={`progress-bar__bg ${hidden ? 'progress-bar__bg--hidden' : ''} ${className}`}> <div className={`progress-bar__bg ${hidden ? 'progress-bar__bg--hidden' : ''} ${className}`}>
<div className='progress-bar__indicator' style={{ width: `${progress}%` }} /> <div className='progress-bar__indicator' style={{ width: `${percentComplete}%` }} />
</div> </div>
); );
} }
@@ -1,6 +1,5 @@
import { PropsWithChildren, useCallback, useContext } from 'react'; import { PropsWithChildren, useCallback, useContext } from 'react';
import { isLocalhost } from '../../api/constants';
import { AppContext } from '../../context/AppContext'; import { AppContext } from '../../context/AppContext';
import PinPage from './PinPage'; import PinPage from './PinPage';
@@ -10,6 +9,7 @@ interface ProtectRouteProps {
} }
export default function ProtectRoute({ permission, children }: PropsWithChildren<ProtectRouteProps>) { export default function ProtectRoute({ permission, children }: PropsWithChildren<ProtectRouteProps>) {
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
const { editorAuth, operatorAuth, validate } = useContext(AppContext); const { editorAuth, operatorAuth, validate } = useContext(AppContext);
const handleValidation = useCallback( const handleValidation = useCallback(
@@ -29,7 +29,7 @@ export default function ProtectRoute({ permission, children }: PropsWithChildren
return false; return false;
}; };
if (isLocalhost || hasRelevantAuth()) { if (isLocal || hasRelevantAuth()) {
// eslint-disable-next-line react/jsx-no-useless-fragment -- trying to make typescript happy // eslint-disable-next-line react/jsx-no-useless-fragment -- trying to make typescript happy
return <>{children}</>; return <>{children}</>;
} }
@@ -1,27 +0,0 @@
.inline {
display: flex;
align-items: center;
gap: 1rem;
}
// attempt to match with ontimeTextInputs
.input {
color: $gray-200;
border: 1px solid transparent;
border-radius: 3px;
background-color: $gray-1200;
padding: 0 1rem;
font-size: 1rem;
height: 2.5rem;
outline: none;
&:hover {
background-color: $gray-1100;
}
&:focus {
background-color: $gray-1000;
color: $gray-50;
border: 1px solid $blue-500;
}
}
@@ -1,33 +0,0 @@
import { useState } from 'react';
import PopoverPicker from '../input/popover-picker/PopoverPicker';
import style from './InlineColourPicker.module.scss';
interface InlineColourPickerProps {
name: string;
value: string;
}
const ensureHex = (value: string) => {
if (!value.startsWith('#')) {
return `#${value}`;
}
return value;
};
export default function InlineColourPicker(props: InlineColourPickerProps) {
const { name, value } = props;
const [colour, setColour] = useState(() => ensureHex(value));
const debouncedChange = (value: string) => {
setColour(value);
};
return (
<div className={style.inline}>
<PopoverPicker color={colour} onChange={debouncedChange} />
<input type='hidden' name={name} value={colour} />
</div>
);
}
@@ -16,7 +16,6 @@ import {
import { isStringBoolean } from '../../../features/viewers/common/viewUtils'; import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import InlineColourPicker from './InlineColourPicker';
import { ParamField } from './types'; import { ParamField } from './types';
interface EditFormInputProps { interface EditFormInputProps {
@@ -24,8 +23,8 @@ interface EditFormInputProps {
} }
export default function ParamInput(props: EditFormInputProps) { export default function ParamInput(props: EditFormInputProps) {
const { paramField } = props;
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { paramField } = props;
const { id, type, defaultValue } = paramField; const { id, type, defaultValue } = paramField;
if (type === 'persist') { if (type === 'persist') {
@@ -82,12 +81,6 @@ export default function ParamInput(props: EditFormInputProps) {
); );
} }
if (type === 'colour') {
const currentvalue = `#${searchParams.get(id) ?? defaultValue}`;
return <InlineColourPicker name={id} value={currentvalue} />;
}
const defaultStringValue = searchParams.get(id) ?? defaultValue; const defaultStringValue = searchParams.get(id) ?? defaultValue;
const { prefix, placeholder } = paramField; const { prefix, placeholder } = paramField;
@@ -19,16 +19,6 @@ import style from './ViewParamsEditor.module.scss';
type ViewParamsObj = { [key: string]: string | FormDataEntryValue }; 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 * Makes a new URLSearchParams object from the given params object
*/ */
@@ -50,13 +40,8 @@ const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOp
// compare which values are different from the default values // compare which values are different from the default values
Object.entries(paramsObj).forEach(([id, value]) => { Object.entries(paramsObj).forEach(([id, value]) => {
if (typeof value === 'string' && value.length) { if (typeof value === 'string' && value.length && defaultValues[id] !== value) {
// we dont know which values contain colours newSearchParams.set(id, value);
// unfortunately this means we run all the strings through the sanitation
const valueWithoutHash = sanitiseColour(value);
if (defaultValues[id] !== valueWithoutHash) {
newSearchParams.set(id, valueWithoutHash);
}
} }
}); });
return newSearchParams; return newSearchParams;
@@ -66,7 +51,6 @@ interface EditFormDrawerProps {
viewOptions: ViewOption[]; viewOptions: ViewOption[];
} }
// TODO: this is a good candidate for memoisation, but needs the paramFields to be stable
export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) { export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { isOpen, onClose, onOpen } = useDisclosure(); const { isOpen, onClose, onOpen } = useDisclosure();
@@ -23,11 +23,10 @@ type MultiOptionsField = {
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string }; type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
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 PersistedField = { type: 'persist'; defaultValue?: string; value: string }; type PersistedField = { type: 'persist'; defaultValue?: string; value: string };
export type ParamField = BaseField & export type ParamField = BaseField &
(OptionsField | MultiOptionsField | StringField | NumberField | BooleanField | ColourField | PersistedField); (StringField | BooleanField | NumberField | OptionsField | MultiOptionsField | PersistedField);
export type ViewOption = ParamSection | ParamField; export type ViewOption = ParamSection | ParamField;
/** /**
@@ -1,33 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { dayInMs } from 'ontime-utils';
import { version } from '../../../../../package.json';
import { APP_VERSION, isLocalhost } from '../api/constants';
import { getLatestVersion, HasUpdate } from '../api/external';
const placeholder: HasUpdate & { hasUpdates: boolean } = { url: '', version: '', hasUpdates: false };
export default function useAppVersion() {
const {
data: fetchData,
status,
isFetching,
isError,
refetch,
} = useQuery({
queryKey: APP_VERSION,
queryFn: getLatestVersion,
placeholderData: (previousData, _previousQuery) => previousData,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
staleTime: dayInMs,
enabled: isLocalhost,
});
const hasUpdates = fetchData?.version && !fetchData.version.includes(version);
const data = fetchData ? { ...fetchData, hasUpdates } : placeholder;
return { data, placeholder, status, isFetching, isError, refetch };
}
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { CustomFields } from 'ontime-types'; import { CustomFields } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchInterval } from '../../ontimeConfig';
import { CUSTOM_FIELDS } from '../api/constants'; import { CUSTOM_FIELDS } from '../api/constants';
import { getCustomFields } from '../api/customFields'; import { getCustomFields } from '../api/customFields';
@@ -14,7 +14,7 @@ export default function useCustomFields() {
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchInterval,
networkMode: 'always', networkMode: 'always',
}); });
@@ -3,7 +3,7 @@ import { GetInfo } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { APP_INFO } from '../api/constants'; import { APP_INFO } from '../api/constants';
import { getInfo } from '../api/session'; import { getInfo } from '../api/db';
import { ontimePlaceholderInfo } from '../models/Info'; import { ontimePlaceholderInfo } from '../models/Info';
export default function useInfo() { export default function useInfo() {
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types'; import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchInterval } from '../../ontimeConfig';
import { RUNDOWN } from '../api/constants'; import { RUNDOWN } from '../api/constants';
import { fetchNormalisedRundown } from '../api/rundown'; import { fetchNormalisedRundown } from '../api/rundown';
@@ -16,7 +16,7 @@ export default function useRundown() {
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchInterval,
networkMode: 'always', networkMode: 'always',
}); });
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching }; return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
@@ -12,7 +12,9 @@ export const useClientPath = () => {
// notify of client path changes // notify of client path changes
useEffect(() => { useEffect(() => {
socketSendJson('set-client-path', pathname + search); //remove leading '/' from path
const fullPath = (pathname.startsWith('/') ? pathname.slice(1) : pathname) + search;
socketSendJson('set-client-path', fullPath);
}, [pathname, search]); }, [pathname, search]);
// navigate to new path when received from server // navigate to new path when received from server
@@ -1,34 +1,11 @@
import { useCallback, useEffect } from 'react'; export default function useElectronEvent() {
import { useNavigate } from 'react-router-dom'; const isElectron = window?.process?.type === 'renderer';
const isElectron = window.process?.type === 'renderer'; const sendToElectron = (channel: string, args?: string | Record<string, unknown>) => {
const ipcRenderer = isElectron ? window.require('electron').ipcRenderer : null; if (isElectron) {
window?.ipcRenderer.send(channel, args);
export function useElectronEvent() {
const sendToElectron = useCallback((channel: string, args?: string | Record<string, unknown>) => {
if (isElectron && ipcRenderer) {
ipcRenderer.send(channel, args);
} }
}, []); };
return { isElectron, sendToElectron }; return { isElectron, sendToElectron };
} }
export function useElectronListener() {
const navigate = useNavigate();
const { isElectron } = useElectronEvent();
// listen to requests to change the editor location
useEffect(() => {
if (isElectron) {
ipcRenderer.on('request-editor-location', (_event: unknown, location: string) => {
navigate(location, { relative: 'route' });
});
}
// Clean the listener after the component is dismounted
return () => {
ipcRenderer?.removeAllListeners();
};
}, [isElectron, navigate]);
}
+55 -40
View File
@@ -1,7 +1,15 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { isOntimeEvent, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types'; import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils'; import {
dayInMs,
getLinkedTimes,
getPreviousEventNormal,
MILLIS_PER_SECOND,
parseUserTime,
reorderArray,
swapEventData,
} from 'ontime-utils';
import { RUNDOWN } from '../api/constants'; import { RUNDOWN } from '../api/constants';
import { import {
@@ -24,16 +32,7 @@ import { useEditorSettings } from '../stores/editorSettings';
*/ */
export const useEventAction = () => { export const useEventAction = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { const { defaultPublic, linkPrevious, defaultDuration, defaultWarnTime, defaultDangerTime } = useEditorSettings();
defaultPublic,
linkPrevious,
defaultTimeStrategy,
defaultDuration,
defaultWarnTime,
defaultDangerTime,
defaultTimerType,
defaultEndAction,
} = useEditorSettings();
/** /**
* Calls mutation to add new event * Calls mutation to add new event
@@ -47,17 +46,17 @@ export const useEventAction = () => {
networkMode: 'always', networkMode: 'always',
}); });
// options to any new block (event / delay / block)
type BaseOptions = { type BaseOptions = {
after?: string; after?: string;
}; };
// options to blocks of type OntimeEvent
type EventOptions = BaseOptions & type EventOptions = BaseOptions &
Partial<{ Partial<{
defaultPublic: boolean; defaultPublic: boolean;
linkPrevious: boolean; linkPrevious: boolean;
lastEventId: string; lastEventId: string;
defaultWarnTime: number;
defaultDangerTime: number;
}>; }>;
/** /**
@@ -69,12 +68,13 @@ export const useEventAction = () => {
// ************* CHECK OPTIONS specific to events // ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEvent)) { if (isOntimeEvent(newEvent)) {
// merge creation time options with event settings
const applicationOptions = { const applicationOptions = {
after: options?.after, after: options?.after,
defaultPublic: options?.defaultPublic ?? defaultPublic, defaultPublic: options?.defaultPublic ?? defaultPublic,
lastEventId: options?.lastEventId, lastEventId: options?.lastEventId,
linkPrevious: options?.linkPrevious ?? linkPrevious, linkPrevious: options?.linkPrevious ?? linkPrevious,
defaultWarnTime,
defaultDangerTime,
}; };
if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) { if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) {
@@ -89,7 +89,6 @@ export const useEventAction = () => {
} }
} }
// Override event with options from editor settings
if (applicationOptions.defaultPublic) { if (applicationOptions.defaultPublic) {
newEvent.isPublic = true; newEvent.isPublic = true;
} }
@@ -105,18 +104,6 @@ export const useEventAction = () => {
if (newEvent.timeWarning === undefined) { if (newEvent.timeWarning === undefined) {
newEvent.timeWarning = parseUserTime(defaultWarnTime); newEvent.timeWarning = parseUserTime(defaultWarnTime);
} }
if (newEvent.timerType === undefined) {
newEvent.timerType = defaultTimerType;
}
if (newEvent.endAction === undefined) {
newEvent.endAction = defaultEndAction;
}
if (newEvent.timeStrategy === undefined) {
newEvent.timeStrategy = defaultTimeStrategy;
}
} }
// handle adding options that concern all event type // handle adding options that concern all event type
@@ -130,18 +117,7 @@ export const useEventAction = () => {
logAxiosError('Failed adding event', error); logAxiosError('Failed adding event', error);
} }
}, },
[ [_addEventMutation, defaultDangerTime, defaultDuration, defaultPublic, defaultWarnTime, linkPrevious, queryClient],
_addEventMutation,
defaultDangerTime,
defaultDuration,
defaultEndAction,
defaultPublic,
defaultTimerType,
defaultTimeStrategy,
defaultWarnTime,
linkPrevious,
queryClient,
],
); );
/** /**
@@ -262,6 +238,44 @@ export const useEventAction = () => {
[_updateEventMutation, queryClient], [_updateEventMutation, queryClient],
); );
/**
* Toggles link of an event to the previous
*/
const linkTimer = useCallback(
async (eventId: string, linkStart: MaybeString) => {
let newEvent: Partial<OntimeEvent> = { id: eventId };
if (!linkStart) {
newEvent.linkStart = null;
} else {
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (!cachedRundown) {
return;
}
const currentEvent = cachedRundown.rundown[eventId] as OntimeEvent;
if (!isOntimeEvent(currentEvent)) {
return;
}
const { previousEvent } = getPreviousEventNormal(cachedRundown.rundown, cachedRundown.order, eventId);
if (!previousEvent) {
newEvent.linkStart = null;
} else {
newEvent.linkStart = previousEvent.id;
const timePatch = getLinkedTimes(currentEvent, previousEvent);
newEvent = { ...newEvent, ...timePatch };
}
}
try {
await _updateEventMutation.mutateAsync(newEvent);
} catch (error) {
logAxiosError('Error updating event', error);
}
},
[_updateEventMutation, queryClient],
);
/** /**
* Calls mutation to edit multiple events * Calls mutation to edit multiple events
* @private * @private
@@ -568,6 +582,7 @@ export const useEventAction = () => {
batchUpdateEvents, batchUpdateEvents,
deleteEvent, deleteEvent,
deleteAllEvents, deleteAllEvents,
linkTimer,
reorderEvent, reorderEvent,
swapEvents, swapEvents,
updateEvent, updateEvent,
+159
View File
@@ -0,0 +1,159 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
export type TLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'none';
export type TOptions = {
logLevel?: TLogLevel;
maxFontSize?: number;
minFontSize?: number;
onFinish?: (fontSize: number) => void;
onStart?: () => void;
resolution?: number;
};
const LOG_LEVEL: Record<TLogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40,
none: 100,
};
const useFitText = ({
logLevel: logLevelOption = 'info',
maxFontSize = 100,
minFontSize = 20,
onFinish,
onStart,
resolution = 5,
}: TOptions = {}) => {
const logLevel = LOG_LEVEL[logLevelOption];
const initState = useCallback(() => {
return {
calcKey: 0,
fontSize: maxFontSize,
fontSizePrev: minFontSize,
fontSizeMax: maxFontSize,
fontSizeMin: minFontSize,
};
}, [maxFontSize, minFontSize]);
const ref = useRef<HTMLDivElement>(null);
const innerHtmlPrevRef = useRef<string | null>();
const isCalculatingRef = useRef(false);
const [state, setState] = useState(initState);
const { calcKey, fontSize, fontSizeMax, fontSizeMin, fontSizePrev } = state;
// Monitor div size changes and recalculate on resize
let animationFrameId: number | null = null;
const [ro] = useState(
() =>
new ResizeObserver(() => {
animationFrameId = window.requestAnimationFrame(() => {
if (isCalculatingRef.current) {
return;
}
onStart && onStart();
isCalculatingRef.current = true;
// `calcKey` is used in the dependencies array of
// `useIsoLayoutEffect` below. It is incremented so that the font size
// will be recalculated even if the previous state didn't change (e.g.
// when the text fit initially).
setState({
...initState(),
calcKey: calcKey + 1,
});
});
}),
);
useEffect(() => {
if (ref.current) {
ro.observe(ref.current);
}
return () => {
animationFrameId && window.cancelAnimationFrame(animationFrameId);
ro.disconnect();
};
}, [animationFrameId, ro]);
// Recalculate when the div contents change
const innerHtml = ref.current && ref.current.innerHTML;
useEffect(() => {
if (calcKey === 0 || isCalculatingRef.current) {
return;
}
if (innerHtml !== innerHtmlPrevRef.current) {
onStart && onStart();
setState({
...initState(),
calcKey: calcKey + 1,
});
}
innerHtmlPrevRef.current = innerHtml;
}, [calcKey, initState, innerHtml, onStart]);
// Check overflow and resize font
useLayoutEffect(() => {
// Don't start calculating font size until the `resizeKey` is incremented
// above in the `ResizeObserver` callback. This avoids an extra resize
// on initialization.
if (calcKey === 0) {
return;
}
const isWithinResolution = Math.abs(fontSize - fontSizePrev) <= resolution;
const isOverflow =
!!ref.current &&
(ref.current.scrollHeight > ref.current.offsetHeight || ref.current.scrollWidth > ref.current.offsetWidth);
const isFailed = isOverflow && fontSize === fontSizePrev;
const isAsc = fontSize > fontSizePrev;
// Return if the font size has been adjusted "enough" (change within `resolution`)
// reduce font size by one increment if it's overflowing.
if (isWithinResolution) {
if (isFailed) {
isCalculatingRef.current = false;
if (logLevel <= LOG_LEVEL.info) {
console.info(`[use-fit-text] reached \`minFontSize = ${minFontSize}\` without fitting text`);
}
} else if (isOverflow) {
setState({
fontSize: isAsc ? fontSizePrev : fontSizeMin,
fontSizeMax,
fontSizeMin,
fontSizePrev,
calcKey,
});
} else {
isCalculatingRef.current = false;
onFinish && onFinish(fontSize);
}
return;
}
// Binary search to adjust font size
let delta: number;
let newMax = fontSizeMax;
let newMin = fontSizeMin;
if (isOverflow) {
delta = isAsc ? fontSizePrev - fontSize : fontSizeMin - fontSize;
newMax = Math.min(fontSizeMax, fontSize);
} else {
delta = isAsc ? fontSizeMax - fontSize : fontSizePrev - fontSize;
newMin = Math.max(fontSizeMin, fontSize);
}
setState({
calcKey,
fontSize: fontSize + delta / 2,
fontSizeMax: newMax,
fontSizeMin: newMin,
fontSizePrev: fontSize,
});
}, [calcKey, fontSize, fontSizeMax, fontSizeMin, fontSizePrev, onFinish, ref, resolution]);
return { fontSize: `${fontSize}%`, ref };
};
export default useFitText;
+6 -53
View File
@@ -1,4 +1,4 @@
import { RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types'; import { RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
import { useRuntimeStore } from '../stores/runtime'; import { useRuntimeStore } from '../stores/runtime';
import { socketSendJson } from '../utils/socket'; import { socketSendJson } from '../utils/socket';
@@ -28,43 +28,11 @@ export const useOperator = () => {
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
}; };
export const useTimerViewControl = () => { export const useMessageControl = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
blackout: state.message.timer.blackout, timer: state.message.timer,
blink: state.message.timer.blink, external: state.message.external,
secondarySource: state.message.timer.secondarySource, onAir: state.onAir,
});
return useRuntimeStore(featureSelector);
};
export const useTimerMessageInput = () => {
const featureSelector = (state: RuntimeStore) => ({
text: state.message.timer.text,
visible: state.message.timer.visible,
});
return useRuntimeStore(featureSelector);
};
export const useExternalMessageInput = () => {
const featureSelector = (state: RuntimeStore) => ({
text: state.message.external,
visible: state.message.timer.secondarySource === 'external',
});
return useRuntimeStore(featureSelector);
};
export const useMessagePreview = () => {
const featureSelector = (state: RuntimeStore) => ({
blink: state.message.timer.blink,
blackout: state.message.timer.blackout,
phase: state.timer.phase,
showAuxTimer: state.message.timer.secondarySource === 'aux',
showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external),
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
timerType: state.eventNow?.timerType ?? null,
}); });
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
@@ -73,11 +41,8 @@ export const useMessagePreview = () => {
export const setMessage = { export const setMessage = {
timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }), timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }),
timerVisible: (payload: boolean) => socketSendJson('message', { timer: { visible: payload } }), timerVisible: (payload: boolean) => socketSendJson('message', { timer: { visible: payload } }),
externalText: (payload: string) => socketSendJson('message', { external: payload }),
timerBlink: (payload: boolean) => socketSendJson('message', { timer: { blink: payload } }), timerBlink: (payload: boolean) => socketSendJson('message', { timer: { blink: payload } }),
timerBlackout: (payload: boolean) => socketSendJson('message', { timer: { blackout: payload } }), timerBlackout: (payload: boolean) => socketSendJson('message', { timer: { blackout: payload } }),
timerSecondary: (payload: TimerMessage['secondarySource']) =>
socketSendJson('message', { timer: { secondarySource: payload } }),
}; };
export const usePlaybackControl = () => { export const usePlaybackControl = () => {
@@ -85,7 +50,6 @@ export const usePlaybackControl = () => {
playback: state.timer.playback, playback: state.timer.playback,
selectedEventIndex: state.runtime.selectedEventIndex, selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents, numEvents: state.runtime.numEvents,
timerPhase: state.timer.phase,
}); });
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
@@ -151,7 +115,6 @@ export const setAuxTimer = {
export const useCuesheet = () => { export const useCuesheet = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback, playback: state.timer.playback,
currentBlockId: state.currentBlock.block?.id ?? null,
selectedEventId: state.eventNow?.id ?? null, selectedEventId: state.eventNow?.id ?? null,
selectedEventIndex: state.runtime.selectedEventIndex, selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents, numEvents: state.runtime.numEvents,
@@ -186,6 +149,7 @@ export const useClock = () => {
/** Used by the progress bar components */ /** Used by the progress bar components */
export const useProgressData = () => { export const useProgressData = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
addedTime: state.timer.addedTime,
current: state.timer.current, current: state.timer.current,
duration: state.timer.duration, duration: state.timer.duration,
timeWarning: state.eventNow?.timeWarning ?? null, timeWarning: state.eventNow?.timeWarning ?? null,
@@ -215,17 +179,6 @@ export const useRuntimePlaybackOverview = () => {
numEvents: state.runtime.numEvents, numEvents: state.runtime.numEvents,
selectedEventIndex: state.runtime.selectedEventIndex, selectedEventIndex: state.runtime.selectedEventIndex,
offset: state.runtime.offset, offset: state.runtime.offset,
currentBlock: state.currentBlock,
});
return useRuntimeStore(featureSelector);
};
export const useTimelineStatus = () => {
const featureSelector = (state: RuntimeStore) => ({
clock: state.clock,
offset: state.runtime.offset,
}); });
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
+1 -1
View File
@@ -7,5 +7,5 @@ export const ontimePlaceholderInfo: GetInfo = {
version: '2.0.0', version: '2.0.0',
serverPort: 4001, serverPort: 4001,
osc: oscPlaceholderSettings, osc: oscPlaceholderSettings,
publicDir: '', cssOverride: '',
}; };
@@ -1,5 +1,3 @@
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
import { validateEndAction, validateTimerType, validateTimeStrategy } from 'ontime-utils';
import { create } from 'zustand'; import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage'; import { booleanFromLocalStorage } from '../utils/localStorage';
@@ -7,63 +5,39 @@ import { booleanFromLocalStorage } from '../utils/localStorage';
type EditorSettingsStore = { type EditorSettingsStore = {
defaultDuration: string; defaultDuration: string;
linkPrevious: boolean; linkPrevious: boolean;
defaultTimeStrategy: TimeStrategy;
defaultWarnTime: string; defaultWarnTime: string;
defaultDangerTime: string; defaultDangerTime: string;
defaultPublic: boolean; defaultPublic: boolean;
defaultTimerType: TimerType;
defaultEndAction: EndAction;
setDefaultDuration: (defaultDuration: string) => void; setDefaultDuration: (defaultDuration: string) => void;
setLinkPrevious: (linkPrevious: boolean) => void; setLinkPrevious: (linkPrevious: boolean) => 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; setDefaultPublic: (defaultPublic: boolean) => void;
setDefaultTimerType: (defaultTimerType: TimerType) => void;
setDefaultEndAction: (defaultEndAction: EndAction) => void;
}; };
export const editorSettingsDefaults = { export const editorSettingsDefaults = {
duration: '00:10:00', duration: '00:10:00',
linkPrevious: true, linkPrevious: true,
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, isPublic: true,
timerType: TimerType.CountDown,
endAction: EndAction.None,
}; };
enum EditorSettingsKeys { enum EditorSettingsKeys {
DefaultDuration = 'ontime-default-duration', DefaultDuration = 'ontime-default-duration',
LinkPrevious = 'ontime-link-previous', LinkPrevious = 'ontime-link-previous',
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', DefaultPublic = 'ontime-default-public',
DefaultTimerType = 'ontime-default-timer-type',
DefaultEndAction = 'ontime-default-end-action',
} }
export const useEditorSettings = create<EditorSettingsStore>((set) => { export const useEditorSettings = create<EditorSettingsStore>((set) => {
return { return {
defaultDuration: localStorage.getItem(EditorSettingsKeys.DefaultDuration) ?? editorSettingsDefaults.duration, defaultDuration: localStorage.getItem(EditorSettingsKeys.DefaultDuration) ?? editorSettingsDefaults.duration,
linkPrevious: booleanFromLocalStorage(EditorSettingsKeys.LinkPrevious, editorSettingsDefaults.linkPrevious), linkPrevious: booleanFromLocalStorage(EditorSettingsKeys.LinkPrevious, editorSettingsDefaults.linkPrevious),
defaultTimeStrategy: validateTimeStrategy(
localStorage.getItem(EditorSettingsKeys.DefaultTimeStrategy),
editorSettingsDefaults.timeStrategy,
),
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), defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, editorSettingsDefaults.isPublic),
defaultTimerType: validateTimerType(
localStorage.getItem(EditorSettingsKeys.DefaultTimerType),
editorSettingsDefaults.timerType,
),
defaultEndAction: validateEndAction(
localStorage.getItem(EditorSettingsKeys.DefaultEndAction),
editorSettingsDefaults.endAction,
),
setDefaultDuration: (defaultDuration) => setDefaultDuration: (defaultDuration) =>
set(() => { set(() => {
@@ -76,12 +50,6 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(linkPrevious)); localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(linkPrevious));
return { linkPrevious }; return { linkPrevious };
}), }),
setTimeStrategy: (defaultTimeStrategy) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.DefaultTimeStrategy, String(defaultTimeStrategy));
return { defaultTimeStrategy };
}),
setWarnTime: (defaultWarnTime) => setWarnTime: (defaultWarnTime) =>
set(() => { set(() => {
localStorage.setItem(EditorSettingsKeys.DefaultWarnTime, String(defaultWarnTime)); localStorage.setItem(EditorSettingsKeys.DefaultWarnTime, String(defaultWarnTime));
@@ -97,15 +65,5 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic)); localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
return { defaultPublic }; return { defaultPublic };
}), }),
setDefaultTimerType: (defaultTimerType) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.DefaultTimerType, String(defaultTimerType));
return { defaultTimerType };
}),
setDefaultEndAction: (defaultEndAction) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.DefaultEndAction, String(defaultEndAction));
return { defaultEndAction };
}),
}; };
}); });
+49 -1
View File
@@ -1,7 +1,55 @@
import isEqual from 'react-fast-compare'; import isEqual from 'react-fast-compare';
import { RuntimeStore, runtimeStorePlaceholder } from 'ontime-types'; import { Playback, RuntimeStore, SimpleDirection, SimplePlayback, TimerPhase } from 'ontime-types';
import { createWithEqualityFn, useStoreWithEqualityFn } from 'zustand/traditional'; import { createWithEqualityFn, useStoreWithEqualityFn } from 'zustand/traditional';
export const runtimeStorePlaceholder: RuntimeStore = {
clock: 0,
timer: {
addedTime: 0,
current: null,
duration: null,
elapsed: null,
expectedFinish: null,
finishedAt: null,
phase: TimerPhase.None,
playback: Playback.Stop,
secondaryTimer: null,
startedAt: null,
},
onAir: false,
message: {
timer: {
text: '',
visible: false,
blink: false,
blackout: false,
},
external: {
text: '',
visible: false,
},
},
runtime: {
selectedEventIndex: null,
numEvents: 0,
offset: 0,
plannedStart: 0,
plannedEnd: 0,
actualStart: null,
expectedEnd: null,
},
eventNow: null,
eventNext: null,
publicEventNow: null,
publicEventNext: null,
auxtimer1: {
current: 0,
direction: SimpleDirection.CountUp,
duration: 0,
playback: SimplePlayback.Stop,
},
};
const deepCompare = <T>(a: T, b: T) => isEqual(a, b); const deepCompare = <T>(a: T, b: T) => isEqual(a, b);
export const runtimeStore = createWithEqualityFn<RuntimeStore>( export const runtimeStore = createWithEqualityFn<RuntimeStore>(
@@ -1,23 +0,0 @@
import { MaybeNumber } from 'ontime-types';
import { clamp } from './math';
/**
* Returns completion percentage of a progress bar
* This code assumes the current time and duration have addedTime already applied
*/
export function getProgress(current: MaybeNumber, duration: MaybeNumber) {
if (current === null || duration === null) {
return 0;
}
if (current <= 0) {
return 100;
}
if (current >= duration) {
return 0;
}
return clamp(((duration - current) / duration) * 100, 0, 100);
}
+3 -20
View File
@@ -1,6 +1,6 @@
import { Log, RundownCached, RuntimeStore } from 'ontime-types'; import { Log, RuntimeStore } from 'ontime-types';
import { CLIENT_LIST, CUSTOM_FIELDS, isProduction, RUNDOWN, RUNTIME, websocketUrl } from '../api/constants'; import { CLIENT_LIST, isProduction, RUNTIME, websocketUrl } from '../api/constants';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
import { import {
getClientId, getClientId,
@@ -36,8 +36,7 @@ export const connectSocket = () => {
} }
socketSendJson('set-client-type', 'ontime'); socketSendJson('set-client-type', 'ontime');
socketSendJson('set-client-path', location.pathname);
socketSendJson('set-client-path', location.pathname + location.search);
}; };
websocket.onclose = () => { websocket.onclose = () => {
@@ -151,11 +150,6 @@ export const connectSocket = () => {
updateDevTools({ eventNow: payload }); updateDevTools({ eventNow: payload });
break; break;
} }
case 'ontime-currentBlock': {
patchRuntime('currentBlock', payload);
updateDevTools({ currentBlock: payload });
break;
}
case 'ontime-publicEventNow': { case 'ontime-publicEventNow': {
patchRuntime('publicEventNow', payload); patchRuntime('publicEventNow', payload);
updateDevTools({ publicEventNow: payload }); updateDevTools({ publicEventNow: payload });
@@ -176,17 +170,6 @@ export const connectSocket = () => {
updateDevTools({ auxtimer1: payload }); updateDevTools({ auxtimer1: payload });
break; break;
} }
case 'ontime-refetch': {
// the refetch message signals that the rundown has changed in the server side
const { revision } = payload;
const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1;
if (revision > currentRevision) {
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
}
break;
}
} }
} catch (_) { } catch (_) {
// ignore unhandled // ignore unhandled
@@ -34,16 +34,3 @@ export const enDash = '';
export const timerPlaceholder = '––:––:––'; export const timerPlaceholder = '––:––:––';
export const timerPlaceholderMin = '––:––'; export const timerPlaceholderMin = '––:––';
/**
* Adds opacity to a given colour if possible
*/
export function alpha(colour: string, amount: number): string {
try {
const withAlpha = Color(colour).alpha(amount).hexa();
return withAlpha;
} catch (_error) {
/* we do not handle errors here */
}
return colour;
}
+7 -37
View File
@@ -1,25 +1,25 @@
import { MaybeNumber, Settings, TimeFormat } from 'ontime-types'; import { MaybeNumber, Settings, TimeFormat } from 'ontime-types';
import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils'; import { formatFromMillis } from 'ontime-utils';
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig'; import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
import { APP_SETTINGS } from '../api/constants'; import { APP_SETTINGS } from '../api/constants';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
/** /**
* Returns current time in milliseconds from midnight * Returns current time in milliseconds
* @returns {number} * @returns {number}
*/ */
export function nowInMillis(): number { export const nowInMillis = () => {
const now = new Date(); const now = new Date();
// extract milliseconds since midnight // extract milliseconds since midnight
let elapsed = now.getHours() * MILLIS_PER_HOUR; let elapsed = now.getHours() * 3600000;
elapsed += now.getMinutes() * MILLIS_PER_MINUTE; elapsed += now.getMinutes() * 60000;
elapsed += now.getSeconds() * MILLIS_PER_SECOND; elapsed += now.getSeconds() * 1000;
elapsed += now.getMilliseconds(); elapsed += now.getMilliseconds();
return elapsed; return elapsed;
} };
/** /**
* @description Resolves format from url and store * @description Resolves format from url and store
@@ -95,33 +95,3 @@ export const formatTime = (
const isNegative = milliseconds < 0; const isNegative = milliseconds < 0;
return `${isNegative ? '-' : ''}${display}`; return `${isNegative ? '-' : ''}${display}`;
}; };
/**
* Handles case for formatting a duration time
* @param duration
* @returns
*/
export function formatDuration(duration: number, hideSeconds = true): string {
// durations should never be negative, we handle it here to flag if there is an issue in future
if (duration <= 0) {
return '0h 0m';
}
const hours = Math.floor(duration / MILLIS_PER_HOUR);
const minutes = Math.floor((duration % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE);
let result = '';
if (hours > 0) {
result += `${hours}h`;
}
if (minutes > 0) {
result += `${minutes}m`;
}
if (!hideSeconds) {
const seconds = Math.floor((duration % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND);
if (seconds > 0) {
result += `${seconds}s`;
}
}
return result;
}
-1
View File
@@ -3,4 +3,3 @@ export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/rele
export const websiteUrl = 'https://www.getontime.no'; export const websiteUrl = 'https://www.getontime.no';
export const documentationUrl = 'https://docs.getontime.no'; export const documentationUrl = 'https://docs.getontime.no';
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
@@ -18,7 +18,7 @@ import useAppSettingsNavigation from './useAppSettingsNavigation';
import style from './AppSettings.module.scss'; import style from './AppSettings.module.scss';
export default function AppSettings() { export default function AppSettings() {
const { close, panel, location, setLocation } = useAppSettingsNavigation(); const { close, panel, location } = useAppSettingsNavigation();
useKeyDown(close, 'Escape'); useKeyDown(close, 'Escape');
return ( return (
@@ -26,7 +26,7 @@ export default function AppSettings() {
<ErrorBoundary> <ErrorBoundary>
<PanelList selectedPanel={panel} location={location} /> <PanelList selectedPanel={panel} location={location} />
<PanelContent onClose={close}> <PanelContent onClose={close}>
{panel === 'project' && <ProjectPanel location={location} setLocation={setLocation} />} {panel === 'project' && <ProjectPanel location={location} />}
{panel === 'general' && <GeneralPanel location={location} />} {panel === 'general' && <GeneralPanel location={location} />}
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />} {panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
{panel === 'sources' && <SourcesPanel />} {panel === 'sources' && <SourcesPanel />}
@@ -40,16 +40,12 @@ ul {
background-color: $gray-1100; background-color: $gray-1100;
} }
&.highlight { &.unsaved::before {
color: $red-400; content: '';
width: 6px;
&::before { height: 6px;
content: ''; border-radius: 3px;
width: 0.5em; background-color: $blue-400;
height: 0.5em;
border-radius: 99px;
background-color: $red-400;
}
} }
&.split { &.split {
@@ -1,90 +1,66 @@
import { Fragment } from 'react'; import { Fragment } from 'react';
import { Tooltip } from '@chakra-ui/react';
import { isKeyEnter } from '../../../common/utils/keyEvent'; import { isKeyEnter } from '../../../common/utils/keyEvent';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayFast } from '../../../ontimeConfig'; import { PanelBaseProps, settingPanels, useSettingsStore } from '../settingsStore';
import { SettingsOption, SettingsOptionId, useAppSettingsMenu } from '../useAppSettingsMenu';
import useAppSettingsNavigation from '../useAppSettingsNavigation'; import useAppSettingsNavigation from '../useAppSettingsNavigation';
import style from './PanelList.module.scss'; import style from './PanelList.module.scss';
export interface PanelBaseProps {
location?: string;
}
interface PanelListProps extends PanelBaseProps { interface PanelListProps extends PanelBaseProps {
selectedPanel: string; selectedPanel: string;
} }
export default function PanelList({ selectedPanel, location }: PanelListProps) { export default function PanelList({ selectedPanel, location }: PanelListProps) {
const { options } = useAppSettingsMenu(); const { setLocation } = useAppSettingsNavigation();
const { hasUnsavedChanges } = useSettingsStore();
return ( return (
<ul className={style.tabs}> <ul className={style.tabs}>
{options.map((panel) => { {settingPanels.map((panel) => {
const isSelected = selectedPanel === panel.id; const unsaved = hasUnsavedChanges(panel.id);
if (panel.highlight) {
return ( const classes = cx([
<Tooltip key={panel.id} label={panel.highlight} openDelay={tooltipDelayFast} shouldWrapChildren> style.primary,
<PanelListItem panel={panel} location={location} isSelected={isSelected} /> selectedPanel === panel.id ? style.active : null,
</Tooltip> panel.split ? style.split : null,
); unsaved ? style.unsaved : null,
} ]);
return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />;
return (
<Fragment key={panel.id}>
<li
key={panel.id}
onClick={() => setLocation(panel.id)}
onKeyDown={(event) => {
isKeyEnter(event) && setLocation(panel.id);
}}
className={classes}
tabIndex={0}
role='button'
>
{panel.label}
</li>
{panel.secondary?.map((secondary) => {
const id = secondary.id.split('__')[1];
const secondaryClasses = cx([style.secondary, location === id ? style.active : null]);
return (
<li
key={secondary.id}
onClick={() => setLocation(secondary.id)}
onKeyDown={(event) => {
isKeyEnter(event) && setLocation(secondary.id);
}}
className={secondaryClasses}
role='button'
>
{secondary.label}
</li>
);
})}
</Fragment>
);
})} })}
</ul> </ul>
); );
} }
interface PanelListItemProps {
panel: SettingsOption;
isSelected: boolean;
location?: string;
}
function PanelListItem(props: PanelListItemProps) {
const { panel, isSelected, location } = props;
const { setLocation } = useAppSettingsNavigation();
const classes = cx([
style.primary,
isSelected && style.active,
panel.split && style.split,
panel.highlight && style.highlight,
]);
return (
<Fragment key={panel.id}>
<li
key={panel.id}
onClick={() => setLocation(panel.id as SettingsOptionId)}
onKeyDown={(event) => {
isKeyEnter(event) && setLocation(panel.id as SettingsOptionId);
}}
className={classes}
tabIndex={0}
role='button'
>
{panel.label}
</li>
{panel.secondary?.map((secondary) => {
const id = secondary.id.split('__')[1];
const secondaryClasses = cx([style.secondary, location === id ? style.active : null]);
return (
<li
key={secondary.id}
onClick={() => setLocation(secondary.id as SettingsOptionId)}
onKeyDown={(event) => {
isKeyEnter(event) && setLocation(secondary.id as SettingsOptionId);
}}
className={secondaryClasses}
role='button'
>
{secondary.label}
</li>
);
})}
</Fragment>
);
}
@@ -2,7 +2,7 @@ import { HTMLAttributes, ReactNode } from 'react';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import style from './PanelUtils.module.scss'; import style from './Panel.module.scss';
export function Header({ children }: { children: ReactNode }) { export function Header({ children }: { children: ReactNode }) {
return <h2 className={style.header}>{children}</h2>; return <h2 className={style.header}>{children}</h2>;
@@ -1,8 +1,9 @@
import { version } from '../../../../../package.json';
import ExternalLink from '../../../../common/components/external-link/ExternalLink'; import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import { documentationUrl, githubUrl, websiteUrl } from '../../../../externals'; import { documentationUrl, githubUrl, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import AppVersion from './AppVersion'; import CheckUpdatesButton from './CheckUpdatesButton';
export default function AboutPanel() { export default function AboutPanel() {
return ( return (
@@ -22,7 +23,8 @@ export default function AboutPanel() {
</Panel.Section> </Panel.Section>
<Panel.Section> <Panel.Section>
<Panel.SubHeader>Current version</Panel.SubHeader> <Panel.SubHeader>Current version</Panel.SubHeader>
<AppVersion /> <Panel.Paragraph>{`You are currently using Ontime ${version}`}</Panel.Paragraph>
<CheckUpdatesButton version={version} />
</Panel.Section> </Panel.Section>
</> </>
); );
@@ -1,31 +0,0 @@
import { version } from '../../../../../package.json';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import useAppVersion from '../../../../common/hooks-query/useAppVersion';
import * as Panel from '../../panel-utils/PanelUtils';
export default function AppVersion() {
const { data, isError } = useAppVersion();
if (isError) {
return (
<Panel.Paragraph>
{`You are currently using Ontime version ${version}`}
<Panel.Error>{`Could not fetch version information: ${isError}`}</Panel.Error>
</Panel.Paragraph>
);
}
if (data.hasUpdates) {
return (
<>
<Panel.Paragraph>{`You are currently using Ontime version ${version}.`}</Panel.Paragraph>
<Panel.Paragraph>
{`A new version ${data.version} is available.`}
<ExternalLink href={data.url}> Please visit the release page to download</ExternalLink>
</Panel.Paragraph>
</>
);
}
return <Panel.Paragraph>{`You are currently using the latest version of Ontime: ${version}`}</Panel.Paragraph>;
}
@@ -0,0 +1,80 @@
import { useState } from 'react';
import { Button } from '@chakra-ui/react';
import { getLatestVersion, HasUpdate } from '../../../../common/api/external';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import style from '../Panel.module.scss';
type CheckFail = {
error: string;
};
type CheckIsLatest = {
latest: true;
};
type CheckRemote = CheckFail | CheckIsLatest | HasUpdate;
interface CheckUpdatesButtonProps {
version: string;
}
export default function CheckUpdatesButton(props: CheckUpdatesButtonProps) {
const { version } = props;
const [updateMessage, setUpdateMessage] = useState<CheckRemote | null>(null);
const [isFetching, setIsFetching] = useState(false);
/**
* Handles version comparison and returns component with message
*/
const versionCheck = async () => {
setIsFetching(true);
try {
const latest = await getLatestVersion();
if (!latest.version.includes(version)) {
// new version, pass data to component
setUpdateMessage(latest);
} else {
setUpdateMessage({ latest: true });
}
} catch {
setUpdateMessage({ error: 'Error reaching server' });
} finally {
setIsFetching(false);
}
};
const disableButton = Boolean(updateMessage && 'version' in updateMessage);
return (
<>
<Button
onClick={versionCheck}
variant='ontime-filled'
isLoading={isFetching}
isDisabled={disableButton}
size='sm'
maxWidth='max-content'
>
Check for updates
</Button>
<ResolveUpdateMessage updateMessage={updateMessage} />
</>
);
}
function ResolveUpdateMessage(props: { updateMessage: CheckRemote | null }) {
const { updateMessage } = props;
if (updateMessage && 'error' in updateMessage) {
return <span className={style.error}>{updateMessage.error}</span>;
}
if (updateMessage && 'url' in updateMessage) {
return <ExternalLink href={updateMessage?.url}>{`New version available: ${updateMessage.version}`}</ExternalLink>;
}
return null;
}
@@ -1,4 +1,4 @@
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import ClientList from './ClientList'; import ClientList from './ClientList';
@@ -5,7 +5,7 @@ import { RedirectClientModal } from '../../../../common/components/client-modal/
import { RenameClientModal } from '../../../../common/components/client-modal/RenameClientModal'; import { RenameClientModal } from '../../../../common/components/client-modal/RenameClientModal';
import { setClientRemote } from '../../../../common/hooks/useSocket'; import { setClientRemote } from '../../../../common/hooks/useSocket';
import { useClientStore } from '../../../../common/stores/clientStore'; import { useClientStore } from '../../../../common/stores/clientStore';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import style from './ClientControlPanel.module.scss'; import style from './ClientControlPanel.module.scss';
@@ -1,7 +1,3 @@
.halfWidth {
width: 50%;
}
.fullWidth { .fullWidth {
width: 100%; width: 100%;
} }
@@ -41,9 +37,3 @@
.flex { .flex {
display: flex; display: flex;
} }
.twoCols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@@ -1,6 +1,6 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import type { PanelBaseProps } from '../../panel-list/PanelList'; import { PanelBaseProps } from '../../settingsStore';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import CustomFields from './custom-fields/CustomFields'; import CustomFields from './custom-fields/CustomFields';
import UrlPresetsForm from './UrlPresetsForm'; import UrlPresetsForm from './UrlPresetsForm';
@@ -13,7 +13,7 @@ import ExternalLink from '../../../../common/components/external-link/ExternalLi
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets'; import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
import { handleLinks } from '../../../../common/utils/linkUtils'; import { handleLinks } from '../../../../common/utils/linkUtils';
import { validateUrlPresetPath } from '../../../../common/utils/urlPresets'; import { validateUrlPresetPath } from '../../../../common/utils/urlPresets';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import style from './FeatureSettings.module.scss'; import style from './FeatureSettings.module.scss';
@@ -33,7 +33,7 @@ export default function UrlPresetsForm() {
setError, setError,
formState: { isSubmitting, isDirty, isValid, errors }, formState: { isSubmitting, isDirty, isValid, errors },
} = useForm<FormData>({ } = useForm<FormData>({
mode: 'onChange', mode: 'onBlur',
defaultValues: { data }, defaultValues: { data },
values: { data }, values: { data },
resetOptions: { resetOptions: {
@@ -78,7 +78,7 @@ export default function UrlPresetsForm() {
const addNew = () => { const addNew = () => {
prepend({ prepend({
enabled: true, enabled: false,
alias: '', alias: '',
pathAndParams: '', pathAndParams: '',
}); });
@@ -4,7 +4,6 @@ import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash'; import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { CustomField, CustomFieldLabel } from 'ontime-types'; import { CustomField, CustomFieldLabel } from 'ontime-types';
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
import Swatch from '../../../../../common/components/input/colour-input/Swatch'; import Swatch from '../../../../../common/components/input/colour-input/Swatch';
import CustomFieldForm from './CustomFieldForm'; import CustomFieldForm from './CustomFieldForm';
@@ -37,7 +36,6 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
onSubmit={handleEdit} onSubmit={handleEdit}
initialColour={colour} initialColour={colour}
initialLabel={label} initialLabel={label}
initialKey={field}
/> />
</td> </td>
</tr> </tr>
@@ -49,12 +47,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
<td> <td>
<Swatch color={colour} /> <Swatch color={colour} />
</td> </td>
<td className={style.halfWidth}>{label}</td> <td className={style.fullWidth}>{label}</td>
<td className={style.fullWidth}>
<CopyTag label='Copy key to use in integrations' copyValue={field}>
{field}
</CopyTag>
</td>
<td className={style.actions}> <td className={style.actions}>
<IconButton <IconButton
size='sm' size='sm'
@@ -2,12 +2,11 @@ import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { Button, Input } from '@chakra-ui/react'; import { Button, Input } from '@chakra-ui/react';
import { CustomField } from 'ontime-types'; import { CustomField } from 'ontime-types';
import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils'; import { isAlphanumeric } from 'ontime-utils';
import { maybeAxiosError } from '../../../../../common/api/utils'; import { maybeAxiosError } from '../../../../../common/api/utils';
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect'; import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields'; import * as Panel from '../../PanelUtils';
import * as Panel from '../../../panel-utils/PanelUtils';
import style from '../FeatureSettings.module.scss'; import style from '../FeatureSettings.module.scss';
@@ -16,13 +15,10 @@ interface CustomFieldsFormProps {
onCancel: () => void; onCancel: () => void;
initialColour?: string; initialColour?: string;
initialLabel?: string; initialLabel?: string;
initialKey?: string;
} }
export default function CustomFieldForm(props: CustomFieldsFormProps) { export default function CustomFieldForm(props: CustomFieldsFormProps) {
const { onSubmit, onCancel, initialColour, initialLabel, initialKey } = props; const { onSubmit, onCancel, initialColour, initialLabel } = props;
const { data } = useCustomFields();
// we use this to force an update // we use this to force an update
const [_, setColour] = useState(initialColour || ''); const [_, setColour] = useState(initialColour || '');
@@ -35,7 +31,7 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
getValues, getValues,
formState: { errors, isSubmitting, isValid, isDirty }, formState: { errors, isSubmitting, isValid, isDirty },
} = useForm({ } = useForm({
defaultValues: { label: initialLabel || '', colour: initialColour || '', key: initialKey || '' }, defaultValues: { label: initialLabel || '', colour: initialColour || '' },
resetOptions: { resetOptions: {
keepDirtyValues: true, keepDirtyValues: true,
}, },
@@ -67,45 +63,31 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
const colour = getValues('colour'); const colour = getValues('colour');
const canSubmit = isDirty && isValid; const canSubmit = isDirty && isValid;
// if initial values are given, we can assume we are in edit mode
const isEditMode = initialKey !== undefined;
return ( return (
<form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}> <form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}>
<div className={style.twoCols}> <div className={style.column}>
<div> <Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description> {errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>} <Input
<Input {...register('label', {
{...register('label', { required: { value: true, message: 'Required field' },
required: { value: true, message: 'Required field' }, validate: (value) => {
onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'), if (value.trim().length === 0) return 'Required field';
validate: (value) => { if (!isAlphanumeric(value)) return 'Only alphanumeric characters are allowed';
if (value.trim().length === 0) return 'Required field'; return true;
if (!isAlphanumericWithSpace(value)) return 'Only alphanumeric characters and space are allowed'; },
if (!isEditMode) { })}
if (isEditMode && Object.keys(data).includes(value)) return 'Custom fields must be unique'; size='sm'
} variant='ontime-filled'
return true; autoComplete='off'
}, />
})}
size='sm'
variant='ontime-filled'
autoComplete='off'
/>
</div>
<div>
<Panel.Description>Key (use in Integrations and API)</Panel.Description>
<Input {...register('key')} disabled size='sm' variant='ontime-filled' autoComplete='off' />
</div>
</div> </div>
<div> <div>
<Panel.Description>Colour</Panel.Description> <Panel.Description>Colour</Panel.Description>
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} /> <SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
</div> </div>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>} {errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<div className={style.buttonRow}> <div className={style.buttonRow}>
<Button size='sm' variant='ontime-ghosted' onClick={onCancel}> <Button size='sm' variant='ontime-ghosted' onClick={onCancel}>
@@ -6,12 +6,13 @@ import { CustomField, CustomFieldLabel } from 'ontime-types';
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields'; import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
import ExternalLink from '../../../../../common/components/external-link/ExternalLink'; import ExternalLink from '../../../../../common/components/external-link/ExternalLink';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { customFieldsDocsUrl } from '../../../../../externals'; import * as Panel from '../../PanelUtils';
import * as Panel from '../../../panel-utils/PanelUtils';
import CustomFieldEntry from './CustomFieldEntry'; import CustomFieldEntry from './CustomFieldEntry';
import CustomFieldForm from './CustomFieldForm'; import CustomFieldForm from './CustomFieldForm';
const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
export default function CustomFields() { export default function CustomFields() {
const { data, refetch } = useCustomFields(); const { data, refetch } = useCustomFields();
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
@@ -58,14 +59,9 @@ export default function CustomFields() {
<Alert status='info' variant='ontime-on-dark-info'> <Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon /> <AlertIcon />
<AlertDescription> <AlertDescription>
Custom fields allow for additional information to be added to an event. Custom fields allow for additional information to be added to an event (eg. light, sound, camera). <br />
<br /> <br />
<br /> This data is not used by Ontime.
This data is not used by Ontime, but provides place for cueing or department specific information (eg.
light, sound, camera).
<br />
<br />
Custom fields can be used width the Integrations feature using the generated key.
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink> <ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
@@ -77,7 +73,6 @@ export default function CustomFields() {
<tr> <tr>
<th>Colour</th> <th>Colour</th>
<th>Name</th> <th>Name</th>
<th>Key (used in Integrations)</th>
<th /> <th />
</tr> </tr>
</thead> </thead>
@@ -1,7 +1,7 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import type { PanelBaseProps } from '../../panel-list/PanelList'; import { PanelBaseProps } from '../../settingsStore';
import * as Panel from '../../panel-utils/PanelUtils';
import EditorSettingsForm from '../interface-panel/EditorSettingsForm'; import EditorSettingsForm from '../interface-panel/EditorSettingsForm';
import * as Panel from '../PanelUtils';
import GeneralPanelForm from './GeneralPanelForm'; import GeneralPanelForm from './GeneralPanelForm';
import ViewSettingsForm from './ViewSettingsForm'; import ViewSettingsForm from './ViewSettingsForm';
@@ -7,7 +7,7 @@ import { postSettings } from '../../../../common/api/settings';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import useSettings from '../../../../common/hooks-query/useSettings'; import useSettings from '../../../../common/hooks-query/useSettings';
import { isOnlyNumbers } from '../../../../common/utils/regex'; import { isOnlyNumbers } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import GeneralPinInput from './GeneralPinInput'; import GeneralPinInput from './GeneralPinInput';
@@ -148,13 +148,12 @@ export default function GeneralPanelForm() {
<option value='en'>English</option> <option value='en'>English</option>
<option value='fr'>French</option> <option value='fr'>French</option>
<option value='de'>German</option> <option value='de'>German</option>
<option value='hu'>Hungarian</option>
<option value='it'>Italian</option> <option value='it'>Italian</option>
<option value='no'>Norwegian</option> <option value='no'>Norwegian</option>
<option value='pt'>Portuguese</option> <option value='pt'>Portuguese</option>
<option value='es'>Spanish</option> <option value='es'>Spanish</option>
<option value='sv'>Swedish</option> <option value='sv'>Swedish</option>
<option value='pl'>Polish</option> <option value='pl'>Polish</option>
</Select> </Select>
</Panel.ListItem> </Panel.ListItem>
</Panel.ListGroup> </Panel.ListGroup>
@@ -9,7 +9,7 @@ import ExternalLink from '../../../../common/components/external-link/ExternalLi
import { PopoverPickerRHF } from '../../../../common/components/input/popover-picker/PopoverPicker'; import { PopoverPickerRHF } from '../../../../common/components/input/popover-picker/PopoverPicker';
import useInfo from '../../../../common/hooks-query/useInfo'; import useInfo from '../../../../common/hooks-query/useInfo';
import useViewSettings from '../../../../common/hooks-query/useViewSettings'; import useViewSettings from '../../../../common/hooks-query/useViewSettings';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import style from './GeneralPanel.module.scss'; import style from './GeneralPanel.module.scss';
@@ -84,8 +84,8 @@ export default function ViewSettingsForm() {
<Alert status='info' variant='ontime-on-dark-info'> <Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon /> <AlertIcon />
<AlertDescription> <AlertDescription>
You can the Ontime views or customise its styles by modifying the provided CSS file. <br /> You can override the styles of the viewers with a custom CSS file. <br />
The CSS file is in the user directory at {`${info.publicDir}/user/styles/override.css`} {info?.cssOverride && `In your installation the file is at ${info?.cssOverride}`}
<br /> <br />
<br /> <br />
<ExternalLink href={cssOverrideDocsUrl}>See the docs</ExternalLink> <ExternalLink href={cssOverrideDocsUrl}>See the docs</ExternalLink>
@@ -126,7 +126,7 @@ export default function ViewSettingsForm() {
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title='Freeze timer on end' title='Freeze timer on end'
description='When a timer hits 00:00:00, it freezes instead of going negative. It invalidates the End Message.' description='Timer in views will stop from going negative and instead stop at 00:00:00'
/> />
<Controller <Controller
control={control} control={control}
@@ -139,7 +139,7 @@ export default function ViewSettingsForm() {
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title='End message' title='End message'
description='Message for negative timers; applies only if the timer isn`t frozen on End. If no message is provided, it continues into negative time' description='Message to show on negative timers if not frozen. If not provided, timer will continue'
/> />
<Input <Input
size='sm' size='sm'
@@ -147,7 +147,7 @@ export default function ViewSettingsForm() {
variant='ontime-filled' variant='ontime-filled'
maxLength={150} maxLength={150}
width='275px' width='275px'
placeholder='Shown when timer reaches end' placeholder='Message shown when timer reaches end'
{...register('endMessage')} {...register('endMessage')}
/> />
</Panel.ListItem> </Panel.ListItem>
@@ -9,7 +9,7 @@ import { maybeAxiosError } from '../../../../common/api/utils';
import { useHttpSettings, usePostHttpSettings } from '../../../../common/hooks-query/useHttpSettings'; import { useHttpSettings, usePostHttpSettings } from '../../../../common/hooks-query/useHttpSettings';
import { isKeyEscape } from '../../../../common/utils/keyEvent'; import { isKeyEscape } from '../../../../common/utils/keyEvent';
import { startsWithHttp } from '../../../../common/utils/regex'; import { startsWithHttp } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import { cycles } from './integrationUtils'; import { cycles } from './integrationUtils';
@@ -27,7 +27,7 @@ export default function HttpIntegrations() {
setError, setError,
formState: { errors, isSubmitting, isDirty, isValid }, formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<HttpSettings>({ } = useForm<HttpSettings>({
mode: 'onChange', mode: 'onBlur',
defaultValues: data, defaultValues: data,
values: data, values: data,
resetOptions: { resetOptions: {
@@ -60,7 +60,7 @@ export default function HttpIntegrations() {
id: generateId(), id: generateId(),
cycle: 'onLoad', cycle: 'onLoad',
message: '', message: '',
enabled: true, enabled: false,
}); });
}; };
@@ -2,8 +2,8 @@ import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
import ExternalLink from '../../../../common/components/external-link/ExternalLink'; import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import type { PanelBaseProps } from '../../panel-list/PanelList'; import { PanelBaseProps } from '../../settingsStore';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import HttpIntegrations from './HttpIntegrations'; import HttpIntegrations from './HttpIntegrations';
import OscIntegrations from './OscIntegrations'; import OscIntegrations from './OscIntegrations';
@@ -10,7 +10,7 @@ import { maybeAxiosError } from '../../../../common/api/utils';
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings'; import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
import { isKeyEscape } from '../../../../common/utils/keyEvent'; import { isKeyEscape } from '../../../../common/utils/keyEvent';
import { isASCII, isASCIIorEmpty, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex'; import { isASCII, isASCIIorEmpty, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import { cycles } from './integrationUtils'; import { cycles } from './integrationUtils';
@@ -28,7 +28,7 @@ export default function OscIntegrations() {
setError, setError,
formState: { errors, isSubmitting, isDirty, isValid }, formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<OSCSettings>({ } = useForm<OSCSettings>({
mode: 'onChange', mode: 'onBlur',
defaultValues: data, defaultValues: data,
values: data, values: data,
resetOptions: { resetOptions: {
@@ -75,7 +75,7 @@ export default function OscIntegrations() {
cycle: 'onLoad', cycle: 'onLoad',
address: '', address: '',
payload: '', payload: '',
enabled: true, enabled: false,
}); });
}; };
@@ -1,34 +1,23 @@
import { Select, Switch } from '@chakra-ui/react'; import { Select, Switch } from '@chakra-ui/react';
import { EndAction, TimerType, TimeStrategy } from 'ontime-types'; import { EndAction, TimerType } from 'ontime-types';
import { parseUserTime } from 'ontime-utils'; import { parseUserTime } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput'; import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { editorSettingsDefaults, useEditorSettings } from '../../../../common/stores/editorSettings'; import { editorSettingsDefaults, useEditorSettings } from '../../../../common/stores/editorSettings';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
export default function EditorSettingsForm() { export default function EditorSettingsForm() {
const { const eventSettings = useEditorSettings((state) => state);
defaultDuration,
linkPrevious,
defaultTimeStrategy,
defaultWarnTime,
defaultDangerTime,
defaultPublic,
defaultTimerType,
defaultEndAction,
setDefaultDuration,
setLinkPrevious,
setTimeStrategy,
setWarnTime,
setDangerTime,
setDefaultPublic,
setDefaultTimerType,
setDefaultEndAction,
} = useEditorSettings((state) => state);
const durationInMs = parseUserTime(defaultDuration); const setDefaultDuration = eventSettings.setDefaultDuration;
const warnTimeInMs = parseUserTime(defaultWarnTime); const setLinkPrevious = eventSettings.setLinkPrevious;
const dangerTimeInMs = parseUserTime(defaultDangerTime); const setWarnTime = eventSettings.setWarnTime;
const setDangerTime = eventSettings.setDangerTime;
const setDefaultPublic = eventSettings.setDefaultPublic;
const durationInMs = parseUserTime(eventSettings.defaultDuration);
const warnTimeInMs = parseUserTime(eventSettings.defaultWarnTime);
const dangerTimeInMs = parseUserTime(eventSettings.defaultDangerTime);
return ( return (
<Panel.Section> <Panel.Section>
@@ -37,36 +26,6 @@ export default function EditorSettingsForm() {
<Panel.Divider /> <Panel.Divider />
<Panel.Section> <Panel.Section>
<Panel.Title>Rundown defaults for new events</Panel.Title> <Panel.Title>Rundown defaults for new events</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Link previous'
description='Whether the start time of new events should be linked to the previous event end time'
/>
<Switch
variant='ontime'
size='lg'
defaultChecked={linkPrevious}
onChange={(event) => setLinkPrevious(event.target.checked)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Timer strategy'
description='Which time should be maintained when event schedule is recalculated'
/>
<Select
variant='ontime'
size='sm'
width='auto'
value={defaultTimeStrategy}
onChange={(event) => setTimeStrategy(event.target.value as TimeStrategy)}
>
<option value={TimeStrategy.LockDuration}>Duration</option>
<option value={TimeStrategy.LockEnd}>End Time</option>
</Select>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.ListGroup> <Panel.ListGroup>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field title='Default duration' description='Default duration for new events' /> <Panel.Field title='Default duration' description='Default duration for new events' />
@@ -78,30 +37,29 @@ export default function EditorSettingsForm() {
/> />
</Panel.ListItem> </Panel.ListItem>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field title='Timer type' description='Default type of timer for new events' /> <Panel.Field
<Select title='Link previous'
description='Whether the start time of new events should be linked to the previous event end time'
/>
<Switch
variant='ontime' variant='ontime'
size='sm' size='lg'
width='auto' defaultChecked={eventSettings.linkPrevious}
value={defaultTimerType} onChange={(event) => setLinkPrevious(event.target.checked)}
onChange={(event) => setDefaultTimerType(event.target.value as TimerType)} />
> </Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Timer type' description='Default type of timer for new events' />
<Select variant='ontime' size='sm' width='auto' isDisabled>
<option value={TimerType.CountDown}>Count down</option> <option value={TimerType.CountDown}>Count down</option>
<option value={TimerType.CountUp}>Count up</option> <option value={TimerType.CountUp}>Count up</option>
<option value={TimerType.TimeToEnd}>Time to end</option> <option value={TimerType.TimeToEnd}>Time to end</option>
<option value={TimerType.Clock}>Clock</option> <option value={TimerType.Clock}>Clock</option>
<option value={TimerType.None}>None</option>
</Select> </Select>
</Panel.ListItem> </Panel.ListItem>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field title='End Action' description='Default end action for new events' /> <Panel.Field title='End Action' description='Default end action for new events' />
<Select <Select variant='ontime' size='sm' width='auto' isDisabled>
variant='ontime'
size='sm'
width='auto'
value={defaultEndAction}
onChange={(event) => setDefaultEndAction(event.target.value as EndAction)}
>
<option value={EndAction.None}>None</option> <option value={EndAction.None}>None</option>
<option value={EndAction.Stop}>Stop</option> <option value={EndAction.Stop}>Stop</option>
<option value={EndAction.LoadNext}>Load next</option> <option value={EndAction.LoadNext}>Load next</option>
@@ -135,14 +93,14 @@ export default function EditorSettingsForm() {
<Switch <Switch
variant='ontime' variant='ontime'
size='lg' size='lg'
defaultChecked={defaultPublic} defaultChecked={eventSettings.defaultPublic}
onChange={(event) => setDefaultPublic(event.target.checked)} onChange={(event) => setDefaultPublic(event.target.checked)}
/> />
</Panel.ListItem> </Panel.ListItem>
</Panel.ListGroup> </Panel.ListGroup>
</Panel.Section> </Panel.Section>
<Panel.Section> <Panel.Section>
<Panel.Title>Run mode</Panel.Title> <Panel.Title>Play mode</Panel.Title>
<Panel.ListGroup> <Panel.ListGroup>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
@@ -1,6 +1,6 @@
import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react'; import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import EditorSettingsForm from './EditorSettingsForm'; import EditorSettingsForm from './EditorSettingsForm';
@@ -4,9 +4,3 @@
gap: $section-spacing; gap: $section-spacing;
row-gap: $element-inner-spacing; row-gap: $element-inner-spacing;
} }
.goIcon {
@include rotate-fourty-five;
margin-left: 0.25rem;
margin-bottom: 0.25rem;
}
@@ -1,33 +1,19 @@
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { serverPort } from '../../../../common/api/constants'; import { serverPort } from '../../../../common/api/constants';
import CopyTag from '../../../../common/components/copy-tag/CopyTag'; import AppLink from '../../../../common/components/app-link/AppLink';
import useInfo from '../../../../common/hooks-query/useInfo'; import useInfo from '../../../../common/hooks-query/useInfo';
import { openLink } from '../../../../common/utils/linkUtils';
import style from './NetworkInterfaces.module.scss'; import style from './NetworkInterfaces.module.scss';
export default function InfoNif() { export default function InfoNif() {
const { data } = useInfo(); const { data } = useInfo();
const handleClick = (address: string) => openLink(address);
return ( return (
<div className={style.interfaces}> <div className={style.interfaces}>
{data?.networkInterfaces?.map((nif) => { {data?.networkInterfaces?.map((nif) => (
const address = `http://${nif.address}:${serverPort}`; <AppLink key={nif.address} href={`http://${nif.address}:${serverPort}`}>
{`${nif.name} - ${nif.address}`}
return ( </AppLink>
<CopyTag ))}
key={nif.name}
copyValue={address}
onClick={() => handleClick(address)}
label='Copy IP or navigate to address'
>
{`${nif.name} - ${nif.address}`} <IoArrowUp className={style.goIcon} />
</CopyTag>
);
})}
</div> </div>
); );
} }
@@ -4,7 +4,7 @@ import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { handleLinks } from '../../../../common/utils/linkUtils'; import { handleLinks } from '../../../../common/utils/linkUtils';
import Log from '../../../log/Log'; import Log from '../../../log/Log';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import style from './NetworkLogExport.module.scss'; import style from './NetworkLogExport.module.scss';
@@ -1,7 +1,7 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import type { PanelBaseProps } from '../../panel-list/PanelList'; import { PanelBaseProps } from '../../settingsStore';
import * as Panel from '../../panel-utils/PanelUtils';
import ClientControlPanel from '../client-control-panel/ClientControlPanel'; import ClientControlPanel from '../client-control-panel/ClientControlPanel';
import * as Panel from '../PanelUtils';
import InfoNif from './NetworkInterfaces'; import InfoNif from './NetworkInterfaces';
import LogExport from './NetworkLogExport'; import LogExport from './NetworkLogExport';
@@ -1,12 +1,11 @@
import { ChangeEvent, useRef, useState } from 'react'; import { ChangeEvent, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Button, Input } from '@chakra-ui/react'; import { Button, Input } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { uploadProjectFile } from '../../../../common/api/db'; import { uploadProjectFile } from '../../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils'; import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
import { validateProjectFile } from '../../../../common/utils/uploadUtils'; import { validateProjectFile } from '../../../../common/utils/uploadUtils';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import ProjectCreateForm from './ProjectCreateForm'; import ProjectCreateForm from './ProjectCreateForm';
import ProjectList from './ProjectList'; import ProjectList from './ProjectList';
@@ -14,17 +13,14 @@ import ProjectList from './ProjectList';
import style from './ProjectPanel.module.scss'; import style from './ProjectPanel.module.scss';
export default function ManageProjects() { export default function ManageProjects() {
const [searchParams, setSearchParams] = useSearchParams(); const [isCreatingProject, setIsCreatingProject] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState<'import' | null>(null); const [loading, setLoading] = useState<'import' | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const isCreatingProject = searchParams.get('new') === 'true';
const handleToggleCreate = () => { const handleToggleCreate = () => {
searchParams.set('new', isCreatingProject ? 'false' : 'true'); setIsCreatingProject((prev) => !prev);
setSearchParams(searchParams);
}; };
const handleSelectFile = () => { const handleSelectFile = () => {
@@ -46,15 +42,14 @@ export default function ManageProjects() {
const errorMessage = maybeAxiosError(error); const errorMessage = maybeAxiosError(error);
setError(`Error uploading file: ${errorMessage}`); setError(`Error uploading file: ${errorMessage}`);
} finally { } finally {
await invalidateAllCaches(); invalidateAllCaches();
} }
setLoading(null); setLoading(null);
}; };
const handleCloseForm = () => { const handleCloseForm = () => {
searchParams.delete('new'); setIsCreatingProject(false);
setSearchParams(searchParams);
}; };
return ( return (
@@ -6,7 +6,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { PROJECT_LIST } from '../../../../common/api/constants'; import { PROJECT_LIST } from '../../../../common/api/constants';
import { createProject } from '../../../../common/api/db'; import { createProject } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import style from './ProjectPanel.module.scss'; import style from './ProjectPanel.module.scss';
@@ -78,7 +78,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
</div> </div>
</Panel.Title> </Panel.Title>
{error && <Panel.Error>{error}</Panel.Error>} {error && <Panel.Error>{error}</Panel.Error>}
<Panel.Section className={style.innerColumn}> <div className={style.innerColumn}>
<label> <label>
Project title Project title
<Input <Input
@@ -145,7 +145,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
{...register('backstageUrl')} {...register('backstageUrl')}
/> />
</label> </label>
</Panel.Section> </div>
</Panel.Section> </Panel.Section>
); );
} }
@@ -6,7 +6,7 @@ import { type ProjectData } from 'ontime-types';
import { postProjectData } from '../../../../common/api/project'; import { postProjectData } from '../../../../common/api/project';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import useProjectData from '../../../../common/hooks-query/useProjectData'; import useProjectData from '../../../../common/hooks-query/useProjectData';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import style from './ProjectPanel.module.scss'; import style from './ProjectPanel.module.scss';
@@ -81,7 +81,7 @@ export default function ProjectData() {
variant='ontime-filled' variant='ontime-filled'
size='sm' size='sm'
maxLength={50} maxLength={50}
placeholder='Project title is shown in production views' placeholder='Your project name'
autoComplete='off' autoComplete='off'
{...register('title')} {...register('title')}
/> />
@@ -9,7 +9,7 @@ export type ProjectFormValues = {
}; };
interface ProjectFormProps { interface ProjectFormProps {
action: 'duplicate' | 'rename' | 'merge'; action: 'duplicate' | 'rename';
filename: string; filename: string;
onCancel: () => void; onCancel: () => void;
onSubmit: (values: ProjectFormValues) => Promise<void>; onSubmit: (values: ProjectFormValues) => Promise<void>;
@@ -1,7 +1,7 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useProjectList } from '../../../../common/hooks-query/useProjectList'; import { useProjectList } from '../../../../common/hooks-query/useProjectList';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import ProjectListItem, { EditMode } from './ProjectListItem'; import ProjectListItem, { EditMode } from './ProjectListItem';
@@ -11,15 +11,13 @@ import {
renameProject, renameProject,
} from '../../../../common/api/db'; } from '../../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils'; import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
import { cx } from '../../../../common/utils/styleUtils'; import * as Panel from '../PanelUtils';
import * as Panel from '../../panel-utils/PanelUtils';
import ProjectForm, { ProjectFormValues } from './ProjectForm'; import ProjectForm, { ProjectFormValues } from './ProjectForm';
import ProjectMergeForm from './ProjectMergeForm';
import style from './ProjectPanel.module.scss'; import style from './ProjectPanel.module.scss';
export type EditMode = 'rename' | 'duplicate' | 'merge' | null; export type EditMode = 'rename' | 'duplicate' | null;
interface ProjectListItemProps { interface ProjectListItemProps {
current?: boolean; current?: boolean;
@@ -102,10 +100,8 @@ export default function ProjectListItem({
handleToggleEditMode(null, null); handleToggleEditMode(null, null);
}; };
const isCurrentlyBeingEdited = filename === editingFilename; const isCurrentlyBeingEdited = editingMode && filename === editingFilename;
const showProjectForm = (editingMode === 'rename' || editingMode === 'duplicate') && filename === editingFilename; const classes = current && !isCurrentlyBeingEdited ? style.current : undefined;
const showMergeForm = editingMode === 'merge' && isCurrentlyBeingEdited;
const classes = cx([current && !isCurrentlyBeingEdited && style.current, isCurrentlyBeingEdited && style.isEditing]);
return ( return (
<> <>
@@ -117,7 +113,7 @@ export default function ProjectListItem({
</tr> </tr>
)} )}
<tr key={filename} className={classes}> <tr key={filename} className={classes}>
{showProjectForm ? ( {isCurrentlyBeingEdited ? (
<td colSpan={99}> <td colSpan={99}>
<ProjectForm <ProjectForm
action={editingMode} action={editingMode}
@@ -129,7 +125,7 @@ export default function ProjectListItem({
) : ( ) : (
<> <>
<td className={style.containCell}>{filename}</td> <td className={style.containCell}>{filename}</td>
<td>{current ? 'Currently loaded' : new Date(updatedAt).toLocaleString()}</td> <td>{new Date(updatedAt).toLocaleString()}</td>
<td className={style.actionButton}> <td className={style.actionButton}>
<ActionMenu <ActionMenu
current={current} current={current}
@@ -137,20 +133,12 @@ export default function ProjectListItem({
onChangeEditMode={handleToggleEditMode} onChangeEditMode={handleToggleEditMode}
onDelete={handleDelete} onDelete={handleDelete}
onLoad={handleLoad} onLoad={handleLoad}
isDisabled={loading || showMergeForm} isDisabled={loading}
onMerge={(filename) => handleToggleEditMode('merge', filename)}
/> />
</td> </td>
</> </>
)} )}
</tr> </tr>
{showMergeForm && (
<tr>
<td colSpan={99}>
<ProjectMergeForm onClose={handleCancel} fileName={filename} />
</td>
</tr>
)}
</> </>
); );
} }
@@ -160,12 +148,11 @@ interface ActionMenuProps {
filename: string; filename: string;
isDisabled: boolean; isDisabled: boolean;
onChangeEditMode: (editMode: EditMode, filename: string) => void; onChangeEditMode: (editMode: EditMode, filename: string) => void;
onDelete: (filename: string) => Promise<void>; onDelete: (filename: string) => void;
onLoad: (filename: string) => Promise<void>; onLoad: (filename: string) => void;
onMerge: (filename: string) => void;
} }
function ActionMenu(props: ActionMenuProps) { function ActionMenu(props: ActionMenuProps) {
const { current, filename, isDisabled, onChangeEditMode, onDelete, onLoad, onMerge } = props; const { current, filename, isDisabled, onChangeEditMode, onDelete, onLoad } = props;
const handleRename = () => { const handleRename = () => {
onChangeEditMode('rename', filename); onChangeEditMode('rename', filename);
@@ -198,9 +185,6 @@ function ActionMenu(props: ActionMenuProps) {
<MenuItem onClick={() => onLoad(filename)} isDisabled={current}> <MenuItem onClick={() => onLoad(filename)} isDisabled={current}>
Load Load
</MenuItem> </MenuItem>
<MenuItem onClick={() => onMerge(filename)} isDisabled={current}>
Partial Load
</MenuItem>
<MenuItem onClick={handleRename}>Rename</MenuItem> <MenuItem onClick={handleRename}>Rename</MenuItem>
<MenuItem onClick={handleDuplicate}>Duplicate</MenuItem> <MenuItem onClick={handleDuplicate}>Duplicate</MenuItem>
<MenuItem onClick={handleDownload}>Download</MenuItem> <MenuItem onClick={handleDownload}>Download</MenuItem>
@@ -1,128 +0,0 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { Button, Switch } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { PROJECT_DATA } from '../../../../common/api/constants';
import { getDb, patchData } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils';
import { makeProjectPatch } from './project.utils';
import style from './ProjectPanel.module.scss';
interface ProjectMergeFromProps {
onClose: () => void;
fileName: string;
}
type ProjectMergeFormValues = {
project: boolean;
rundown: boolean;
viewSettings: boolean;
urlPresets: boolean;
osc: boolean;
http: boolean;
};
export default function ProjectMergeForm(props: ProjectMergeFromProps) {
const { onClose, fileName } = props;
const [error, setError] = useState<string | null>(null);
const queryClient = useQueryClient();
const {
handleSubmit,
register,
formState: { isSubmitting, isValid, isDirty },
} = useForm<ProjectMergeFormValues>({
defaultValues: {
project: false,
rundown: false,
viewSettings: false,
urlPresets: false,
osc: false,
http: false,
},
resetOptions: {
keepDirtyValues: true,
},
});
const handleSubmitCreate = async (values: ProjectMergeFormValues) => {
const allFalse = Object.values(values).every((value) => !value);
if (allFalse) {
setError('At least one option must be selected');
return;
}
try {
setError(null);
// make patch object
const { data } = await getDb(fileName);
const patch = await makeProjectPatch(data, values);
// request patch
await patchData(patch);
await queryClient.invalidateQueries({ queryKey: PROJECT_DATA });
onClose();
} catch (error) {
setError(maybeAxiosError(error));
}
};
return (
<Panel.Section as='form' onSubmit={handleSubmit(handleSubmitCreate)}>
<Panel.Title>
Merge {`"${fileName}"`}
<div className={style.createActionButtons}>
<Button onClick={onClose} variant='ontime-ghosted' size='sm' isDisabled={isSubmitting}>
Cancel
</Button>
<Button
isDisabled={!isValid || !isDirty}
type='submit'
isLoading={isSubmitting}
variant='ontime-filled'
size='sm'
>
Merge
</Button>
</div>
</Panel.Title>
{error && <Panel.Error>{error}</Panel.Error>}
<Panel.Section className={cx([style.innerColumn, style.inlineLabels])}>
<Panel.Description>
Select partial data from {`"${fileName}"`} to merge into the current project.
<br /> This process is irreversible.
</Panel.Description>
<label>
<Switch variant='ontime' {...register('project')} />
Project data
</label>
<label>
<Switch variant='ontime' {...register('rundown')} />
Rundown + Custom Fields
</label>
<label>
<Switch variant='ontime' {...register('viewSettings')} />
View Settings
</label>
<label>
<Switch variant='ontime' {...register('urlPresets')} />
URL Presets
</label>
<label>
<Switch variant='ontime' {...register('osc')} />
OSC Integration
</label>
<label>
<Switch variant='ontime' {...register('http')} />
HTTP Integration
</label>
</Panel.Section>
</Panel.Section>
);
}
@@ -2,10 +2,6 @@
background-color: $blue-1100; background-color: $blue-1100;
} }
.isEditing {
color: $blue-500;
}
.actionButton { .actionButton {
flex: 1; flex: 1;
text-align: right; text-align: right;
@@ -54,10 +50,3 @@
flex-direction: column; flex-direction: column;
gap: 1em; gap: 1em;
} }
.inlineLabels {
label {
display: flex;
gap: 1rem;
}
}
@@ -1,28 +1,17 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import type { PanelBaseProps } from '../../panel-list/PanelList'; import { PanelBaseProps } from '../../settingsStore';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import QuickStart from '../../quick-start/QuickStart';
import type { SettingsOptionId } from '../../useAppSettingsMenu';
import ManageProjects from './ManageProjects'; import ManageProjects from './ManageProjects';
import ProjectData from './ProjectData'; import ProjectData from './ProjectData';
interface ProjectPanelProps extends PanelBaseProps { export default function ProjectPanel({ location }: PanelBaseProps) {
setLocation: (location: SettingsOptionId) => void;
}
export default function ProjectPanel({ location, setLocation }: ProjectPanelProps) {
const projectRef = useScrollIntoView<HTMLDivElement>('data', location); const projectRef = useScrollIntoView<HTMLDivElement>('data', location);
const manageRef = useScrollIntoView<HTMLDivElement>('manage', location); const manageRef = useScrollIntoView<HTMLDivElement>('manage', location);
const handleQuickClose = () => {
setLocation('project');
};
return ( return (
<> <>
<Panel.Header>Project</Panel.Header> <Panel.Header>Project</Panel.Header>
<QuickStart isOpen={location === 'create'} onClose={handleQuickClose} />
<div ref={projectRef}> <div ref={projectRef}>
<ProjectData /> <ProjectData />
</div> </div>
@@ -1,17 +0,0 @@
import { DatabaseModel, isKeyOfType } from 'ontime-types';
export async function makeProjectPatch(data: DatabaseModel, mergeKeys: Record<string, boolean>) {
const patchObject: Partial<DatabaseModel> = {};
for (const key in mergeKeys) {
if (isKeyOfType(key, data) && mergeKeys[key]) {
// if the rundown is merged we also need the custom fields
if (key === 'rundown') {
patchObject.customFields = data['customFields'];
}
Object.assign(patchObject, { [key]: data[key] });
}
}
return patchObject;
}
@@ -10,9 +10,8 @@ import {
useDisclosure, useDisclosure,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { isLocalhost } from '../../../../common/api/constants'; import useElectronEvent from '../../../../common/hooks/useElectronEvent';
import { useElectronEvent } from '../../../../common/hooks/useElectronEvent'; import * as Panel from '../PanelUtils';
import * as Panel from '../../panel-utils/PanelUtils';
export default function ShutdownPanel() { export default function ShutdownPanel() {
const { isElectron, sendToElectron } = useElectronEvent(); const { isElectron, sendToElectron } = useElectronEvent();
@@ -32,10 +31,9 @@ export default function ShutdownPanel() {
This will shutdown the Ontime server. <br /> This will shutdown the Ontime server. <br />
The runtime state will be lost, but your project is kept for next time. The runtime state will be lost, but your project is kept for next time.
</Panel.Paragraph> </Panel.Paragraph>
<Button colorScheme='red' onClick={onOpen} maxWidth='350px' isDisabled={!(isElectron || isLocalhost)}> <Button colorScheme='red' onClick={onOpen} maxWidth='350px' isDisabled={!isElectron}>
Shutdown ontime Shutdown ontime
</Button> </Button>
<Panel.Description>Note: Ontime can only be shutdown from the machine it is running in.</Panel.Description>
<AlertDialog variant='ontime' isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}> <AlertDialog variant='ontime' isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}>
<AlertDialogOverlay> <AlertDialogOverlay>
<AlertDialogContent> <AlertDialogContent>
@@ -7,7 +7,7 @@ import { getWorksheetNames } from '../../../../common/api/sheets';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import CopyTag from '../../../../common/components/copy-tag/CopyTag'; import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import { openLink } from '../../../../common/utils/linkUtils'; import { openLink } from '../../../../common/utils/linkUtils';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import useGoogleSheet from './useGoogleSheet'; import useGoogleSheet from './useGoogleSheet';
import { useSheetStore } from './useSheetStore'; import { useSheetStore } from './useSheetStore';
@@ -193,7 +193,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
<Panel.ListGroup> <Panel.ListGroup>
<div className={style.buttonRow}> <div className={style.buttonRow}>
{isAuthenticating && <Spinner />} {isAuthenticating && <Spinner />}
<CopyTag copyValue={authKey ?? ''} label='Google Auth Key' disabled={!canAuthenticate} size='sm'> <CopyTag label='Google Auth Key' disabled={!canAuthenticate} size='sm'>
{authKey ? authKey : 'Upload files to generate Auth Key'} {authKey ? authKey : 'Upload files to generate Auth Key'}
</CopyTag> </CopyTag>
<Button <Button
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { CustomFields, OntimeRundown } from 'ontime-types'; import { CustomFields, OntimeRundown } from 'ontime-types';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import PreviewSpreadsheet from './preview/PreviewRundown'; import PreviewSpreadsheet from './preview/PreviewRundown';
import useGoogleSheet from './useGoogleSheet'; import useGoogleSheet from './useGoogleSheet';
@@ -12,7 +12,7 @@ import {
import { getWorksheetNames } from '../../../../common/api/sheets'; import { getWorksheetNames } from '../../../../common/api/sheets';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import { validateExcelImport } from '../../../../common/utils/uploadUtils'; import { validateExcelImport } from '../../../../common/utils/uploadUtils';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../PanelUtils';
import ImportMapForm from './import-map/ImportMapForm'; import ImportMapForm from './import-map/ImportMapForm';
import GSheetInfo from './GSheetInfo'; import GSheetInfo from './GSheetInfo';
@@ -70,17 +70,6 @@ export default function SourcesPanel() {
fileInputRef.current?.click(); fileInputRef.current?.click();
}; };
const resetFlow = () => {
// we purposely omit clearing the authentication status
setImportFlow('none');
setRundown(null);
setHasFile('none');
setWorksheets(null);
setCustomFields(null);
setError('');
setSheetId(null);
};
const openGSheetFlow = async () => { const openGSheetFlow = async () => {
const result = await verifyAuth(); const result = await verifyAuth();
if (result) { if (result) {
@@ -100,7 +89,7 @@ export default function SourcesPanel() {
}; };
const cancelGSheetFlow = () => { const cancelGSheetFlow = () => {
resetFlow(); setImportFlow('none');
}; };
const handleSubmitImportPreview = async (importMap: ImportMap) => { const handleSubmitImportPreview = async (importMap: ImportMap) => {
@@ -121,7 +110,9 @@ export default function SourcesPanel() {
}; };
const cancelImportMap = async () => { const cancelImportMap = async () => {
resetFlow(); setImportFlow('none');
setHasFile('none');
setWorksheets(null);
if (authenticationStatus === 'authenticated') { if (authenticationStatus === 'authenticated') {
const result = await verifyAuth(); const result = await verifyAuth();
if (result) { if (result) {
@@ -136,7 +127,6 @@ export default function SourcesPanel() {
setHasFile('none'); setHasFile('none');
setWorksheets(null); setWorksheets(null);
setCustomFields(null); setCustomFields(null);
setError('');
}; };
const handleSubmitExport = async (importMap: ImportMap) => { const handleSubmitExport = async (importMap: ImportMap) => {
@@ -144,6 +134,15 @@ export default function SourcesPanel() {
await exportRundown(sheetId, importMap); await exportRundown(sheetId, importMap);
}; };
const resetFlow = () => {
setImportFlow('none');
setRundown(null);
setHasFile('none');
setWorksheets(null);
setCustomFields(null);
setError('');
};
const isExcelFlow = importFlow === 'excel'; const isExcelFlow = importFlow === 'excel';
const isGSheetFlow = importFlow === 'gsheet'; const isGSheetFlow = importFlow === 'gsheet';
const isAuthenticated = authenticationStatus === 'authenticated'; const isAuthenticated = authenticationStatus === 'authenticated';
@@ -3,9 +3,10 @@ import { useFieldArray, useForm } from 'react-hook-form';
import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react'; import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash'; import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { ImportMap, isAlphanumericWithSpace } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import * as Panel from '../../../panel-utils/PanelUtils'; import { isAlphanumeric } from '../../../../../common/utils/regex';
import * as Panel from '../../PanelUtils';
import useGoogleSheet from '../useGoogleSheet'; import useGoogleSheet from '../useGoogleSheet';
import { useSheetStore } from '../useSheetStore'; import { useSheetStore } from '../useSheetStore';
@@ -31,7 +32,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
register, register,
formState: { errors, isValid }, formState: { errors, isValid },
} = useForm<NamedImportMap>({ } = useForm<NamedImportMap>({
mode: 'onChange', mode: 'onBlur',
defaultValues: namedImportMap, defaultValues: namedImportMap,
values: namedImportMap, values: namedImportMap,
}); });
@@ -77,7 +78,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
const isLoading = Boolean(loading); const isLoading = Boolean(loading);
const canSubmitSpreadsheet = isSpreadsheet && !isLoading; const canSubmitSpreadsheet = isSpreadsheet && !isLoading;
const canSubmitGSheet = !isLoading && !stepData.worksheet.error; const canSubmitGSheet = !isLoading;
const canSubmit = !hasErrors && isValid && (canSubmitSpreadsheet || canSubmitGSheet); const canSubmit = !hasErrors && isValid && (canSubmitSpreadsheet || canSubmitGSheet);
return ( return (
@@ -121,7 +122,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
<thead> <thead>
<tr> <tr>
<th>Ontime field</th> <th>Ontime field</th>
<th>Column name in spreadsheet</th> <th>From spreadsheet name</th>
<th className={style.singleActionCell} /> <th className={style.singleActionCell} />
</tr> </tr>
</thead> </thead>
@@ -189,10 +190,9 @@ export default function ImportMapForm(props: ImportMapFormProps) {
defaultValue={ontimeName} defaultValue={ontimeName}
placeholder='Name of the field as shown in Ontime' placeholder='Name of the field as shown in Ontime'
{...register(`custom.${index}.ontimeName`, { {...register(`custom.${index}.ontimeName`, {
validate: (value) => { pattern: {
if (!isAlphanumericWithSpace(value)) value: isAlphanumeric,
return 'Only alphanumeric characters and space are allowed'; message: 'Custom field name must be alphanumeric',
return true;
}, },
})} })}
/> />
@@ -5,7 +5,7 @@ import { millisToString } from 'ontime-utils';
import Tag from '../../../../../common/components/tag/Tag'; import Tag from '../../../../../common/components/tag/Tag';
import { getAccessibleColour } from '../../../../../common/utils/styleUtils'; import { getAccessibleColour } from '../../../../../common/utils/styleUtils';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../PanelUtils';
import style from './PreviewRundown.module.scss'; import style from './PreviewRundown.module.scss';
@@ -24,8 +24,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
// we only count Ontime Events which are 1 based in client // we only count Ontime Events which are 1 based in client
let eventIndex = 0; let eventIndex = 0;
const fieldKeys = Object.keys(customFields); const fieldHeaders = Object.keys(customFields);
const fieldLabels = fieldKeys.map((key) => customFields[key].label);
return ( return (
<Panel.Table> <Panel.Table>
@@ -45,8 +44,8 @@ export default function PreviewRundown(props: PreviewRundownProps) {
<th>Colour</th> <th>Colour</th>
<th>Timer Type</th> <th>Timer Type</th>
<th>End Action</th> <th>End Action</th>
{fieldLabels.map((label) => ( {fieldHeaders.map((field) => (
<th key={label}>{label}</th> <th key={field}>{field}</th>
))} ))}
</tr> </tr>
</thead> </thead>
@@ -103,7 +102,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
<Tag>{event.endAction}</Tag> <Tag>{event.endAction}</Tag>
</td> </td>
{isOntimeEvent(event) && {isOntimeEvent(event) &&
fieldKeys.map((field) => { fieldHeaders.map((field) => {
let value = ''; let value = '';
if (field in event.custom) { if (field in event.custom) {
value = event.custom[field]; value = event.custom[field];
@@ -1,8 +0,0 @@
.scrollContainer {
max-height: 70vh;
overflow: auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
@@ -1,186 +0,0 @@
import { Controller, useForm } from 'react-hook-form';
import {
Button,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Select,
Switch,
} from '@chakra-ui/react';
import { QuickStartData } from 'ontime-types';
import { parseUserTime } from 'ontime-utils';
import { quickProject } from '../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../common/api/utils';
import TimeInput from '../../../common/components/input/time-input/TimeInput';
import { editorSettingsDefaults, useEditorSettings } from '../../../common/stores/editorSettings';
import * as Panel from '../panel-utils/PanelUtils';
import { quickStartDefaults } from './quickStart.utils';
import style from './QuickStart.module.scss';
interface QuickStartProps {
isOpen: boolean;
onClose: () => void;
}
export default function QuickStart(props: QuickStartProps) {
const { isOpen, onClose } = props;
const { defaultWarnTime, defaultDangerTime, setDangerTime, setWarnTime } = useEditorSettings();
const {
control,
handleSubmit,
register,
formState: { errors, isSubmitting, isValid },
setError,
} = useForm<QuickStartData>({
defaultValues: quickStartDefaults,
values: quickStartDefaults,
resetOptions: {
keepDirtyValues: true,
},
});
const onSubmit = async (formData: QuickStartData) => {
try {
if (formData.project.title === '') {
formData.project.title = 'untitled';
}
await quickProject(formData);
await invalidateAllCaches();
onClose();
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
}
};
const warnTimeInMs = parseUserTime(defaultWarnTime);
const dangerTimeInMs = parseUserTime(defaultDangerTime);
return (
<Modal isOpen={isOpen} onClose={onClose} closeOnOverlayClick={false} variant='ontime'>
<ModalOverlay />
<ModalCloseButton />
<ModalContent maxWidth='max(640px, 40vw)'>
<form onSubmit={handleSubmit(onSubmit)} id='quick-start'>
<ModalHeader>Create new project...</ModalHeader>
<ModalBody className={style.scrollContainer}>
<ModalCloseButton />
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='Project title' description='Shown as the title in some views' />
<Input
variant='ontime-filled'
size='sm'
maxLength={150}
placeholder='Project title'
autoComplete='off'
width='20rem'
{...register('project.title')}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Time format'
description='Default time format to show in views 12 /24 hours'
error={errors.settings?.timeFormat?.message}
/>
<Select variant='ontime' size='sm' width='auto' isDisabled={false} {...register('settings.timeFormat')}>
<option value='12'>12 hours 11:00:10 PM</option>
<option value='24'>24 hours 23:00:10</option>
</Select>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Views language'
description='Language to be displayed in views'
error={errors.settings?.language?.message}
/>
<Select variant='ontime' size='sm' width='auto' isDisabled={false} {...register('settings.language')}>
<option value='en'>English</option>
<option value='fr'>French</option>
<option value='de'>German</option>
<option value='hu'>Hungarian</option>
<option value='it'>Italian</option>
<option value='no'>Norwegian</option>
<option value='pt'>Portuguese</option>
<option value='es'>Spanish</option>
<option value='sv'>Swedish</option>
<option value='pl'>Polish</option>
</Select>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='Warning time' description='Default threshold for warning time in an event' />
<TimeInput<'warnTime'>
name='warnTime'
submitHandler={(_field, value) => setWarnTime(value)}
time={warnTimeInMs}
placeholder={editorSettingsDefaults.warnTime}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Danger time' description='Default threshold for danger time in an event' />
<TimeInput<'dangerTime'>
name='dangerTime'
submitHandler={(_field, value) => setDangerTime(value)}
time={dangerTimeInMs}
placeholder={editorSettingsDefaults.dangerTime}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Freeze timer on end'
description='When a timer hits 00:00:00, it freezes instead of going negative. It invalidates the End Message.'
/>
<Controller
control={control}
name='viewSettings.freezeEnd'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='End message'
description='Message for negative timers; applies only if the timer isn`t frozen on End. If no message is provided, it continues into negative time'
/>
<Input
size='sm'
autoComplete='off'
variant='ontime-filled'
maxLength={150}
width='20rem'
placeholder='Shown when timer reaches end'
{...register('viewSettings.endMessage')}
/>
</Panel.ListItem>
</Panel.ListGroup>
</ModalBody>
<ModalFooter>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button variant='ontime-ghosted' size='md' onClick={onClose} isDisabled={false}>
Cancel
</Button>
<Button variant='ontime-filled' size='md' type='submit' isDisabled={!isValid} isLoading={isSubmitting}>
Create project
</Button>
</ModalFooter>
</form>
</ModalContent>
</Modal>
);
}
@@ -1,15 +0,0 @@
import type { QuickStartData } from 'ontime-types';
export const quickStartDefaults: QuickStartData = {
project: {
title: '',
},
settings: {
timeFormat: '24',
language: 'en',
},
viewSettings: {
freezeEnd: false,
endMessage: '',
},
};
@@ -1,21 +1,17 @@
import { useMemo } from 'react'; import { create } from 'zustand';
import useAppVersion from '../../common/hooks-query/useAppVersion';
export type SettingsOption = { export type SettingsOption = {
id: string; id: string;
label: string; label: string;
secondary?: Readonly<SettingsOption[]>; secondary?: Readonly<SettingsOption[]>;
split?: boolean; split?: boolean;
highlight?: string;
}; };
const staticOptions = [ export const settingPanels: Readonly<SettingsOption[]> = [
{ {
id: 'project', id: 'project',
label: 'Project', label: 'Project',
secondary: [ secondary: [
{ id: 'project__create', label: 'Create...' },
{ id: 'project__data', label: 'Project data' }, { id: 'project__data', label: 'Project data' },
{ id: 'project__manage', label: 'Manage projects' }, { id: 'project__manage', label: 'Manage projects' },
], ],
@@ -81,19 +77,30 @@ const staticOptions = [
}, },
] as const; ] as const;
export type SettingsOptionId = (typeof staticOptions)[number]['id']; export type SettingsOptionId = (typeof settingPanels)[number]['id'];
export function useAppSettingsMenu() { export interface PanelBaseProps {
const { data } = useAppVersion(); location?: string;
const options: Readonly<SettingsOption[]> = useMemo(
() =>
staticOptions.map((option) => ({
...option,
highlight: option.id === 'about' && data.hasUpdates ? 'New version available' : undefined,
})),
[data],
);
return { options };
} }
type SettingsStore = {
unsavedChanges: Set<SettingsOptionId>;
hasUnsavedChanges: (panelId: SettingsOptionId) => boolean;
addUnsavedChanges: (panelId: SettingsOptionId) => void;
removeUnsavedChanges: (panelId: SettingsOptionId) => void;
};
export const useSettingsStore = create<SettingsStore>((set, get) => ({
unsavedChanges: new Set(),
hasUnsavedChanges: (panelId: SettingsOptionId) => get().unsavedChanges.has(panelId),
addUnsavedChanges: (panelId: SettingsOptionId) =>
set((state) => {
state.unsavedChanges.add(panelId);
return { unsavedChanges: new Set(state.unsavedChanges) };
}),
removeUnsavedChanges: (panelId: SettingsOptionId) =>
set((state) => {
state.unsavedChanges.delete(panelId);
return { unsavedChanges: new Set(state.unsavedChanges) };
}),
}));
@@ -1,7 +1,7 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { SettingsOptionId } from './useAppSettingsMenu'; import { SettingsOptionId } from './settingsStore';
const settingsKey = 'settings'; const settingsKey = 'settings';
@@ -1,9 +1,10 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { Input } from '@chakra-ui/react'; import { IconButton, Input } from '@chakra-ui/react';
import { IoEye } from '@react-icons/all-files/io5/IoEye'; import { IoEye } from '@react-icons/all-files/io5/IoEye';
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline'; import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn'; import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayMid } from '../../../ontimeConfig'; import { tooltipDelayMid } from '../../../ontimeConfig';
import style from './InputRow.module.scss'; import style from './InputRow.module.scss';
@@ -12,13 +13,15 @@ interface InputRowProps {
label: string; label: string;
placeholder: string; placeholder: string;
text: string; text: string;
visible: boolean; visible?: boolean;
actionHandler: () => void; readonly?: boolean;
actionHandler: (action: string, payload: object) => void;
changeHandler: (newValue: string) => void; changeHandler: (newValue: string) => void;
className?: string;
} }
export default function InputRow(props: InputRowProps) { export default function InputRow(props: InputRowProps) {
const { label, placeholder, text, visible, actionHandler, changeHandler } = props; const { label, placeholder, text, visible, actionHandler, changeHandler, className, readonly } = props;
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const cursorPositionRef = useRef(0); const cursorPositionRef = useRef(0);
@@ -36,27 +39,41 @@ export default function InputRow(props: InputRowProps) {
changeHandler(event.target.value); changeHandler(event.target.value);
}; };
const classes = cx([style.inputRow, className]);
return ( return (
<div className={style.inputRow}> <div className={classes}>
<label className={`${style.label} ${visible ? style.active : ''}`}>{label}</label> <label className={`${style.label} ${visible ? style.active : ''}`}>{label}</label>
<div className={style.inputItems}> <div className={style.inputItems}>
<Input <Input
ref={inputRef} ref={inputRef}
size='sm' size='sm'
variant='ontime-filled' variant='ontime-filled'
readOnly={readonly}
disabled={readonly}
value={text} value={text}
onChange={handleInputChange} onChange={handleInputChange}
placeholder={placeholder} placeholder={placeholder}
/> />
<TooltipActionBtn {readonly ? (
clickHandler={actionHandler} <IconButton
tooltip={visible ? 'Make invisible' : 'Make visible'} size='sm'
aria-label={`Toggle ${label}`} isDisabled
openDelay={tooltipDelayMid} icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />} aria-label={`Toggle ${label}`}
variant={visible ? 'ontime-filled' : 'ontime-subtle'} variant={visible ? 'ontime-filled' : 'ontime-subtle'}
size='sm' />
/> ) : (
<TooltipActionBtn
clickHandler={() => actionHandler('update', { field: 'isPublic', value: !visible })}
tooltip={visible ? 'Make invisible' : 'Make visible'}
aria-label={`Toggle ${label}`}
openDelay={tooltipDelayMid}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
size='sm'
/>
)}
</div> </div>
</div> </div>
); );
@@ -1,3 +1,23 @@
.messageContainer {
display: flex;
flex-direction: column;
gap: $section-spacing;
}
.buttonSection {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $element-spacing;
margin-top: -0.5rem;
}
.singleAction {
display: flex;
flex-direction: column;
gap: $element-spacing;
margin-top: $element-inner-spacing;
}
.label { .label {
font-size: $inner-section-text-size; font-size: $inner-section-text-size;
color: $label-gray; color: $label-gray;
@@ -6,73 +26,3 @@
color: $action-text-color; color: $action-text-color;
} }
} }
.previewContainer {
display: grid;
gap: $element-spacing;
grid-template-columns: 2fr 1fr;
}
.preview {
background-color: $ui-black;
display: grid;
place-content: center;
text-align: center;
position: relative;
}
.options {
display: flex;
flex-direction: column;
gap: $element-spacing;
}
.eventStatus {
position: absolute;
left: 0;
margin: 0.5rem 0.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.mainContent {
font-size: 1rem;
font-weight: 600;
color: var(--override-colour, $ui-white);
&[data-phase='pending'] {
color: $ontime-roll;
}
&[data-phase='overtime'] {
color: $playback-negative;
}
&[data-phase='none'] {
opacity: $opacity-disabled;
}
}
.secondaryContent {
border-top: 1px solid $white-7;
}
.blackout {
display: none;
}
.timerIndicators {
display: flex;
flex-direction: column;
}
.statusIcon {
color: $gray-1000;
&[data-active='true'] {
color: $active-indicator;
}
}
.divider {
border-top: 1px solid $gray-1000;
}
@@ -1,52 +1,64 @@
import { setMessage, useExternalMessageInput, useTimerMessageInput } from '../../../common/hooks/useSocket'; import { Button } from '@chakra-ui/react';
import { IoEye } from '@react-icons/all-files/io5/IoEye';
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
import { IoSunny } from '@react-icons/all-files/io5/IoSunny';
import { IoSunnyOutline } from '@react-icons/all-files/io5/IoSunnyOutline';
import { setMessage, useMessageControl } from '../../../common/hooks/useSocket';
import { enDash } from '../../../common/utils/styleUtils';
import InputRow from './InputRow'; import InputRow from './InputRow';
import TimerControlsPreview from './TimerViewControl';
import style from './MessageControl.module.scss';
const noop = () => undefined;
export default function MessageControl() { export default function MessageControl() {
return ( const message = useMessageControl();
<> const blink = message.timer.blink;
<TimerControlsPreview /> const blackout = message.timer.blackout;
<TimerMessageInput />
<ExternalInput />
</>
);
}
function TimerMessageInput() {
const { text, visible } = useTimerMessageInput();
return ( return (
<InputRow <div className={style.messageContainer}>
label='Timer Message' <InputRow
placeholder='Message shown fullscreen in stage timer' label='Timer'
text={text} placeholder='Message shown in stage timer'
visible={visible} text={message.timer.text}
changeHandler={(newValue) => setMessage.timerText(newValue)} visible={message.timer.visible}
actionHandler={() => setMessage.timerVisible(!visible)} changeHandler={(newValue) => setMessage.timerText(newValue)}
/> actionHandler={() => setMessage.timerVisible(!message.timer.visible)}
); />
} <div className={style.buttonSection}>
<Button
function ExternalInput() { size='sm'
const { text, visible } = useExternalMessageInput(); className={`${blink ? style.blink : ''}`}
variant={blink ? 'ontime-filled' : 'ontime-subtle'}
const toggleExternal = () => { leftIcon={blink ? <IoSunny size='1rem' /> : <IoSunnyOutline size='1rem' />}
if (visible) { onClick={() => setMessage.timerBlink(!blink)}
setMessage.timerSecondary(null); data-testid='toggle timer blink'
} else { >
setMessage.timerSecondary('external'); Blink
} </Button>
}; <Button
size='sm'
return ( className={style.blackoutButton}
<InputRow variant={blackout ? 'ontime-filled' : 'ontime-subtle'}
label='External Message' leftIcon={blackout ? <IoEye size='1rem' /> : <IoEyeOffOutline size='1rem' />}
placeholder='Message shown as secondary text in stage timer' onClick={() => setMessage.timerBlackout(!blackout)}
text={text} data-testid='toggle timer blackout'
visible={visible} >
changeHandler={(newValue) => setMessage.externalText(newValue)} Blackout screen
actionHandler={toggleExternal} </Button>
/> </div>
<InputRow
label='External Message (read only)'
placeholder={enDash}
readonly
text={message.external.text}
visible={message.external.visible}
changeHandler={noop}
actionHandler={noop}
/>
</div>
); );
} }
@@ -1,22 +1,18 @@
import { memo } from 'react'; import { memo } from 'react';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
import { handleLinks } from '../../../common/utils/linkUtils'; import { handleLinks } from '../../../common/utils/linkUtils';
import { cx } from '../../../common/utils/styleUtils';
import { Corner } from '../../editors/editor-utils/EditorUtils';
import MessageControl from './MessageControl'; import MessageControl from './MessageControl';
import style from '../../editors/Editor.module.scss'; import style from '../../editors/Editor.module.scss';
const MessageControlExport = () => { const MessageControlExport = () => {
const isExtracted = window.location.pathname.includes('/messagecontrol');
const classes = cx([style.content, style.contentColumnLayout]);
return ( return (
<div className={style.messages} data-testid='panel-messages-control'> <div className={style.messages} data-testid='panel-messages-control'>
{!isExtracted && <Corner onClick={(event) => handleLinks(event, 'messagecontrol')} />} <IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'messagecontrol')} />
<div className={classes}> <div className={style.content}>
<ErrorBoundary> <ErrorBoundary>
<MessageControl /> <MessageControl />
</ErrorBoundary> </ErrorBoundary>

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