Compare commits

...

24 Commits

Author SHA1 Message Date
Carlos Valente 4f298e6fd9 version bump (#487) 2023-08-12 11:38:36 +02:00
Carlos Valente f3610059ef fix paused (#486)
* fix: prevent timer continue on paused count up

* refactor: throw if not found on delete
2023-08-12 11:24:35 +02:00
Carlos Valente 023aac4ead fix: midnight roll (#484)
* refactor: extract utilities and types

* refactor: extract and simplify progress bar logic

* refactor: duration validation handles midnight

* fix: prevent stale event loader

* refactor: consider next event in timer invalidation

* refactor: reload changes on changed roll target

* refactor: timer load accounts for midnight

* fix: issues with midnight on roll

* refactor: prevent stale secondary event
2023-08-12 10:42:43 +02:00
Carlos Valente 8c9cb908cf chore: improve demo file (#482) 2023-08-12 10:00:30 +02:00
Carlos Valente 839cee18f3 feat: add french translations (#483) 2023-08-12 10:00:14 +02:00
Olayinka Zadat O b83ebf53a9 Adding Alias as a Parameter (#471)
* redirect on alias to a new page

* move alias to a HOC wrapper
2023-08-11 14:53:36 +02:00
Carlos Valente 1b8392bba4 dx: improve local build command (#480) 2023-08-11 10:55:24 +02:00
Carlos Valente af1241bec7 feat: event description (#481)
* feat: enable configuration of <Info> panel

* feat: add description field to event data
2023-08-11 10:55:02 +02:00
Carlos Valente c361ff4b3f refactor: handle undefined (#479) 2023-08-11 10:54:24 +02:00
Cody Wilson b579a4c57e dist-mac: create separate dists for aarch64, x64 (#470)
* dist-mac: create separate dists for aarch64, x64
2023-08-11 10:51:00 +02:00
Carlos Valente e49918e8bb style: show event title in info panel (#467) 2023-07-23 15:41:58 +02:00
Carlos Valente 60799d3d88 Refactor error messages (#463)
* refactor: improve error messages to user

* refactor: remove unused
2023-07-23 15:12:25 +02:00
Carlos Valente 31fc222876 many hotfixes (#465)
* style: disable menu bar in run mode

* fix: external devices in dev mode

* fix: add missing elements to translations

* ux: improve translation strings

* refactor: improve typing

* style: blink on event change

* refactor: param options is object

---------

Co-authored-by: Hannes Rüger <hannesrueger@gmx.de>
2023-07-23 15:10:53 +02:00
Carlos Valente bb1eb2838f fix csv export (#466)
* style: cleanup formatting

* fix: prevent issues with csv export

* chore: update test snapshots
2023-07-23 15:06:25 +02:00
Carlos Valente 3603b836f6 refactor: cuesheet v2 (#435)
* refactor: typescript migration

* chore: remove prop-types package

* refactor: file structure

* refactor: migrate ontime table to tanstack table 8

* refactor: rundown controller uses service as data source

* refactor: convert to typescript

* feat: caching store

* refactor: add delay values to rundown

* feat: toggle past visibility

* chore: update tests

* refactor: add extra fields to CSV

* style: show skipped events

* chore: add route to navigation menu

* style: allow jumping to bottom

* chore: add tests
2023-07-22 09:32:40 +02:00
Carlos Valente bee7a8dcb4 fix: prevent typerror on fullscreen request (#461) 2023-07-21 23:12:15 +02:00
Carlos Valente e3444747bb fix: prevent clipboard error in secure context (#462) 2023-07-21 23:12:03 +02:00
Carlos Valente db1155ce1a feat: allow renaming connection (#457)
* feat: allow renaming connection
2023-07-21 20:15:54 +02:00
Carlos Valente 6ef6b0fdcd fix: app image cannot find resources (#458)
* fix: typo prevents linux from finding resources
2023-07-20 21:35:09 +02:00
Carlos Valente c73f7e2207 fix: issues with clock overflow on midnight (#456)
* fix: issues with clock overflow on midnight

* chore: version bump
2023-07-17 13:53:50 +02:00
Carlos Valente 4a0d83d970 fix: fullscreen permissions (#453)
attempt fixing issues with going fullscreen by configuring the Content Security Policy
2023-07-17 13:51:03 +02:00
Carlos Valente b89d0787d4 refactor: roll handover (#451)
* style: tweak active colour

* refactor: create shared playback validation

* feat: allow handover from and to roll
2023-07-15 11:06:10 +02:00
Carlos Valente 91a2d395cb style: small tweaks on code style and editor UI (#450)
* fix: notes > note

* style: improve roll visibility
2023-07-11 21:54:10 +02:00
Carlos Valente 6c9222bce3 chore: improve bug reporting data (#443) 2023-07-10 22:15:13 +02:00
166 changed files with 4872 additions and 3022 deletions
+3 -1
View File
@@ -35,7 +35,9 @@ jobs:
- name: Release - name: Release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
with: with:
files: './apps/electron/dist/ontime-macOS.dmg' files: |
'./apps/electron/dist/ontime-macOS-x64.dmg'
'./apps/electron/dist/ontime-macOS-arm64.dmg'
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+6
View File
@@ -20,6 +20,12 @@ From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i` - __Install the project dependencies__ by running `pnpm i`
- __Run dev mode__ by running `turbo dev` - __Run dev mode__ by running `turbo dev`
### Debugging backend
To debug backend code in Node.js:
- Open two separate terminals and navigate to the `apps/client` and `apps/server` directories.
- In each terminal, run the command `pnpm dev` to start the development servers for both the client and server applications.
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by running `pnpm dev:inspect`.
## TESTING ## TESTING
Generally we have 2 types of tests. Generally we have 2 types of tests.
-6
View File
@@ -1,6 +0,0 @@
{
"extends": [
"stylelint-config-standard-scss",
"stylelint-config-prettier"
]
}
+4 -10
View File
@@ -1,9 +1,9 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "2.0.2", "version": "2.3.8",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@chakra-ui/react": "^2.5.5", "@chakra-ui/react": "^2.7.0",
"@dnd-kit/core": "^6.0.8", "@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^7.0.2", "@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.1", "@dnd-kit/utilities": "^3.2.1",
@@ -14,7 +14,8 @@
"@sentry/tracing": "^7.46.0", "@sentry/tracing": "^7.46.0",
"@tanstack/react-query": "^4.28.0", "@tanstack/react-query": "^4.28.0",
"@tanstack/react-query-devtools": "^4.29.0", "@tanstack/react-query-devtools": "^4.29.0",
"autosize": "^5.0.2", "@tanstack/react-table": "^8.9.2",
"autosize": "^6.0.1",
"axios": "^1.2.0", "axios": "^1.2.0",
"color": "^4.2.3", "color": "^4.2.3",
"csv-stringify": "^6.2.3", "csv-stringify": "^6.2.3",
@@ -27,7 +28,6 @@
"react-hook-form": "^7.43.5", "react-hook-form": "^7.43.5",
"react-qr-code": "^2.0.11", "react-qr-code": "^2.0.11",
"react-router-dom": "^6.3.0", "react-router-dom": "^6.3.0",
"react-table": "^7.7.0",
"typeface-open-sans": "^1.1.13", "typeface-open-sans": "^1.1.13",
"web-vitals": "^3.1.1", "web-vitals": "^3.1.1",
"zustand": "^4.3.6" "zustand": "^4.3.6"
@@ -40,7 +40,6 @@
"build:local": "cross-env NODE_ENV=local vite build", "build:local": "cross-env NODE_ENV=local vite build",
"build:docker": "vite build", "build:docker": "vite build",
"lint": "eslint .", "lint": "eslint .",
"stylelint": "npx stylelint \"**/*.scss\"\n",
"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"
@@ -65,7 +64,6 @@
"@testing-library/user-event": "^14.1.1", "@testing-library/user-event": "^14.1.1",
"@types/color": "^3.0.3", "@types/color": "^3.0.3",
"@types/luxon": "^3.2.0", "@types/luxon": "^3.2.0",
"@types/prop-types": "^15.7.5",
"@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",
@@ -84,11 +82,7 @@
"ontime-types": "workspace:*", "ontime-types": "workspace:*",
"ontime-utils": "workspace:*", "ontime-utils": "workspace:*",
"prettier": "^2.8.3", "prettier": "^2.8.3",
"prop-types": "^15.8.1",
"sass": "^1.57.1", "sass": "^1.57.1",
"stylelint": "^14.16.1",
"stylelint-config-prettier": "^9.0.4",
"stylelint-config-standard-scss": "^6.1.0",
"typescript": "^4.9.4", "typescript": "^4.9.4",
"vite": "^4.3.1", "vite": "^4.3.1",
"vite-plugin-compression2": "^0.9.0", "vite-plugin-compression2": "^0.9.0",
+3 -1
View File
@@ -9,6 +9,7 @@ import { AppContextProvider } from './common/context/AppContext';
import { ContextMenuProvider } from './common/context/ContextMenuContext'; import { ContextMenuProvider } from './common/context/ContextMenuContext';
import useElectronEvent from './common/hooks/useElectronEvent'; import useElectronEvent from './common/hooks/useElectronEvent';
import { ontimeQueryClient } from './common/queryClient'; import { ontimeQueryClient } from './common/queryClient';
import { socketClientName } from './common/stores/connectionName';
import { connectSocket } from './common/utils/socket'; import { connectSocket } from './common/utils/socket';
import theme from './theme/theme'; import theme from './theme/theme';
import { TranslationProvider } from './translation/TranslationProvider'; import { TranslationProvider } from './translation/TranslationProvider';
@@ -18,7 +19,8 @@ import AppRouter from './AppRouter';
// @ts-expect-error no types from font import // @ts-expect-error no types from font import
import('typeface-open-sans'); import('typeface-open-sans');
connectSocket(); const preferredClientName = socketClientName.getState().name;
connectSocket(preferredClientName);
function App() { function App() {
const { isElectron, sendToElectron } = useElectronEvent(); const { isElectron, sendToElectron } = useElectronEvent();
+15 -32
View File
@@ -1,11 +1,10 @@
import { lazy, Suspense, useEffect } from 'react'; import { lazy, Suspense } from 'react';
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; import { Navigate, Route, Routes } from 'react-router-dom';
import useAliases from './common/hooks-query/useAliases';
import withData from './features/viewers/ViewWrapper'; import withData from './features/viewers/ViewWrapper';
import withAlias from './features/AliasWrapper';
const Editor = lazy(() => import('./features/editors/ProtectedEditor')); const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
const Table = lazy(() => import('./features/table/ProtectedTable')); const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
const TimerView = lazy(() => import('./features/viewers/timer/Timer')); const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer')); const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
@@ -17,14 +16,14 @@ const Public = lazy(() => import('./features/viewers/public/Public'));
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerWrapper')); const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerWrapper'));
const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock')); const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock'));
const STimer = withData(TimerView); const STimer = withAlias(withData(TimerView));
const SMinimalTimer = withData(MinimalTimerView); const SMinimalTimer = withAlias(withData(MinimalTimerView));
const SClock = withData(ClockView); const SClock = withAlias(withData(ClockView));
const SCountdown = withData(Countdown); const SCountdown = withAlias(withData(Countdown));
const SBackstage = withData(Backstage); const SBackstage = withAlias(withData(Backstage));
const SPublic = withData(Public); const SPublic = withAlias(withData(Public));
const SLowerThird = withData(Lower); const SLowerThird = withAlias(withData(Lower));
const SStudio = withData(StudioClock); const SStudio = withAlias(withData(StudioClock));
const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper')); const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = lazy(() => import('./features/rundown/RundownExport')); const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
@@ -33,22 +32,6 @@ const MessageControl = lazy(() => import('./features/control/message/MessageCont
const Info = lazy(() => import('./features/info/InfoExport')); const Info = lazy(() => import('./features/info/InfoExport'));
export default function AppRouter() { export default function AppRouter() {
const { data } = useAliases();
const location = useLocation();
const navigate = useNavigate();
// navigate if is alias route
useEffect(() => {
if (!data) return;
for (const d of data) {
if (`/${d.alias}` === location.pathname && d.enabled) {
navigate(`/${d.pathAndParams}`);
break;
}
}
}, [data, location, navigate]);
return ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
<Routes> <Routes>
@@ -76,9 +59,9 @@ export default function AppRouter() {
{/*/!* Protected Routes *!/*/} {/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} /> <Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<Table />} /> <Route path='/cuesheet' element={<Cuesheet />} />
<Route path='/cuelist' element={<Table />} /> <Route path='/cuelist' element={<Cuesheet />} />
<Route path='/table' element={<Table />} /> <Route path='/table' element={<Cuesheet />} />
{/*/!* Protected Routes - Elements *!/*/} {/*/!* Protected Routes - Elements *!/*/}
<Route <Route
+6 -3
View File
@@ -12,9 +12,12 @@ export const APP_SETTINGS = ['appSettings'];
export const VIEW_SETTINGS = ['viewSettings']; export const VIEW_SETTINGS = ['viewSettings'];
export const RUNTIME = ['runtimeStore']; export const RUNTIME = ['runtimeStore'];
export const serverPort = import.meta.env.DEV ? STATIC_PORT : window.location.port; const location = window.location;
export const serverURL = import.meta.env.DEV ? `http://localhost:${serverPort}` : window.location.origin; const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
export const websocketUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:${serverPort}/ws`;
export const serverPort = import.meta.env.DEV ? STATIC_PORT : location.port;
export const serverURL = import.meta.env.DEV ? `http://${location.hostname}:${serverPort}` : location.origin;
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
export const eventURL = `${serverURL}/eventdata`; export const eventURL = `${serverURL}/eventdata`;
export const rundownURL = `${serverURL}/events`; export const rundownURL = `${serverURL}/events`;
+20
View File
@@ -0,0 +1,20 @@
import axios, { AxiosError } from 'axios';
import { LogLevel } from 'ontime-types';
import { generateId, millisToString } from 'ontime-utils';
import { addLog } from '../stores/logger';
import { nowInMillis } from '../utils/time';
export function logAxiosError(prepend: string, error: unknown) {
const message = axios.isAxiosError(error)
? `${prepend} ${(error as AxiosError).response?.statusText ?? ''}: ${(error as AxiosError).response?.data ?? ''}`
: `${prepend}: ${error}`;
addLog({
id: generateId(),
origin: 'SERVER',
time: millisToString(nowInMillis()),
level: LogLevel.Error,
text: message,
});
}
-8
View File
@@ -28,14 +28,6 @@ export async function requestPutEvent(data: Partial<OntimeRundownEntry>) {
return axios.put(rundownURL, data); return axios.put(rundownURL, data);
} }
/**
* @description HTTP request to modify event
* @return {Promise}
*/
export async function requestPatchEvent(data: OntimeRundownEntry) {
return axios.patch(rundownURL, data);
}
export type ReorderEntry = { export type ReorderEntry = {
eventId: string; eventId: string;
from: number; from: number;
@@ -3,6 +3,9 @@ import React from 'react';
// skipcq: JS-C1003 - sentry does not expose itself as an ES Module. // skipcq: JS-C1003 - sentry does not expose itself as an ES Module.
import * as Sentry from '@sentry/react'; import * as Sentry from '@sentry/react';
import { runtime } from '@/common/stores/runtime';
import { hasConnected, reconnectAttempts, shouldReconnect } from '@/common/utils/socket';
import style from './ErrorBoundary.module.scss'; import style from './ErrorBoundary.module.scss';
class ErrorBoundary extends React.Component { class ErrorBoundary extends React.Component {
@@ -25,7 +28,9 @@ class ErrorBoundary extends React.Component {
}); });
Sentry.withScope((scope) => { Sentry.withScope((scope) => {
scope.setExtras(error); scope.setExtras('error', error);
scope.setExtras('store', runtime.getState());
scope.setExtras('hasSocket', { hasConnected, shouldReconnect, reconnectAttempts });
const eventId = Sentry.captureException(error); const eventId = Sentry.captureException(error);
this.setState({ eventId, info }); this.setState({ eventId, info });
}); });
@@ -3,12 +3,7 @@ import { Textarea, TextareaProps } from '@chakra-ui/react';
// @ts-expect-error no types from library // @ts-expect-error no types from library
import autosize from 'autosize/dist/autosize'; import autosize from 'autosize/dist/autosize';
interface AutoTextAreaProps extends TextareaProps { export const AutoTextArea = (props: TextareaProps) => {
isDark?: boolean;
}
export const AutoTextArea = (props: AutoTextAreaProps) => {
const { isDark, ...rest } = props;
const ref = useRef<HTMLTextAreaElement>(null); const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => { useEffect(() => {
@@ -26,8 +21,8 @@ export const AutoTextArea = (props: AutoTextAreaProps) => {
resize='none' resize='none'
ref={ref} ref={ref}
transition='height none' transition='height none'
variant={isDark ? 'ontime-filled' : 'ontime-filled-on-light'} variant='ontime-transparent'
{...rest} {...props}
/> />
); );
}; };
@@ -1,6 +1,7 @@
import { KeyboardEvent, useEffect, useRef, useState } from 'react'; import { KeyboardEvent, memo, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Link, useLocation, useSearchParams } from 'react-router-dom'; import { Link, useLocation, useSearchParams } from 'react-router-dom';
import { useDisclosure } from '@chakra-ui/react';
import { IoApps } from '@react-icons/all-files/io5/IoApps'; import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp'; import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoContract } from '@react-icons/all-files/io5/IoContract'; import { IoContract } from '@react-icons/all-files/io5/IoContract';
@@ -14,9 +15,11 @@ import useFullscreen from '../../hooks/useFullscreen';
import { useKeyDown } from '../../hooks/useKeyDown'; import { useKeyDown } from '../../hooks/useKeyDown';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
import RenameClientModal from './rename-client-modal/RenameClientModal';
import style from './NavigationMenu.module.scss'; import style from './NavigationMenu.module.scss';
export default function NavigationMenu() { function NavigationMenu() {
const location = useLocation(); const location = useLocation();
const { isFullScreen, toggleFullScreen } = useFullscreen(); const { isFullScreen, toggleFullScreen } = useFullscreen();
@@ -27,8 +30,10 @@ export default function NavigationMenu() {
const menuRef = useRef<HTMLDivElement | null>(null); const menuRef = useRef<HTMLDivElement | null>(null);
useClickOutside(menuRef, () => setShowMenu(false)); useClickOutside(menuRef, () => setShowMenu(false));
const { isOpen, onOpen, onClose } = useDisclosure();
const toggleMenu = () => setShowMenu((prev) => !prev); const toggleMenu = () => setShowMenu((prev) => !prev);
useKeyDown(toggleMenu, ' ', { isDisabled: searchParams.get('edit') === 'true' }); useKeyDown(toggleMenu, ' ', { isDisabled: searchParams.get('edit') === 'true' || isOpen });
useEffect(() => { useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null; let fadeOut: NodeJS.Timeout | null = null;
@@ -51,6 +56,7 @@ export default function NavigationMenu() {
const isKeyEnter = (event: KeyboardEvent<HTMLDivElement>) => event.key === 'Enter'; const isKeyEnter = (event: KeyboardEvent<HTMLDivElement>) => event.key === 'Enter';
const handleFullscreen = () => toggleFullScreen(); const handleFullscreen = () => toggleFullScreen();
const handleMirror = () => toggleMirror(); const handleMirror = () => toggleMirror();
const showEditFormDrawer = () => { const showEditFormDrawer = () => {
searchParams.append('edit', 'true'); searchParams.append('edit', 'true');
setSearchParams(searchParams); setSearchParams(searchParams);
@@ -58,6 +64,7 @@ export default function NavigationMenu() {
return createPortal( return createPortal(
<div id='navigation-menu-portal' ref={menuRef} className={mirror ? style.mirror : ''}> <div id='navigation-menu-portal' ref={menuRef} className={mirror ? style.mirror : ''}>
<RenameClientModal isOpen={isOpen} onClose={onClose} />
<div className={`${style.buttonContainer} ${!showButton && !showMenu ? style.hidden : ''}`}> <div className={`${style.buttonContainer} ${!showButton && !showMenu ? style.hidden : ''}`}>
<button onClick={toggleMenu} aria-label='toggle menu' className={style.navButton}> <button onClick={toggleMenu} aria-label='toggle menu' className={style.navButton}>
<IoApps /> <IoApps />
@@ -92,11 +99,24 @@ export default function NavigationMenu() {
Flip Screen Flip Screen
<IoSwapVertical /> <IoSwapVertical />
</div> </div>
{/*<div className={style.link} tabIndex={0}>*/} <div
{/* Rename Client*/} className={style.link}
{/*</div>*/} tabIndex={0}
role='button'
onClick={onOpen}
onKeyDown={(event) => {
isKeyEnter(event) && onOpen();
}}
>
Rename Client
</div>
</div> </div>
<hr className={style.separator} /> <hr className={style.separator} />
<Link to='/cuesheet' className={style.link} tabIndex={0}>
Cuesheet
<IoArrowUp className={style.linkIcon} />
</Link>
<hr className={style.separator} />
{navigatorConstants.map((route) => ( {navigatorConstants.map((route) => (
<Link <Link
key={route.url} key={route.url}
@@ -116,3 +136,5 @@ export default function NavigationMenu() {
document.body, document.body,
); );
} }
export default memo(NavigationMenu);
@@ -0,0 +1,5 @@
.modalBody {
display: flex;
flex-direction: column;
gap: 1rem;
}
@@ -0,0 +1,73 @@
import { useEffect, useState } from 'react';
import {
Button,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react';
import { setClientName } from '../../../hooks/useSocket';
import { useSocketClientName } from '../../../stores/connectionName';
import style from './RenameClientModal.module.scss';
interface RenameClientModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function RenameClientModal({ isOpen, onClose }: RenameClientModalProps) {
const { name: clientName, persistName } = useSocketClientName();
const [newName, setNewName] = useState(clientName);
useEffect(() => {
setNewName(clientName);
}, [isOpen, clientName]);
const handleRename = async () => {
if (newName) {
await setClientName(newName);
persistName(newName);
onClose();
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size='sm'
closeOnOverlayClick={false}
motionPreset='slideInBottom'
scrollBehavior='inside'
preserveScrollBarGap
variant='ontime-small'
>
<ModalOverlay />
<ModalContent>
<ModalHeader>Rename client</ModalHeader>
<ModalCloseButton />
<ModalBody className={style.modalBody}>
<Input
placeholder='Connection must have a name'
defaultValue={newName}
onChange={(e) => setNewName(e.target.value)}
variant='ontime-filled-on-light'
/>
<Button
isDisabled={newName === clientName || !newName}
onClick={handleRename}
width='100%'
variant='ontime-filled'
>
Save
</Button>
</ModalBody>
</ModalContent>
</Modal>
);
}
@@ -15,12 +15,12 @@ export default function ParamInput({ paramField }: EditFormInputProps) {
if (type === 'option') { if (type === 'option') {
const optionFromParams = searchParams.get(id); const optionFromParams = searchParams.get(id);
const defaultOptionValue = paramField.values.find((value) => value === optionFromParams); const defaultOptionValue = optionFromParams || undefined;
return ( return (
<Select placeholder='Select an option' variant='ontime' name={id} defaultValue={defaultOptionValue}> <Select placeholder='Select an option' variant='ontime' name={id} defaultValue={defaultOptionValue}>
{paramField.values.map((value) => ( {Object.entries(paramField.values).map(([key, value]) => (
<option key={value} value={value}> <option key={key} value={key}>
{value} {value}
</option> </option>
))} ))}
@@ -5,7 +5,7 @@ export const TIME_FORMAT_OPTION: ParamField = {
title: '12 / 24 hour timer', title: '12 / 24 hour timer',
description: 'Whether to show the time in 12 or 24 hour mode. Overrides the global setting from preferences', description: 'Whether to show the time in 12 or 24 hour mode. Overrides the global setting from preferences',
type: 'option', type: 'option',
values: ['12', '24'], values: { '12': '12 hour AM/PM', '24': '24 hour' },
}; };
export const CLOCK_OPTIONS: ParamField[] = [ export const CLOCK_OPTIONS: ParamField[] = [
@@ -45,7 +45,7 @@ export const CLOCK_OPTIONS: ParamField[] = [
title: 'Align Horizontal', title: 'Align Horizontal',
description: 'Moves the horizontally in page to start = left | center | end = right', description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option', type: 'option',
values: ['start', 'center', 'end'], values: { start: 'Start', center: 'Center', end: 'End' },
}, },
{ {
id: 'offsetx', id: 'offsetx',
@@ -58,7 +58,7 @@ export const CLOCK_OPTIONS: ParamField[] = [
title: 'Align Vertical', title: 'Align Vertical',
description: 'Moves the vertically in page to start = left | center | end = right', description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option', type: 'option',
values: ['start', 'center', 'end'], values: { start: 'Start', center: 'Center', end: 'End' },
}, },
{ {
id: 'offsety', id: 'offsety',
@@ -106,7 +106,7 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
title: 'Align Horizontal', title: 'Align Horizontal',
description: 'Moves the horizontally in page to start = left | center | end = right', description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option', type: 'option',
values: ['start', 'center', 'end'], values: { start: 'Start', center: 'Center', end: 'End' },
}, },
{ {
id: 'offsetx', id: 'offsetx',
@@ -119,7 +119,7 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
title: 'Align Vertical', title: 'Align Vertical',
description: 'Moves the vertically in page to start = left | center | end = right', description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option', type: 'option',
values: ['start', 'center', 'end'], values: { start: 'Start', center: 'Center', end: 'End' },
}, },
{ {
id: 'offsety', id: 'offsety',
@@ -4,7 +4,7 @@ type BaseField = {
description: string; description: string;
}; };
type OptionsField = { type: 'option'; values: string[] }; type OptionsField = { type: 'option'; values: Record<string, string> };
type StringField = { type: 'string' }; type StringField = { type: 'string' };
type BooleanField = { type: 'boolean' }; type BooleanField = { type: 'boolean' };
type NumberField = { type: 'number' }; type NumberField = { type: 'number' };
@@ -1,79 +0,0 @@
import { createContext, useCallback, useState } from 'react';
import { useLocalStorage } from '../hooks/useLocalStorage';
export const TableSettingsContext = createContext({
theme: '',
showSettings: false,
followSelected: false,
toggleSettings: () => undefined,
toggleTheme: () => undefined,
toggleFollow: () => undefined,
});
export const TableSettingsProvider = ({ children }) => {
const [theme, setTheme] = useLocalStorage('table-color-theme', 'dark');
const [followSelected, setFollowSelected] = useLocalStorage('table-follow-selected', false);
const [showSettings, setShowSettings] = useState(false);
/**
* @description Toggles the current value of dark mode
* @param {string} val - 'light' or 'dark'
*/
const toggleTheme = useCallback(
(val) => {
if (val === undefined) {
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
} else {
setTheme(val);
}
},
[setTheme]
);
/**
* @description Toggles visibility state for settings
* @param {boolean} val - whether the settings window is visible
*/
const toggleSettings = useCallback(
(val) => {
if (val === undefined) {
setShowSettings((prev) => !prev);
} else {
setShowSettings(val);
}
},
[setShowSettings]
);
/**
* @description Toggles follow option
* @param {boolean} val - whether the window follows selected event
*/
const toggleFollow = useCallback(
(val) => {
if (val === undefined) {
setFollowSelected((prev) => !prev);
} else {
setFollowSelected(val);
}
},
[setFollowSelected]
);
return (
<TableSettingsContext.Provider
value={{
theme,
showSettings,
followSelected,
toggleSettings,
toggleTheme,
toggleFollow,
}}
>
{children}
</TableSettingsContext.Provider>
);
};
@@ -3,6 +3,7 @@ import { OSCSettings } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { OSC_SETTINGS } from '../api/apiConstants'; import { OSC_SETTINGS } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils';
import { getOSC, postOSC, postOscSubscriptions } from '../api/ontimeApi'; import { getOSC, postOSC, postOscSubscriptions } from '../api/ontimeApi';
import { oscPlaceholderSettings } from '../models/OscSettings'; import { oscPlaceholderSettings } from '../models/OscSettings';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
@@ -25,6 +26,7 @@ export default function useOscSettings() {
export function useOscSettingsMutation() { export function useOscSettingsMutation() {
const { isLoading, mutateAsync } = useMutation({ const { isLoading, mutateAsync } = useMutation({
mutationFn: postOSC, mutationFn: postOSC,
onError: (error) => logAxiosError('Error saving OSC settings', error),
onSuccess: (res) => ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data), onSuccess: (res) => ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data),
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }), onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
}); });
@@ -34,6 +36,7 @@ export function useOscSettingsMutation() {
export function usePostOscSubscriptions() { export function usePostOscSubscriptions() {
const { isLoading, mutateAsync } = useMutation({ const { isLoading, mutateAsync } = useMutation({
mutationFn: postOscSubscriptions, mutationFn: postOscSubscriptions,
onError: (error) => logAxiosError('Error saving OSC settings', error),
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }), onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
}); });
return { isLoading, mutateAsync }; return { isLoading, mutateAsync };
+15 -41
View File
@@ -1,9 +1,9 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import axios, { AxiosError } from 'axios';
import { OntimeRundown, OntimeRundownEntry, SupportedEvent } from 'ontime-types'; import { OntimeRundown, OntimeRundownEntry, SupportedEvent } from 'ontime-types';
import { RUNDOWN_TABLE, RUNDOWN_TABLE_KEY } from '../api/apiConstants'; import { RUNDOWN_TABLE, RUNDOWN_TABLE_KEY } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils';
import { import {
ReorderEntry, ReorderEntry,
requestApplyDelay, requestApplyDelay,
@@ -13,16 +13,14 @@ import {
requestPutEvent, requestPutEvent,
requestReorderEvent, requestReorderEvent,
} from '../api/eventsApi'; } from '../api/eventsApi';
import { useLocalEvent } from '../stores/localEvent'; import { useEditorSettings } from '../stores/editorSettings';
import { useEmitLog } from '../stores/logger';
/** /**
* @description Set of utilities for events * @description Set of utilities for events
*/ */
export const useEventAction = () => { export const useEventAction = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { emitError } = useEmitLog(); const eventSettings = useEditorSettings((state) => state.eventSettings);
const eventSettings = useLocalEvent((state) => state.eventSettings);
const defaultPublic = eventSettings.defaultPublic; const defaultPublic = eventSettings.defaultPublic;
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd; const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
@@ -94,14 +92,10 @@ export const useEventAction = () => {
// @ts-expect-error -- we know that the object is well formed now // @ts-expect-error -- we know that the object is well formed now
await _addEventMutation.mutateAsync(newEvent); await _addEventMutation.mutateAsync(newEvent);
} catch (error) { } catch (error) {
if (!axios.isAxiosError(error)) { logAxiosError('Error fetching data', error);
emitError(`Error fetching data: ${(error as AxiosError).message}`);
} else {
emitError(`Error fetching data: ${error}`);
}
} }
}, },
[_addEventMutation, defaultPublic, emitError, queryClient, startTimeIsLastEnd], [_addEventMutation, defaultPublic, queryClient, startTimeIsLastEnd],
); );
/** /**
@@ -144,14 +138,10 @@ export const useEventAction = () => {
try { try {
await _updateEventMutation.mutateAsync(event); await _updateEventMutation.mutateAsync(event);
} catch (error) { } catch (error) {
if (!axios.isAxiosError(error)) { logAxiosError('Error updating event', error);
emitError(`Error updating event: ${(error as AxiosError).message}`);
} else {
emitError(`Error updating event: ${error}`);
}
} }
}, },
[_updateEventMutation, emitError], [_updateEventMutation],
); );
/** /**
@@ -196,14 +186,10 @@ export const useEventAction = () => {
try { try {
await _deleteEventMutation.mutateAsync(eventId); await _deleteEventMutation.mutateAsync(eventId);
} catch (error) { } catch (error) {
if (!axios.isAxiosError(error)) { logAxiosError('Error deleting event', error);
emitError(`Error deleting event: ${(error as AxiosError).message}`);
} else {
emitError(`Error deleting event: ${error}`);
}
} }
}, },
[_deleteEventMutation, emitError], [_deleteEventMutation],
); );
/** /**
@@ -245,13 +231,9 @@ export const useEventAction = () => {
try { try {
await _deleteAllEventsMutation.mutateAsync(); await _deleteAllEventsMutation.mutateAsync();
} catch (error) { } catch (error) {
if (!axios.isAxiosError(error)) { logAxiosError('Error deleting events', error);
emitError(`Error deleting events: ${(error as AxiosError).message}`);
} else {
emitError(`Error deleting events: ${error}`);
}
} }
}, [_deleteAllEventsMutation, emitError]); }, [_deleteAllEventsMutation]);
/** /**
* Calls mutation to apply a delay * Calls mutation to apply a delay
@@ -273,14 +255,10 @@ export const useEventAction = () => {
try { try {
await _applyDelayMutation.mutateAsync(delayEventId); await _applyDelayMutation.mutateAsync(delayEventId);
} catch (error) { } catch (error) {
if (!axios.isAxiosError(error)) { logAxiosError('Error applying delay', error);
emitError(`Error applying delay: ${(error as AxiosError).message}`);
} else {
emitError(`Error applying delay: ${error}`);
}
} }
}, },
[_applyDelayMutation, emitError], [_applyDelayMutation],
); );
/** /**
@@ -332,14 +310,10 @@ export const useEventAction = () => {
}; };
await _reorderEventMutation.mutateAsync(reorderObject); await _reorderEventMutation.mutateAsync(reorderObject);
} catch (error) { } catch (error) {
if (!axios.isAxiosError(error)) { logAxiosError('Error re-ordering event', error);
emitError(`Error re-ordering event: ${(error as AxiosError).message}`);
} else {
emitError(`Error re-ordering event: ${error}`);
}
} }
}, },
[_reorderEventMutation, emitError], [_reorderEventMutation],
); );
return { addEvent, updateEvent, deleteEvent, deleteAllEvents, applyDelay, reorderEvent }; return { addEvent, updateEvent, deleteEvent, deleteAllEvents, applyDelay, reorderEvent };
+15 -9
View File
@@ -34,24 +34,30 @@ export default function useFullscreen() {
const toggleFullScreen = useCallback(() => { const toggleFullScreen = useCallback(() => {
if (!document.fullscreenElement && !(document as WebkitDocument).webkitIsFullScreen) { if (!document.fullscreenElement && !(document as WebkitDocument).webkitIsFullScreen) {
// Fullscreen mode is not active, so we can enter fullscreen mode // Fullscreen mode is not active, so we can enter fullscreen mode
if (document.documentElement.requestFullscreen) { const element = document.documentElement;
if (element.requestFullscreen) {
// Standard fullscreen API is supported // Standard fullscreen API is supported
document.documentElement.requestFullscreen(); element.requestFullscreen().catch(() => {
// @ts-expect-error -- the whole casting dance is not worth it /* nothing to do */
} else if (document.documentElement.webkitRequestFullscreen) { });
} else if (element.webkitRequestFullscreen) {
// iOS Safari fullscreen API is supported // iOS Safari fullscreen API is supported
// @ts-expect-error -- we know this is not undefined now element.webkitRequestFullscreen().catch(() => {
(document.documentElement as WebkitDocument).webkitRequestFullscreen(); /* nothing to do */
});
} }
} else { } else {
// Fullscreen mode is active, so we can exit fullscreen mode // Fullscreen mode is active, so we can exit fullscreen mode
if (document.exitFullscreen) { if (document.exitFullscreen) {
// Standard fullscreen API is supported // Standard fullscreen API is supported
document.exitFullscreen(); document.exitFullscreen().catch((error) => {
console.error('Error while trying to exit fullscreen:', error);
});
} else if ((document as WebkitDocument).webkitExitFullscreen) { } else if ((document as WebkitDocument).webkitExitFullscreen) {
// iOS Safari fullscreen API is supported // iOS Safari fullscreen API is supported
// @ts-expect-error -- we know this is not undefined now (document as WebkitDocument).webkitExitFullscreen().catch(() => {
(document as WebkitDocument).webkitExitFullscreen(); /* nothing to do */
});
} }
} }
}, []); }, []);
@@ -105,3 +105,5 @@ export const useTimer = () => {
return useRuntimeStore(featureSelector, deepCompare); return useRuntimeStore(featureSelector, deepCompare);
}; };
export const setClientName = (newName: string) => socketSendJson('set-client-name', newName);
@@ -2,6 +2,7 @@ import { EventData } from 'ontime-types';
export const eventDataPlaceholder: EventData = { export const eventDataPlaceholder: EventData = {
title: '', title: '',
description: '',
publicUrl: '', publicUrl: '',
publicInfo: '', publicInfo: '',
backstageUrl: '', backstageUrl: '',
@@ -0,0 +1,26 @@
import { useStore } from 'zustand';
import { createStore } from 'zustand/vanilla';
interface SocketClientNameState {
name?: string;
setName: (newValue: string) => void;
persistName: (newValue: string) => void;
}
const clientNameKey = 'ontime-client-name';
function persistKeyToStorage(newValue: string) {
localStorage.setItem(clientNameKey, newValue);
}
export const socketClientName = createStore<SocketClientNameState>((set) => ({
name: localStorage.getItem(clientNameKey) ?? undefined,
setName: (newValue: string) => set(() => ({ name: newValue })),
persistName: (newValue: string) =>
set(() => {
persistKeyToStorage(newValue);
return { name: newValue };
}),
}));
export const useSocketClientName = () => useStore(socketClientName);
@@ -0,0 +1,67 @@
import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage';
type EditorSettings = {
showQuickEntry: boolean;
startTimeIsLastEnd: boolean;
defaultPublic: boolean;
showNif: boolean;
};
type EditorSettingsStore = {
eventSettings: EditorSettings;
setLocalEventSettings: (newState: EditorSettings) => void;
setShowQuickEntry: (showQuickEntry: boolean) => void;
setStartTimeIsLastEnd: (startTimeIsLastEnd: boolean) => void;
setDefaultPublic: (defaultPublic: boolean) => void;
setShowNif: (showNif: boolean) => void;
};
enum EditorSettingsKeys {
ShowQuickEntry = 'ontime-show-quick-entry',
StartTimeIsLastEnd = 'ontime-start-is-last-end',
DefaultPublic = 'ontime-default-public',
ShowNif = 'ontime-show-nif',
}
export const useEditorSettings = create<EditorSettingsStore>((set) => ({
eventSettings: {
showQuickEntry: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, false),
startTimeIsLastEnd: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true),
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true),
showNif: booleanFromLocalStorage(EditorSettingsKeys.ShowNif, true),
},
setLocalEventSettings: (value) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(value.showQuickEntry));
localStorage.setItem(EditorSettingsKeys.StartTimeIsLastEnd, String(value.startTimeIsLastEnd));
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(value.defaultPublic));
return { eventSettings: value };
}),
setShowQuickEntry: (showQuickEntry) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(showQuickEntry));
return { eventSettings: { ...state.eventSettings, showQuickEntry } };
}),
setStartTimeIsLastEnd: (startTimeIsLastEnd) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.StartTimeIsLastEnd, String(startTimeIsLastEnd));
return { eventSettings: { ...state.eventSettings, startTimeIsLastEnd } };
}),
setDefaultPublic: (defaultPublic) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
return { eventSettings: { ...state.eventSettings, defaultPublic } };
}),
setShowNif: (showNif) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.ShowNif, String(showNif));
return { eventSettings: { ...state.eventSettings, showNif } };
}),
}));
@@ -1,57 +0,0 @@
import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage';
type EventSettings = {
showQuickEntry: boolean;
startTimeIsLastEnd: boolean;
defaultPublic: boolean;
};
type LocalEventStore = {
eventSettings: EventSettings;
setLocalEventSettings: (newState: EventSettings) => void;
setShowQuickEntry: (showQuickEntry: boolean) => void;
setStartTimeIsLastEnd: (startTimeIsLastEnd: boolean) => void;
setDefaultPublic: (defaultPublic: boolean) => void;
};
enum LocalEventKeys {
ShowQuickEntry = 'ontime-show-quick-entry',
StartTimeIsLastEnd = 'ontime-start-is-last-end',
DefaultPublic = 'ontime-default-public',
}
export const useLocalEvent = create<LocalEventStore>((set) => ({
eventSettings: {
showQuickEntry: booleanFromLocalStorage(LocalEventKeys.ShowQuickEntry, false),
startTimeIsLastEnd: booleanFromLocalStorage(LocalEventKeys.ShowQuickEntry, true),
defaultPublic: booleanFromLocalStorage(LocalEventKeys.ShowQuickEntry, true),
},
setLocalEventSettings: (value) =>
set(() => {
localStorage.setItem(LocalEventKeys.ShowQuickEntry, String(value.showQuickEntry));
localStorage.setItem(LocalEventKeys.StartTimeIsLastEnd, String(value.startTimeIsLastEnd));
localStorage.setItem(LocalEventKeys.DefaultPublic, String(value.defaultPublic));
return { eventSettings: value };
}),
setShowQuickEntry: (showQuickEntry) =>
set((state) => {
localStorage.setItem(LocalEventKeys.ShowQuickEntry, String(showQuickEntry));
return { eventSettings: { ...state.eventSettings, showQuickEntry } };
}),
setStartTimeIsLastEnd: (startTimeIsLastEnd) =>
set((state) => {
localStorage.setItem(LocalEventKeys.StartTimeIsLastEnd, String(startTimeIsLastEnd));
return { eventSettings: { ...state.eventSettings, startTimeIsLastEnd } };
}),
setDefaultPublic: (defaultPublic) =>
set((state) => {
localStorage.setItem(LocalEventKeys.DefaultPublic, String(defaultPublic));
return { eventSettings: { ...state.eventSettings, defaultPublic } };
}),
}));
+2 -2
View File
@@ -1,5 +1,5 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { Log, LogLevel } from 'ontime-types'; import { Log, LogLevel, LogOrigin } from 'ontime-types';
import { generateId, millisToString } from 'ontime-utils'; import { generateId, millisToString } from 'ontime-utils';
import { useStore } from 'zustand'; import { useStore } from 'zustand';
import { createStore } from 'zustand/vanilla'; import { createStore } from 'zustand/vanilla';
@@ -34,7 +34,7 @@ export function useEmitLog() {
const _emit = useCallback((text: string, level: LogLevel) => { const _emit = useCallback((text: string, level: LogLevel) => {
const log = { const log = {
id: generateId(), id: generateId(),
origin: 'CLIENT', origin: LogOrigin.Client,
time: millisToString(nowInMillis()), time: millisToString(nowInMillis()),
level, level,
text, text,
@@ -1,4 +1,5 @@
import { validateAlias } from '../aliases'; import { resolvePath } from 'react-router-dom';
import { validateAlias, generateURLFromAlias, getAliasRoute } from '../aliases';
describe('An alias fails if incorrect', () => { describe('An alias fails if incorrect', () => {
const testsToFail = [ const testsToFail = [
@@ -23,3 +24,79 @@ describe('An alias fails if incorrect', () => {
}), }),
); );
}); });
describe('generateURLFromAlias and getAliasRoute function', () => {
test('generate the expected url from an alias', () => {
const testData = [
{
enabled: true,
alias: 'demopage',
pathAndParams: '/timer?user=guest',
},
];
const expected = [
{
url: '/timer?user=guest&alias=demopage',
},
];
expect(generateURLFromAlias(testData[0])).toStrictEqual(expected[0].url);
});
test('generate the url to redirect to when the current URL is just the alias', () => {
const aliases = [
{
enabled: true,
alias: 'demopage',
pathAndParams: '/timer?user=guest',
},
];
// let current location be the alias
const location = resolvePath(aliases[0].alias);
const expected = [
{
url: '/timer?user=guest&alias=demopage',
},
];
expect(getAliasRoute(location, aliases, null)).toStrictEqual(expected[0].url);
});
test('generate the url to redirect to when the current URL the same url but with a change of params', () => {
const aliases = [
{
enabled: true,
alias: 'demopage',
pathAndParams: '/timer?user=guest',
},
];
// let current location be the actual url with alias attached to it
const location = resolvePath(aliases[0].pathAndParams);
const urlSearchParams = new URLSearchParams(location.search);
urlSearchParams.append('alias', aliases[0].alias); //
// update current alias with extra param
aliases[0].pathAndParams += '&eventId=674';
const expected = [
{
url: '/timer?user=guest&eventId=674&alias=demopage',
},
];
expect(getAliasRoute(location, aliases, urlSearchParams)).toStrictEqual(expected[0].url);
});
test('generate no url to redirect to when the current URL the same url', () => {
const aliases = [
{
enabled: true,
alias: 'demopage',
pathAndParams: '/timer?user=guest',
},
];
// let current location be the actual url with alias attached to it
const location = resolvePath(aliases[0].pathAndParams);
const urlSearchParams = new URLSearchParams(location.search);
urlSearchParams.append('alias', aliases[0].alias); //
expect(getAliasRoute(location, aliases, urlSearchParams)).toBeNull();
});
});
@@ -1,4 +1,21 @@
import { forgivingStringToMillis, millisToDelayString, millisToMinutes, millisToSeconds } from '../dateConfig'; import {
forgivingStringToMillis,
millisToDelayString,
millisToMinutes,
millisToSeconds,
secondsInMillis,
} from '../dateConfig';
describe('test secondsInMillis function', () => {
it('return 0 if value is null', () => {
expect(secondsInMillis(null)).toBe(0);
});
it('returns the seconds value of a millis date', () => {
const date = 1686255053619; // Thu Jun 08 2023 20:10:53
const seconds = secondsInMillis(date);
expect(seconds).toBe(53);
});
});
describe('test millisToSeconds function', () => { describe('test millisToSeconds function', () => {
it('test with null values', () => { it('test with null values', () => {
@@ -1,27 +0,0 @@
import { calculateDuration, DAY_TO_MS } from '../timesManager';
describe('calculateDuration()', () => {
describe('Given start and end values', () => {
it('calculates duration correctly', () => {
const testStart = 1;
const testEnd = 2;
const val = calculateDuration(testStart, testEnd);
expect(val).toBe(testEnd - testStart);
});
});
describe('Handles edge cases', () => {
it('when start is after end', () => {
const testStart = 3;
const testEnd = 2;
const val = calculateDuration(testStart, testEnd);
expect(val).toBe(testEnd + DAY_TO_MS - testStart);
});
it('when both are equal', () => {
const testStart = 1;
const testEnd = 1;
const val = calculateDuration(testStart, testEnd);
expect(val).toBe(testEnd - testStart);
});
});
});
+50 -2
View File
@@ -1,10 +1,13 @@
import { Alias } from 'ontime-types';
import isEqual from 'react-fast-compare';
import { Location, resolvePath } from 'react-router-dom';
/** /**
* Validates an alias against defined parameters * Validates an alias against defined parameters
* @param {string} alias * @param {string} alias
* @returns {{message: string, status: boolean}} * @returns {{message: string, status: boolean}}
*/ */
export const validateAlias = (alias: string) => { export const validateAlias = (alias: string) => {
const valid = { status: true, message: 'ok' }; const valid = { status: true, message: 'ok' };
if (alias === '' || alias == null) { if (alias === '' || alias == null) {
@@ -26,4 +29,49 @@ export const validateAlias = (alias: string) => {
} }
return valid; return valid;
}; };
/**
* Gets the URL to send an alias to
* @param location
* @param data
* @param searchParams
*/
export const getAliasRoute = (location: Location, data: Alias[], searchParams: URLSearchParams) => {
const currentURL = location.pathname.substring(1);
// we need to check if the whole url here is an alias, so we can redirect
const foundAlias = data.filter((d) => d.alias === currentURL && d.enabled)[0];
if (foundAlias) {
return generateURLFromAlias(foundAlias);
}
const aliasOnPage = searchParams.get('alias');
for (const d of data) {
if (aliasOnPage) {
// if the alias fits the alias on this page, but the URL is diferent, we redirect user to the new URL
// if we have the same alias and its enabled and its not empty
if (d.alias !== '' && d.enabled && d.alias === aliasOnPage) {
const newAliasPath = resolvePath(d.pathAndParams);
const urlParams = new URLSearchParams(newAliasPath.search);
urlParams.set('alias', d.alias);
// we confirm either the url parameters does not match or the url path doesnt
if (!isEqual(urlParams, searchParams) || newAliasPath.pathname !== location.pathname) {
// we then redirect to the alias route, since the view listening to this alias has an outdated URL
return `${newAliasPath.pathname}?${urlParams}`;
}
}
}
}
return null;
};
/**
* Generate URL from an alias
* @param aliasData
*/
export const generateURLFromAlias = (aliasData: Alias) => {
const newAliasPath = resolvePath(aliasData.pathAndParams);
const urlParams = new URLSearchParams(newAliasPath.search);
urlParams.set('alias', aliasData.alias);
return `${newAliasPath.pathname}?${urlParams}`;
};
@@ -1,4 +1,4 @@
// we need to this as a promise because safari // we need to this as a promise because safari
export default async function copyToClipboard(text: string) { export default async function copyToClipboard(text: string) {
setTimeout(async () => await navigator.clipboard.writeText(text)); setTimeout(async () => await navigator.clipboard?.writeText(text));
} }
@@ -5,6 +5,13 @@ import { mth, mtm, mts } from './timeConstants';
export const timeFormat = 'HH:mm'; export const timeFormat = 'HH:mm';
export const timeFormatSeconds = 'HH:mm:ss'; export const timeFormatSeconds = 'HH:mm:ss';
export function secondsInMillis(millis: number | null) {
if (!millis) {
return 0;
}
return Math.floor((millis % mtm) / mts);
}
/** /**
* @description Converts milliseconds to seconds * @description Converts milliseconds to seconds
* @param {number | null} millis - time in seconds * @param {number | null} millis - time in seconds
+11 -2
View File
@@ -41,7 +41,7 @@ export const getEventsWithDelay = (rundown: OntimeRundownEntry[]): OntimeEvent[]
* @param {number} limit - max number of events to return * @param {number} limit - max number of events to return
* @returns {Object[]} Event list with maximum <limit> objects * @returns {Object[]} Event list with maximum <limit> objects
*/ */
export const trimRundown = (rundown: OntimeRundownEntry[], selectedId: string, limit: number) => { export const trimRundown = (rundown: OntimeEvent[], selectedId: string, limit: number): OntimeEvent[] => {
if (rundown == null) return []; if (rundown == null) return [];
const BEFORE = 2; const BEFORE = 2;
@@ -78,7 +78,7 @@ export const formatEventList = (
selectedId: string, selectedId: string,
nextId: string, nextId: string,
options: FormatEventListOptionsProp, options: FormatEventListOptionsProp,
) => { ): ScheduleEvent[] => {
if (rundown == null) return []; if (rundown == null) return [];
const { showEnd = false } = options; const { showEnd = false } = options;
@@ -103,6 +103,15 @@ export const formatEventList = (
return formattedEvents; return formattedEvents;
}; };
export type ScheduleEvent = {
id: string;
time: string;
title: string;
isNow: boolean;
isNext: boolean;
colour: string;
};
/** /**
* @description Creates a safe duplicate of an event * @description Creates a safe duplicate of an event
* @param {object} event * @param {object} event
+16 -3
View File
@@ -2,19 +2,27 @@ import { Log, RuntimeStore } from 'ontime-types';
import { RUNTIME, websocketUrl } from '../api/apiConstants'; import { RUNTIME, websocketUrl } from '../api/apiConstants';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
import { socketClientName } from '../stores/connectionName';
import { addLog } from '../stores/logger'; import { addLog } from '../stores/logger';
import { runtime } from '../stores/runtime'; import { runtime } from '../stores/runtime';
export let websocket: WebSocket | null = null; export let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null; let reconnectTimeout: NodeJS.Timeout | null = null;
const reconnectInterval = 1000; const reconnectInterval = 1000;
let shouldReconnect = true; export let shouldReconnect = true;
export let hasConnected = false;
export const connectSocket = () => { export let reconnectAttempts = 0;
export const connectSocket = (preferredClientName?: string) => {
websocket = new WebSocket(websocketUrl); websocket = new WebSocket(websocketUrl);
websocket.onopen = () => { websocket.onopen = () => {
clearTimeout(reconnectTimeout as NodeJS.Timeout); clearTimeout(reconnectTimeout as NodeJS.Timeout);
hasConnected = true;
reconnectAttempts = 0;
if (preferredClientName) {
socketSendJson('set-client-name', preferredClientName);
}
}; };
websocket.onclose = () => { websocket.onclose = () => {
@@ -23,6 +31,7 @@ export const connectSocket = () => {
reconnectTimeout = setTimeout(() => { reconnectTimeout = setTimeout(() => {
console.warn('WebSocket: attempting reconnect'); console.warn('WebSocket: attempting reconnect');
if (websocket && websocket.readyState === WebSocket.CLOSED) { if (websocket && websocket.readyState === WebSocket.CLOSED) {
reconnectAttempts += 1;
connectSocket(); connectSocket();
} }
}, reconnectInterval); }, reconnectInterval);
@@ -45,6 +54,10 @@ export const connectSocket = () => {
// TODO: implement partial store updates // TODO: implement partial store updates
switch (type) { switch (type) {
case 'client-name': {
socketClientName.getState().setName(payload);
break;
}
case 'ontime-log': { case 'ontime-log': {
addLog(payload as Log); addLog(payload as Log);
break; break;
@@ -10,15 +10,8 @@ export const mts = 1000;
*/ */
export const mtm = 1000 * 60; export const mtm = 1000 * 60;
/** /**
* millis to hours * millis to hours
* @type {number} * @type {number}
*/ */
export const mth = 1000 * 60 * 60; export const mth = 1000 * 60 * 60;
/**
* milliseconds in a day
* @type {number}
*/
export const DAY_TO_MS = 86400000;
@@ -1,16 +1,5 @@
export type TimeEntryField = 'timeStart' | 'timeEnd' | 'durationOverride'; export type TimeEntryField = 'timeStart' | 'timeEnd' | 'durationOverride';
/**
* @description Milliseconds in a day
*/
export const DAY_TO_MS = 86400000;
/**
* @description calculates duration from given values
*/
export const calculateDuration = (start: number, end: number): number =>
start > end ? end + DAY_TO_MS - start : end - start;
/** /**
* @description Checks which field the value relates to * @description Checks which field the value relates to
*/ */
+28
View File
@@ -0,0 +1,28 @@
/* eslint-disable react/display-name */
import useAliases from '../common/hooks-query/useAliases';
import { getAliasRoute } from '../common/utils/aliases';
import { ComponentType, useEffect } from 'react';
import { useSearchParams, useNavigate, useLocation } from 'react-router-dom';
const withAlias = <P extends object>(Component: ComponentType<P>) => {
return (props: Partial<P>) => {
const { data } = useAliases();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const location = useLocation();
// navigate if is alias route
useEffect(() => {
if (!data) return;
const url = getAliasRoute(location, data, searchParams);
// navigate to this route if its not empty
if (url) {
navigate(url);
}
}, [data, searchParams, navigate, location]);
return <Component {...props} />;
};
};
export default withAlias;
@@ -5,8 +5,9 @@ import { IoPlaySkipBack } from '@react-icons/all-files/io5/IoPlaySkipBack';
import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward'; import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward';
import { IoReload } from '@react-icons/all-files/io5/IoReload'; import { IoReload } from '@react-icons/all-files/io5/IoReload';
import { IoStop } from '@react-icons/all-files/io5/IoStop'; import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { IoTimeOutline } from '@react-icons/all-files/io5/IoTimeOutline'; import { IoTime } from '@react-icons/all-files/io5/IoTime';
import { Playback } from 'ontime-types'; import { Playback } from 'ontime-types';
import { validatePlayback } from 'ontime-utils';
import { setPlayback } from '../../../../common/hooks/useSocket'; import { setPlayback } from '../../../../common/hooks/useSocket';
import { tooltipDelayMid } from '../../../../ontimeConfig'; import { tooltipDelayMid } from '../../../../ontimeConfig';
@@ -34,7 +35,13 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
const noEvents = numEvents === 0; const noEvents = numEvents === 0;
const disableGo = isRolling || noEvents || (isLast && !isArmed); const disableGo = isRolling || noEvents || (isLast && !isArmed);
const disablePrev = isRolling || noEvents || isFirst; const disablePrev = noEvents || isFirst;
const playbackCan = validatePlayback(playback);
const disableStart = !playbackCan.start;
const disablePause = !playbackCan.pause;
const disableRoll = !playbackCan.roll || noEvents;
const disableStop = !playbackCan.stop;
const goModeText = selectedEventIndex === null || isArmed ? 'Start' : 'Next'; const goModeText = selectedEventIndex === null || isArmed ? 'Start' : 'Next';
const goModeAction = () => { const goModeAction = () => {
@@ -51,21 +58,11 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
{goModeText} {goModeText}
</TapButton> </TapButton>
<div className={style.playbackContainer}> <div className={style.playbackContainer}>
<TapButton <TapButton onClick={setPlayback.start} disabled={disableStart} theme={Playback.Play} active={isPlaying}>
onClick={setPlayback.start}
disabled={isStopped || isRolling}
theme={Playback.Play}
active={isPlaying}
>
<IoPlay /> <IoPlay />
</TapButton> </TapButton>
<TapButton <TapButton onClick={setPlayback.pause} disabled={disablePause} theme={Playback.Pause} active={isPaused}>
onClick={setPlayback.pause}
disabled={isStopped || isRolling || isArmed}
theme={Playback.Pause}
active={isPaused}
>
<IoPause /> <IoPause />
</TapButton> </TapButton>
</div> </div>
@@ -82,21 +79,16 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
</Tooltip> </Tooltip>
</div> </div>
<div className={style.extra}> <div className={style.extra}>
<TapButton <TapButton onClick={setPlayback.roll} disabled={disableRoll} theme={Playback.Roll} active={isRolling}>
onClick={setPlayback.roll} <IoTime />
disabled={!isStopped || noEvents}
theme={Playback.Roll}
active={isRolling}
>
<IoTimeOutline />
</TapButton> </TapButton>
<Tooltip label='Reload event' openDelay={tooltipDelayMid}> <Tooltip label='Reload event' openDelay={tooltipDelayMid}>
<TapButton onClick={setPlayback.reload} disabled={isStopped || isRolling}> <TapButton onClick={setPlayback.reload} disabled={isStopped}>
<IoReload className={style.invertX} /> <IoReload className={style.invertX} />
</TapButton> </TapButton>
</Tooltip> </Tooltip>
<Tooltip label='Unload Event' openDelay={tooltipDelayMid}> <Tooltip label='Unload Event' openDelay={tooltipDelayMid}>
<TapButton onClick={setPlayback.stop} disabled={isStopped && !isRolling} theme={Playback.Stop}> <TapButton onClick={setPlayback.stop} disabled={disableStop} theme={Playback.Stop}>
<IoStop /> <IoStop />
</TapButton> </TapButton>
</Tooltip> </Tooltip>
@@ -0,0 +1,157 @@
@use '../../theme/ontimeColours' as *;
@use '../../theme/v2Styles' as *;
$table-font-size: calc(1rem - 2px);
$table-header-font-size: calc(1rem - 3px);
@mixin ellipsis-overflow() {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cuesheetContainer {
grid-area: table;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: auto;
padding-bottom: 640px; // allow focus to reach last elements
}
.cuesheet {
font-size: $table-font-size;
font-weight: 400;
tr {
display: flex;
}
th, td {
margin: 1px;
font-weight: inherit;
font-size: inherit;
text-align: left;
position: relative;
@include ellipsis-overflow;
}
}
.tableHeader,
.eventRow {
.indexColumn {
min-width: 2rem;
text-align: right;
font-weight: 400;
}
}
.tableHeader {
position: sticky;
top: 0;
z-index: 10;
background-color: $gray-1300;
font-size: $table-header-font-size;
color: $gray-700;
font-weight: 400;
}
.eventRow {
vertical-align: top;
td {
background-color: $gray-1250;
border-radius: 2px;
padding: 0.25rem;
}
&.skip {
text-decoration: line-through;
opacity: $opacity-disabled !important; // fighting inline styles
}
}
.blockRow {
width: 100%;
background-color: $gray-1350;
font-size: 1rem;
height: 2.5rem;
td {
align-self: flex-end;
position: sticky;
left: 1rem;
padding: 0.25rem 0;
}
}
.delayRow {
width: 100%;
color: $ontime-delay-text;
td {
position: sticky;
left: 47.5%; // center of the screen, ish
padding: 0.5rem 0;
}
}
.check {
font-size: 1.5rem;
margin: 0 auto;
}
.time {
display: flex;
gap: 0.5rem;
align-items: center;
> * {
@include ellipsis-overflow;
}
}
.delaySymbol {
svg {
font-size: 1.5rem;
color: $ontime-delay;
margin: 0 auto;
}
}
.delayedTime {
color: $ontime-delay-text;
font-size: calc(1rem - 2px);
}
.resizer {
cursor: col-resize;
opacity: 0;
display: inline-block;
width: 3px;
height: 100%;
position: absolute;
right: 0;
top: 0;
transform: translateX(50%);
background-color: $action-blue;
user-select: none;
touch-action: none;
transition-duration: $transition-time-action;
transition-property: width, background-color;
&:hover {
opacity: $opacity-disabled;
width: 6px;
}
&.isResizing {
opacity: 1;
width: 6px;
background-color: $action-blue;
}
}
@@ -0,0 +1,278 @@
import { MutableRefObject, useEffect, useRef } from 'react';
import { Tooltip } from '@chakra-ui/react';
import {
closestCenter,
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, OntimeRundownEntry, SupportedEvent } from 'ontime-types';
import { useLocalStorage } from '../../common/hooks/useLocalStorage';
import { millisToDelayString } from '../../common/utils/dateConfig';
import { getAccessibleColour } from '../../common/utils/styleUtils';
import { tooltipDelayFast } from '../../ontimeConfig';
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
import { useCuesheetSettings } from './store/CuesheetSettings';
import { SortableCell } from './tableElements/SortableCell';
import { initialColumnOrder } from './cuesheetCols';
import style from './Cuesheet.module.scss';
const pastOpacity = '0.2';
interface CuesheetProps {
data: OntimeRundown;
columns: ColumnDef<OntimeRundownEntry>[];
handleUpdate: (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: unknown) => void;
selectedId: string | null;
}
export default function Cuesheet({ data, columns, handleUpdate, selectedId }: CuesheetProps) {
const followSelected = useCuesheetSettings((state) => state.followSelected);
const showSettings = useCuesheetSettings((state) => state.showSettings);
const showDelayBlock = useCuesheetSettings((state) => state.showDelayBlock);
const showPrevious = useCuesheetSettings((state) => state.showPrevious);
const [columnVisibility, setColumnVisibility] = useLocalStorage('table-hidden', {});
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>('table-order', initialColumnOrder);
const [columnSizing, setColumnSizing] = useLocalStorage('table-sizes', {});
const selectedRef = useRef<HTMLTableRowElement | null>(null);
const table = useReactTable({
data,
columns,
columnResizeMode: 'onChange',
state: {
columnOrder,
columnVisibility,
columnSizing,
},
meta: {
handleUpdate,
},
onColumnVisibilityChange: setColumnVisibility,
onColumnSizingChange: setColumnSizing,
getCoreRowModel: getCoreRowModel(),
});
const tableContainerRef = useRef<HTMLDivElement>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
delay: 100,
tolerance: 50,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 100,
tolerance: 50,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
// when selection moves, view should follow
useEffect(() => {
function scrollToComponent(
componentRef: MutableRefObject<HTMLTableRowElement>,
scrollRef: MutableRefObject<HTMLDivElement>,
) {
const componentRect = componentRef.current.getBoundingClientRect();
const scrollRect = scrollRef.current.getBoundingClientRect();
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - 100;
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
}
if (!followSelected) {
return;
}
if (selectedRef.current && tableContainerRef.current) {
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
scrollToComponent(
selectedRef as MutableRefObject<HTMLTableRowElement>,
tableContainerRef as MutableRefObject<HTMLDivElement>,
);
});
}
// eslint-disable-next-line -- the prompt seems incorrect, we need the refs
}, [selectedRef.current, tableContainerRef.current, followSelected]);
const handleOnDragEnd = (event: DragEndEvent) => {
const { delta, active, over } = event;
// cancel if delta y is greater than 200
if (delta.y > 200) return;
// cancel if we do not have an over id
if (over?.id == null) return;
// get index of from
const fromIndex = columnOrder.indexOf(active.id as string);
// get index of to
const toIndex = columnOrder.indexOf(over.id as string);
if (toIndex === -1) {
return;
}
const reorderedCols = [...columnOrder];
const reorderedItem = reorderedCols.splice(fromIndex, 1);
reorderedCols.splice(toIndex, 0, reorderedItem[0]);
saveColumnOrder(reorderedCols);
};
const resetColumnOrder = () => {
saveColumnOrder(initialColumnOrder);
};
const setAllVisible = () => {
table.toggleAllColumnsVisible(true);
};
const resetColumnResizing = () => {
setColumnSizing({});
};
let eventIndex = 0;
let isPast = Boolean(selectedId);
return (
<>
{showSettings && (
<CuesheetTableSettings
columns={table.getAllLeafColumns()}
handleResetResizing={resetColumnResizing}
handleResetReordering={resetColumnOrder}
handleClearToggles={setAllVisible}
/>
)}
<div ref={tableContainerRef} className={style.cuesheetContainer}>
<table className={style.cuesheet}>
<thead className={style.tableHeader}>
{table.getHeaderGroups().map((headerGroup) => {
const key = headerGroup.id;
return (
<DndContext key={key} sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleOnDragEnd}>
<tr key={headerGroup.id}>
<th className={style.indexColumn}>
<Tooltip label='Event Order' openDelay={tooltipDelayFast}>
#
</Tooltip>
</th>
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
{headerGroup.headers.map((header) => {
const width = header.getSize();
return (
<SortableCell key={header.column.columnDef.id} header={header} style={{ width }}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</SortableCell>
);
})}
</SortableContext>
</tr>
</DndContext>
);
})}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => {
const entryType = row.original.type as SupportedEvent;
const key = row.original.id;
const isSelected = selectedId === key;
if (isSelected) {
isPast = false;
}
if (entryType === SupportedEvent.Block) {
const title = (row.original as OntimeBlock).title;
return (
<tr key={key} className={style.blockRow}>
<td>{title}</td>
</tr>
);
}
if (entryType === SupportedEvent.Delay) {
const delayVal = (row.original as OntimeDelay).duration;
if (!showDelayBlock || delayVal === 0) {
return null;
}
const delayTime = millisToDelayString(delayVal);
return (
<tr key={key} className={style.delayRow}>
<td>{delayTime}</td>
</tr>
);
}
if (entryType === SupportedEvent.Event) {
eventIndex++;
const isSelected = key === selectedId;
if (isSelected) {
isPast = false;
}
if (isPast && !showPrevious) {
return null;
}
const bgFallback = 'transparent';
const bgColour = (row.original as OntimeEvent).colour || bgFallback;
const textColour = bgColour === bgFallback ? undefined : getAccessibleColour(bgColour);
const isSkipped = (row.original as OntimeEvent).skip;
let rowBgColour: string | undefined;
if (row.original.id === selectedId) {
rowBgColour = '#D20300'; // $red-700
}
return (
<tr
key={key}
className={`${style.eventRow} ${isSkipped ? style.skip : ''}`}
style={{ opacity: `${isPast ? pastOpacity : '1'}` }}
ref={isSelected ? selectedRef : undefined}
>
<td className={style.indexColumn} style={{ backgroundColor: bgColour, color: textColour?.color }}>
{eventIndex}
</td>
{row.getVisibleCells().map((cell) => {
return (
<td key={cell.id} style={{ width: cell.column.getSize(), backgroundColor: rowBgColour }}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
);
}
// currently there is no scenario where entryType is not handled above, either way...
return null;
})}
</tbody>
</table>
</div>
</>
);
}
@@ -0,0 +1,24 @@
@use '../../theme/v2Styles' as *;
@use '../../theme/ontimeColours' as *;
.tableWrapper {
width: 100%;
height: 100vh;
padding: 1rem;
display: grid;
grid-template-rows: auto auto 1fr;
grid-template-areas:
'header'
'settings'
'table';
gap: 1rem;
background-color: $gray-1300;
color: white;
& > * {
border: 1px solid $white-10;
border-radius: 4px;
}
}
@@ -1,23 +1,25 @@
import { useCallback, useContext, useEffect } from 'react'; import { useCallback, useEffect, useMemo } from 'react';
import { EventData, OntimeRundownEntry } from 'ontime-types';
import { TableSettingsContext } from '../../common/context/TableSettingsContext'; import Empty from '../../common/components/state/Empty';
import { useEventAction } from '../../common/hooks/useEventAction'; import { useEventAction } from '../../common/hooks/useEventAction';
import { useCuesheet } from '../../common/hooks/useSocket'; import { useCuesheet } from '../../common/hooks/useSocket';
import useRundown from '../../common/hooks-query/useRundown'; import useRundown from '../../common/hooks-query/useRundown';
import useUserFields from '../../common/hooks-query/useUserFields'; import useUserFields from '../../common/hooks-query/useUserFields';
import OntimeTable from './OntimeTable'; import CuesheetTableHeader from './cuesheet-table-header/CuesheetTableHeader';
import TableHeader from './TableHeader'; import Cuesheet from './Cuesheet';
import { makeCSV, makeTable } from './tableUtils'; import { makeCuesheetColumns } from './cuesheetCols';
import { makeCSV, makeTable } from './cuesheetUtils';
import style from './Table.module.scss'; import styles from './CuesheetWrapper.module.scss';
export default function TableWrapper() { export default function CuesheetWrapper() {
const { data: rundown } = useRundown(); const { data: rundown } = useRundown();
const { data: userFields } = useUserFields(); const { data: userFields } = useUserFields();
const { updateEvent } = useEventAction(); const { updateEvent } = useEventAction();
const featureData = useCuesheet(); const featureData = useCuesheet();
const { theme } = useContext(TableSettingsContext); const columns = useMemo(() => makeCuesheetColumns(userFields), [userFields]);
// Set window title // Set window title
useEffect(() => { useEffect(() => {
@@ -25,14 +27,18 @@ export default function TableWrapper() {
}, []); }, []);
const handleUpdate = useCallback( const handleUpdate = useCallback(
async (rowIndex, accessor, payload) => { async (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: unknown) => {
if (!rundown) {
return;
}
if (rowIndex == null || accessor == null || payload == null) { if (rowIndex == null || accessor == null || payload == null) {
return; return;
} }
// check if value is the same // check if value is the same
const event = rundown[rowIndex]; const event = rundown[rowIndex];
if (event == null) { if (!event) {
return; return;
} }
@@ -63,35 +69,37 @@ export default function TableWrapper() {
); );
const exportHandler = useCallback( const exportHandler = useCallback(
(headerData) => { (headerData: EventData) => {
if (!headerData || !rundown || !userFields) { if (!headerData || !rundown || !userFields) {
return; return;
} }
const sheetData = makeTable(headerData, rundown, userFields); const sheetData = makeTable(headerData, rundown, userFields);
const csvContent = makeCSV(sheetData); const csvContent = makeCSV(sheetData);
const encodedUri = encodeURI(csvContent);
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement('a');
link.setAttribute('href', encodedUri); link.setAttribute('href', url);
link.setAttribute('download', 'ontime export.csv'); link.setAttribute('download', 'ontime export.csv');
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
// Clean up the URL.createObjectURL to release resources
URL.revokeObjectURL(url);
}, },
[rundown, userFields], [rundown, userFields],
); );
if (typeof rundown === 'undefined' || typeof userFields === 'undefined') { if (!rundown || !userFields) {
return <span>loading...</span>; return <Empty text='Loading...' />;
} }
return ( return (
<div className={theme === 'dark' ? style.tableWrapper__dark : style.tableWrapper} data-testid='cuesheet'> <div className={styles.tableWrapper} data-testid='cuesheet'>
<TableHeader handleCSVExport={exportHandler} featureData={featureData} /> <CuesheetTableHeader handleCSVExport={exportHandler} featureData={featureData} />
<OntimeTable <Cuesheet data={rundown} columns={columns} handleUpdate={handleUpdate} selectedId={featureData.selectedEventId} />
tableData={rundown}
userFields={userFields}
handleUpdate={handleUpdate}
selectedId={featureData.selectedEventId}
/>
</div> </div>
); );
} }
@@ -0,0 +1,11 @@
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import CuesheetWrapper from './CuesheetWrapper';
export default function ProtectedCuesheet() {
return (
<ProtectRoute permission='operator'>
<CuesheetWrapper />
</ProtectRoute>
);
}
@@ -25,8 +25,11 @@ exports[`makeTable() > returns array of arrays with given fields 1`] = `
"Presenter Name", "Presenter Name",
"Event Subtitle", "Event Subtitle",
"Is Public? (x)", "Is Public? (x)",
"Notes", "Note",
"Colour", "Colour",
"End Action",
"Timer Type",
"Skip?",
"user0:test", "user0:test",
], ],
[ [
@@ -38,6 +41,9 @@ exports[`makeTable() > returns array of arrays with given fields 1`] = `
"x", "x",
"", "",
"", "",
"",
"",
"",
"test", "test",
"test", "test",
"", "",
@@ -1,4 +1,4 @@
import { makeCSV, makeTable, parseField } from '../tableUtils'; import { makeCSV, makeTable, parseField } from '../cuesheetUtils';
describe('parseField()', () => { describe('parseField()', () => {
it('returns a string from given millis on timeStart and TimeEnd', () => { it('returns a string from given millis on timeStart and TimeEnd', () => {
@@ -35,7 +35,7 @@ describe('parseField()', () => {
{ field: 'title', value: 'test' }, { field: 'title', value: 'test' },
{ field: 'presenter', value: 'test' }, { field: 'presenter', value: 'test' },
{ field: 'subtitle', value: 'test' }, { field: 'subtitle', value: 'test' },
{ field: 'notes', value: 'test' }, { field: 'note', value: 'test' },
{ field: 'colour', value: 'test' }, { field: 'colour', value: 'test' },
{ field: 'user0', value: 'test' }, { field: 'user0', value: 'test' },
{ field: 'user1', value: 'test' }, { field: 'user1', value: 'test' },
@@ -84,7 +84,7 @@ describe('make CSV()', () => {
it('joins an array of arrays with commas and newlines', () => { it('joins an array of arrays with commas and newlines', () => {
const testdata = [['field'], ['after newline', 'after comma'], ['', 'after empty']]; const testdata = [['field'], ['after newline', 'after comma'], ['', 'after empty']];
expect(makeCSV(testdata)).toMatchInlineSnapshot(` expect(makeCSV(testdata)).toMatchInlineSnapshot(`
"data:text/csv;charset=utf-8,field "field
after newline,after comma after newline,after comma
,after empty ,after empty
" "
@@ -0,0 +1,119 @@
@use '../../../theme/ontimeColours' as *;
@use '../../../theme/v2Styles' as *;
$label-colour: $gray-700;
$active-colour: $gray-500;
@mixin label {
font-size: $inner-section-text-size;
color: $label-colour;
text-align: center;
align-self: end;
}
@mixin time {
font-family: "Open Sans Light", $ontime-font-family;
font-size: 2rem;
text-align: center;
}
.header {
grid-area: header;
display: grid;
width: 100%;
padding: 0.25rem 1rem;
height: max-content;
column-gap: 2rem;
grid-template-areas:
'event playback timer clock actions';
grid-template-columns: 1fr auto auto auto auto;
align-items: center;
justify-items: center;
}
.event {
grid-area: event;
justify-self: start;
.title {
font-size: 1.75rem;
}
.eventNow {
justify-self: start;
font-size: 1.5rem;
}
}
.playback {
grid-area: playback;
display: flex;
flex-direction: column;
justify-items: center;
align-items: center;
.playbackLabel {
@include label;
}
svg {
font-size: 2rem;
height: 3rem;
color: $label-colour;
}
}
.timer {
grid-area: timer;
.timerLabel {
@include label;
}
.value {
@include time;
}
}
.clock {
grid-area: clock;
.clockLabel {
@include label;
}
.value {
grid-area: clock;
@include time;
}
}
.headerActions {
grid-area: actions;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.125rem;
color: $label-colour;
height: 100%;
.actionIcon {
cursor: pointer;
&:hover {
color: $active-colour;
}
&.enabled {
color: $active-indicator;
}
}
}
@media (min-width: 1200px) {
// in large screens we want to space the buttons
.headerActions {
padding-left: 10vw;
}
}
@@ -0,0 +1,100 @@
import { Tooltip } from '@chakra-ui/react';
import { IoContract } from '@react-icons/all-files/io5/IoContract';
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { EventData, Playback } from 'ontime-types';
import { formatDisplay } from 'ontime-utils';
import useFullscreen from '../../../common/hooks/useFullscreen';
import { useTimer } from '../../../common/hooks/useSocket';
import useEventData from '../../../common/hooks-query/useEventData';
import { formatTime } from '../../../common/utils/time';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { useCuesheetSettings } from '../store/CuesheetSettings';
import PlaybackIcon from '../tableElements/PlaybackIcon';
import style from './CuesheetTableHeader.module.scss';
interface CuesheetTableHeaderProps {
handleCSVExport: (headerData: EventData) => void;
featureData: {
playback: Playback;
selectedEventIndex: number | null;
numEvents: number;
titleNow: string | null;
};
}
export default function CuesheetTableHeader({ handleCSVExport, featureData }: CuesheetTableHeaderProps) {
const followSelected = useCuesheetSettings((state) => state.followSelected);
const showSettings = useCuesheetSettings((state) => state.showSettings);
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
const toggleFollow = useCuesheetSettings((state) => state.toggleFollow);
const timer = useTimer();
const { isFullScreen, toggleFullScreen } = useFullscreen();
const { data: event } = useEventData();
const exportCsv = () => {
if (event) {
handleCSVExport(event);
}
};
const selected = !featureData.numEvents
? 'No events'
: `Event ${featureData.selectedEventIndex != null ? featureData.selectedEventIndex + 1 : '-'}/${
featureData.numEvents ? featureData.numEvents : '-'
}`;
// prepare presentation variables
const isOvertime = (timer.current ?? 0) < 0;
const timerNow = timer.current == null ? '-' : `${isOvertime ? '-' : ''}${formatDisplay(timer.current)}`;
const timeNow = formatTime(timer.clock, {
showSeconds: true,
format: 'hh:mm:ss a',
});
return (
<div className={style.header}>
<div className={style.event}>
<div className={style.title}>{event?.title || '-'}</div>
<div className={style.eventNow}>{featureData?.titleNow || '-'}</div>
</div>
<div className={style.playback}>
<div className={style.playbackLabel}>{selected}</div>
<PlaybackIcon state={featureData.playback} />
</div>
<div className={style.timer}>
<div className={style.timerLabel}>Running Timer</div>
<div className={style.value}>{timerNow}</div>
</div>
<div className={style.clock}>
<div className={style.clockLabel}>Time Now</div>
<div className={style.value}>{timeNow}</div>
</div>
<div className={style.headerActions}>
<Tooltip openDelay={tooltipDelayFast} label='Toggle follow'>
<span onClick={() => toggleFollow()} className={`${style.actionIcon} ${followSelected ? style.enabled : ''}`}>
<IoLocate />
</span>
</Tooltip>
<Tooltip openDelay={tooltipDelayFast} label='Toggle settings'>
<span onClick={() => toggleSettings()} className={`${style.actionIcon} ${showSettings ? style.enabled : ''}`}>
<IoSettingsOutline />
</span>
</Tooltip>
<Tooltip openDelay={tooltipDelayFast} label='Toggle Fullscreen'>
<span onClick={() => toggleFullScreen()} className={style.actionIcon}>
{isFullScreen ? <IoContract /> : <IoExpand />}
</span>
</Tooltip>
<Tooltip openDelay={tooltipDelayFast} label='Export rundown to CSV'>
<span className={style.actionIcon} onClick={exportCsv}>
CSV
</span>
</Tooltip>
</div>
</div>
);
}
@@ -0,0 +1,44 @@
@use '../../../theme/v2Styles' as *;
@use '../../../theme/ontimeColours' as *;
.tableSettings {
grid-area: settings;
padding: 1rem;
background-color: $ui-black;
display: flex;
font-size: $inner-section-text-size;
}
.leftPanel {
display: flex;
flex-direction: column;
width: 100%;
gap: 0.5rem;
}
.sectionTitle {
color: $gray-700;
text-transform: uppercase;
}
.options {
display: flex;
flex-wrap: wrap;
column-gap: 1rem;
row-gap: 0.25em;
}
.option {
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
}
.rightPanel {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-left: 2rem;
}
@@ -0,0 +1,83 @@
import { Button, Checkbox, Switch } from '@chakra-ui/react';
import { Column } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
import { useCuesheetSettings } from '../store/CuesheetSettings';
import style from './CuesheetTableSettings.module.scss';
// reusable button styles
const buttonProps = {
size: 'sm',
variant: 'ontime-subtle',
};
interface CuesheetTableSettingsProps {
columns: Column<OntimeRundownEntry, unknown>[];
handleResetResizing: () => void;
handleResetReordering: () => void;
handleClearToggles: () => void;
}
export default function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
const showPrevious = useCuesheetSettings((state) => state.showPrevious);
const togglePreviousVisibility = useCuesheetSettings((state) => state.togglePreviousVisibility);
const showDelayBlock = useCuesheetSettings((state) => state.showDelayBlock);
const toggleDelayVisibility = useCuesheetSettings((state) => state.toggleDelayVisibility);
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
const toggleDelayedTimes = useCuesheetSettings((state) => state.toggleDelayedTimes);
return (
<div className={style.tableSettings}>
<div className={style.leftPanel}>
<div className={style.sectionTitle}>Toggle column visibility</div>
<div className={style.options}>
{columns.map((column) => {
const columnHeader = column.columnDef.header;
const visible = column.getIsVisible();
return (
<label key={`${column.id}-${visible}`} className={style.option}>
<Checkbox
variant='ontime-ondark'
defaultChecked={visible}
onChange={column.getToggleVisibilityHandler()}
/>
{columnHeader}
</label>
);
})}
</div>
<div className={style.sectionTitle}>Table Options</div>
<div className={style.options}>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={showPrevious} onChange={() => togglePreviousVisibility()} />
Show past events
</label>
</div>
<div className={style.sectionTitle}>Delay Flow</div>
<div className={style.options}>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={showDelayedTimes} onChange={() => toggleDelayedTimes()} />
Show delayed times
</label>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={showDelayBlock} onChange={() => toggleDelayVisibility()} />
Show delay blocks
</label>
</div>
</div>
<div className={style.rightPanel}>
<Button onClick={handleClearToggles} {...buttonProps}>
Show All
</Button>
<Button onClick={handleResetResizing} {...buttonProps}>
Reset Resizing
</Button>
<Button onClick={handleResetReordering} {...buttonProps}>
Reset Reordering
</Button>
</div>
</div>
);
}
@@ -0,0 +1,195 @@
import { useCallback } from 'react';
import { Tooltip } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp';
import { CellContext, ColumnDef } from '@tanstack/react-table';
import { OntimeEvent, OntimeRundownEntry, UserFields } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { millisToDelayString } from '../../common/utils/dateConfig';
import { tooltipDelayFast } from '../../ontimeConfig';
import { useCuesheetSettings } from './store/CuesheetSettings';
import EditableCell from './tableElements/EditableCell';
import style from './Cuesheet.module.scss';
function makePublic(row: CellContext<OntimeRundownEntry, unknown>) {
const cellValue = row.getValue();
return cellValue ? <IoCheckmark className={style.check} /> : '';
}
function DelayIndicator(props: { delayValue: number }) {
const { delayValue } = props;
if (delayValue < 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronDown />
</span>
</Tooltip>
);
}
if (delayValue > 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronUp />
</span>
</Tooltip>
);
}
return null;
}
function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEntry, unknown>) {
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
const cellValue = (getValue() as number | null) ?? 0;
const delayValue = (original as OntimeEvent)?.delay ?? 0;
return (
<span className={style.time}>
<DelayIndicator delayValue={delayValue} />
{millisToString(cellValue)}
{delayValue !== 0 && showDelayedTimes && (
<span className={style.delayedTime}>{` ${millisToString(cellValue + delayValue)}`}</span>
)}
</span>
);
}
function MakeUserField({ getValue, row: { index }, column: { id }, table }: CellContext<OntimeRundownEntry, unknown>) {
const update = useCallback(
(newValue: string) => {
// @ts-expect-error -- we inject this into react-table
table.options.meta?.handleUpdate(index, id, newValue);
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
[id, index],
);
const initialValue = getValue() as string;
return <EditableCell value={initialValue} handleUpdate={update} />;
}
export function makeCuesheetColumns(userFields?: UserFields): ColumnDef<OntimeRundownEntry>[] {
return [
{
accessorKey: 'isPublic',
id: 'isPublic',
header: 'Public',
cell: makePublic,
size: 45,
},
{
accessorKey: 'timeStart',
id: 'timeStart',
header: 'Start',
cell: MakeTimer,
size: 75,
},
{
accessorKey: 'timeEnd',
id: 'timeEnd',
header: 'End',
cell: MakeTimer,
size: 75,
},
{
accessorKey: 'duration',
id: 'duration',
header: 'Duration',
cell: (row) => millisToString(row.getValue() as number | null),
size: 75,
},
{
accessorKey: 'title',
id: 'title',
header: 'Title',
cell: (row) => row.getValue(),
},
{
accessorKey: 'subtitle',
id: 'subtitle',
header: 'Subtitle',
cell: (row) => row.getValue(),
},
{
accessorKey: 'presenter',
id: 'presenter',
header: 'Presenter',
cell: (row) => row.getValue(),
},
{
accessorKey: 'note',
id: 'note',
header: 'Note',
cell: (row) => row.getValue(),
},
{
accessorKey: 'user0',
id: 'user0',
header: userFields?.user0 || 'User 0',
cell: MakeUserField,
},
{
accessorKey: 'user1',
id: 'user1',
header: userFields?.user1 || 'User 1',
cell: MakeUserField,
},
{
accessorKey: 'user2',
id: 'user2',
header: userFields?.user2 || 'User 2',
cell: MakeUserField,
},
{
accessorKey: 'user3',
id: 'user3',
header: userFields?.user3 || 'User 3',
cell: MakeUserField,
},
{
accessorKey: 'user4',
id: 'user4',
header: userFields?.user4 || 'User 4',
cell: MakeUserField,
},
{
accessorKey: 'user5',
id: 'user5',
header: userFields?.user5 || 'User 5',
cell: MakeUserField,
},
{
accessorKey: 'user6',
id: 'user6',
header: userFields?.user6 || 'User 6',
cell: MakeUserField,
},
{
accessorKey: 'user7',
id: 'user7',
header: userFields?.user7 || 'User 7',
cell: MakeUserField,
},
{
accessorKey: 'user8',
id: 'user8',
header: userFields?.user8 || 'User 8',
cell: MakeUserField,
},
{
accessorKey: 'user9',
id: 'user9',
header: userFields?.user9 || 'User 9',
cell: MakeUserField,
},
];
}
export const initialColumnOrder: string[] = makeCuesheetColumns().map((column) => column.id as string);
@@ -1,4 +1,5 @@
import { stringify } from 'csv-stringify/browser/esm/sync'; import { stringify } from 'csv-stringify/browser/esm/sync';
import { EventData, OntimeEntryCommonKeys, OntimeRundown, UserFields } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
/** /**
@@ -8,14 +9,15 @@ import { millisToString } from 'ontime-utils';
* @return {string} * @return {string}
*/ */
export const parseField = (field, data) => { export const parseField = (field: keyof OntimeRundown, data: unknown): string => {
let val; let val;
switch (field) { switch (field) {
case 'timeStart': case 'timeStart':
case 'timeEnd': case 'timeEnd':
val = millisToString(data); val = millisToString(data as number | null);
break; break;
case 'isPublic': case 'isPublic':
case 'skip':
val = data ? 'x' : ''; val = data ? 'x' : '';
break; break;
default: default:
@@ -25,17 +27,18 @@ export const parseField = (field, data) => {
if (typeof data === 'undefined') { if (typeof data === 'undefined') {
return ''; return '';
} }
return val; // all other values are strings
return val as string;
}; };
/** /**
* @description Creates an array of arrays usable by xlsx for export * @description Creates an array of arrays usable by xlsx for export
* @param {object} headerData * @param {object} headerData
* @param {array} tableData * @param {array} rundown
* @param {object} userFields * @param {object} userFields
* @return {(string[])[]} * @return {(string[])[]}
*/ */
export const makeTable = (headerData, tableData, userFields) => { export const makeTable = (headerData: EventData, rundown: OntimeRundown, userFields: UserFields): string[][] => {
const data = [ const data = [
['Ontime · Schedule Template'], ['Ontime · Schedule Template'],
['Event Name', headerData?.title || ''], ['Event Name', headerData?.title || ''],
@@ -44,15 +47,18 @@ export const makeTable = (headerData, tableData, userFields) => {
[], [],
]; ];
const fieldOrder = [ const fieldOrder: OntimeEntryCommonKeys[] = [
'timeStart', 'timeStart',
'timeEnd', 'timeEnd',
'title', 'title',
'presenter', 'presenter',
'subtitle', 'subtitle',
'isPublic', 'isPublic',
'notes', 'note',
'colour', 'colour',
'endAction',
'timerType',
'skip',
'user0', 'user0',
'user1', 'user1',
'user2', 'user2',
@@ -72,20 +78,23 @@ export const makeTable = (headerData, tableData, userFields) => {
'Presenter Name', 'Presenter Name',
'Event Subtitle', 'Event Subtitle',
'Is Public? (x)', 'Is Public? (x)',
'Notes', 'Note',
'Colour', 'Colour',
'End Action',
'Timer Type',
'Skip?',
]; ];
for (const field in userFields) { for (const field in userFields) {
const fieldValue = userFields[field]; const fieldValue = userFields[field as keyof UserFields];
const displayName = `${field}${fieldValue !== field && fieldValue !== '' ? `:${fieldValue}` : ''}`; const displayName = `${field}${fieldValue !== field && fieldValue !== '' ? `:${fieldValue}` : ''}`;
fieldTitles.push(displayName); fieldTitles.push(displayName);
} }
data.push(fieldTitles); data.push(fieldTitles);
tableData.forEach((entry) => { rundown.forEach((entry) => {
const row = []; const row: string[] = [];
fieldOrder.forEach((field) => row.push(parseField(field, entry[field]))); fieldOrder.forEach((field) => row.push(parseField(field, entry[field])));
data.push(row); data.push(row);
}); });
@@ -98,8 +107,7 @@ export const makeTable = (headerData, tableData, userFields) => {
* @param {array[]} arrayOfArrays * @param {array[]} arrayOfArrays
* @return {string} * @return {string}
*/ */
export const makeCSV = (arrayOfArrays) => { export const makeCSV = (arrayOfArrays: string[][]) => {
let csvData = 'data:text/csv;charset=utf-8,';
const stringifiedData = stringify(arrayOfArrays); const stringifiedData = stringify(arrayOfArrays);
return csvData + stringifiedData; return stringifiedData;
}; };
@@ -1,7 +1,9 @@
import { OntimeEntryCommonKeys, OntimeEvent } from 'ontime-types';
/** /**
* @description set default column order * @description set default column order
*/ */
export const defaultColumnOrder = [ export const defaultColumnOrder: OntimeEntryCommonKeys[] = [
'isPublic', 'isPublic',
'timeStart', 'timeStart',
'timeEnd', 'timeEnd',
@@ -25,7 +27,7 @@ export const defaultColumnOrder = [
/** /**
* @description set default hidden columns * @description set default hidden columns
*/ */
export const defaultHiddenColumns = [ export const defaultHiddenColumns: (keyof OntimeEvent)[] = [
'user0', 'user0',
'user1', 'user1',
'user2', 'user2',
@@ -0,0 +1,65 @@
import { create } from 'zustand';
import { booleanFromLocalStorage } from '../../../common/utils/localStorage';
interface CuesheetSettings {
showSettings: boolean;
followSelected: boolean;
showPrevious: boolean;
showDelayBlock: boolean;
showDelayedTimes: boolean;
toggleSettings: (newValue?: boolean) => void;
toggleFollow: (newValue?: boolean) => void;
togglePreviousVisibility: (newValue?: boolean) => void;
toggleDelayVisibility: (newValue?: boolean) => void;
toggleDelayedTimes: (newValue?: boolean) => void;
}
function toggle(oldValue: boolean, value?: boolean) {
if (typeof value === 'undefined') {
return !oldValue;
}
return value;
}
enum CuesheetKeys {
Follow = 'ontime-cuesheet-follow-selected',
DelayVisibility = 'ontime-cuesheet-show-delay',
PreviousVisibility = 'ontime-cuesheet-show-previous',
DelayedTimes = 'ontime-cuesheet-show-delayed',
}
export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
showSettings: false,
followSelected: booleanFromLocalStorage(CuesheetKeys.Follow, false),
showPrevious: booleanFromLocalStorage(CuesheetKeys.PreviousVisibility, true),
showDelayBlock: booleanFromLocalStorage(CuesheetKeys.DelayVisibility, true),
showDelayedTimes: booleanFromLocalStorage(CuesheetKeys.DelayedTimes, false),
toggleSettings: (newValue?: boolean) => set((state) => ({ showSettings: toggle(state.showSettings, newValue) })),
toggleFollow: (newValue?: boolean) =>
set((state) => {
const followSelected = toggle(state.followSelected, newValue);
localStorage.setItem(CuesheetKeys.Follow, String(followSelected));
return { followSelected };
}),
togglePreviousVisibility: (newValue?: boolean) =>
set((state) => {
const showPrevious = toggle(state.showPrevious, newValue);
localStorage.setItem(CuesheetKeys.PreviousVisibility, String(showPrevious));
return { showPrevious };
}),
toggleDelayVisibility: (newValue?: boolean) =>
set((state) => {
const showDelayBlock = toggle(state.showDelayBlock, newValue);
localStorage.setItem(CuesheetKeys.DelayVisibility, String(showDelayBlock));
return { showDelayBlock };
}),
toggleDelayedTimes: (newValue?: boolean) =>
set((state) => {
const showDelayedTimes = toggle(state.showDelayedTimes, newValue);
localStorage.setItem(CuesheetKeys.DelayedTimes, String(showDelayedTimes));
return { showDelayedTimes };
}),
}));
@@ -0,0 +1,40 @@
import { ChangeEvent, memo, useCallback, useEffect, useState } from 'react';
import { AutoTextArea } from '../../../common/components/input/auto-text-area/AutoTextArea';
interface EditableCellProps {
value: string;
handleUpdate: (newValue: string) => void;
}
const EditableCell = (props: EditableCellProps) => {
const { value: initialValue, handleUpdate } = props;
// We need to keep and update the state of the cell normally
const [value, setValue] = useState(initialValue);
const onChange = useCallback((event: ChangeEvent<HTMLTextAreaElement>) => setValue(event.target.value), []);
// We'll only update the external data when the input is blurred
const onBlur = useCallback(() => handleUpdate(value), [handleUpdate, value]);
// If the initialValue is changed external, sync it up with our state
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
return (
<AutoTextArea
size='sm'
value={value}
onChange={onChange}
onBlur={onBlur}
rows={1}
transition='none'
spellCheck={false}
style={{ padding: 0 }}
/>
);
};
export default memo(EditableCell);
@@ -0,0 +1,36 @@
import { Tooltip } from '@chakra-ui/react';
import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { Playback } from 'ontime-types';
import { tooltipDelayFast } from '../../../ontimeConfig';
interface PlaybackIconProps {
state: Playback;
}
export default function PlaybackIcon(props: PlaybackIconProps) {
const { state } = props;
// if timer is Pause or Armed
let label = 'Timer Paused';
let Icon = IoPause;
if (state === Playback.Roll) {
label = 'Timer Rolling';
Icon = IoPlay;
} else if (state === Playback.Play) {
label = 'Timer Playing';
Icon = IoPlay;
} else if (state === Playback.Stop) {
label = 'Timer Stopped';
Icon = IoStop;
}
return (
<Tooltip openDelay={tooltipDelayFast} label={label} shouldWrapChildren>
<Icon />
</Tooltip>
);
}
@@ -0,0 +1,48 @@
import { CSSProperties, ReactNode } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Header } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
import { cx } from '../../../common/utils/styleUtils';
import styles from '../Cuesheet.module.scss';
interface SortableCellProps {
header: Header<OntimeRundownEntry, unknown>;
style: CSSProperties;
children: ReactNode;
}
export function SortableCell({ header, style, children }: SortableCellProps) {
const { column, colSpan } = header;
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: column.id,
});
// build drag styles
const dragStyle = {
...style,
opacity: isDragging ? 0.5 : 1,
transform: CSS.Translate.toString(transform),
transition,
};
const resizerClasses = cx([styles.resizer, header.column.getIsResizing() ? styles.isResizing : null]);
return (
<th ref={setNodeRef} style={dragStyle} colSpan={colSpan}>
<div {...attributes} {...listeners}>
{children}
</div>
<div
{...{
onMouseDown: header.getResizeHandler(),
onTouchStart: header.getResizeHandler(),
}}
className={resizerClasses}
/>
</th>
);
}
@@ -1,13 +1,13 @@
import { memo, useState } from 'react'; import { memo, useState } from 'react';
import { Select, Switch } from '@chakra-ui/react'; import { Select, Switch } from '@chakra-ui/react';
import { EndAction, OntimeEvent, TimerType } from 'ontime-types'; import { EndAction, OntimeEvent, TimerType } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { calculateDuration, millisToString } from 'ontime-utils';
import TimeInput from '../../../common/components/input/time-input/TimeInput'; import TimeInput from '../../../common/components/input/time-input/TimeInput';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEventAction } from '../../../common/hooks/useEventAction';
import { millisToDelayString } from '../../../common/utils/dateConfig'; import { millisToDelayString } from '../../../common/utils/dateConfig';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import { calculateDuration, TimeEntryField, validateEntry } from '../../../common/utils/timesManager'; import { TimeEntryField, validateEntry } from '../../../common/utils/timesManager';
import style from '../EventEditor.module.scss'; import style from '../EventEditor.module.scss';
+21 -4
View File
@@ -2,11 +2,28 @@
@use '../../theme/v2Styles' as *; @use '../../theme/v2Styles' as *;
.panelHeader { .panelHeader {
font-size: $inner-section-text-size;
font-family: $ontime-font-family;
color: $label-gray;
display: flex; display: flex;
justify-content: end; justify-content: space-between;
gap: 1rem;
.title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.selected {
min-width: max-content;
font-size: $inner-section-text-size;
color: $label-gray;
}
}
.description {
color: $label-gray;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.labels { .labels {
+9 -8
View File
@@ -1,14 +1,15 @@
import { useInfoPanel } from '../../common/hooks/useSocket'; import { useInfoPanel } from '../../common/hooks/useSocket';
import { useEditorSettings } from '../../common/stores/editorSettings';
import InfoHeader from './info-header/InfoHeader';
import CollapsableInfo from './CollapsableInfo'; import CollapsableInfo from './CollapsableInfo';
import InfoLogger from './InfoLogger'; import InfoLogger from './InfoLogger';
import InfoNif from './InfoNif'; import InfoNif from './InfoNif';
import InfoTitles from './InfoTitles'; import InfoTitles from './InfoTitles';
import style from './Info.module.scss';
export default function Info() { export default function Info() {
const data = useInfoPanel(); const data = useInfoPanel();
const showNif = useEditorSettings((state) => state.eventSettings.showNif);
const titlesNow = { const titlesNow = {
title: data.titles.titleNow || '', title: data.titles.titleNow || '',
@@ -32,12 +33,12 @@ export default function Info() {
return ( return (
<> <>
<div className={style.panelHeader}> <InfoHeader selected={selected} />
<span>{selected}</span> {showNif && (
</div> <CollapsableInfo title='Network Info'>
<CollapsableInfo title='Network Info'> <InfoNif />
<InfoNif /> </CollapsableInfo>
</CollapsableInfo> )}
<CollapsableInfo title='Playing Now'> <CollapsableInfo title='Playing Now'>
<InfoTitles data={titlesNow} /> <InfoTitles data={titlesNow} />
</CollapsableInfo> </CollapsableInfo>
+27 -35
View File
@@ -1,19 +1,11 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { LogOrigin } from 'ontime-types';
import { clearLogs, useLogData } from '../../common/stores/logger'; import { clearLogs, useLogData } from '../../common/stores/logger';
import style from './InfoLogger.module.scss'; import style from './InfoLogger.module.scss';
enum LogFilter {
User = 'USER',
Client = 'CLIENT',
Server = 'SERVER',
RX = 'RX',
TX = 'TX',
Playback = 'PLAYBACK',
}
export default function InfoLogger() { export default function InfoLogger() {
const { logs: logData } = useLogData(); const { logs: logData } = useLogData();
@@ -24,35 +16,35 @@ export default function InfoLogger() {
const [showPlayback, setShowPlayback] = useState(true); const [showPlayback, setShowPlayback] = useState(true);
const [showUser, setShowUser] = useState(true); const [showUser, setShowUser] = useState(true);
const matchers: LogFilter[] = []; const matchers: LogOrigin[] = [];
if (showUser) { if (showUser) {
matchers.push(LogFilter.User); matchers.push(LogOrigin.User);
} }
if (showClient) { if (showClient) {
matchers.push(LogFilter.Client); matchers.push(LogOrigin.Client);
} }
if (showServer) { if (showServer) {
matchers.push(LogFilter.Server); matchers.push(LogOrigin.Server);
} }
if (showRx) { if (showRx) {
matchers.push(LogFilter.RX); matchers.push(LogOrigin.Rx);
} }
if (showTx) { if (showTx) {
matchers.push(LogFilter.TX); matchers.push(LogOrigin.Tx);
} }
if (showPlayback) { if (showPlayback) {
matchers.push(LogFilter.Playback); matchers.push(LogOrigin.Playback);
} }
const filteredData = logData.filter((entry) => matchers.some((match) => entry.origin === match)); const filteredData = logData.filter((entry) => matchers.some((match) => entry.origin === match));
const disableOthers = useCallback((toEnable: LogFilter) => { const disableOthers = useCallback((toEnable: LogOrigin) => {
toEnable === LogFilter.User ? setShowUser(true) : setShowUser(false); toEnable === LogOrigin.User ? setShowUser(true) : setShowUser(false);
toEnable === LogFilter.Client ? setShowClient(true) : setShowClient(false); toEnable === LogOrigin.Client ? setShowClient(true) : setShowClient(false);
toEnable === LogFilter.Server ? setShowServer(true) : setShowServer(false); toEnable === LogOrigin.Server ? setShowServer(true) : setShowServer(false);
toEnable === LogFilter.RX ? setShowRx(true) : setShowRx(false); toEnable === LogOrigin.Rx ? setShowRx(true) : setShowRx(false);
toEnable === LogFilter.TX ? setShowTx(true) : setShowTx(false); toEnable === LogOrigin.Tx ? setShowTx(true) : setShowTx(false);
toEnable === LogFilter.Playback ? setShowPlayback(true) : setShowPlayback(false); toEnable === LogOrigin.Playback ? setShowPlayback(true) : setShowPlayback(false);
}, []); }, []);
return ( return (
@@ -62,55 +54,55 @@ export default function InfoLogger() {
variant={showUser ? 'ontime-filled' : 'ontime-subtle'} variant={showUser ? 'ontime-filled' : 'ontime-subtle'}
size='xs' size='xs'
onClick={() => setShowUser((s) => !s)} onClick={() => setShowUser((s) => !s)}
onAuxClick={() => disableOthers(LogFilter.User)} onAuxClick={() => disableOthers(LogOrigin.User)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogFilter.User} {LogOrigin.User}
</Button> </Button>
<Button <Button
variant={showClient ? 'ontime-filled' : 'ontime-subtle'} variant={showClient ? 'ontime-filled' : 'ontime-subtle'}
size='xs' size='xs'
onClick={() => setShowClient((s) => !s)} onClick={() => setShowClient((s) => !s)}
onAuxClick={() => disableOthers(LogFilter.Client)} onAuxClick={() => disableOthers(LogOrigin.Client)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogFilter.Client} {LogOrigin.Client}
</Button> </Button>
<Button <Button
variant={showServer ? 'ontime-filled' : 'ontime-subtle'} variant={showServer ? 'ontime-filled' : 'ontime-subtle'}
size='xs' size='xs'
onClick={() => setShowServer((s) => !s)} onClick={() => setShowServer((s) => !s)}
onAuxClick={() => disableOthers(LogFilter.Server)} onAuxClick={() => disableOthers(LogOrigin.Server)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogFilter.Server} {LogOrigin.Server}
</Button> </Button>
<Button <Button
variant={showPlayback ? 'ontime-filled' : 'ontime-subtle'} variant={showPlayback ? 'ontime-filled' : 'ontime-subtle'}
size='xs' size='xs'
onClick={() => setShowPlayback((s) => !s)} onClick={() => setShowPlayback((s) => !s)}
onAuxClick={() => disableOthers(LogFilter.Playback)} onAuxClick={() => disableOthers(LogOrigin.Playback)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogFilter.Playback} {LogOrigin.Playback}
</Button> </Button>
<Button <Button
variant={showRx ? 'ontime-filled' : 'ontime-subtle'} variant={showRx ? 'ontime-filled' : 'ontime-subtle'}
size='xs' size='xs'
onClick={() => setShowRx((s) => !s)} onClick={() => setShowRx((s) => !s)}
onAuxClick={() => disableOthers(LogFilter.RX)} onAuxClick={() => disableOthers(LogOrigin.Rx)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogFilter.RX} {LogOrigin.Rx}
</Button> </Button>
<Button <Button
variant={showTx ? 'ontime-filled' : 'ontime-subtle'} variant={showTx ? 'ontime-filled' : 'ontime-subtle'}
size='xs' size='xs'
onClick={() => setShowTx((s) => !s)} onClick={() => setShowTx((s) => !s)}
onAuxClick={() => disableOthers(LogFilter.TX)} onAuxClick={() => disableOthers(LogOrigin.Tx)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogFilter.TX} {LogOrigin.Tx}
</Button> </Button>
<Button variant='ontime-outlined' size='xs' onClick={clearLogs}> <Button variant='ontime-outlined' size='xs' onClick={clearLogs}>
Clear Clear
@@ -0,0 +1,17 @@
import useEventData from '../../../common/hooks-query/useEventData';
import style from '../Info.module.scss';
export default function InfoHeader({ selected }: { selected: string }) {
const { data } = useEventData();
return (
<>
<div className={style.panelHeader}>
<span className={style.title}>{data?.title || ''}</span>
<span className={style.selected}>{selected}</span>
</div>
<div className={style.description}>{data?.description || ''}</div>
</>
);
}
@@ -107,6 +107,7 @@ const MenuBar = (props: MenuBarProps) => {
<div className={style.gap} /> <div className={style.gap} />
<TooltipActionBtn <TooltipActionBtn
{...buttonStyle} {...buttonStyle}
isDisabled={appMode === AppMode.Run}
icon={<IoColorWand />} icon={<IoColorWand />}
className={isQuickStartOpen ? style.open : ''} className={isQuickStartOpen ? style.open : ''}
clickHandler={onQuickStartOpen} clickHandler={onQuickStartOpen}
@@ -115,6 +116,7 @@ const MenuBar = (props: MenuBarProps) => {
/> />
<TooltipActionBtn <TooltipActionBtn
{...buttonStyle} {...buttonStyle}
isDisabled={appMode === AppMode.Run}
icon={<IoPushOutline />} icon={<IoPushOutline />}
className={isUploadOpen ? style.open : ''} className={isUploadOpen ? style.open : ''}
clickHandler={onUploadOpen} clickHandler={onUploadOpen}
@@ -125,6 +127,7 @@ const MenuBar = (props: MenuBarProps) => {
<TooltipActionBtn <TooltipActionBtn
{...buttonStyle} {...buttonStyle}
icon={<IoSaveOutline />} icon={<IoSaveOutline />}
isDisabled={appMode === AppMode.Run}
clickHandler={downloadRundown} clickHandler={downloadRundown}
tooltip='Export project file' tooltip='Export project file'
aria-label='Export project file' aria-label='Export project file'
@@ -154,6 +157,7 @@ const MenuBar = (props: MenuBarProps) => {
<div className={style.gap} /> <div className={style.gap} />
<TooltipActionBtn <TooltipActionBtn
{...buttonStyle} {...buttonStyle}
isDisabled={appMode === AppMode.Run}
icon={isIntegrationOpen ? <IoExtensionPuzzle /> : <IoExtensionPuzzleOutline />} icon={isIntegrationOpen ? <IoExtensionPuzzle /> : <IoExtensionPuzzleOutline />}
className={isIntegrationOpen ? style.open : ''} className={isIntegrationOpen ? style.open : ''}
clickHandler={onIntegrationOpen} clickHandler={onIntegrationOpen}
@@ -163,6 +167,7 @@ const MenuBar = (props: MenuBarProps) => {
/> />
<TooltipActionBtn <TooltipActionBtn
{...buttonStyle} {...buttonStyle}
isDisabled={appMode === AppMode.Run}
icon={<IoSettingsOutline />} icon={<IoSettingsOutline />}
className={isSettingsOpen ? style.open : ''} className={isSettingsOpen ? style.open : ''}
clickHandler={onSettingsOpen} clickHandler={onSettingsOpen}
@@ -102,6 +102,18 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
/> />
</label> </label>
</div> </div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Event description
<Input
variant='ontime-filled-on-light'
size='sm'
maxLength={100}
placeholder='Euro Love, Malmö 2024'
{...register('description')}
/>
</label>
</div>
<div className={styles.entryRow}> <div className={styles.entryRow}>
<label className={styles.sectionTitle}> <label className={styles.sectionTitle}>
Public Info Public Info
@@ -5,6 +5,7 @@ import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
import { IoRemove } from '@react-icons/all-files/io5/IoRemove'; import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
import { Alias } from 'ontime-types'; import { Alias } from 'ontime-types';
import { logAxiosError } from '../../../common/api/apiUtils';
import { postAliases } from '../../../common/api/ontimeApi'; import { postAliases } from '../../../common/api/ontimeApi';
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn'; import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
import useAliases from '../../../common/hooks-query/useAliases'; import useAliases from '../../../common/hooks-query/useAliases';
@@ -53,7 +54,7 @@ export default function AliasesForm() {
try { try {
await postAliases(formData.aliases); await postAliases(formData.aliases);
} catch (error) { } catch (error) {
emitError(`Error saving aliases: ${error}`); logAxiosError('Error saving aliases', error);
} finally { } finally {
await refetch(); await refetch();
} }
@@ -130,7 +131,7 @@ export default function AliasesForm() {
isDisabled={disableInputs} isDisabled={disableInputs}
/> />
<TooltipActionBtn <TooltipActionBtn
clickHandler={(event) => handleLinks(event, alias.pathAndParams)} clickHandler={(event) => handleLinks(event, alias.alias)}
tooltip='Test alias' tooltip='Test alias'
aria-label='Test alias' aria-label='Test alias'
size='xs' size='xs'
@@ -3,9 +3,9 @@ import { useForm } from 'react-hook-form';
import { Input, Select } from '@chakra-ui/react'; import { Input, Select } from '@chakra-ui/react';
import type { Settings } from 'ontime-types'; import type { Settings } from 'ontime-types';
import { logAxiosError } from '../../../common/api/apiUtils';
import { postSettings } from '../../../common/api/ontimeApi'; import { postSettings } from '../../../common/api/ontimeApi';
import useSettings from '../../../common/hooks-query/useSettings'; import useSettings from '../../../common/hooks-query/useSettings';
import { useEmitLog } from '../../../common/stores/logger';
import { isOnlyNumbers } from '../../../common/utils/regex'; import { isOnlyNumbers } from '../../../common/utils/regex';
import ModalLoader from '../modal-loader/ModalLoader'; import ModalLoader from '../modal-loader/ModalLoader';
import ModalSplitInput from '../ModalSplitInput'; import ModalSplitInput from '../ModalSplitInput';
@@ -17,7 +17,6 @@ import style from './SettingsModal.module.scss';
export default function AppSettingsModal() { export default function AppSettingsModal() {
const { data, status, isFetching, refetch } = useSettings(); const { data, status, isFetching, refetch } = useSettings();
const { emitError } = useEmitLog();
const { const {
handleSubmit, handleSubmit,
register, register,
@@ -41,7 +40,7 @@ export default function AppSettingsModal() {
try { try {
await postSettings(formData); await postSettings(formData);
} catch (error) { } catch (error) {
emitError(`Error saving settings: ${error}`); logAxiosError('Error saving settings', error);
} finally { } finally {
await refetch(); await refetch();
} }
@@ -119,6 +118,7 @@ export default function AppSettingsModal() {
> >
<Select backgroundColor='white' size='sm' width='auto' isDisabled={disableInputs} {...register('language')}> <Select backgroundColor='white' size='sm' width='auto' isDisabled={disableInputs} {...register('language')}>
<option value='en'>English</option> <option value='en'>English</option>
<option value='fr'>French</option>
<option value='de'>German</option> <option value='de'>German</option>
<option value='no'>Norwegian</option> <option value='no'>Norwegian</option>
<option value='pt'>Portuguese</option> <option value='pt'>Portuguese</option>
@@ -3,9 +3,9 @@ import { useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertIcon, AlertTitle, Input } from '@chakra-ui/react'; import { Alert, AlertDescription, AlertIcon, AlertTitle, Input } from '@chakra-ui/react';
import { UserFields } from 'ontime-types'; import { UserFields } from 'ontime-types';
import { logAxiosError } from '../../../common/api/apiUtils';
import { postUserFields } from '../../../common/api/ontimeApi'; import { postUserFields } from '../../../common/api/ontimeApi';
import useUserFields from '../../../common/hooks-query/useUserFields'; import useUserFields from '../../../common/hooks-query/useUserFields';
import { useEmitLog } from '../../../common/stores/logger';
import ModalLoader from '../modal-loader/ModalLoader'; import ModalLoader from '../modal-loader/ModalLoader';
import { inputProps } from '../modalHelper'; import { inputProps } from '../modalHelper';
import ModalLink from '../ModalLink'; import ModalLink from '../ModalLink';
@@ -16,9 +16,8 @@ import style from './SettingsModal.module.scss';
const userFieldsDocsUrl = 'https://ontime.gitbook.io/v2/features/user-fields'; const userFieldsDocsUrl = 'https://ontime.gitbook.io/v2/features/user-fields';
export default function CuesheetSettings() { export default function CuesheetSettingsForm() {
const { data, status, isFetching, refetch } = useUserFields(); const { data, status, isFetching, refetch } = useUserFields();
const { emitError } = useEmitLog();
const { const {
handleSubmit, handleSubmit,
register, register,
@@ -42,7 +41,7 @@ export default function CuesheetSettings() {
try { try {
await postUserFields(formData); await postUserFields(formData);
} catch (error) { } catch (error) {
emitError(`Error saving cuesheet settings: ${error}`); logAxiosError('Error saving cuesheet settings', error);
} finally { } finally {
await refetch(); await refetch();
} }
@@ -1,15 +1,16 @@
import { Switch } from '@chakra-ui/react'; import { Switch } from '@chakra-ui/react';
import { useLocalEvent } from '../../../common/stores/localEvent'; import { useEditorSettings } from '../../../common/stores/editorSettings';
import ModalSplitInput from '../ModalSplitInput'; import ModalSplitInput from '../ModalSplitInput';
import style from './SettingsModal.module.scss'; import style from './SettingsModal.module.scss';
export default function EditorSettings() { export default function EditorSettings() {
const eventSettings = useLocalEvent((state) => state.eventSettings); const eventSettings = useEditorSettings((state) => state.eventSettings);
const setShowQuickEntry = useLocalEvent((state) => state.setShowQuickEntry); const setShowQuickEntry = useEditorSettings((state) => state.setShowQuickEntry);
const setStartTimeIsLastEnd = useLocalEvent((state) => state.setStartTimeIsLastEnd); const setStartTimeIsLastEnd = useEditorSettings((state) => state.setStartTimeIsLastEnd);
const setDefaultPublic = useLocalEvent((state) => state.setDefaultPublic); const setDefaultPublic = useEditorSettings((state) => state.setDefaultPublic);
const setShowNif = useEditorSettings((state) => state.setShowNif);
return ( return (
<div className={style.sectionContainer}> <div className={style.sectionContainer}>
@@ -39,6 +40,18 @@ export default function EditorSettings() {
onChange={(event) => setDefaultPublic(event.target.checked)} onChange={(event) => setDefaultPublic(event.target.checked)}
/> />
</ModalSplitInput> </ModalSplitInput>
<span className={style.title}>Info settings</span>
<ModalSplitInput
field=''
title='Show network'
description='Whether to available show network interfaces in the panel'
>
<Switch
variant='ontime-on-light'
defaultChecked={eventSettings.showQuickEntry}
onChange={(event) => setShowNif(event.target.checked)}
/>
</ModalSplitInput>
</div> </div>
); );
} }
@@ -3,9 +3,9 @@ import { useForm } from 'react-hook-form';
import { Input, Textarea } from '@chakra-ui/react'; import { Input, Textarea } from '@chakra-ui/react';
import { EventData } from 'ontime-types'; import { EventData } from 'ontime-types';
import { logAxiosError } from '../../../common/api/apiUtils';
import { postEventData } from '../../../common/api/eventDataApi'; import { postEventData } from '../../../common/api/eventDataApi';
import useEventData from '../../../common/hooks-query/useEventData'; import useEventData from '../../../common/hooks-query/useEventData';
import { useEmitLog } from '../../../common/stores/logger';
import ModalLoader from '../modal-loader/ModalLoader'; import ModalLoader from '../modal-loader/ModalLoader';
import { inputProps } from '../modalHelper'; import { inputProps } from '../modalHelper';
import ModalInput from '../ModalInput'; import ModalInput from '../ModalInput';
@@ -15,7 +15,6 @@ import style from './SettingsModal.module.scss';
export default function EventDataForm() { export default function EventDataForm() {
const { data, status, isFetching, refetch } = useEventData(); const { data, status, isFetching, refetch } = useEventData();
const { emitError } = useEmitLog();
const { const {
handleSubmit, handleSubmit,
register, register,
@@ -39,7 +38,7 @@ export default function EventDataForm() {
try { try {
await postEventData(formData); await postEventData(formData);
} catch (error) { } catch (error) {
emitError(`Error saving event settings: ${error}`); logAxiosError('Error saving event settings', error);
} finally { } finally {
await refetch(); await refetch();
} }
@@ -72,6 +71,21 @@ export default function EventDataForm() {
{...register('title')} {...register('title')}
/> />
</ModalInput> </ModalInput>
<ModalInput
field='description'
title='Event description'
description='Free field, shown in editor'
error={errors.description?.message}
>
<Input
{...inputProps}
variant='ontime-filled-on-light'
maxLength={100}
placeholder='Euro Love, Malmö 2024'
isDisabled={disableInputs}
{...register('description')}
/>
</ModalInput>
<div style={{ height: '16px' }} /> <div style={{ height: '16px' }} />
<ModalInput field='publicInfo' title='Public Info' description='Information shown in public screens'> <ModalInput field='publicInfo' title='Public Info' description='Information shown in public screens'>
<Textarea <Textarea
@@ -4,7 +4,7 @@ import ModalWrapper from '../ModalWrapper';
import AliasesForm from './AliasesForm'; import AliasesForm from './AliasesForm';
import AppSettingsModal from './AppSettings'; import AppSettingsModal from './AppSettings';
import CuesheetSettings from './CuesheetSettings'; import CuesheetSettingsForm from './CuesheetSettingsForm';
import EditorSettings from './EditorSettings'; import EditorSettings from './EditorSettings';
import EventDataForm from './EventDataForm'; import EventDataForm from './EventDataForm';
import ViewSettingsForm from './ViewSettingsForm'; import ViewSettingsForm from './ViewSettingsForm';
@@ -39,7 +39,7 @@ export default function SettingsModal(props: ModalManagerProps) {
<EditorSettings /> <EditorSettings />
</TabPanel> </TabPanel>
<TabPanel> <TabPanel>
<CuesheetSettings /> <CuesheetSettingsForm />
</TabPanel> </TabPanel>
<TabPanel> <TabPanel>
<ViewSettingsForm /> <ViewSettingsForm />
@@ -3,15 +3,15 @@ import { useForm } from 'react-hook-form';
import { Input, Switch } from '@chakra-ui/react'; import { Input, Switch } from '@chakra-ui/react';
import { ViewSettings } from 'ontime-types'; import { ViewSettings } from 'ontime-types';
import { logAxiosError } from '../../../common/api/apiUtils';
import { postViewSettings } from '../../../common/api/ontimeApi'; import { postViewSettings } from '../../../common/api/ontimeApi';
import { PopoverPickerRHF } from '../../../common/components/input/popover-picker/PopoverPicker'; import { PopoverPickerRHF } from '../../../common/components/input/popover-picker/PopoverPicker';
import useViewSettings from '../../../common/hooks-query/useViewSettings'; import useViewSettings from '../../../common/hooks-query/useViewSettings';
import { useEmitLog } from '../../../common/stores/logger';
import { mtm } from '../../../common/utils/timeConstants'; import { mtm } from '../../../common/utils/timeConstants';
import ModalLoader from '../modal-loader/ModalLoader';
import { inputProps } from '../modalHelper'; import { inputProps } from '../modalHelper';
import ModalInput from '../ModalInput'; import ModalInput from '../ModalInput';
import ModalSplitInput from '../ModalSplitInput'; import ModalSplitInput from '../ModalSplitInput';
import ModalLoader from '../modal-loader/ModalLoader';
import OntimeModalFooter from '../OntimeModalFooter'; import OntimeModalFooter from '../OntimeModalFooter';
import InputMillisWithString from './InputMillisWithString'; import InputMillisWithString from './InputMillisWithString';
@@ -20,7 +20,6 @@ import style from './SettingsModal.module.scss';
export default function ViewSettingsForm() { export default function ViewSettingsForm() {
const { data, status, refetch, isFetching } = useViewSettings(); const { data, status, refetch, isFetching } = useViewSettings();
const { emitError } = useEmitLog();
const { const {
control, control,
handleSubmit, handleSubmit,
@@ -60,7 +59,7 @@ export default function ViewSettingsForm() {
try { try {
await postViewSettings(newData); await postViewSettings(newData);
} catch (error) { } catch (error) {
emitError(`Error saving view settings: ${error}`); logAxiosError('Error saving view settings', error);
} finally { } finally {
await refetch(); await refetch();
} }
+3 -10
View File
@@ -6,7 +6,7 @@ import { OntimeRundown, Playback, SupportedEvent } from 'ontime-types';
import { useEventAction } from '../../common/hooks/useEventAction'; import { useEventAction } from '../../common/hooks/useEventAction';
import { useRundownEditor } from '../../common/hooks/useSocket'; import { useRundownEditor } from '../../common/hooks/useSocket';
import { AppMode, useAppMode } from '../../common/stores/appModeStore'; import { AppMode, useAppMode } from '../../common/stores/appModeStore';
import { useLocalEvent } from '../../common/stores/localEvent'; import { useEditorSettings } from '../../common/stores/editorSettings';
import { cloneEvent, getFirstEvent, getNextEvent, getPreviousEvent } from '../../common/utils/eventsManager'; import { cloneEvent, getFirstEvent, getNextEvent, getPreviousEvent } from '../../common/utils/eventsManager';
import QuickAddBlock from './quick-add-block/QuickAddBlock'; import QuickAddBlock from './quick-add-block/QuickAddBlock';
@@ -26,7 +26,7 @@ export default function Rundown(props: RundownProps) {
const featureData = useRundownEditor(); const featureData = useRundownEditor();
const { addEvent, reorderEvent } = useEventAction(); const { addEvent, reorderEvent } = useEventAction();
const eventSettings = useLocalEvent((state) => state.eventSettings); const eventSettings = useEditorSettings((state) => state.eventSettings);
const defaultPublic = eventSettings.defaultPublic; const defaultPublic = eventSettings.defaultPublic;
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd; const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
const showQuickEntry = eventSettings.showQuickEntry; const showQuickEntry = eventSettings.showQuickEntry;
@@ -203,7 +203,6 @@ export default function Rundown(props: RundownProps) {
return <RundownEmpty handleAddNew={() => insertAtCursor(SupportedEvent.Event, null)} />; return <RundownEmpty handleAddNew={() => insertAtCursor(SupportedEvent.Event, null)} />;
} }
let cumulativeDelay = 0;
let previousEnd = 0; let previousEnd = 0;
let thisEnd = 0; let thisEnd = 0;
let previousEventId: string | undefined; let previousEventId: string | undefined;
@@ -217,14 +216,9 @@ export default function Rundown(props: RundownProps) {
<div className={style.list}> <div className={style.list}>
{statefulEntries.map((entry, index) => { {statefulEntries.map((entry, index) => {
if (index === 0) { if (index === 0) {
cumulativeDelay = 0;
eventIndex = -1; eventIndex = -1;
} }
if (entry.type === SupportedEvent.Delay && entry.duration !== null) { if (entry.type === SupportedEvent.Event) {
cumulativeDelay += entry.duration;
} else if (entry.type === SupportedEvent.Block) {
cumulativeDelay = 0;
} else if (entry.type === SupportedEvent.Event) {
eventIndex++; eventIndex++;
previousEnd = thisEnd; previousEnd = thisEnd;
thisEnd = entry.timeEnd; thisEnd = entry.timeEnd;
@@ -248,7 +242,6 @@ export default function Rundown(props: RundownProps) {
selected={isSelected} selected={isSelected}
hasCursor={hasCursor} hasCursor={hasCursor}
next={isNext} next={isNext}
delay={cumulativeDelay}
previousEnd={previousEnd} previousEnd={previousEnd}
previousEventId={previousEventId} previousEventId={previousEventId}
playback={isSelected ? featureData.playback : undefined} playback={isSelected ? featureData.playback : undefined}
@@ -1,12 +1,12 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types'; import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types';
import { calculateDuration } from 'ontime-utils';
import { useEventAction } from '../../common/hooks/useEventAction'; import { useEventAction } from '../../common/hooks/useEventAction';
import { useAppMode } from '../../common/stores/appModeStore'; import { useAppMode } from '../../common/stores/appModeStore';
import { useLocalEvent } from '../../common/stores/localEvent'; import { useEditorSettings } from '../../common/stores/editorSettings';
import { useEmitLog } from '../../common/stores/logger'; import { useEmitLog } from '../../common/stores/logger';
import { cloneEvent } from '../../common/utils/eventsManager'; import { cloneEvent } from '../../common/utils/eventsManager';
import { calculateDuration } from '../../common/utils/timesManager';
import BlockBlock from './block-block/BlockBlock'; import BlockBlock from './block-block/BlockBlock';
import DelayBlock from './delay-block/DelayBlock'; import DelayBlock from './delay-block/DelayBlock';
@@ -22,7 +22,6 @@ interface RundownEntryProps {
selected: boolean; selected: boolean;
hasCursor: boolean; hasCursor: boolean;
next: boolean; next: boolean;
delay: number;
previousEnd: number; previousEnd: number;
previousEventId?: string; previousEventId?: string;
playback?: Playback; // we only care about this if this event is playing playback?: Playback; // we only care about this if this event is playing
@@ -38,7 +37,6 @@ export default function RundownEntry(props: RundownEntryProps) {
selected, selected,
hasCursor, hasCursor,
next, next,
delay,
previousEnd, previousEnd,
previousEventId, previousEventId,
playback, playback,
@@ -63,7 +61,7 @@ export default function RundownEntry(props: RundownEntryProps) {
} }
}, [cursor, data.id, openId, setCursor, setEditId]); }, [cursor, data.id, openId, setCursor, setEditId]);
const eventSettings = useLocalEvent((state) => state.eventSettings); const eventSettings = useEditorSettings((state) => state.eventSettings);
const defaultPublic = eventSettings.defaultPublic; const defaultPublic = eventSettings.defaultPublic;
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd; const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
@@ -167,7 +165,7 @@ export default function RundownEntry(props: RundownEntryProps) {
timerType={data.timerType} timerType={data.timerType}
title={data.title} title={data.title}
note={data.note} note={data.note}
delay={delay} delay={data.delay || 0}
previousEnd={previousEnd} previousEnd={previousEnd}
colour={data.colour} colour={data.colour}
isPast={isPast} isPast={isPast}
@@ -1,4 +1,4 @@
import { Playback } from 'ontime-types'; import { MaybeNumber, Playback } from 'ontime-types';
import { useTimer } from '../../../../common/hooks/useSocket'; import { useTimer } from '../../../../common/hooks/useSocket';
import { clamp } from '../../../../common/utils/math'; import { clamp } from '../../../../common/utils/math';
@@ -9,18 +9,26 @@ interface EventBlockProgressBarProps {
playback?: Playback; playback?: Playback;
} }
export function getPercentComplete(remaining: MaybeNumber, total: MaybeNumber): number {
if (remaining === null || total === null) {
return 0;
}
if (remaining <= 0) {
return 100;
}
if (remaining === total) {
return 0;
}
return clamp(100 - (remaining * 100) / total, 0, 100);
}
export default function EventBlockProgressBar(props: EventBlockProgressBarProps) { export default function EventBlockProgressBar(props: EventBlockProgressBarProps) {
const { playback } = props; const { playback } = props;
const timer = useTimer(); const timer = useTimer();
const now = Math.floor(Math.max((timer?.current ?? 1) / 1000, 0)); const progress = `${getPercentComplete(timer.current, timer.duration)}%`;
const complete = (timer?.duration ?? 1) / 1000;
const elapsed = clamp(100 - (now * 100) / complete, 0, 100);
const progress = `${elapsed}%`;
if ((timer?.current ?? 0) < 0) {
return <div className={`${style.progressBar} ${style.overtime}`} style={{ width: '100%' }} />;
}
return <div className={`${style.progressBar} ${playback ? style[playback] : ''}`} style={{ width: progress }} />; return <div className={`${style.progressBar} ${playback ? style[playback] : ''}`} style={{ width: progress }} />;
} }
@@ -1,11 +1,11 @@
import { memo, useCallback, useState } from 'react'; import { memo, useCallback, useState } from 'react';
import { OntimeEvent } from 'ontime-types'; import { OntimeEvent } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { calculateDuration, millisToString } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput'; import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { useEventAction } from '../../../../common/hooks/useEventAction'; import { useEventAction } from '../../../../common/hooks/useEventAction';
import { millisToDelayString } from '../../../../common/utils/dateConfig'; import { millisToDelayString } from '../../../../common/utils/dateConfig';
import { calculateDuration, TimeEntryField, validateEntry } from '../../../../common/utils/timesManager'; import { TimeEntryField, validateEntry } from '../../../../common/utils/timesManager';
import style from '../EventBlock.module.scss'; import style from '../EventBlock.module.scss';
@@ -0,0 +1,27 @@
import { dayInMs } from 'ontime-utils';
import { getPercentComplete } from '../EventBlockProgressBar';
describe('getPercentComplete()', () => {
describe('calculates progress in normal cases', () => {
const testScenarios = [
{ current: 0, duration: 0, expect: 100 },
{ current: 0, duration: 100, expect: 100 },
{ current: 0, duration: dayInMs, expect: 100 },
{ current: 10, duration: 100, expect: 90 },
{ current: 50, duration: 100, expect: 50 },
{ current: 100, duration: 100, expect: 0 },
];
testScenarios.forEach((testCase) => {
it(`handles ${testCase.current} / ${testCase.duration}`, () => {
const progress = getPercentComplete(testCase.current, testCase.duration);
expect(progress).toBe(testCase.expect);
});
});
});
it('is 0 if we dont have a current or duration', () => {
const progress = getPercentComplete(null, null);
expect(progress).toBe(0);
});
});
@@ -3,7 +3,7 @@ import { Button, Checkbox, Tooltip } from '@chakra-ui/react';
import { SupportedEvent } from 'ontime-types'; import { SupportedEvent } from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEventAction } from '../../../common/hooks/useEventAction';
import { useLocalEvent } from '../../../common/stores/localEvent'; import { useEditorSettings } from '../../../common/stores/editorSettings';
import { useEmitLog } from '../../../common/stores/logger'; import { useEmitLog } from '../../../common/stores/logger';
import { tooltipDelayMid } from '../../../ontimeConfig'; import { tooltipDelayMid } from '../../../ontimeConfig';
@@ -25,7 +25,7 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
const doStartTime = useRef<HTMLInputElement | null>(null); const doStartTime = useRef<HTMLInputElement | null>(null);
const doPublic = useRef<HTMLInputElement | null>(null); const doPublic = useRef<HTMLInputElement | null>(null);
const eventSettings = useLocalEvent((state) => state.eventSettings); const eventSettings = useEditorSettings((state) => state.eventSettings);
const defaultPublic = eventSettings.defaultPublic; const defaultPublic = eventSettings.defaultPublic;
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd; const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
@@ -1,245 +0,0 @@
import { useCallback, useContext, useEffect, useMemo } from 'react';
import { useBlockLayout, useColumnOrder, useResizeColumns, useTable } from 'react-table';
import { Tooltip } from '@chakra-ui/react';
import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import PropTypes from 'prop-types';
import { TableSettingsContext } from '../../common/context/TableSettingsContext';
import { useLocalStorage } from '../../common/hooks/useLocalStorage';
import { tooltipDelayFast } from '../../ontimeConfig';
import SortableCell from './tableElements/SortableCell';
import TableSettings from './tableElements/TableSettings';
import BlockRow from './tableRows/BlockRow';
import DelayRow from './tableRows/DelayRow';
import EventRow from './tableRows/EventRow';
import { makeColumns } from './columns';
import { defaultColumnOrder, defaultHiddenColumns } from './defaults';
import style from './Table.module.scss';
export default function OntimeTable({ tableData, userFields, selectedId, handleUpdate }) {
const { followSelected, showSettings } = useContext(TableSettingsContext);
const [columnOrder, saveColumnOrder] = useLocalStorage('table-order', defaultColumnOrder);
const [columnSize, saveColumnSize] = useLocalStorage('table-sizes', {});
const [hiddenColumns, saveHiddenColumns] = useLocalStorage('table-hidden', defaultHiddenColumns);
const columns = useMemo(() => makeColumns(columnSize, userFields), [columnSize, userFields]);
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
setColumnOrder,
allColumns,
setHiddenColumns,
toggleHideAllColumns,
state,
} = useTable(
{
columns,
data: tableData,
initialState: {
hiddenColumns,
},
handleUpdate,
},
useColumnOrder,
useBlockLayout,
useResizeColumns
);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
delay: 100,
tolerance: 50,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 100,
tolerance: 50,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleResetReordering = useCallback(() => {
saveColumnOrder(defaultColumnOrder);
setColumnOrder(defaultColumnOrder);
}, [saveColumnOrder, setColumnOrder]);
const handleResetResizing = useCallback(() => {
saveColumnSize({});
}, [saveColumnSize]);
const handleResetToggles = useCallback(() => {
setHiddenColumns(defaultHiddenColumns);
saveHiddenColumns(defaultHiddenColumns);
}, [saveHiddenColumns, setHiddenColumns]);
const clearToggles = useCallback(() => {
toggleHideAllColumns(false);
saveHiddenColumns([]);
}, [saveHiddenColumns, toggleHideAllColumns]);
const handleOnDragEnd = useCallback((event) => {
const { delta, active, over } = event;
// cancel if delta y is greater than 200
if (delta.y > 200) return;
const cols = [...columnOrder];
// get index of from
const fromIndex = cols.findIndex((i) => i === active.id);
// get index of to
const toIndex = cols.findIndex((i) => i === over.id);
if (toIndex === -1) {
return;
}
// reorder
const [reorderedItem] = cols.splice(fromIndex, 1);
cols.splice(toIndex, 0, reorderedItem);
saveColumnOrder(cols);
setColumnOrder(cols);
}, [columnOrder, saveColumnOrder, setColumnOrder]);
// save hidden columns object to local storage
useEffect(() => {
saveHiddenColumns(state.hiddenColumns);
}, [saveHiddenColumns, state.hiddenColumns]);
// save column sizes to local storage
useEffect(() => {
// property changes from title of column to null on resize end
if (state.columnResizing?.isResizingColumn !== null) {
return;
}
const cols = state.columnResizing.columnWidths;
saveColumnSize((prev) => ({ ...prev, ...cols }));
}, [saveColumnSize, state.columnResizing]);
// scroll to active cue
useEffect(() => {
if (followSelected) {
const el = document.getElementById(selectedId);
if (el) {
el.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest',
});
}
}
}, [followSelected, selectedId]);
// keep order of events
let eventIndex = 0;
// keep delay (ms)
let cumulativeDelay = 0;
return (
<>
{showSettings && (
<TableSettings
columns={allColumns}
handleResetResizing={handleResetResizing}
handleResetReordering={handleResetReordering}
handleResetToggles={handleResetToggles}
handleClearToggles={clearToggles}
/>
)}
<table {...getTableProps()} className={style.ontimeTable}>
<thead className={style.tableHeader}>
{headerGroups.map((headerGroup) => {
const { key, ...restHeaderGroupProps } = headerGroup.getHeaderGroupProps();
return (
<DndContext
key={key}
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleOnDragEnd}
>
<tr {...restHeaderGroupProps}>
<th className={style.indexColumn}>
<Tooltip label='Event Order' openDelay={tooltipDelayFast}>
#
</Tooltip>
</th>
<SortableContext
key={key}
items={headerGroup.headers}
strategy={horizontalListSortingStrategy}
>
{headerGroup.headers.map((column) => {
const { key } = column.getHeaderProps();
return <SortableCell key={key} column={column} />;
})}
</SortableContext>
</tr>
</DndContext>
);
})}
</thead>
<tbody {...getTableBodyProps} className={style.tableBody}>
{/*This is saving in place of a default component*/}
{/* eslint-disable-next-line array-callback-return */}
{rows.map((row) => {
prepareRow(row);
const { key } = row.getRowProps();
const type = row.original.type;
if (type === 'event') {
eventIndex++;
return (
<EventRow
key={key}
row={row}
index={eventIndex}
selectedId={selectedId}
delay={cumulativeDelay}
/>
);
}
if (type === 'delay') {
if (row.original.duration != null) {
cumulativeDelay += row.original.duration;
}
return <DelayRow key={key} row={row} />;
}
if (type === 'block') {
cumulativeDelay = 0;
return <BlockRow key={key} row={row} />;
}
})}
</tbody>
</table>
</>
);
}
OntimeTable.propTypes = {
tableData: PropTypes.array,
userFields: PropTypes.object,
handleUpdate: PropTypes.func.isRequired,
selectedId: PropTypes.string,
showSettings: PropTypes.bool,
followSelected: PropTypes.bool,
};
@@ -1,14 +0,0 @@
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import { TableSettingsProvider } from '../../common/context/TableSettingsContext';
import TableWrapper from './TableWrapper';
export default function ProtectedTable() {
return (
<ProtectRoute permission='operator'>
<TableSettingsProvider>
<TableWrapper />
</TableSettingsProvider>
</ProtectRoute>
);
}
@@ -1,310 +0,0 @@
@use '../../theme/main' as *;
.tableWrapper,
.tableWrapper__dark {
font-family: "Open Sans", sans-serif;
font-size: 16px;
width: 100%;
height: 100vh;
padding: 2rem;
display: grid;
grid-template-columns: calc(100vw - 4rem);
grid-template-rows: auto auto 1fr;
grid-template-areas:
'header'
'settings'
'table';
gap: 1rem;
overflow: scroll;
& > * {
width: 100%;
border: 1px solid $ontime-pink;
border-radius: 4px;
}
.header {
grid-area: header;
display: grid;
height: max-content;
grid-template-areas:
'name playback running time actions'
'now playback running time actions';
grid-template-columns: 1fr auto 10em 12.5em auto;
align-items: center;
padding: 0.25em 1em;
.headerName {
grid-area: name;
font-size: 1.5em;
}
.headerName:after {
content: '\200b';
}
.headerNow {
grid-area: now;
font-size: 1.25em;
}
.headerNow:after {
content: '\200b';
}
.headerPlayback {
grid-area: playback;
color: $ontime-pink;
text-align: center;
svg {
font-size: 2em;
}
}
.headerRunning {
grid-area: running;
text-align: center;
}
.headerClock {
grid-area: time;
text-align: center;
}
.headerActions {
grid-area: actions;
display: flex;
gap: 8px;
font-size: 1.5em;
padding-left: 10vw;
color: darken($ontime-pink, 8%);
}
}
.tableSettings {
grid-area: settings;
padding: 1rem;
.options,
.buttonRow {
display: flex;
flex-wrap: wrap;
flex-direction: row;
gap: 2rem;
}
.options {
padding-left: 0.5em;
}
.buttonRow {
padding-top: 2em;
}
}
.ontimeTable {
grid-area: table;
border-collapse: separate;
border-spacing: 16px;
padding: 1rem;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: auto;
th, td {
touch-action: auto;
padding: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
td {
border-radius: 4px;
}
.tableHeader {
position: sticky;
top: -1em;
z-index: 10;
th {
font-size: 0.9em;
font-weight: 200;
text-align: left;
.resizer {
display: inline-block;
width: 10px;
height: 100%;
position: absolute;
right: 0;
top: 0;
transform: translateX(50%);
z-index: 1;
touch-action: none;
}
}
}
.tableBody {
tr {
td:hover {
background-color: lighten($ontime-pink, 10%) !important;
color: black;
}
}
.selected > td {
background-color: rgba($ontime-accent, 0.8);
}
}
.indexColumn {
font-weight: 200;
text-align: right;
width: 2.5em;
background: transparent;
}
.blockCell,
.delayCell {
width: 100%;
font-weight: 400;
font-size: 0.9em;
text-align: center;
color: black;
}
.delayCell {
background-color: $block-delay-color;
}
.blockCell {
background-color: $block-block-color;
}
}
}
$bg-theme-light: #fcfcfc;
$cell-theme-light: #ececec;
$text-theme-light: #202020;
$bg-theme-dark: #121212;
$bg2-theme-dark: #1c1c1c;
$cell-theme-dark: #2d2d2d;
$text-theme-dark: white;
.tableWrapper {
background-color: $bg-theme-light;
color: black;
* {
background-color: $bg-theme-light;
scrollbar-color: $ontime-pink rgba(0, 0, 0, 0.13);
}
*::-webkit-scrollbar {
background-color: rgba(0, 0, 0, 0.07);
}
*::-webkit-scrollbar-thumb {
background-color: lighten($ontime-pink, 15%);
}
td {
background-color: $cell-theme-light;
border: 1px solid $bg-theme-light;
color: #121212;
}
.actionText:hover,
.actionIcon:hover,
.actionDisabled:hover {
color: black;
transition: 300ms;
}
}
.tableWrapper__dark {
background-color: $bg-theme-dark;
color: white;
* {
background-color: $bg-theme-dark;
scrollbar-color: $ontime-pink rgba(255, 255, 255, 0.13);
}
td {
background-color: $cell-theme-dark;
border: 1px solid $bg-theme-dark;
color: #ececec;
}
.actionText:hover,
.actionIcon:hover,
.actionDisabled:hover {
color: white;
transition: 300ms;
}
}
.timer {
font-size: 1.7em;
letter-spacing: 1px;
line-height: 1.2em;
font-weight: 200;
}
.label {
color: $ontime-pink;
font-size: 0.8em;
font-weight: 200;
line-height: 0.75em;
}
svg {
background-color: inherit !important;
}
@mixin action-element() {
cursor: pointer;
}
.actionIcon {
@include action-element();
}
.actionText {
@include action-element();
font-size: 0.65em;
}
.actionDisabled {
@include action-element();
opacity: 0.6;
}
.dragging {
border: 1px solid $ontime-pink;
z-index: 10;
}
.check {
font-size: 1.5em;
background-color: transparent !important;
margin: 0 auto;
}
@keyframes rotation {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@@ -1,106 +0,0 @@
import { useContext } from 'react';
import { Divider, Tooltip } from '@chakra-ui/react';
import { FiSettings } from '@react-icons/all-files/fi/FiSettings';
import { FiTarget } from '@react-icons/all-files/fi/FiTarget';
import { IoContract } from '@react-icons/all-files/io5/IoContract';
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoMoon } from '@react-icons/all-files/io5/IoMoon';
import { formatDisplay } from 'ontime-utils';
import PropTypes from 'prop-types';
import { TableSettingsContext } from '../../common/context/TableSettingsContext';
import useFullscreen from '../../common/hooks/useFullscreen';
import { useTimer } from '../../common/hooks/useSocket';
import useEventData from '../../common/hooks-query/useEventData';
import { formatTime } from '../../common/utils/time';
import { tooltipDelayFast } from '../../ontimeConfig';
import PlaybackIcon from './tableElements/PlaybackIcon';
import style from './Table.module.scss';
export default function TableHeader({ handleCSVExport, featureData }) {
const { followSelected, showSettings, toggleTheme, toggleSettings, toggleFollow } = useContext(TableSettingsContext);
const timer = useTimer();
const { isFullScreen, toggleFullScreen } = useFullscreen();
const { data: event } = useEventData();
const selected = !featureData.numEvents
? 'No events'
: `Event ${featureData.selectedEventIndex != null ? featureData.selectedEventIndex + 1 : '-'}/${
featureData.numEvents ? featureData.numEvents : '-'
}`;
// prepare presentation variables
const isOvertime = timer.current < 0;
const timerNow = `${isOvertime ? '-' : ''}${formatDisplay(timer.current)}`;
const timeNow = formatTime(timer.clock, {
showSeconds: true,
format: 'hh:mm:ss a',
});
return (
<div className={style.header}>
<div className={style.headerName}>{event?.title || ''}</div>
<div className={style.headerNow}>{featureData.titleNow}</div>
<div className={style.headerPlayback}>
<span className={style.label}>{selected}</span>
<br />
<PlaybackIcon state={featureData.playback} />
</div>
<div className={style.headerRunning}>
<span className={style.label}>Running Timer</span>
<br />
<span className={style.timer}>{timerNow}</span>
</div>
<div className={style.headerClock}>
<span className={style.label}>Time Now</span>
<br />
<span className={style.timer}>{timeNow}</span>
</div>
<div className={style.headerActions}>
<Tooltip openDelay={tooltipDelayFast} label='Follow selected'>
<span className={followSelected ? style.actionIcon : style.actionDisabled}>
<FiTarget onClick={() => toggleFollow()} />
</span>
</Tooltip>
<Tooltip openDelay={tooltipDelayFast} label='Show settings'>
<span className={showSettings ? style.actionIcon : style.actionDisabled}>
<FiSettings onClick={() => toggleSettings()} />
</span>
</Tooltip>
<Tooltip openDelay={tooltipDelayFast} label='Toggle dark mode'>
<span className={style.actionIcon}>
<IoMoon onClick={() => toggleTheme()} />
</span>
</Tooltip>
<Tooltip openDelay={tooltipDelayFast} label='Toggle Fullscreen'>
<span className={style.actionIcon}>
{isFullScreen ? (
<IoContract onClick={() => toggleFullScreen()} />
) : (
<IoExpand onClick={() => toggleFullScreen()} />
)}
</span>
</Tooltip>
<Divider />
<Tooltip openDelay={tooltipDelayFast} label='Export to CSV'>
<span className={style.actionText} onClick={() => handleCSVExport(event)}>
CSV
</span>
</Tooltip>
</div>
</div>
);
}
TableHeader.propTypes = {
handleCSVExport: PropTypes.func.isRequired,
featureData: PropTypes.shape({
playback: PropTypes.string,
selectedEventId: PropTypes.string,
selectedEventIndex: PropTypes.number,
numEvents: PropTypes.number,
titleNow: PropTypes.string,
}),
};
-104
View File
@@ -1,104 +0,0 @@
import { FiCheck } from '@react-icons/all-files/fi/FiCheck';
import EditableCell from './tableElements/EditableCell';
import style from './Table.module.scss';
import { millisToString } from 'ontime-utils';
/**
* React - Table column object
* @param sizes
* @param userFields
*/
export const makeColumns = (sizes, userFields) => {
return [
{
Header: 'Public',
accessor: 'isPublic',
Cell: ({ cell: { value } }) => (value ? <FiCheck className={style.check} /> : ''),
width: sizes?.isPublic || 50,
},
{
Header: 'Start',
accessor: 'timeStart',
Cell: ({ cell: { value, delayed } }) => millisToString(delayed || value),
width: sizes?.timeStart || 90,
},
{
Header: 'End',
accessor: 'timeEnd',
Cell: ({ cell: { value, delayed } }) => millisToString(delayed || value),
width: sizes?.timeEnd || 90,
},
{
Header: 'Duration',
accessor: 'duration',
Cell: ({ cell: { value } }) => millisToString(value),
width: sizes?.duration || 90,
},
{ Header: 'Title', accessor: 'title', width: sizes?.title || 400 },
{ Header: 'Subtitle', accessor: 'subtitle', width: sizes?.subtitle || 350 },
{ Header: 'Presenter', accessor: 'presenter', width: sizes?.presenter || 250 },
{ Header: 'Notes', accessor: 'note', width: sizes?.note || 500 },
{
Header: userFields.user0 || 'User 0',
accessor: 'user0',
Cell: EditableCell,
width: sizes?.user0 || 200,
},
{
Header: userFields.user1 || 'User 1',
accessor: 'user1',
Cell: EditableCell,
width: sizes?.user1 || 200,
},
{
Header: userFields.user2 || 'User 2',
accessor: 'user2',
Cell: EditableCell,
width: sizes?.user2 || 200,
},
{
Header: userFields.user3 || 'User 3',
accessor: 'user3',
Cell: EditableCell,
width: sizes?.user3 || 200,
},
{
Header: userFields.user4 || 'User 4',
accessor: 'user4',
Cell: EditableCell,
width: sizes?.user4 || 200,
},
{
Header: userFields.user5 || 'User 5',
accessor: 'user5',
Cell: EditableCell,
width: sizes?.user5 || 200,
},
{
Header: userFields.user6 || 'User 6',
accessor: 'user6',
Cell: EditableCell,
width: sizes?.user6 || 200,
},
{
Header: userFields.user7 || 'User 7',
accessor: 'user7',
Cell: EditableCell,
width: sizes?.user7 || 200,
},
{
Header: userFields.user8 || 'User 8',
accessor: 'user8',
Cell: EditableCell,
width: sizes?.user8 || 200,
},
{
Header: userFields.user9 || 'User 9',
accessor: 'user9',
Cell: EditableCell,
width: sizes?.user9 || 200,
},
];
};
@@ -1,56 +0,0 @@
import { useCallback, useContext, useEffect, useState } from 'react';
import { AutoTextArea } from '@/common/components/input/auto-text-area/AutoTextArea';
import { TableSettingsContext } from '@/common/context/TableSettingsContext';
import PropTypes from 'prop-types';
/**
* Shamelessly copied from react-table docs
* Plugged into chakra-ui editable component
* @description Custom editable field for table component
* @param props
* @return {JSX.Element}
* @constructor
*/
export default function EditableCell(props) {
const {
value: initialValue,
row: { index },
column: { id },
handleUpdate,
} = props;
const { theme } = useContext(TableSettingsContext);
// We need to keep and update the state of the cell normally
const [value, setValue] = useState(initialValue);
const onChange = useCallback((e) => setValue(e.target.value), []);
// We'll only update the external data when the input is blurred
const onBlur = useCallback(() => handleUpdate(index, id, value), [handleUpdate, id, index, value]);
// If the initialValue is changed external, sync it up with our state
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
return (
<AutoTextArea
size='sm'
value={value}
onChange={onChange}
onBlur={onBlur}
rows={3}
transition='none'
spellCheck={false}
isDark={theme === "dark"}
/>
);
}
EditableCell.propTypes = {
value: PropTypes.string,
row: PropTypes.object,
column: PropTypes.object,
handleUpdate: PropTypes.func,
};
@@ -1,50 +0,0 @@
import { Tooltip } from '@chakra-ui/react';
import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { IoTimeOutline } from '@react-icons/all-files/io5/IoTimeOutline';
import { Playback } from 'ontime-types';
import { tooltipDelayFast } from '../../../ontimeConfig';
interface PlaybackIconProps {
state: Playback;
}
export default function PlaybackIcon(props: PlaybackIconProps) {
const { state } = props;
if (state === Playback.Stop) {
return (
<Tooltip openDelay={tooltipDelayFast} label='Timer Stopped' shouldWrapChildren>
<IoStop />
</Tooltip>
);
}
if (state === Playback.Play) {
return (
<Tooltip openDelay={tooltipDelayFast} label='Timer Playing' shouldWrapChildren>
<IoPlay />
</Tooltip>
);
}
if (state === Playback.Pause) {
return (
<Tooltip openDelay={tooltipDelayFast} label='Timer Paused' shouldWrapChildren>
<IoPause />
</Tooltip>
);
}
if (state === Playback.Roll) {
return (
<Tooltip openDelay={tooltipDelayFast} label='Timer Rolling' shouldWrapChildren>
<IoTimeOutline />
</Tooltip>
);
}
return '';
}
@@ -1,37 +0,0 @@
import { Tooltip } from '@chakra-ui/react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import PropTypes from 'prop-types';
import { tooltipDelayFast } from '../../../ontimeConfig';
import styles from '../Table.module.scss';
export default function SortableCell({ column }) {
const { style, ...restColumn } = column.getHeaderProps();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: column.id,
});
// build drag styles
const dragStyle = {
...style,
transform: CSS.Translate.toString(transform),
transition,
};
return (
<th {...restColumn} ref={setNodeRef} style={dragStyle} className={isDragging ? styles.dragging : ''}>
<div {...attributes} {...listeners}>
<Tooltip label={column.Header} openDelay={tooltipDelayFast}>
{column.render('Header')}
</Tooltip>
</div>
<div {...column.getResizerProps()} className={styles.resizer} />
</th>
);
}
SortableCell.propTypes = {
column: PropTypes.object.isRequired,
};
@@ -1,56 +0,0 @@
import { Button } from '@chakra-ui/react';
import PropTypes from 'prop-types';
import style from '../Table.module.scss';
// reusable button styles
const buttonProps = {
colorScheme: 'blue',
size: 'sm',
variant: 'ghost',
};
export default function TableSettings(props) {
const {
columns,
handleResetResizing,
handleResetReordering,
handleResetToggles,
handleClearToggles,
} = props;
return (
<div className={style.tableSettings}>
<div className={style.hSeparator}>Select and order fields to show in table</div>
<div className={style.options}>
{columns.map((column) => (
<label key={column.id}>
<input type='checkbox' {...column.getToggleHiddenProps()} /> {column.Header}
</label>
))}
</div>
<div className={style.buttonRow}>
<Button onClick={handleResetResizing} {...buttonProps}>
Reset Resizing
</Button>
<Button onClick={handleResetReordering} {...buttonProps}>
Reset Reordering
</Button>
<Button onClick={handleResetToggles} {...buttonProps}>
Reset Toggles
</Button>
<Button onClick={handleClearToggles} {...buttonProps}>
Show All
</Button>
</div>
</div>
);
}
TableSettings.propTypes = {
columns: PropTypes.array,
handleResetResizing: PropTypes.func.isRequired,
handleResetReordering: PropTypes.func.isRequired,
handleResetToggles: PropTypes.func.isRequired,
handleClearToggles: PropTypes.func.isRequired,
};
@@ -1,16 +0,0 @@
import PropTypes from 'prop-types';
import style from '../Table.module.scss';
export default function BlockRow(props) {
const { row } = props;
return (
<tr {...row.getRowProps()}>
<td className={style.blockCell}>{row.original?.title || 'Block'}</td>
</tr>
);
}
BlockRow.propTypes = {
row: PropTypes.object.isRequired,
};
@@ -1,21 +0,0 @@
import PropTypes from 'prop-types';
import { millisToDelayString } from '../../../common/utils/dateConfig';
import style from '../Table.module.scss';
export default function DelayRow(props) {
const { row } = props;
const delayVal = row.original.duration;
const delayTime = delayVal !== 0 ? millisToDelayString(delayVal) : null;
return (
<tr {...row.getRowProps()}>
<td className={style.delayCell}>{delayTime}</td>
</tr>
);
}
DelayRow.propTypes = {
row: PropTypes.object.isRequired,
};
@@ -1,46 +0,0 @@
import PropTypes from 'prop-types';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import style from '../Table.module.scss';
export default function EventRow(props) {
const { row, index, selectedId, delay } = props;
const selected = row.original.id === selectedId;
const colours = row.original.colour
? getAccessibleColour(row.original.colour)
: {};
return (
<tr {...row.getRowProps()} className={selected ? style.selected : ''} id={row.original.id}>
<td className={style.indexColumn}>{index}</td>
{row.cells.map((cell) => {
const { key, style, ...restCellProps } = cell.getCellProps();
const dynamicStyles = { ...style, ...colours };
// Inject delay value if exits
if (delay !== 0 && delay != null) {
const col = cell.column.Header;
if (col === 'End' || col === 'Start') {
cell.delayed = cell.value + delay;
}
}
return (
<td key={key} style={{ ...dynamicStyles }} {...restCellProps}>
{cell.render('Cell')}
</td>
);
})}
</tr>
);
}
EventRow.propTypes = {
row: PropTypes.object.isRequired,
index: PropTypes.number.isRequired,
selectedId: PropTypes.string,
delay: PropTypes.number,
};
@@ -1,4 +1,5 @@
@use '../../../theme/viewerDefs' as *; @use '../../../theme/viewerDefs' as *;
@use '../../../theme/v2Styles' as *;
.backstage { .backstage {
margin: 0; margin: 0;
@@ -86,6 +87,10 @@
background-color: var(--card-background-color-override, $viewer-card-bg-color); background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding: 16px 24px; padding: 16px 24px;
border-radius: 8px; border-radius: 8px;
&.blink {
animation: blink 0.5s ease-in-out 3;
}
} }
.timer-group { .timer-group {
@@ -119,7 +124,7 @@
grid-area: schedule; grid-area: schedule;
overflow: hidden; overflow: hidden;
height: 100%; height: 100%;
margin-left: clamp(16px, 5vw, 64px);; margin-left: clamp(16px, 5vw, 64px);
} }
.schedule-nav-container { .schedule-nav-container {
@@ -143,9 +148,20 @@
} }
.qr { .qr {
margin-left: clamp(16px, 5vw, 64px);; margin-left: clamp(16px, 5vw, 64px);
padding: 4px; padding: 4px;
background-color: white; background-color: white;
} }
} }
} }
/* =================== AMIMATION ===================*/
@keyframes blink {
0% {
background-color: var(--card-background-color-blink-override, $playback-start);
}
20% {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
}
}
@@ -1,4 +1,4 @@
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import QRCode from 'react-qr-code'; import QRCode from 'react-qr-code';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { EventData, Message, OntimeEvent, ViewSettings } from 'ontime-types'; import { EventData, Message, OntimeEvent, ViewSettings } from 'ontime-types';
@@ -43,12 +43,24 @@ export default function Backstage(props: BackstageProps) {
const { isMirrored, publ, title, time, backstageEvents, selectedId, general, viewSettings } = props; const { isMirrored, publ, title, time, backstageEvents, selectedId, general, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const [blinkClass, setBlinkClass] = useState(false);
// Set window title // Set window title
useEffect(() => { useEffect(() => {
document.title = 'ontime - Backstage Screen'; document.title = 'ontime - Backstage Screen';
}, []); }, []);
// blink on change
useEffect(() => {
setBlinkClass(false);
const timer = setTimeout(() => {
setBlinkClass(true);
}, 10);
return () => clearTimeout(timer);
}, [selectedId]);
// defer rendering until we load stylesheets // defer rendering until we load stylesheets
if (!shouldRender) { if (!shouldRender) {
return null; return null;
@@ -57,7 +69,9 @@ export default function Backstage(props: BackstageProps) {
const clock = formatTime(time.clock, formatOptions); const clock = formatTime(time.clock, formatOptions);
const startedAt = formatTime(time.startedAt, formatOptions); const startedAt = formatTime(time.startedAt, formatOptions);
const isNegative = (time.current ?? 0) < 0; const isNegative = (time.current ?? 0) < 0;
const expectedFinish = isNegative ? 'In overtime' : formatTime(time.expectedFinish, formatOptions); const expectedFinish = isNegative
? getLocalizedString('countdown.overtime')
: formatTime(time.expectedFinish, formatOptions);
const qrSize = Math.max(window.innerWidth / 15, 128); const qrSize = Math.max(window.innerWidth / 15, 128);
const filteredEvents = getEventsWithDelay(backstageEvents); const filteredEvents = getEventsWithDelay(backstageEvents);
@@ -99,7 +113,7 @@ export default function Backstage(props: BackstageProps) {
<AnimatePresence> <AnimatePresence>
{title.showNow && ( {title.showNow && (
<motion.div <motion.div
className='event now' className={`event now ${blinkClass ? 'blink' : ''}`}
key='now' key='now'
variants={titleVariants} variants={titleVariants}
initial='hidden' initial='hidden'
@@ -1,4 +1,5 @@
import { DAY_TO_MS } from '../../../../common/utils/timeConstants'; import { dayInMs } from 'ontime-utils';
import { fetchTimerData, sanitiseTitle, TimerMessage } from '../countdown.helpers'; import { fetchTimerData, sanitiseTitle, TimerMessage } from '../countdown.helpers';
describe('sanitiseTitle() function', () => { describe('sanitiseTitle() function', () => {
@@ -73,11 +74,11 @@ describe('fetchTimerData() function', () => {
const timeNow = 15000; const timeNow = 15000;
const followId = 'testId'; const followId = 'testId';
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue }; const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue };
const time = { clock: timeNow, current: DAY_TO_MS + endMockValue - startMockValue }; const time = { clock: timeNow, current: dayInMs + endMockValue - startMockValue };
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent'); const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
expect(message).toBe(TimerMessage.waiting); expect(message).toBe(TimerMessage.waiting);
expect(timer).toBe(DAY_TO_MS + endMockValue - startMockValue); expect(timer).toBe(dayInMs + endMockValue - startMockValue);
}); });
it('handle an current event that finishes after midnight', () => { it('handle an current event that finishes after midnight', () => {
@@ -86,11 +87,11 @@ describe('fetchTimerData() function', () => {
const timeNow = 15000; const timeNow = 15000;
const followId = 'testId'; const followId = 'testId';
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue }; const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue };
const time = { clock: timeNow, current: DAY_TO_MS + endMockValue - startMockValue }; const time = { clock: timeNow, current: dayInMs + endMockValue - startMockValue };
const { message, timer } = fetchTimerData(time, follow, followId); const { message, timer } = fetchTimerData(time, follow, followId);
expect(message).toBe(TimerMessage.running); expect(message).toBe(TimerMessage.running);
expect(timer).toBe(DAY_TO_MS + endMockValue - startMockValue); expect(timer).toBe(dayInMs + endMockValue - startMockValue);
}); });
it('handle an event that finishes after midnight but hasnt started', () => { it('handle an event that finishes after midnight but hasnt started', () => {
@@ -99,7 +100,7 @@ describe('fetchTimerData() function', () => {
const timeNow = 2000; const timeNow = 2000;
const followId = 'testId'; const followId = 'testId';
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue }; const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue };
const time = { clock: timeNow, current: DAY_TO_MS + endMockValue - startMockValue }; const time = { clock: timeNow, current: dayInMs + endMockValue - startMockValue };
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent'); const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
expect(message).toBe(TimerMessage.toStart); expect(message).toBe(TimerMessage.toStart);
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { formatDisplay, millisToString } from 'ontime-utils'; import type { OntimeRundown, ViewSettings } from 'ontime-types';
import PropTypes from 'prop-types'; import { formatDisplay } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/apiConstants'; import { overrideStylesURL } from '../../../common/api/apiConstants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu'; import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
@@ -9,8 +9,16 @@ import { STUDIO_CLOCK_OPTIONS } from '../../../common/components/view-params-edi
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import useFitText from '../../../common/hooks/useFitText'; import useFitText from '../../../common/hooks/useFitText';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { formatEventList, getEventsWithDelay, trimRundown } from '../../../common/utils/eventsManager'; import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { secondsInMillis } from '../../../common/utils/dateConfig';
import {
formatEventList,
getEventsWithDelay,
type ScheduleEvent,
trimRundown,
} from '../../../common/utils/eventsManager';
import { formatTime } from '../../../common/utils/time'; import { formatTime } from '../../../common/utils/time';
import { TitleManager } from '../ViewWrapper';
import './StudioClock.scss'; import './StudioClock.scss';
@@ -19,25 +27,25 @@ const formatOptions = {
format: 'hh:mm', format: 'hh:mm',
}; };
StudioClock.propTypes = { interface StudioClockProps {
isMirrored: PropTypes.bool, isMirrored: boolean;
title: PropTypes.object, title: TitleManager;
time: PropTypes.object, time: TimeManagerType;
backstageEvents: PropTypes.array, backstageEvents: OntimeRundown;
selectedId: PropTypes.string, selectedId: string | null;
nextId: PropTypes.string, nextId: string | null;
onAir: PropTypes.bool, onAir: boolean;
viewSettings: PropTypes.object, viewSettings: ViewSettings;
}; }
export default function StudioClock(props) { export default function StudioClock(props: StudioClockProps) {
const { isMirrored, title, time, backstageEvents, selectedId, nextId, onAir, viewSettings } = props; const { isMirrored, title, time, backstageEvents, selectedId, nextId, onAir, viewSettings } = props;
// deferring rendering seems to affect styling (font and useFitText) // deferring rendering seems to affect styling (font and useFitText)
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { fontSize: titleFontSize, ref: titleRef } = useFitText({ maxFontSize: 500 }); const { fontSize: titleFontSize, ref: titleRef } = useFitText({ maxFontSize: 500 });
const [schedule, setSchedule] = useState([]); const [schedule, setSchedule] = useState<ScheduleEvent[]>([]);
const activeIndicators = [...Array(12).keys()]; const activeIndicators = [...Array(12).keys()];
const secondsIndicators = [...Array(60).keys()]; const secondsIndicators = [...Array(60).keys()];
@@ -59,16 +67,16 @@ export default function StudioClock(props) {
} }
const delayed = getEventsWithDelay(backstageEvents); const delayed = getEventsWithDelay(backstageEvents);
const events = delayed.filter((e) => e.type === 'event'); const trimmed = trimRundown(delayed, selectedId || '', MAX_TITLES);
const trimmed = trimRundown(events, selectedId, MAX_TITLES);
const formatted = formatEventList(trimmed, selectedId, nextId, { const formatted = formatEventList(trimmed, selectedId || '', nextId || '', {
showEnd: false, showEnd: false,
}); });
setSchedule(formatted); setSchedule(formatted);
}, [backstageEvents, nextId, selectedId]); }, [backstageEvents, nextId, selectedId]);
const clock = formatTime(time.clock, formatOptions); const clock = formatTime(time.clock, formatOptions);
const [, , secondsNow] = millisToString(time.clock).split(':'); const secondsNow = secondsInMillis(time.clock);
const isNegative = (time.current ?? 0) < 0; const isNegative = (time.current ?? 0) < 0;
return ( return (
-1
View File
@@ -30,7 +30,6 @@ html,
} }
@media (min-width: 1450px) and (max-width: 1666px) { @media (min-width: 1450px) and (max-width: 1666px) {
body, body,
html, html,
.App { .App {
+2
View File
@@ -18,6 +18,8 @@ Sentry.init({
tracesSampleRate: 1.0, tracesSampleRate: 1.0,
release: ONTIME_VERSION, release: ONTIME_VERSION,
enabled: import.meta.env.PROD, enabled: import.meta.env.PROD,
ignoreErrors: ['top.GLOBALS', 'Unable to preload CSS'],
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
}); });
root.render( root.render(
-132
View File
@@ -1,132 +0,0 @@
@use "./ontimeColours" as *;
$transition-time-action: 0.1s;
$transition-time-feedback: 0.3s;
//////////////////////////////////// general app colours
$bg-black-gradient: #202020;
$bg-black: #121212;
$bg-black-100: #070707;
$bg-black-200: #1a1a1a; // container borders
$bg-black-300: #1f1f1f; // container text
$bg-gray-1100: #232323; // container borders
$bg-gray-1050: #242424; // container borders
$bg-gray-1000: #262626; // container borders
$bg-gray-950: #292929;
$bg-gray-900: #303030;
$bg-gray-800: #404040;
$bg-gray-700: #505050;
$bg-gray-500: #666666;
$bg-gray-100: #c0c0c0; // borders and whatnot
$bg-overlay: rgba(0, 0, 0, 0.85);
// $ontime-accent: #4bffab);
$ontime-accent: #58A151;
$ontime-accent-text: mix($bg-black, $ontime-accent, 10%);
$ontime-pink: #ff7597;
$ontime-pink-variant: #ff6969;
$ontime-roll: #0274B6;
$ontime-delay: #F57C13;
$action-blue: #3182ce;
$ontime-paused: #c05621;
$opacity-disabled: 0.4;
$ontime-red: #E4281E;
//rgba(255, 255, 255, 0.39); - $bg-gray-700
//rgba(255, 255, 255, 0.13); - $bg-gray-900
//rgba(255, 255, 255, 0.05); - $bg-gray-1000
//rgba(255, 255, 255, 0.07) - $bg-gray-1000
// text input bg -
// was rgba(255, 255, 255, 0.03)
// container level 1 - $bg-gray-1000
// container level 2 - $bg-gray-1100
// container level 2 border - rgba(0, 0, 0, 0.05)
// indent in level 2 - $bg-black-300
// outdent in level2 - $bg-gray-950
//////////////////////////////////// editor
$notes-color: #f6f6f6;
$text-white: #fffffa;
$text-gray-disabled: #505050;
$label-gray: #aaa;
$header-gray: $label-gray;
$clocks: #ddd;
$bg-gray: #f4f4f8;
$text-delay: #F57C13;
$light-bg: #2b6cb0;
$light-bg-transparent: #2b6cb055;
$light-text: #2b6cb022;
$info-gray: #aaa;
$info-gray-hover: #ddd;
$warning-orange: #dd6b20;
$error-red: #e53e3e;
//////////////////////////////////// viewers
$title-white: #fffd;
//////////////////////////////////// block elements
$bg-container-over: #0b1521;
$bg-container-over-l1: #132337;
$bg-container-l1: #202020;
$bg-container-l2: #232323;
$bg-container-l3: #2b2b2b;
$border-l1: 1px solid $bg-gray-1000;
$border-l3: 1px solid $bg-gray-900;
$block-delay-color: #E2720D;
$delay-text: #d69e2e;
$block-delay-border: #d69e2e55;
$block-block-color: #7347AD;
$block-border: 1px solid $bg-gray-1100;
//////////////////////////////////// utils
@mixin ellipsis {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
//////////////////////////////////// custom inputs
$input-bg: rgba(255, 255, 255, 0.03);
$input-hover-bg: rgba(255, 255, 255, 0.13);
$input-bg-delayed: rgba(214, 158, 46, 0.07);
$input-hover-bg-delayed: rgba(214, 158, 46, 0.17);
$input-border: 1px solid transparent;
$input-delayed-border: 1px solid $block-delay-border;
//////////////////////////////////// general app element overriders
// no decoration on lists
ul {
list-style-type: none;
}
// no resizing on text areas
textarea {
resize: none !important;
}
// Define style for a link
a:hover {
color: $ontime-pink;
}
// horizontal separator
.hSeparator {
width: 100%;
border-bottom: 1px solid $light-bg;
margin: 1em auto;
display: flex;
align-items: center;
}
// inline vertical separator
.vSpan {
margin: 0 0.5em;
}
+3 -3
View File
@@ -24,7 +24,7 @@ $ontime-delay-text: #E69056;
$ontime-paused: #c05621; $ontime-paused: #c05621;
$ontime-stop: #E4281E; $ontime-stop: #E4281E;
$playback-negative: $red-500; $playback-negative: $red-500;
$active-indicator: #899948; $active-indicator: #8bb33d;
$text-black: $gray-1350; $text-black: $gray-1350;
// interface panels // interface panels
@@ -51,8 +51,8 @@ $ontime-font-family: "Open Sans", "Segoe UI", sans-serif;
$label-gray: $gray-400; $label-gray: $gray-400;
$secondary-text-gray: $gray-400; $secondary-text-gray: $gray-400;
$section-white: $ui-white; $section-white: $ui-white;
$inner-section-text-size: 14px; $inner-section-text-size: calc(1rem - 2px);
$text-body-size: 15px; $text-body-size: calc(1rem - 1px);
.blink { .blink {
animation: blink $blinking-time linear infinite; animation: blink $blinking-time linear infinite;
@@ -36,6 +36,13 @@ export const ontimeInputFilledOnLight = {
export const ontimeTextAreaFilled = { export const ontimeTextAreaFilled = {
...commonStyles, ...commonStyles,
}; };
export const ontimeTextAreaTransparent = {
...commonStyles,
backgroundColor: 'transparent',
_hover: {
backgroundColor: 'rgba(255, 255, 255, 0.10)', // $white-10
},
};
export const ontimeTextAreaFilledOnLight = { export const ontimeTextAreaFilledOnLight = {
borderRadius: '3px', borderRadius: '3px',
+2
View File
@@ -23,6 +23,7 @@ import {
ontimeInputFilledOnLight, ontimeInputFilledOnLight,
ontimeTextAreaFilled, ontimeTextAreaFilled,
ontimeTextAreaFilledOnLight, ontimeTextAreaFilledOnLight,
ontimeTextAreaTransparent,
} from './ontimeTextInputs'; } from './ontimeTextInputs';
import { ontimeTooltip } from './ontimeTooltip'; import { ontimeTooltip } from './ontimeTooltip';
@@ -96,6 +97,7 @@ const theme = extendTheme({
}, },
variants: { variants: {
'ontime-filled': { ...ontimeTextAreaFilled }, 'ontime-filled': { ...ontimeTextAreaFilled },
'ontime-transparent': { ...ontimeTextAreaTransparent },
'ontime-filled-on-light': { ...ontimeTextAreaFilledOnLight }, 'ontime-filled-on-light': { ...ontimeTextAreaFilledOnLight },
}, },
}, },

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