mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-09 09:23:51 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f298e6fd9 | |||
| f3610059ef | |||
| 023aac4ead | |||
| 8c9cb908cf | |||
| 839cee18f3 | |||
| b83ebf53a9 | |||
| 1b8392bba4 | |||
| af1241bec7 | |||
| c361ff4b3f | |||
| b579a4c57e | |||
| e49918e8bb | |||
| 60799d3d88 | |||
| 31fc222876 | |||
| bb1eb2838f | |||
| 3603b836f6 | |||
| bee7a8dcb4 | |||
| e3444747bb | |||
| db1155ce1a | |||
| 6ef6b0fdcd | |||
| c73f7e2207 | |||
| 4a0d83d970 | |||
| b89d0787d4 | |||
| 91a2d395cb | |||
| 6c9222bce3 |
@@ -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 }}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": [
|
|
||||||
"stylelint-config-standard-scss",
|
|
||||||
"stylelint-config-prettier"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
.modalBody {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
+73
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 } };
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
-23
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+7
-1
@@ -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",
|
||||||
"",
|
"",
|
||||||
+3
-3
@@ -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
|
||||||
"
|
"
|
||||||
+119
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+44
@@ -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);
|
||||||
+22
-14
@@ -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;
|
||||||
};
|
};
|
||||||
+4
-2
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
-4
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
+27
@@ -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,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
@@ -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);
|
||||||
|
|||||||
+27
-19
@@ -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 (
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user